Compare commits
75 Commits
develop
...
codex/spli
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d4f312595 | |||
| e19d33adb9 | |||
| 99f7f58fcb | |||
| 6bc7840fb1 | |||
| d3b7f0d613 | |||
| efa46d1ee5 | |||
| 149bb999dc | |||
| 288acbf0c8 | |||
| b45dd2ae05 | |||
| c5bdb10445 | |||
| 7dbf786c56 | |||
| 59f2264a2e | |||
| a0f41baa36 | |||
| c3d3aaa699 | |||
| 301b76c03e | |||
| ab6de6996f | |||
| 0092ec4cde | |||
| 12ad0c8b78 | |||
| b5d4c61783 | |||
| f4990a4f55 | |||
| ab44626a0f | |||
| 95edefa84f | |||
| f914c28bc5 | |||
| 73488384e4 | |||
| c6352d781f | |||
| d02dbe10de | |||
| 2ef1e09986 | |||
| 6df8c525ef | |||
| f264ce4a2f | |||
| 371adbcb50 | |||
| 3a930c9d8c | |||
| 1bdf12f174 | |||
| 3e8925c609 | |||
| d12b0c01fc | |||
| e16f401dc5 | |||
| 68844d67df | |||
| ec8e748a43 | |||
| 62f50d9c28 | |||
| cab4313c70 | |||
| aab7533438 | |||
| 62b39cdf58 | |||
| 6ab5f50f95 | |||
| 4bb8507e3f | |||
| b3fad00f80 | |||
| 5c9a291920 | |||
| 781cbbb026 | |||
| 499d2d3367 | |||
| eeec4359b0 | |||
| 11f2c0ccb2 | |||
| f89cba4a24 | |||
| 49be90a82c | |||
| bb7250e4ac | |||
| 4f1a2f8bf6 | |||
| 7d1f5f89ed | |||
| b1c8eea976 | |||
| 27b71077b1 | |||
| 3e18b833c6 | |||
| 0cd898d1c1 | |||
| 8476ab16e5 | |||
| a8f2c6f3f9 | |||
| a961b1b415 | |||
| 7489b5ef97 | |||
| b716b370ac | |||
| abd5a73b51 | |||
| 1ed79c3a1e | |||
| 8789496ae6 | |||
| 7d41dd86e7 | |||
| 81bed1513c | |||
| d13eb0a9a4 | |||
| 71f8e0b84c | |||
| 03885d2e09 | |||
| 88eef527d5 | |||
| c971b40eae | |||
| 327561b2e9 | |||
| 185a311a38 |
43
.codex/skills/design-vpn-client-ui/SKILL.md
Normal file
43
.codex/skills/design-vpn-client-ui/SKILL.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
name: design-vpn-client-ui
|
||||||
|
description: Design, implement, review, or refine the client-facing VPN interfaces in this repository using the established calm monospace visual language and smooth state-driven motion. Use for the current macOS client and future end-user gateway client screens, especially power controls, subscriptions, traffic usage, proxy copy controls, server selection, responsive layout, hover feedback, transitions, and animation polish. Do not use for the administrative gateway UI unless the user explicitly asks to apply the client visual language there.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design VPN Client UI
|
||||||
|
|
||||||
|
Preserve the repo's focused one-screen VPN client language: a centered primary action, quiet technical typography, restrained green state color, and motion that feels slow, fluid, and deliberate without moving layout.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Read `PRODUCT.md` and the complete client component and styles before editing.
|
||||||
|
2. Inspect the supplied screenshot or live UI. Trace the real DOM and state change that causes the visual issue.
|
||||||
|
3. Read [visual-language.md](references/visual-language.md) for layout, hierarchy, color, and typography work.
|
||||||
|
4. Read [motion-and-interaction.md](references/motion-and-interaction.md) for animation, hover, refresh, input, copy, or state-transition work.
|
||||||
|
5. Reuse existing React state, CSS variables, formatters, and API paths. Prefer a narrow CSS/markup change over a new abstraction or dependency.
|
||||||
|
6. Keep geometry stable across every state. Reserve space before animating content.
|
||||||
|
7. Implement `prefers-reduced-motion` alongside every new animation.
|
||||||
|
8. Run `npm test`, `npm run build`, and `git diff --check`. Visually inspect when a runnable client is available.
|
||||||
|
|
||||||
|
## Non-negotiable decisions
|
||||||
|
|
||||||
|
- Keep the power action on the screen's central vertical axis. Place subscription content to its right without shifting that axis.
|
||||||
|
- Keep the power hit target generous while rendering only the icon, never a large enclosing green circle.
|
||||||
|
- Use green only for active, selected, successful, or refreshed states. Keep inactive power gray, including hover.
|
||||||
|
- Never let labels, timers, feedback, icons, progress, or server rows shift neighboring content.
|
||||||
|
- Animate state, opacity, blur, glow, color, filter, and transform. Do not animate layout properties.
|
||||||
|
- Let visible cycles finish. Never stop a spinner mid-turn or remount a list before its exit animation completes.
|
||||||
|
- Prefer one clear value over unsupported detail. Hide subscription fields the provider does not supply.
|
||||||
|
- Keep client UI compact and calm. Do not introduce dashboard cards, decorative chrome, or admin-console density.
|
||||||
|
|
||||||
|
## Acceptance pass
|
||||||
|
|
||||||
|
Before handing off, verify:
|
||||||
|
|
||||||
|
- Power on/off is unmistakable without reading the label.
|
||||||
|
- Switching on/off preserves the exact positions of title, timer, and hint.
|
||||||
|
- Refresh and copy feedback cannot change element width or alignment.
|
||||||
|
- Server separators are compact and only slightly wider than their content.
|
||||||
|
- Repeated polling does not replay decorative list animations.
|
||||||
|
- Manual refresh has an obvious but non-jarring response.
|
||||||
|
- Keyboard focus remains visible even when the text caret is intentionally hidden.
|
||||||
|
- Narrow screens return to a simple single-column layout.
|
||||||
4
.codex/skills/design-vpn-client-ui/agents/openai.yaml
Normal file
4
.codex/skills/design-vpn-client-ui/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Design VPN Client UI"
|
||||||
|
short_description: "Design the repo's calm animated VPN client UI."
|
||||||
|
default_prompt: "Use $design-vpn-client-ui to design or refine the VPN client interface in this repository."
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Motion and interaction
|
||||||
|
|
||||||
|
## Motion character
|
||||||
|
|
||||||
|
Aim for fluid, slightly viscous motion: noticeable, calm, and complete. Avoid bounce, elastic easing, abrupt unmounts, decorative page choreography, or tiny effects too weak to communicate feedback.
|
||||||
|
|
||||||
|
Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arrivals. Typical timing:
|
||||||
|
|
||||||
|
- hover and press: 180-300 ms;
|
||||||
|
- state color and glow: 600-900 ms;
|
||||||
|
- content reveal: 600-850 ms;
|
||||||
|
- numeric tween and progress: about 900 ms;
|
||||||
|
- copy feedback: about 800 ms;
|
||||||
|
- server cascade: 620-760 ms per row with 90-110 ms stagger.
|
||||||
|
|
||||||
|
## Power state
|
||||||
|
|
||||||
|
- Transition gray to green slowly when connecting and green to gray when disconnecting.
|
||||||
|
- Animate icon color, localized light, and SVG shadow together.
|
||||||
|
- Let the light expand and brighten on enable, then contract and fade on disable.
|
||||||
|
- Keep the hit target and all surrounding geometry fixed.
|
||||||
|
- Use a short press compression, followed by a slower release.
|
||||||
|
|
||||||
|
## Changing text and numbers
|
||||||
|
|
||||||
|
- Put alternate labels in fixed-size slots.
|
||||||
|
- Reveal connection title, timer, and hint with opacity plus light blur, never vertical layout movement.
|
||||||
|
- Do not reanimate the timer every second; animate only its initial appearance.
|
||||||
|
- Tween numeric traffic values from old to new with `requestAnimationFrame` or an equivalent stable counter.
|
||||||
|
- Animate progress width concurrently and add a brief glow that fully fades.
|
||||||
|
- Never translate changing numbers if the user asked for a fluid morph; use numerical interpolation, opacity, color, blur, and light.
|
||||||
|
|
||||||
|
## Refresh
|
||||||
|
|
||||||
|
- Use a clean, symmetric SVG refresh icon aligned in the same flex row as its label.
|
||||||
|
- Spin for at least one full cycle. If the request finishes mid-cycle, continue to the next cycle boundary before stopping.
|
||||||
|
- Update data immediately when it arrives; finishing the icon cycle must not delay the data.
|
||||||
|
- Manual refresh may replay meaningful data and server transitions.
|
||||||
|
- Background polling should update quietly and must not repeatedly replay the server cascade.
|
||||||
|
- On updated traffic, tween the number, advance the bar, and emit a visible but brief green flare.
|
||||||
|
|
||||||
|
## Server cascade
|
||||||
|
|
||||||
|
- On initial display, reveal rows from top to bottom with a small negative Y offset, opacity, and blur.
|
||||||
|
- On manual refresh, animate an explicit exit phase first. Fade rows top to bottom, then remount and enter top to bottom.
|
||||||
|
- Wait for the last exit delay and duration before starting entry.
|
||||||
|
- Disable pointer interaction during exit.
|
||||||
|
- Do not replay on ping updates or unrelated renders.
|
||||||
|
|
||||||
|
## Subscription input
|
||||||
|
|
||||||
|
- Show the public domain while retaining the full URL internally.
|
||||||
|
- Disable browser autocomplete suggestions and neutralize autofill backgrounds.
|
||||||
|
- Hide the blinking caret when the paste-first interaction does not need it, while preserving keyboard input and focus outline.
|
||||||
|
- When an existing subscription is being edited and the field is idle, use the green underline as a five-second timeout indicator: start bright, fade to quiet, then restore display mode.
|
||||||
|
- Pause the timeout once the user enters content.
|
||||||
|
- Close and clear unfinished input on outside click or Escape.
|
||||||
|
|
||||||
|
## First-run initialization
|
||||||
|
|
||||||
|
- With no subscription, show only the centered subscription input. Hide power, proxy controls, usage, and servers.
|
||||||
|
- After a valid subscription loads, keep the subscription and server list centered. Require an explicit server choice instead of silently selecting the first server.
|
||||||
|
- On server choice, slide the subscription column to the right while revealing the power column on the viewport's central axis.
|
||||||
|
- Preserve the chosen server on later visits, but return to first-run initialization after subscription deletion.
|
||||||
|
- Deleting a subscription must stop the VPN, clear its cached/configured state, and return the UI to the centered input without leaving stale controls visible.
|
||||||
|
|
||||||
|
## Copy feedback
|
||||||
|
|
||||||
|
- Keep protocol buttons fixed-size and centered.
|
||||||
|
- Copy the complete protocol URL while showing a shared address separately.
|
||||||
|
- Overlay green `Copied` feedback in the same fixed box; do not append text or move the label.
|
||||||
|
- Make feedback appear immediately, hold briefly, and fade fully before restoring the original label. Keep the whole cycle near 800 ms.
|
||||||
|
|
||||||
|
## Reduced motion
|
||||||
|
|
||||||
|
Under `prefers-reduced-motion: reduce`, remove transitions and keyframe animations while preserving final state, focus, color contrast, copy wording, and all functionality.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Visual language
|
||||||
|
|
||||||
|
## Scene and character
|
||||||
|
|
||||||
|
Design for a macOS user glancing at a small VPN control surface in a quiet desktop environment. The UI should feel soft, precise, dependable, and slightly terminal-like, not like a network administration dashboard.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
- Make one primary action dominant: the VPN power icon.
|
||||||
|
- Keep the power control centered on the viewport's vertical axis, not merely centered inside a left column.
|
||||||
|
- Build the left flow vertically: power icon, stable connection copy, proxy address, copy actions.
|
||||||
|
- Place subscription identity, usage, expiry, and servers in a compact column to the right.
|
||||||
|
- Collapse to one centered column on narrow screens.
|
||||||
|
- Avoid cards and enclosing frames. Use spacing, type, thin rules, and state color for hierarchy.
|
||||||
|
- Keep server rows vertical and narrow. Underlines should be only slightly wider than the server label and ping.
|
||||||
|
|
||||||
|
## Geometry and alignment
|
||||||
|
|
||||||
|
- Reserve identical height for mutually exclusive content such as timer versus connection hint.
|
||||||
|
- Give copy buttons fixed width. Overlay temporary feedback instead of replacing text in normal flow.
|
||||||
|
- Align icons and labels in the same flex row. Do not position an icon by guessed absolute offsets.
|
||||||
|
- Preserve a generous invisible hit area around icon-only controls.
|
||||||
|
- Center proxy address and protocol actions with the power column.
|
||||||
|
- Treat one-pixel optical misalignment as a defect when controls sit beside uppercase labels.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- Prefer the existing JetBrains Mono / SF Mono stack for the client surface.
|
||||||
|
- Use uppercase, tracked, muted micro-labels for metadata.
|
||||||
|
- Use stronger weight and size for the subscription domain and connection state.
|
||||||
|
- Use tabular numerals for timers and changing numeric data.
|
||||||
|
- Avoid display fonts, oversized headings, and mixed type families.
|
||||||
|
|
||||||
|
## Color and light
|
||||||
|
|
||||||
|
- Preserve green-tinted dark and light neutrals through the existing OKLCH variables.
|
||||||
|
- Inactive power stays neutral gray even on hover; active power becomes green.
|
||||||
|
- Use green for active VPN, selected server underline, progress, copy success, refresh success, and focus.
|
||||||
|
- Prefer localized `drop-shadow`, `text-shadow`, or a soft radial light layer over filled green containers.
|
||||||
|
- Let glow support state recognition. Do not leave every element glowing continuously.
|
||||||
|
|
||||||
|
## Data presentation
|
||||||
|
|
||||||
|
- Show subscription domain, not the credential-like full URL.
|
||||||
|
- Show used traffic and total limit as the primary statistic.
|
||||||
|
- Omit upload/download breakdown when provider support is absent or ambiguous.
|
||||||
|
- Show expiry as both date and remaining days, with correct Russian forms.
|
||||||
|
- If there is no total, say `без лимита` and omit the progress bar.
|
||||||
|
- Hide unavailable rows instead of showing empty placeholders or zeros that imply real measurements.
|
||||||
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.vpn-proxy
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
PORT=3456
|
PORT=3456
|
||||||
|
APP_MODE=gateway
|
||||||
|
CLIENT_UI_PORT=3456
|
||||||
|
CLIENT_PROXY_PORT=8082
|
||||||
|
BASE_IMAGE=debian:bookworm-slim
|
||||||
|
SINGBOX_VERSION=1.12.13
|
||||||
|
INSTALL_RUNTIME_DEPS=true
|
||||||
|
INSTALL_SINGBOX=true
|
||||||
PROXY_PORT=8080
|
PROXY_PORT=8080
|
||||||
|
PROXY_BIND_IP=0.0.0.0
|
||||||
TPROXY_PORT=7895
|
TPROXY_PORT=7895
|
||||||
TPROXY_MARK=1
|
TPROXY_MARK=1
|
||||||
TPROXY_TABLE=100
|
TPROXY_TABLE=100
|
||||||
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
ROUTING_RU_DIRECT=true
|
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -1,28 +1,107 @@
|
|||||||
name: Build Gateway Image
|
name: Build and Deploy Gateway
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /opt/vpn-proxy
|
||||||
|
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
|
||||||
|
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
|
||||||
|
APT_MIRROR: http://mirror.yandex.ru/debian
|
||||||
|
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
|
||||||
|
SINGBOX_VERSION: 1.12.13
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Clone repository
|
- name: Clone repository
|
||||||
env:
|
env:
|
||||||
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" .
|
rm -rf repo
|
||||||
|
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
||||||
|
cd repo
|
||||||
git checkout ${{ gitea.sha }}
|
git checkout ${{ gitea.sha }}
|
||||||
|
|
||||||
- name: Build and push gateway image
|
- name: Build and push gateway image
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd repo
|
||||||
|
|
||||||
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||||
|
|
||||||
|
echo "Build runner: $(hostname)"
|
||||||
|
echo "Base image: ${{ env.BASE_IMAGE }}"
|
||||||
|
echo "Docker context: $(docker context show 2>/dev/null || true)"
|
||||||
|
docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'
|
||||||
|
|
||||||
|
if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \
|
||||||
|
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc 'command -v npm >/dev/null'; then
|
||||||
|
echo "Runtime base image ${{ env.BASE_IMAGE }} is missing npm; building it now."
|
||||||
|
BASE_IMAGE="${{ env.RUNTIME_BASE_SOURCE_IMAGE }}" \
|
||||||
|
RUNTIME_BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||||
|
APT_MIRROR="${{ env.APT_MIRROR }}" \
|
||||||
|
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
|
||||||
|
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
|
./scripts/build-runtime-base.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
npm run build
|
||||||
|
else
|
||||||
|
echo "Host npm not found; building frontend inside ${{ env.BASE_IMAGE }}"
|
||||||
|
docker run --rm \
|
||||||
|
--network host \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
"${{ env.BASE_IMAGE }}" \
|
||||||
|
sh -lc 'npm ci --no-audit --no-fund && npm run build'
|
||||||
|
fi
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
docker build -t "${IMAGE}:latest" -t "${IMAGE}:${{ gitea.sha }}" .
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
|
--network host \
|
||||||
|
--pull=false \
|
||||||
|
--build-arg BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||||
|
--build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
|
--build-arg INSTALL_RUNTIME_DEPS=false \
|
||||||
|
--build-arg INSTALL_SINGBOX=false \
|
||||||
|
-t "${IMAGE}:latest" \
|
||||||
|
-t "${IMAGE}:${{ gitea.sha }}" \
|
||||||
|
.
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
docker push "${IMAGE}:${{ gitea.sha }}"
|
docker push "${IMAGE}:${{ gitea.sha }}"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: lxc-111
|
||||||
|
needs: build-and-push
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
env:
|
||||||
|
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
|
rm -rf repo
|
||||||
|
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
||||||
|
cd repo
|
||||||
|
git checkout ${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Pull and deploy gateway image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd repo
|
||||||
|
|
||||||
|
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
|
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||||
|
|
||||||
|
echo "Deploy runner: $(hostname)"
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
DEPLOY_PATH="${{ env.DEPLOY_PATH }}" GATEWAY_IMAGE="${IMAGE}:${{ gitea.sha }}" bash scripts/deploy-gateway.sh
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,11 +1,9 @@
|
|||||||
# Local archive with the previous implementation and runtime secrets
|
|
||||||
_archive/
|
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
.env
|
.env
|
||||||
*.env.local
|
*.env.local
|
||||||
data/
|
data/
|
||||||
.vpn-proxy/
|
.vpn-proxy/
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Node/Vite
|
# Node/Vite
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
40
Dockerfile
40
Dockerfile
@@ -1,19 +1,23 @@
|
|||||||
FROM node:22-bookworm-slim AS ui-build
|
ARG BASE_IMAGE=debian:bookworm-slim
|
||||||
WORKDIR /app
|
FROM ${BASE_IMAGE}
|
||||||
COPY package.json ./
|
|
||||||
RUN npm install
|
|
||||||
COPY index.html vite.config.js ./
|
|
||||||
COPY src/web ./src/web
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
ARG SINGBOX_VERSION=1.12.13
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
|
ARG INSTALL_RUNTIME_DEPS=true
|
||||||
|
ARG INSTALL_SINGBOX=true
|
||||||
|
COPY dist /app/dist
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 nodejs dumb-init \
|
apt-get update \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& apt-get install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs dumb-init \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*; \
|
||||||
|
else \
|
||||||
|
command -v dumb-init >/dev/null \
|
||||||
|
&& command -v node >/dev/null \
|
||||||
|
&& command -v iptables >/dev/null \
|
||||||
|
&& command -v ipset >/dev/null; \
|
||||||
|
fi
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
||||||
|
set -eux; \
|
||||||
arch="$(dpkg --print-architecture)"; \
|
arch="$(dpkg --print-architecture)"; \
|
||||||
case "$arch" in \
|
case "$arch" in \
|
||||||
amd64) sb_arch="amd64" ;; \
|
amd64) sb_arch="amd64" ;; \
|
||||||
@@ -24,10 +28,13 @@ RUN set -eux; \
|
|||||||
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
chmod +x /usr/local/bin/sing-box; \
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
rm -rf /tmp/sing-box*
|
rm -rf /tmp/sing-box*; \
|
||||||
|
else \
|
||||||
|
command -v sing-box >/dev/null; \
|
||||||
|
fi
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=ui-build /app/dist /app/dist
|
COPY package.json /app/package.json
|
||||||
COPY src/server /app/src/server
|
COPY src/server /app/src/server
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
@@ -36,7 +43,10 @@ RUN chmod +x /entrypoint.sh \
|
|||||||
|
|
||||||
ENV PORT=3456 \
|
ENV PORT=3456 \
|
||||||
PROXY_PORT=8080 \
|
PROXY_PORT=8080 \
|
||||||
|
PROXY_BIND_IP=0.0.0.0 \
|
||||||
TPROXY_PORT=7895 \
|
TPROXY_PORT=7895 \
|
||||||
|
DIRECT_BYPASS_CACHE=false \
|
||||||
|
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
||||||
DATA_DIR=/var/lib/vpn-proxy \
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
SING_BOX_CACHE=/var/lib/sing-box/cache.db
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db
|
||||||
|
|||||||
54
Dockerfile.client
Normal file
54
Dockerfile.client
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
ARG NODE_BUILD_IMAGE=node:20-alpine
|
||||||
|
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
|
FROM ${NODE_BUILD_IMAGE} AS web-build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY index.html vite.config.js ./
|
||||||
|
COPY src/web ./src/web
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM ${RUNTIME_IMAGE}
|
||||||
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64) sb_arch="amd64" ;; \
|
||||||
|
arm64) sb_arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=web-build /src/dist /app/dist
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
COPY src/server /app/src/server
|
||||||
|
COPY entrypoint.client.sh /entrypoint.client.sh
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint.client.sh \
|
||||||
|
&& mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box
|
||||||
|
|
||||||
|
ENV APP_MODE=client \
|
||||||
|
PORT=3456 \
|
||||||
|
PROXY_PORT=8082 \
|
||||||
|
PROXY_BIND_IP=0.0.0.0 \
|
||||||
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
||||||
|
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
||||||
|
ROUTING_RU_DIRECT=true \
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
EXPOSE 3456 8082
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"]
|
||||||
52
Dockerfile.runtime-base
Normal file
52
Dockerfile.runtime-base
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
|
ARG APT_MIRROR=http://mirror.yandex.ru/debian
|
||||||
|
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
|
||||||
|
ARG HTTP_PROXY
|
||||||
|
ARG HTTPS_PROXY
|
||||||
|
ARG NO_PROXY
|
||||||
|
ARG http_proxy
|
||||||
|
ARG https_proxy
|
||||||
|
ARG no_proxy
|
||||||
|
|
||||||
|
RUN export http_proxy="${http_proxy:-${HTTP_PROXY:-}}" \
|
||||||
|
&& export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}" \
|
||||||
|
&& export no_proxy="${no_proxy:-${NO_PROXY:-}}" \
|
||||||
|
&& for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do \
|
||||||
|
[ -f "$file" ] || continue; \
|
||||||
|
sed -i \
|
||||||
|
-e "s|http://deb.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
|
||||||
|
-e "s|http://security.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
|
||||||
|
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||||
|
"$file"; \
|
||||||
|
done \
|
||||||
|
&& apt-get \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=20 \
|
||||||
|
-o Acquire::https::Timeout=20 \
|
||||||
|
-o Acquire::ForceIPv4=true \
|
||||||
|
update \
|
||||||
|
&& apt-get \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=20 \
|
||||||
|
-o Acquire::https::Timeout=20 \
|
||||||
|
-o Acquire::ForceIPv4=true \
|
||||||
|
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs npm dumb-init \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
export http_proxy="${http_proxy:-${HTTP_PROXY:-}}"; \
|
||||||
|
export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}"; \
|
||||||
|
export no_proxy="${no_proxy:-${NO_PROXY:-}}"; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64) sb_arch="amd64" ;; \
|
||||||
|
arm64) sb_arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*
|
||||||
35
PRODUCT.md
Normal file
35
PRODUCT.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
product
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
People running either a local macOS proxy client or a small Linux VPN gateway. They open the client only to add a subscription, choose a server, turn the VPN on or off, and copy the connection address.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
Provide one small, dependable control surface for the macOS client and the system gateway. Success means the connection state is obvious, while the gateway address and proxy URLs are ready to copy from the same screen.
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
Soft, calm, precise. Familiar to macOS users, with sharper geometry and a quiet monospace character.
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
Not an admin dashboard, network console, settings maze, or enclosing card. Avoid sidebars, technical route diagrams, framed content areas, decorative effects, and routing-rule administration.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- One screen, one primary action.
|
||||||
|
- Use plain language and hide implementation details.
|
||||||
|
- Make connection state unmistakable without relying on color alone.
|
||||||
|
- Prefer native controls and predictable macOS behavior.
|
||||||
|
- Show saved subscriptions as a domain, not as a credential-like URL.
|
||||||
|
- Make servers directly selectable instead of hiding them in a dropdown.
|
||||||
|
- Keep advanced and server-only features out of the client path.
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
Support keyboard navigation, visible focus, sufficient contrast, system light and dark themes, and reduced motion preferences.
|
||||||
53
README.md
53
README.md
@@ -1,34 +1,43 @@
|
|||||||
# VPN Proxy Gateway
|
# VPN Proxy
|
||||||
|
|
||||||
Новая версия проекта начинается с `gateway`-режима: контейнер поднимается в `network_mode: host`, применяет TProxy-правила на хосте и запускает `sing-box` как прозрачный gateway для устройств в локальной сети.
|
Один компактный VPN-клиент в двух режимах:
|
||||||
|
|
||||||
## Что уже заложено
|
- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или HTTP/SOCKS5 Proxy;
|
||||||
|
- `client` — локальный proxy-клиент для macOS.
|
||||||
|
|
||||||
- Web UI на Vite + React.
|
В обоих режимах пользователь добавляет подписку, выбирает сервер и включает VPN на одном экране.
|
||||||
- Один простой Node control-server вместо отдельного backend framework.
|
|
||||||
- Парсинг subscription URL: JSON config, base64 список, plain-text VLESS links.
|
|
||||||
- Routing lists управляются из UI: можно отправлять отдельные домены/CIDR/порты в `direct`, `vpn` или `block`.
|
|
||||||
- Генерация `sing-box` config для gateway:
|
|
||||||
- `tproxy` inbound на `7895`;
|
|
||||||
- `mixed` inbound на `8080`;
|
|
||||||
- private IP ranges напрямую;
|
|
||||||
- RU rule sets напрямую;
|
|
||||||
- остальное через выбранный outbound.
|
|
||||||
- Docker entrypoint с idempotent TProxy setup/cleanup.
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Gateway
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose -f docker-compose.gateway.yml up -d --build
|
docker compose -f docker-compose.gateway.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
UI будет доступен на хосте по `http://<gateway-host>:3456`.
|
Интерфейс: `http://<gateway-ip>:3456`.
|
||||||
|
|
||||||
## Важные ограничения v0.1
|
После подключения экран показывает:
|
||||||
|
|
||||||
- IPv4 TProxy first. IPv6 routing будет отдельным этапом.
|
- `Gateway` — адрес, который можно назначить устройству как основной шлюз;
|
||||||
- DNS-перехват пока не включен. Для корректного gateway-сценария лучше выдать клиентам DNS через роутер/DHCP.
|
- `Proxy` — один адрес на порту `8080`, доступный как `HTTP` и `SOCKS5`.
|
||||||
- Контейнер должен запускаться с `network_mode: host`, `NET_ADMIN`, `NET_RAW`.
|
|
||||||
- `_archive/` игнорируется git, потому что там лежит старая реализация и runtime state.
|
Весь перехваченный публичный TCP/UDP и весь proxy-трафик идут через выбранный VPN. Приватные и локальные сети не перехватываются, чтобы сохранить доступ к Gateway и LAN.
|
||||||
- Gateway не видит process name на клиентском ПК, поэтому правила для игр задаются через домены, suffix, IP CIDR и порты.
|
|
||||||
|
Proxy по умолчанию разрешён только из приватных сетей. Диапазоны задаются через `PROXY_ALLOWED_CIDRS`.
|
||||||
|
|
||||||
|
## macOS client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-macos-client.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
По умолчанию интерфейс доступен на `http://127.0.0.1:3456`, локальный HTTP/SOCKS5 proxy — на `127.0.0.1:8082`.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
docker compose -f docker-compose.gateway.yml config
|
||||||
|
docker compose -f docker-compose.client.yml config
|
||||||
|
```
|
||||||
|
|||||||
42
docker-compose.client.yml
Normal file
42
docker-compose.client.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
services:
|
||||||
|
vpn-proxy-client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.client
|
||||||
|
args:
|
||||||
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||||
|
container_name: vpn-proxy-client
|
||||||
|
environment:
|
||||||
|
APP_MODE: client
|
||||||
|
PORT: ${PORT:-3456}
|
||||||
|
PROXY_PORT: ${CLIENT_PROXY_PORT:-8082}
|
||||||
|
PROXY_BIND_IP: 0.0.0.0
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
HTTPS_PROXY: ""
|
||||||
|
ALL_PROXY: ""
|
||||||
|
http_proxy: ""
|
||||||
|
https_proxy: ""
|
||||||
|
all_proxy: ""
|
||||||
|
NO_PROXY: "localhost,127.0.0.1,host.docker.internal"
|
||||||
|
no_proxy: "localhost,127.0.0.1,host.docker.internal"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
|
||||||
|
- "127.0.0.1:${CLIENT_PROXY_PORT:-8082}:${CLIENT_PROXY_PORT:-8082}"
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-client-cache:/var/lib/sing-box
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vpn-proxy-client-data:
|
||||||
|
sing-box-client-cache:
|
||||||
@@ -3,21 +3,32 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim}
|
||||||
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||||
|
INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
|
||||||
|
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
|
||||||
container_name: vpn-proxy-gateway
|
container_name: vpn-proxy-gateway
|
||||||
network_mode: host
|
network_mode: host
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- path: .env
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /etc/sing-box/config.json
|
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
volumes:
|
volumes:
|
||||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
- sing-box-cache:/var/lib/sing-box
|
- sing-box-cache:/var/lib/sing-box
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
vpn-proxy-data:
|
vpn-proxy-data:
|
||||||
|
|||||||
22
docker-compose.server.yml
Normal file
22
docker-compose.server.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
vpn-proxy-gateway:
|
||||||
|
image: ${GATEWAY_IMAGE}
|
||||||
|
container_name: vpn-proxy-gateway
|
||||||
|
network_mode: host
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-cache:/var/lib/sing-box
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vpn-proxy-data:
|
||||||
|
sing-box-cache:
|
||||||
105
docs/roadmap.md
105
docs/roadmap.md
@@ -1,105 +0,0 @@
|
|||||||
# Roadmap: VPN Proxy rebuild
|
|
||||||
|
|
||||||
## Целевая модель
|
|
||||||
|
|
||||||
Проект должен стать multi-mode системой вокруг `sing-box`:
|
|
||||||
|
|
||||||
| Режим | Назначение | Runtime | Статус |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
|
|
||||||
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
|
|
||||||
| `windows-gaming` | Windows для игр/Discord/Vesktop | native `sing-box.exe` + ProxiFyre | позже приводим в порядок |
|
|
||||||
|
|
||||||
## Gateway mode
|
|
||||||
|
|
||||||
Цель: контейнер, который становится прозрачным gateway для сети.
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- `sing-box` внутри контейнера.
|
|
||||||
- `network_mode: host`.
|
|
||||||
- `CAP_NET_ADMIN` и `CAP_NET_RAW`.
|
|
||||||
- TProxy inbound на `7895`.
|
|
||||||
- Mixed HTTP/SOCKS inbound на `8080`.
|
|
||||||
- Web UI на `3456`.
|
|
||||||
- Subscription URL вводится в UI, парсится, пользователь выбирает сервер.
|
|
||||||
- Пользовательские routing lists управляются из UI.
|
|
||||||
- Генерируется `/etc/sing-box/config.json`.
|
|
||||||
- `sing-box check` перед применением.
|
|
||||||
- Restart `sing-box` после применения.
|
|
||||||
- Idempotent iptables setup.
|
|
||||||
- Cleanup iptables/ip rule/ip route при остановке контейнера.
|
|
||||||
|
|
||||||
Маршрутизация v1:
|
|
||||||
|
|
||||||
- private IP ranges -> `direct`.
|
|
||||||
- пользовательские списки -> `direct`, `vpn` или `block`.
|
|
||||||
- `geoip-ru` -> `direct`.
|
|
||||||
- `geosite-category-ru` -> `direct`.
|
|
||||||
- все остальное -> выбранный VPN outbound.
|
|
||||||
|
|
||||||
Порядок правил:
|
|
||||||
|
|
||||||
1. safety private-direct, чтобы не ломать LAN.
|
|
||||||
2. custom routing lists из UI.
|
|
||||||
3. RU direct rules.
|
|
||||||
4. default VPN outbound.
|
|
||||||
|
|
||||||
Формат пользовательского списка:
|
|
||||||
|
|
||||||
- `name`.
|
|
||||||
- `enabled`.
|
|
||||||
- `outbound`: `direct`, `vpn`, `block`.
|
|
||||||
- `domains`: exact domains.
|
|
||||||
- `domainSuffixes`: доменные suffix, удобно для игр/сервисов.
|
|
||||||
- `domainKeywords`: keyword matching.
|
|
||||||
- `ipCidrs`: CIDR ranges.
|
|
||||||
- `ports`: TCP/UDP ports.
|
|
||||||
- `networks`: `tcp`, `udp`.
|
|
||||||
- UI должен автосохранять списки с debounce, чтобы polling state не затирал незавершенное редактирование.
|
|
||||||
|
|
||||||
Важно: gateway не видит process name на клиентском ПК. Для сценария вроде "League of Legends всегда direct" нужны домены, CIDR и порты Riot, а не имя процесса.
|
|
||||||
|
|
||||||
Отдельно решить позже:
|
|
||||||
|
|
||||||
- DNS strategy: DHCP DNS, DNS redirect или local DNS inbound.
|
|
||||||
- IPv6 TProxy.
|
|
||||||
- nftables backend.
|
|
||||||
- health checks и smoke diagnostics.
|
|
||||||
- secret storage через Infisical/Vault/env.
|
|
||||||
|
|
||||||
## Desktop proxy mode
|
|
||||||
|
|
||||||
Цель: сохранить удобный Docker-сценарий для Mac/Linux без TProxy.
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- UI на `3456`.
|
|
||||||
- Mixed inbound на `8080`.
|
|
||||||
- Subscription parser.
|
|
||||||
- Выбор сервера.
|
|
||||||
- Fallback proxy через `urltest`.
|
|
||||||
- Direct mode toggle.
|
|
||||||
- Не требует `NET_ADMIN`.
|
|
||||||
|
|
||||||
## Windows gaming mode
|
|
||||||
|
|
||||||
Цель: сохранить сценарий для Discord/Vesktop/игр.
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- Native `sing-box.exe`.
|
|
||||||
- Scheduled task или Windows service.
|
|
||||||
- ProxiFyre + WinPacketFilter для приложений, которые не умеют proxy.
|
|
||||||
- Управление из PowerShell helper.
|
|
||||||
- Позже можно сделать Electron/Tauri UI поверх privileged helper.
|
|
||||||
|
|
||||||
## Рабочий порядок
|
|
||||||
|
|
||||||
1. Сделать новый gateway root.
|
|
||||||
2. Реализовать Docker image + entrypoint TProxy lifecycle.
|
|
||||||
3. Реализовать маленький control-server.
|
|
||||||
4. Реализовать Vite + React UI для subscription -> server select -> apply.
|
|
||||||
5. Добавить gateway docs/install script.
|
|
||||||
6. Потом переносить desktop-proxy.
|
|
||||||
7. Потом приводить Windows mode к новой архитектуре.
|
|
||||||
21
entrypoint.client.sh
Executable file
21
entrypoint.client.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${PORT:-3456}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8082}"
|
||||||
|
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
|
||||||
|
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
|
||||||
|
SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[client-entrypoint] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$DATA_DIR" "$(dirname "$SING_BOX_CONFIG")" "$(dirname "$SING_BOX_CACHE")"
|
||||||
|
|
||||||
|
export APP_MODE=client
|
||||||
|
export PORT PROXY_PORT DATA_DIR SING_BOX_CONFIG SING_BOX_CACHE
|
||||||
|
export PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
|
||||||
|
log "starting VPN proxy client UI on :${PORT}, local proxy on :${PROXY_PORT}"
|
||||||
|
exec node /app/src/server/index.js
|
||||||
47
entrypoint.sh
Normal file → Executable file
47
entrypoint.sh
Normal file → Executable file
@@ -5,6 +5,11 @@ TPROXY_PORT="${TPROXY_PORT:-7895}"
|
|||||||
TPROXY_MARK="${TPROXY_MARK:-1}"
|
TPROXY_MARK="${TPROXY_MARK:-1}"
|
||||||
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
||||||
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||||
|
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||||
|
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
||||||
|
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
@@ -15,8 +20,14 @@ ipt() {
|
|||||||
iptables -w "$@"
|
iptables -w "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup_proxy_firewall() {
|
||||||
|
ipt -D INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -D INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -F "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -X "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
cleanup_tproxy() {
|
cleanup_tproxy() {
|
||||||
log "cleanup tproxy rules"
|
|
||||||
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
@@ -24,16 +35,41 @@ cleanup_tproxy() {
|
|||||||
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enable_ip_forwarding() {
|
||||||
|
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
|
||||||
|
printf '1' > /proc/sys/net/ipv4/ip_forward || true
|
||||||
|
elif command -v sysctl >/dev/null 2>&1; then
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_proxy_firewall() {
|
||||||
|
if [[ "$PROXY_FIREWALL" != "true" || "$PROXY_BIND_IP" == "127.0.0.1" || "$PROXY_BIND_IP" == "::1" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
ipt -N "$PROXY_INPUT_CHAIN"
|
||||||
|
for cidr in $PROXY_ALLOWED_CIDRS; do
|
||||||
|
ipt -A "$PROXY_INPUT_CHAIN" -s "$cidr" -j RETURN
|
||||||
|
done
|
||||||
|
ipt -A "$PROXY_INPUT_CHAIN" -j DROP
|
||||||
|
ipt -I INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
|
||||||
|
ipt -I INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
|
||||||
|
}
|
||||||
|
|
||||||
setup_tproxy() {
|
setup_tproxy() {
|
||||||
log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
|
log "setup tproxy on port ${TPROXY_PORT}"
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
|
enable_ip_forwarding
|
||||||
|
|
||||||
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
||||||
|
|
||||||
ipt -t mangle -N "$TPROXY_CHAIN"
|
ipt -t mangle -N "$TPROXY_CHAIN"
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
||||||
|
|
||||||
|
# Private/local destinations stay reachable; every intercepted public packet goes to VPN.
|
||||||
for cidr in $BYPASS_CIDRS; do
|
for cidr in $BYPASS_CIDRS; do
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
||||||
done
|
done
|
||||||
@@ -44,20 +80,21 @@ setup_tproxy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setup_tproxy
|
setup_tproxy
|
||||||
|
setup_proxy_firewall
|
||||||
|
|
||||||
node /app/src/server/index.js &
|
node /app/src/server/index.js &
|
||||||
APP_PID=$!
|
APP_PID=$!
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
log "shutdown requested"
|
|
||||||
kill "$APP_PID" 2>/dev/null || true
|
kill "$APP_PID" 2>/dev/null || true
|
||||||
wait "$APP_PID" 2>/dev/null || true
|
wait "$APP_PID" 2>/dev/null || true
|
||||||
|
cleanup_proxy_firewall
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
}
|
}
|
||||||
|
|
||||||
trap 'shutdown; exit 0' SIGTERM SIGINT
|
trap 'shutdown; exit 0' SIGTERM SIGINT
|
||||||
|
|
||||||
wait "$APP_PID"
|
wait "$APP_PID"
|
||||||
STATUS=$?
|
STATUS=$?
|
||||||
|
cleanup_proxy_firewall
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
exit "$STATUS"
|
exit "$STATUS"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>VPN Proxy Gateway</title>
|
<title>VPN</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1663
package-lock.json
generated
Normal file
1663
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"test": "node --test",
|
||||||
"start": "node src/server/index.js"
|
"start": "node src/server/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"vite": "^7.0.0",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
},
|
"vite": "^7.0.0"
|
||||||
"devDependencies": {}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
112
scripts/build-on-107-deploy-111.sh
Executable file
112
scripts/build-on-107-deploy-111.sh
Executable file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUILD_HOST="${BUILD_HOST:-107}"
|
||||||
|
DEPLOY_HOST="${DEPLOY_HOST:-111}"
|
||||||
|
BUILD_PATH="${BUILD_PATH:-/opt/vpn-proxy-build}"
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-vpn-proxy-gateway}"
|
||||||
|
GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo manual)"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
|
||||||
|
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
|
||||||
|
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
|
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||||
|
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
||||||
|
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
|
||||||
|
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
|
||||||
|
AUTO_BUILD_RUNTIME_BASE="${AUTO_BUILD_RUNTIME_BASE:-true}"
|
||||||
|
SSH_CONNECT_TIMEOUT="${SSH_CONNECT_TIMEOUT:-10}"
|
||||||
|
|
||||||
|
echo "Build host: ${BUILD_HOST}"
|
||||||
|
echo "Deploy host: ${DEPLOY_HOST}"
|
||||||
|
echo "Image: ${GATEWAY_IMAGE}"
|
||||||
|
echo "Base image: ${BASE_IMAGE}"
|
||||||
|
echo "Runtime base source: ${RUNTIME_BASE_SOURCE_IMAGE}"
|
||||||
|
|
||||||
|
ensure_known_host() {
|
||||||
|
local host="$1"
|
||||||
|
if [ "${host}" = "local" ]; then return 0; fi
|
||||||
|
local scan_host="${host#*@}"
|
||||||
|
scan_host="${scan_host%%:*}"
|
||||||
|
mkdir -p "${HOME}/.ssh"
|
||||||
|
chmod 700 "${HOME}/.ssh"
|
||||||
|
if ! ssh-keygen -F "${scan_host}" >/dev/null 2>&1; then
|
||||||
|
ssh-keyscan -H "${scan_host}" >> "${HOME}/.ssh/known_hosts"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_cmd() {
|
||||||
|
ssh \
|
||||||
|
-o BatchMode=yes \
|
||||||
|
-o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \
|
||||||
|
-o ServerAliveInterval=15 \
|
||||||
|
-o ServerAliveCountMax=4 \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Syncing source to ${BUILD_HOST}:${BUILD_PATH}"
|
||||||
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
BUILD_PATH="$(pwd)"
|
||||||
|
echo "Using local source at ${BUILD_PATH}"
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "mkdir -p '${BUILD_PATH}'"
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.vpn-proxy' \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude 'dist' \
|
||||||
|
./ "${BUILD_HOST}:${BUILD_PATH}/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building image on ${BUILD_HOST}"
|
||||||
|
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' ."
|
||||||
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
bash -lc "${BUILD_COMMAND}"
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "${BUILD_COMMAND}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Loading image into ${DEPLOY_HOST}"
|
||||||
|
if [ "${BUILD_HOST}" = "local" ] && [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
docker image inspect "${GATEWAY_IMAGE}" >/dev/null
|
||||||
|
elif [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
echo "Checking SSH access to ${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" "true"
|
||||||
|
echo "Transferring image to ${DEPLOY_HOST}"
|
||||||
|
docker save "${GATEWAY_IMAGE}" | ssh_cmd "${DEPLOY_HOST}" "docker load"
|
||||||
|
elif [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | docker load
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | ssh_cmd "${DEPLOY_HOST}" "docker load"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Copying deploy script to ${DEPLOY_HOST}:${DEPLOY_PATH}"
|
||||||
|
if [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
mkdir -p "${DEPLOY_PATH}"
|
||||||
|
cp scripts/deploy-gateway.sh "${DEPLOY_PATH}/deploy-gateway.sh"
|
||||||
|
else
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||||
|
rsync -az \
|
||||||
|
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||||
|
scripts/deploy-gateway.sh "${DEPLOY_HOST}:${DEPLOY_PATH}/deploy-gateway.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting gateway on ${DEPLOY_HOST}"
|
||||||
|
if [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
cd "${DEPLOY_PATH}"
|
||||||
|
chmod +x ./deploy-gateway.sh
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH}" GATEWAY_IMAGE="${GATEWAY_IMAGE}" PULL_IMAGE=false ./deploy-gateway.sh
|
||||||
|
else
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" \
|
||||||
|
"cd '${DEPLOY_PATH}' && chmod +x ./deploy-gateway.sh && DEPLOY_PATH='${DEPLOY_PATH}' GATEWAY_IMAGE='${GATEWAY_IMAGE}' PULL_IMAGE=false ./deploy-gateway.sh"
|
||||||
|
fi
|
||||||
33
scripts/build-runtime-base.sh
Executable file
33
scripts/build-runtime-base.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
|
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||||
|
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
|
||||||
|
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}"
|
||||||
|
HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
|
||||||
|
HTTPS_PROXY="${HTTPS_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTPS Proxy:/ {print $2; exit}')}"
|
||||||
|
NO_PROXY="${NO_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/No Proxy:/ {print $2; exit}')}"
|
||||||
|
|
||||||
|
echo "Building runtime base: ${RUNTIME_BASE_IMAGE}"
|
||||||
|
echo "Source base image: ${BASE_IMAGE}"
|
||||||
|
echo "APT mirror: ${APT_MIRROR}"
|
||||||
|
echo "APT security mirror: ${APT_SECURITY_MIRROR}"
|
||||||
|
if [ -n "${HTTP_PROXY}" ]; then echo "HTTP proxy: ${HTTP_PROXY}"; fi
|
||||||
|
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
||||||
|
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
|
||||||
|
--build-arg APT_MIRROR="${APT_MIRROR}" \
|
||||||
|
--build-arg APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR}" \
|
||||||
|
--build-arg HTTP_PROXY="${HTTP_PROXY}" \
|
||||||
|
--build-arg HTTPS_PROXY="${HTTPS_PROXY}" \
|
||||||
|
--build-arg NO_PROXY="${NO_PROXY}" \
|
||||||
|
--build-arg http_proxy="${HTTP_PROXY}" \
|
||||||
|
--build-arg https_proxy="${HTTPS_PROXY}" \
|
||||||
|
--build-arg no_proxy="${NO_PROXY}" \
|
||||||
|
-f Dockerfile.runtime-base \
|
||||||
|
-t "${RUNTIME_BASE_IMAGE}" \
|
||||||
|
.
|
||||||
75
scripts/deploy-gateway.sh
Normal file
75
scripts/deploy-gateway.sh
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
|
||||||
|
GATEWAY_IMAGE="${GATEWAY_IMAGE:?GATEWAY_IMAGE is required}"
|
||||||
|
PULL_IMAGE="${PULL_IMAGE:-true}"
|
||||||
|
|
||||||
|
echo "Preparing deploy directory: ${DEPLOY_PATH}"
|
||||||
|
mkdir -p "${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
cat > "${DEPLOY_PATH}/docker-compose.server.yml" <<EOF
|
||||||
|
services:
|
||||||
|
vpn-proxy-gateway:
|
||||||
|
image: ${GATEWAY_IMAGE}
|
||||||
|
container_name: vpn-proxy-gateway
|
||||||
|
network_mode: host
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-cache:/var/lib/sing-box
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:\${PORT:-3456}/api/state"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vpn-proxy-data:
|
||||||
|
sing-box-cache:
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
|
||||||
|
cat > "${DEPLOY_PATH}/.env" <<'EOF'
|
||||||
|
PORT=3456
|
||||||
|
PROXY_PORT=8080
|
||||||
|
PROXY_BIND_IP=0.0.0.0
|
||||||
|
TPROXY_PORT=7895
|
||||||
|
TPROXY_MARK=1
|
||||||
|
TPROXY_TABLE=100
|
||||||
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
|
TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_SRC_BYPASS
|
||||||
|
TPROXY_SOURCE_FORWARD_CHAIN=VPN_PROXY_FWD_BYPASS
|
||||||
|
TPROXY_SOURCE_NAT_CHAIN=VPN_PROXY_NAT_BYPASS
|
||||||
|
TPROXY_BYPASS_SOURCE_CIDRS=
|
||||||
|
ROUTING_RU_DIRECT=true
|
||||||
|
LOG_LEVEL=info
|
||||||
|
EOF
|
||||||
|
echo "Created default .env. Edit ${DEPLOY_PATH}/.env if this server needs different ports."
|
||||||
|
else
|
||||||
|
echo "Preserving existing .env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
echo "Pulling image: ${GATEWAY_IMAGE}"
|
||||||
|
if [ "${PULL_IMAGE}" = "true" ]; then
|
||||||
|
docker compose -f docker-compose.server.yml pull
|
||||||
|
else
|
||||||
|
echo "Skipping image pull; using local image ${GATEWAY_IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting gateway..."
|
||||||
|
docker compose -f docker-compose.server.yml up -d
|
||||||
|
|
||||||
|
echo "Current container:"
|
||||||
|
docker ps --filter "name=vpn-proxy-gateway"
|
||||||
281
scripts/install-macos-client.sh
Executable file
281
scripts/install-macos-client.sh
Executable file
@@ -0,0 +1,281 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
|
||||||
|
REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}"
|
||||||
|
BRANCH="${VPN_PROXY_BRANCH:-master}"
|
||||||
|
COMPOSE_FILE="docker-compose.client.yml"
|
||||||
|
DEFAULT_PROXY_PORT="8082"
|
||||||
|
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||||
|
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||||
|
CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[vpn-proxy-client] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf '[vpn-proxy-client] error: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
need() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_valid_port() {
|
||||||
|
case "$1" in
|
||||||
|
''|*[!0-9]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_proxy_port() {
|
||||||
|
local value=""
|
||||||
|
if [ -n "$REQUESTED_PROXY_PORT" ]; then
|
||||||
|
if ! is_valid_port "$REQUESTED_PROXY_PORT"; then
|
||||||
|
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$REQUESTED_PROXY_PORT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -r /dev/tty ]; then
|
||||||
|
while true; do
|
||||||
|
printf 'Proxy port for local apps [%s]: ' "$DEFAULT_PROXY_PORT" >/dev/tty
|
||||||
|
IFS= read -r value </dev/tty || value=""
|
||||||
|
value="${value:-$DEFAULT_PROXY_PORT}"
|
||||||
|
if is_valid_port "$value"; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'Enter a port from 1024 to 65535.\n' >/dev/tty
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_valid_port "$DEFAULT_PROXY_PORT"; then
|
||||||
|
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$DEFAULT_PROXY_PORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
published_port_conflicts() {
|
||||||
|
local port="$1"
|
||||||
|
local line
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
case "$line" in
|
||||||
|
"${CLIENT_CONTAINER_NAME}"$'\t'*) ;;
|
||||||
|
*) printf '%s\n' "$line" ;;
|
||||||
|
esac
|
||||||
|
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_port_conflicts() {
|
||||||
|
published_port_conflicts "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_proxy_port_available() {
|
||||||
|
local port="$1"
|
||||||
|
local conflicts
|
||||||
|
|
||||||
|
conflicts="$(proxy_port_conflicts "$port")"
|
||||||
|
if [ -z "$conflicts" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[vpn-proxy-client] proxy port %s is already used:\n%s\n' \
|
||||||
|
"$port" "$conflicts" >&2
|
||||||
|
die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_single_port_available() {
|
||||||
|
local label="$1"
|
||||||
|
local port="$2"
|
||||||
|
local conflicts
|
||||||
|
|
||||||
|
conflicts="$(published_port_conflicts "$port")"
|
||||||
|
if [ -z "$conflicts" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[vpn-proxy-client] %s port %s is already used:\n%s\n' \
|
||||||
|
"$label" "$port" "$conflicts" >&2
|
||||||
|
die "choose another ${label} port or stop the conflicting container"
|
||||||
|
}
|
||||||
|
|
||||||
|
first_free_port() {
|
||||||
|
local start="$1"
|
||||||
|
local port
|
||||||
|
|
||||||
|
for port in $(seq "$start" 65535); do
|
||||||
|
if [ -z "$(published_port_conflicts "$port")" ]; then
|
||||||
|
printf '%s\n' "$port"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_ui_port() {
|
||||||
|
local value="$1"
|
||||||
|
local suggested
|
||||||
|
|
||||||
|
if ! is_valid_port "$value"; then
|
||||||
|
die "CLIENT_UI_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(published_port_conflicts "$value")" ]; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$REQUESTED_UI_PORT" ] || [ ! -r /dev/tty ]; then
|
||||||
|
assert_single_port_available "UI" "$value"
|
||||||
|
fi
|
||||||
|
|
||||||
|
suggested="$(first_free_port "$((value + 1))" || true)"
|
||||||
|
suggested="${suggested:-3457}"
|
||||||
|
while true; do
|
||||||
|
printf 'UI port %s is busy. Choose UI port [%s]: ' "$value" "$suggested" >/dev/tty
|
||||||
|
IFS= read -r value </dev/tty || value=""
|
||||||
|
value="${value:-$suggested}"
|
||||||
|
if is_valid_port "$value" && [ -z "$(published_port_conflicts "$value")" ]; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'Enter a free port from 1024 to 65535.\n' >/dev/tty
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_ui_outside_proxy_range() {
|
||||||
|
if [ "$UI_PORT" = "$PROXY_PORT" ]; then
|
||||||
|
die "UI port ${UI_PORT} overlaps proxy port"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_client_ui() {
|
||||||
|
local ui_port="${UI_PORT:-3456}"
|
||||||
|
local ui_url="http://127.0.0.1:${ui_port}/api/state"
|
||||||
|
local attempt
|
||||||
|
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if curl --noproxy "*" -fsS "$ui_url" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '\n[vpn-proxy-client] client did not become ready at %s\n' "$ui_url" >&2
|
||||||
|
printf '[vpn-proxy-client] docker compose status:\n' >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps >&2 || true
|
||||||
|
printf '\n[vpn-proxy-client] recent service logs:\n' >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs --tail=120 vpn-proxy-client >&2 || true
|
||||||
|
die "client UI is not ready; see Docker status and logs above"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
|
||||||
|
if [ -f .env ] && grep -q "^${key}=" .env; then
|
||||||
|
awk -v key="$key" -v value="$value" '
|
||||||
|
BEGIN { prefix = key "=" }
|
||||||
|
index($0, prefix) == 1 { print key "=" value; next }
|
||||||
|
{ print }
|
||||||
|
' .env > "$tmp"
|
||||||
|
else
|
||||||
|
[ -f .env ] && cat .env > "$tmp"
|
||||||
|
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$tmp" .env
|
||||||
|
}
|
||||||
|
|
||||||
|
get_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
[ -f .env ] || return 0
|
||||||
|
awk -v key="$key" '
|
||||||
|
BEGIN { prefix = key "=" }
|
||||||
|
index($0, prefix) == 1 { print substr($0, length(prefix) + 1); exit }
|
||||||
|
' .env
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||||
|
die "this installer is intended for macOS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
need git
|
||||||
|
need docker
|
||||||
|
need curl
|
||||||
|
|
||||||
|
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
||||||
|
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
||||||
|
|
||||||
|
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
log "updating $INSTALL_DIR"
|
||||||
|
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" checkout "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH"
|
||||||
|
else
|
||||||
|
log "cloning $REPO_URL#$BRANCH to $INSTALL_DIR"
|
||||||
|
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||||
|
git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f .env && -f .env.example ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROXY_PORT="$(ask_proxy_port)"
|
||||||
|
assert_proxy_port_available "$PROXY_PORT"
|
||||||
|
UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}"
|
||||||
|
UI_PORT="${UI_PORT:-3456}"
|
||||||
|
UI_PORT="$(choose_ui_port "$UI_PORT")"
|
||||||
|
assert_ui_outside_proxy_range
|
||||||
|
|
||||||
|
set_env_value APP_MODE client
|
||||||
|
set_env_value CLIENT_UI_PORT "$UI_PORT"
|
||||||
|
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
|
||||||
|
set_env_value PROXY_PORT "$PROXY_PORT"
|
||||||
|
|
||||||
|
log "UI port: http://127.0.0.1:${UI_PORT}"
|
||||||
|
log "proxy port: 127.0.0.1:${PROXY_PORT}"
|
||||||
|
|
||||||
|
log "building and starting Docker client"
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
|
wait_for_client_ui
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
VPN Proxy Client is running.
|
||||||
|
|
||||||
|
UI:
|
||||||
|
http://127.0.0.1:${UI_PORT}
|
||||||
|
|
||||||
|
Proxy:
|
||||||
|
HTTP/SOCKS5 127.0.0.1:${PROXY_PORT}
|
||||||
|
This is the only Docker-published proxy port. Re-run the installer with VPN_PROXY_CLIENT_PORT=<port> to change it.
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
cd ~/.vpn-proxy-client
|
||||||
|
docker compose -f docker-compose.client.yml logs -f
|
||||||
|
docker compose -f docker-compose.client.yml restart
|
||||||
|
docker compose -f docker-compose.client.yml down
|
||||||
|
|
||||||
|
Optional macOS system proxy example:
|
||||||
|
networksetup -setwebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
|
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
|
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
|
|
||||||
|
Disable later:
|
||||||
|
networksetup -setwebproxystate Wi-Fi off
|
||||||
|
networksetup -setsecurewebproxystate Wi-Fi off
|
||||||
|
networksetup -setsocksfirewallproxystate Wi-Fi off
|
||||||
|
|
||||||
|
EOF
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR || path.resolve('.vpn-proxy');
|
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||||
|
const parsePort = (value, fallback) => {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isInteger(parsed) ? parsed : fallback;
|
||||||
|
};
|
||||||
|
const proxyPort = parsePort(
|
||||||
|
process.env.PROXY_PORT,
|
||||||
|
process.env.APP_MODE === "client" ? 8082 : 8080,
|
||||||
|
);
|
||||||
|
|
||||||
export const settings = {
|
export const settings = {
|
||||||
port: Number(process.env.PORT || 3456),
|
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||||
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
port: parsePort(process.env.PORT, 3456),
|
||||||
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
proxyPort,
|
||||||
bindIp: process.env.PROXY_BIND_IP || '0.0.0.0',
|
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||||
|
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
||||||
dataDir,
|
dataDir,
|
||||||
distDir: process.env.DIST_DIR || '/app/dist',
|
distDir: process.env.DIST_DIR || "/app/dist",
|
||||||
configPath: process.env.SING_BOX_CONFIG || '/etc/sing-box/config.json',
|
configPath:
|
||||||
cachePath: process.env.SING_BOX_CACHE || '/var/lib/sing-box/cache.db',
|
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
||||||
statePath: path.join(dataDir, 'state.json'),
|
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||||
customRulesPath: path.join(dataDir, 'custom-rules.json'),
|
statePath: path.join(dataDir, "state.json"),
|
||||||
subscriptionCachePath: path.join(dataDir, 'subscription-cache.json'),
|
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||||
hwidPath: path.join(dataDir, 'hwid'),
|
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || 'true') !== 'false',
|
hwidPath: path.join(dataDir, "hwid"),
|
||||||
logLevel: process.env.LOG_LEVEL || 'info',
|
logLevel: process.env.LOG_LEVEL || "info",
|
||||||
appName: 'VPN Proxy Gateway',
|
appName: "VPN Proxy Gateway",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import http from 'node:http';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { settings } from './config.js';
|
import { settings } from './config.js';
|
||||||
import { fetchSubscription } from './subscription.js';
|
import { tcpPing } from './ping.js';
|
||||||
import { buildGatewayConfig, writeSingboxConfig } from './singbox.js';
|
import { buildSharedProxyInfo } from './sharedProxy.js';
|
||||||
|
import {
|
||||||
|
buildGatewayConfig,
|
||||||
|
removeSingboxConfig,
|
||||||
|
writeSingboxConfig,
|
||||||
|
} from './singbox.js';
|
||||||
|
import { fetchSubscription, fetchSubscriptionInfo } from './subscription.js';
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 1_000_000;
|
||||||
|
|
||||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
|
||||||
@@ -13,8 +21,9 @@ let singboxStartedAt = null;
|
|||||||
|
|
||||||
function readJson(filePath, fallback) {
|
function readJson(filePath, fallback) {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(filePath)) return fallback;
|
return fs.existsSync(filePath)
|
||||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
? JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||||
|
: fallback;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -26,35 +35,54 @@ function writeJson(filePath, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendJson(res, statusCode, payload) {
|
function sendJson(res, statusCode, payload) {
|
||||||
const body = JSON.stringify(payload, null, 2);
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
res.writeHead(statusCode, {
|
res.end(JSON.stringify(payload));
|
||||||
'content-type': 'application/json; charset=utf-8',
|
|
||||||
'content-length': Buffer.byteLength(body),
|
|
||||||
});
|
|
||||||
res.end(body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBody(req) {
|
function readBody(req) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
req.on('data', (chunk) => chunks.push(chunk));
|
let size = 0;
|
||||||
|
let tooLarge = false;
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BODY_BYTES) {
|
||||||
|
tooLarge = true;
|
||||||
|
const error = new Error('Тело запроса слишком большое');
|
||||||
|
error.statusCode = 413;
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
|
if (tooLarge) return;
|
||||||
if (!chunks.length) return resolve({});
|
if (!chunks.length) return resolve({});
|
||||||
try {
|
try {
|
||||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||||
} catch {
|
} catch {
|
||||||
reject(new Error('Invalid JSON body'));
|
const error = new Error('Невалидный JSON в теле запроса');
|
||||||
|
error.statusCode = 400;
|
||||||
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subscriptionHost(url) {
|
||||||
|
try {
|
||||||
|
return `${new URL(url).host}/…`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function checkSingboxConfig() {
|
function checkSingboxConfig() {
|
||||||
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], {
|
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
throw new Error((result.stderr || result.stdout || 'sing-box check failed').trim());
|
throw new Error((result.stderr || result.stdout || 'sing-box check failed').trim());
|
||||||
}
|
}
|
||||||
@@ -62,11 +90,14 @@ function checkSingboxConfig() {
|
|||||||
|
|
||||||
function stopSingbox() {
|
function stopSingbox() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!singboxProcess) return resolve();
|
if (!singboxProcess) {
|
||||||
|
singboxStartedAt = null;
|
||||||
|
return resolve();
|
||||||
|
}
|
||||||
|
|
||||||
const current = singboxProcess;
|
const current = singboxProcess;
|
||||||
singboxProcess = null;
|
singboxProcess = null;
|
||||||
|
singboxStartedAt = null;
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
current.kill('SIGKILL');
|
current.kill('SIGKILL');
|
||||||
resolve();
|
resolve();
|
||||||
@@ -76,71 +107,70 @@ function stopSingbox() {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
current.kill('SIGTERM');
|
current.kill('SIGTERM');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startSingbox() {
|
async function startSingbox() {
|
||||||
if (!fs.existsSync(settings.configPath)) return false;
|
if (!fs.existsSync(settings.configPath)) return false;
|
||||||
|
|
||||||
checkSingboxConfig();
|
checkSingboxConfig();
|
||||||
await stopSingbox();
|
await stopSingbox();
|
||||||
|
|
||||||
singboxProcess = spawn('sing-box', ['run', '-c', settings.configPath], {
|
const child = spawn('sing-box', ['run', '-c', settings.configPath], {
|
||||||
stdio: 'inherit',
|
stdio: ['ignore', 'inherit', 'inherit'],
|
||||||
});
|
});
|
||||||
|
singboxProcess = child;
|
||||||
singboxStartedAt = new Date().toISOString();
|
singboxStartedAt = new Date().toISOString();
|
||||||
|
child.once('exit', () => {
|
||||||
singboxProcess.once('exit', (code, signal) => {
|
if (singboxProcess === child) {
|
||||||
console.log(`[control] sing-box exited: code=${code} signal=${signal}`);
|
singboxProcess = null;
|
||||||
if (singboxProcess?.exitCode === code) singboxProcess = null;
|
singboxStartedAt = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicState() {
|
function publicState() {
|
||||||
const state = readJson(settings.statePath, {});
|
const state = readJson(settings.statePath, {});
|
||||||
const customRules = readJson(settings.customRulesPath, []);
|
|
||||||
return {
|
return {
|
||||||
mode: 'gateway',
|
mode: settings.appMode,
|
||||||
port: settings.port,
|
port: settings.port,
|
||||||
proxyPort: settings.proxyPort,
|
proxyPort: settings.proxyPort,
|
||||||
tproxyPort: settings.tproxyPort,
|
|
||||||
routingRuDirect: settings.routingRuDirect,
|
|
||||||
configExists: fs.existsSync(settings.configPath),
|
configExists: fs.existsSync(settings.configPath),
|
||||||
singboxRunning: Boolean(singboxProcess),
|
singboxRunning: Boolean(singboxProcess),
|
||||||
singboxStartedAt,
|
singboxStartedAt,
|
||||||
customRules,
|
subscriptionHost: subscriptionHost(state.subscriptionUrl),
|
||||||
...state,
|
hasSubscription: Boolean(state.subscriptionUrl),
|
||||||
|
selectedTag: state.selectedTag || '',
|
||||||
|
userInfo: state.userInfo || {},
|
||||||
|
fetchedAt: state.fetchedAt || null,
|
||||||
|
servers: (state.servers || []).map((server) => ({
|
||||||
|
...server,
|
||||||
|
tag: String(server.tag || '').trim(),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeList(value) {
|
async function applySelectedServer(selectedTag) {
|
||||||
if (Array.isArray(value)) {
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
return value.map((item) => String(item || '').trim()).filter(Boolean);
|
if (!cached?.config) throw new Error('Сначала загрузите подписку');
|
||||||
}
|
|
||||||
return String(value || '')
|
|
||||||
.split(/\r?\n|,/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCustomRules(input) {
|
const previousConfig = fs.existsSync(settings.configPath)
|
||||||
const rules = Array.isArray(input) ? input : [];
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
return rules.map((rule, index) => ({
|
: null;
|
||||||
id: String(rule.id || `rule-${Date.now()}-${index}`),
|
writeSingboxConfig(buildGatewayConfig(cached.config, selectedTag));
|
||||||
name: String(rule.name || `Rule ${index + 1}`).trim(),
|
try {
|
||||||
enabled: rule.enabled !== false,
|
await startSingbox();
|
||||||
outbound: ['direct', 'vpn', 'block'].includes(rule.outbound) ? rule.outbound : 'direct',
|
} catch (error) {
|
||||||
domains: normalizeList(rule.domains),
|
if (previousConfig === null) removeSingboxConfig();
|
||||||
domainSuffixes: normalizeList(rule.domainSuffixes),
|
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
|
||||||
domainKeywords: normalizeList(rule.domainKeywords),
|
throw error;
|
||||||
ipCidrs: normalizeList(rule.ipCidrs),
|
}
|
||||||
ports: normalizeList(rule.ports),
|
writeJson(settings.statePath, {
|
||||||
networks: normalizeList(rule.networks).filter((network) => ['tcp', 'udp'].includes(network)),
|
...readJson(settings.statePath, {}),
|
||||||
}));
|
selectedTag,
|
||||||
|
appliedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleApi(req, res) {
|
async function handleApi(req, res) {
|
||||||
@@ -148,71 +178,84 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, publicState());
|
return sendJson(res, 200, publicState());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && req.url === '/api/rules') {
|
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, buildSharedProxyInfo({
|
||||||
success: true,
|
appMode: settings.appMode,
|
||||||
rules: readJson(settings.customRulesPath, []),
|
proxyPort: settings.proxyPort,
|
||||||
});
|
running: Boolean(singboxProcess),
|
||||||
|
hostHeader: req.headers.host,
|
||||||
|
sharedProxyHost: settings.sharedProxyHost,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'PUT' && req.url === '/api/rules') {
|
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
|
||||||
const body = await readBody(req);
|
const state = readJson(settings.statePath, {});
|
||||||
const rules = normalizeCustomRules(body.rules);
|
const results = await Promise.all((state.servers || []).map(async (server) => ({
|
||||||
writeJson(settings.customRulesPath, rules);
|
tag: String(server.tag || '').trim(),
|
||||||
return sendJson(res, 200, { success: true, rules });
|
...await tcpPing(server.server, server.server_port),
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
})));
|
||||||
|
return sendJson(res, 200, { success: true, results });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||||
const body = await readBody(req);
|
const { url = '' } = await readBody(req);
|
||||||
const url = String(body.url || '').trim();
|
const normalizedUrl = String(url).trim();
|
||||||
if (!url) return sendJson(res, 400, { success: false, error: 'Subscription URL is required' });
|
const parsed = await fetchSubscription(normalizedUrl);
|
||||||
|
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
|
||||||
const parsed = await fetchSubscription(url);
|
|
||||||
writeJson(settings.subscriptionCachePath, { url, ...parsed });
|
|
||||||
|
|
||||||
const prevState = readJson(settings.statePath, {});
|
|
||||||
writeJson(settings.statePath, {
|
writeJson(settings.statePath, {
|
||||||
...prevState,
|
subscriptionUrl: normalizedUrl,
|
||||||
subscriptionUrl: url,
|
|
||||||
servers: parsed.servers,
|
servers: parsed.servers,
|
||||||
userInfo: parsed.userInfo,
|
userInfo: parsed.userInfo,
|
||||||
fetchedAt: parsed.fetchedAt,
|
fetchedAt: parsed.fetchedAt,
|
||||||
});
|
});
|
||||||
|
await stopSingbox();
|
||||||
|
removeSingboxConfig();
|
||||||
return sendJson(res, 200, { success: true, ...parsed });
|
return sendJson(res, 200, { success: true, ...parsed });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/apply') {
|
if (req.method === 'POST' && req.url === '/api/subscription/refresh-info') {
|
||||||
const body = await readBody(req);
|
const state = readJson(settings.statePath, {});
|
||||||
const selectedTag = String(body.selectedTag || '').trim();
|
if (!state.subscriptionUrl) {
|
||||||
if (!selectedTag) return sendJson(res, 400, { success: false, error: 'selectedTag is required' });
|
return sendJson(res, 400, { success: false, error: 'Подписка не настроена' });
|
||||||
|
}
|
||||||
|
const info = await fetchSubscriptionInfo(state.subscriptionUrl);
|
||||||
|
writeJson(settings.statePath, { ...state, ...info });
|
||||||
const cached = readJson(settings.subscriptionCachePath, null);
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
if (!cached?.config) {
|
if (cached) writeJson(settings.subscriptionCachePath, { ...cached, ...info });
|
||||||
return sendJson(res, 400, { success: false, error: 'Fetch subscription before applying a server' });
|
return sendJson(res, 200, { success: true, ...info });
|
||||||
}
|
}
|
||||||
|
|
||||||
const customRules = readJson(settings.customRulesPath, []);
|
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||||
const generated = buildGatewayConfig({ ...cached.config, customRules }, selectedTag);
|
await stopSingbox();
|
||||||
writeSingboxConfig(generated);
|
removeSingboxConfig();
|
||||||
|
fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||||
|
writeJson(settings.statePath, {});
|
||||||
|
return sendJson(res, 200, { success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/apply') {
|
||||||
|
const { selectedTag = '' } = await readBody(req);
|
||||||
|
const tag = String(selectedTag).trim();
|
||||||
|
if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' });
|
||||||
|
await applySelectedServer(tag);
|
||||||
|
return sendJson(res, 200, { success: true, selectedTag: tag });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||||
|
await stopSingbox();
|
||||||
|
return sendJson(res, 200, { success: true, singboxRunning: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||||
|
if (!fs.existsSync(settings.configPath)) {
|
||||||
|
return sendJson(res, 400, { success: false, error: 'Сначала выберите сервер' });
|
||||||
|
}
|
||||||
await startSingbox();
|
await startSingbox();
|
||||||
|
return sendJson(res, 200, { success: true, singboxRunning: true });
|
||||||
const prevState = readJson(settings.statePath, {});
|
|
||||||
writeJson(settings.statePath, {
|
|
||||||
...prevState,
|
|
||||||
selectedTag,
|
|
||||||
appliedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return sendJson(res, 200, {
|
|
||||||
success: true,
|
|
||||||
selectedTag,
|
|
||||||
configPath: settings.configPath,
|
|
||||||
singboxRunning: Boolean(singboxProcess),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendJson(res, 404, { success: false, error: 'Not found' });
|
return sendJson(res, 404, { success: false, error: 'Не найдено' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const mime = {
|
const mime = {
|
||||||
@@ -224,51 +267,50 @@ const mime = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function serveStatic(req, res) {
|
function serveStatic(req, res) {
|
||||||
const requestPath = new URL(req.url, `http://localhost:${settings.port}`).pathname;
|
const pathname = new URL(req.url, `http://localhost:${settings.port}`).pathname;
|
||||||
const cleanPath = requestPath === '/' ? '/index.html' : requestPath;
|
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||||
const filePath = path.resolve(settings.distDir, `.${cleanPath}`);
|
const filePath = path.resolve(settings.distDir, requested);
|
||||||
const distRoot = path.resolve(settings.distDir);
|
const relative = path.relative(path.resolve(settings.distDir), filePath);
|
||||||
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
if (!filePath.startsWith(distRoot)) {
|
|
||||||
res.writeHead(403);
|
res.writeHead(403);
|
||||||
return res.end('Forbidden');
|
return res.end('Forbidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||||
? filePath
|
? filePath
|
||||||
: path.join(settings.distDir, 'index.html');
|
: path.join(settings.distDir, 'index.html');
|
||||||
|
res.writeHead(200, { 'content-type': mime[path.extname(finalPath)] || 'application/octet-stream' });
|
||||||
const ext = path.extname(finalPath);
|
|
||||||
res.writeHead(200, { 'content-type': mime[ext] || 'application/octet-stream' });
|
|
||||||
fs.createReadStream(finalPath).pipe(res);
|
fs.createReadStream(finalPath).pipe(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.url?.startsWith('/api/')) {
|
return req.url?.startsWith('/api/')
|
||||||
return await handleApi(req, res);
|
? await handleApi(req, res)
|
||||||
}
|
: serveStatic(req, res);
|
||||||
return serveStatic(req, res);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[control] request failed', error);
|
console.error('[control] request failed', error);
|
||||||
return sendJson(res, 500, { success: false, error: error.message || String(error) });
|
return sendJson(res, error.statusCode || 500, {
|
||||||
|
success: false,
|
||||||
|
error: error.message || String(error),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGTERM', async () => {
|
async function shutdown() {
|
||||||
await stopSingbox();
|
await stopSingbox();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
}
|
||||||
|
|
||||||
process.on('SIGINT', async () => {
|
process.on('SIGTERM', shutdown);
|
||||||
await stopSingbox();
|
process.on('SIGINT', shutdown);
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await startSingbox().catch((error) => {
|
const state = readJson(settings.statePath, {});
|
||||||
console.warn(`[control] sing-box was not started: ${error.message}`);
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
});
|
if (!fs.existsSync(settings.configPath) && state.selectedTag && cached?.config) {
|
||||||
|
writeSingboxConfig(buildGatewayConfig(cached.config, state.selectedTag));
|
||||||
|
}
|
||||||
|
await startSingbox().catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
||||||
|
|
||||||
server.listen(settings.port, '0.0.0.0', () => {
|
server.listen(settings.port, '0.0.0.0', () => {
|
||||||
console.log(`[control] gateway UI listening on :${settings.port}`);
|
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
50
src/server/ping.js
Normal file
50
src/server/ping.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// TCP-пинг: меряем время до открытия TCP-соединения с хостом:портом.
|
||||||
|
// Это не ICMP-ping, но для VPN-серверов точнее (проверяем именно тот порт, куда подключается клиент).
|
||||||
|
|
||||||
|
import net from "node:net";
|
||||||
|
import dns from "node:dns/promises";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = 3000;
|
||||||
|
|
||||||
|
export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
|
||||||
|
const start = Date.now();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
const finish = (result) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setTimeout(timeout);
|
||||||
|
socket.once("connect", () =>
|
||||||
|
finish({ ok: true, latency: Date.now() - start }),
|
||||||
|
);
|
||||||
|
socket.once("timeout", () =>
|
||||||
|
finish({ ok: false, latency: null, error: "timeout" }),
|
||||||
|
);
|
||||||
|
socket.once("error", (err) =>
|
||||||
|
finish({ ok: false, latency: null, error: err.code || err.message }),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.connect(port, host);
|
||||||
|
} catch (err) {
|
||||||
|
finish({ ok: false, latency: null, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveHost(host) {
|
||||||
|
if (net.isIP(host)) return host;
|
||||||
|
try {
|
||||||
|
const result = await dns.lookup(host);
|
||||||
|
return result.address;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/server/sharedProxy.js
Normal file
94
src/server/sharedProxy.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
function normalizeControlUrl(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
|
||||||
|
const url = new URL(withProtocol);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) {
|
||||||
|
throw new Error("Gateway URL must use http or https");
|
||||||
|
}
|
||||||
|
url.hash = "";
|
||||||
|
url.search = "";
|
||||||
|
url.pathname = url.pathname.replace(/\/api\/shared-proxy\/?$/, "") || "/";
|
||||||
|
return url.toString().replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyHostFromHeader(hostHeader) {
|
||||||
|
const raw = String(hostHeader || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
if (raw.startsWith("[")) {
|
||||||
|
const end = raw.indexOf("]");
|
||||||
|
return end > 0 ? raw.slice(1, end) : "";
|
||||||
|
}
|
||||||
|
return raw.split(":")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyInfo(proxy) {
|
||||||
|
if (!proxy || typeof proxy !== "object") return null;
|
||||||
|
const host = String(proxy.host || "").trim();
|
||||||
|
const port = Number.parseInt(proxy.port, 10);
|
||||||
|
const protocol = proxy.protocol === "http" ? "http" : "socks5";
|
||||||
|
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { host, port, protocol };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSharedProxyInfo({
|
||||||
|
appMode,
|
||||||
|
proxyPort,
|
||||||
|
running,
|
||||||
|
hostHeader,
|
||||||
|
sharedProxyHost,
|
||||||
|
}) {
|
||||||
|
const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader);
|
||||||
|
const port = Number.parseInt(proxyPort, 10);
|
||||||
|
const available =
|
||||||
|
appMode === "gateway" &&
|
||||||
|
Boolean(running) &&
|
||||||
|
host &&
|
||||||
|
Number.isInteger(port) &&
|
||||||
|
port > 0 &&
|
||||||
|
port <= 65535;
|
||||||
|
|
||||||
|
const proxy = available
|
||||||
|
? {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
protocol: "socks5",
|
||||||
|
httpUrl: `http://${host}:${port}`,
|
||||||
|
socksUrl: `socks5://${host}:${port}`,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
available,
|
||||||
|
mode: appMode,
|
||||||
|
proxy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkSharedProxyGateway(controlUrl, fetchImpl = fetch) {
|
||||||
|
const baseUrl = normalizeControlUrl(controlUrl);
|
||||||
|
const response = await fetchImpl(`${baseUrl}/api/shared-proxy`, {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || data.success === false) {
|
||||||
|
throw new Error(data.error || `Gateway returned ${response.status}`);
|
||||||
|
}
|
||||||
|
if (!data.available) {
|
||||||
|
throw new Error("Gateway shared proxy is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedProxy = normalizeProxyInfo(data.proxy);
|
||||||
|
if (!sharedProxy) {
|
||||||
|
throw new Error("Gateway returned invalid shared proxy settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sharedProxyEnabled: true,
|
||||||
|
sharedProxyControlUrl: baseUrl,
|
||||||
|
sharedProxy,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,168 +3,70 @@ import path from 'node:path';
|
|||||||
import { settings } from './config.js';
|
import { settings } from './config.js';
|
||||||
|
|
||||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||||
const CUSTOM_OUTBOUNDS = new Set(['direct', 'vpn', 'block']);
|
const MIXED_INBOUND = 'mixed-in';
|
||||||
|
const TPROXY_INBOUND = 'tproxy-in';
|
||||||
function clone(value) {
|
|
||||||
return JSON.parse(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findOutbound(subscriptionConfig, selectedTag) {
|
function findOutbound(subscriptionConfig, selectedTag) {
|
||||||
const outbounds = Array.isArray(subscriptionConfig?.outbounds) ? subscriptionConfig.outbounds : [];
|
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
|
||||||
return outbounds.find((outbound) => outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type));
|
? subscriptionConfig.outbounds
|
||||||
}
|
: [];
|
||||||
|
const tag = String(selectedTag || '').trim();
|
||||||
function ruleSets() {
|
return outbounds.find((outbound) => (
|
||||||
if (!settings.routingRuDirect) return [];
|
String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(outbound.type)
|
||||||
|
));
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: 'remote',
|
|
||||||
tag: 'geoip-ru',
|
|
||||||
format: 'binary',
|
|
||||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs',
|
|
||||||
download_detour: 'direct',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'remote',
|
|
||||||
tag: 'geosite-category-ru',
|
|
||||||
format: 'binary',
|
|
||||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs',
|
|
||||||
download_detour: 'direct',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueClean(values) {
|
|
||||||
return Array.from(
|
|
||||||
new Set(
|
|
||||||
(Array.isArray(values) ? values : [])
|
|
||||||
.map((value) => String(value || '').trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePorts(values) {
|
|
||||||
return uniqueClean(values)
|
|
||||||
.map((value) => Number.parseInt(value, 10))
|
|
||||||
.filter((value) => Number.isInteger(value) && value > 0 && value <= 65535);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSingboxRule(customRule, vpnTag) {
|
|
||||||
if (!customRule?.enabled) return null;
|
|
||||||
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
|
|
||||||
|
|
||||||
const rule = {};
|
|
||||||
const domains = uniqueClean(customRule.domains);
|
|
||||||
const domainSuffixes = uniqueClean(customRule.domainSuffixes);
|
|
||||||
const domainKeywords = uniqueClean(customRule.domainKeywords);
|
|
||||||
const ipCidrs = uniqueClean(customRule.ipCidrs);
|
|
||||||
const ports = parsePorts(customRule.ports);
|
|
||||||
const networks = uniqueClean(customRule.networks).filter((network) => ['tcp', 'udp'].includes(network));
|
|
||||||
|
|
||||||
if (domains.length) rule.domain = domains;
|
|
||||||
if (domainSuffixes.length) rule.domain_suffix = domainSuffixes;
|
|
||||||
if (domainKeywords.length) rule.domain_keyword = domainKeywords;
|
|
||||||
if (ipCidrs.length) rule.ip_cidr = ipCidrs;
|
|
||||||
if (ports.length) rule.port = ports;
|
|
||||||
if (networks.length) rule.network = networks;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!rule.domain &&
|
|
||||||
!rule.domain_suffix &&
|
|
||||||
!rule.domain_keyword &&
|
|
||||||
!rule.ip_cidr &&
|
|
||||||
!rule.port &&
|
|
||||||
!rule.network
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
rule.outbound = customRule.outbound === 'vpn' ? vpnTag : customRule.outbound;
|
|
||||||
return rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
function customRouteRules(customRules, vpnTag) {
|
|
||||||
return (Array.isArray(customRules) ? customRules : [])
|
|
||||||
.map((rule) => toSingboxRule(rule, vpnTag))
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeRules(customRules, vpnTag) {
|
|
||||||
const rules = [
|
|
||||||
{
|
|
||||||
ip_is_private: true,
|
|
||||||
outbound: 'direct',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
rules.push(...customRouteRules(customRules, vpnTag));
|
|
||||||
|
|
||||||
if (settings.routingRuDirect) {
|
|
||||||
rules.push({
|
|
||||||
rule_set: ['geoip-ru', 'geosite-category-ru'],
|
|
||||||
outbound: 'direct',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return rules;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
||||||
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
|
const clientMode = settings.appMode === 'client';
|
||||||
if (!selectedOutbound) {
|
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
||||||
throw new Error(`Selected outbound not found: ${selectedTag}`);
|
if (!vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||||
}
|
|
||||||
|
|
||||||
const vpnOutbound = clone(selectedOutbound);
|
|
||||||
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
|
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
|
||||||
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
|
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
|
||||||
vpnOutbound.packet_encoding = 'xudp';
|
vpnOutbound.packet_encoding = 'xudp';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const inbounds = [
|
||||||
log: {
|
...(!clientMode ? [{
|
||||||
level: settings.logLevel,
|
|
||||||
timestamp: true,
|
|
||||||
},
|
|
||||||
experimental: {
|
|
||||||
cache_file: {
|
|
||||||
enabled: true,
|
|
||||||
path: settings.cachePath,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dns: {
|
|
||||||
independent_cache: true,
|
|
||||||
},
|
|
||||||
inbounds: [
|
|
||||||
{
|
|
||||||
type: 'tproxy',
|
type: 'tproxy',
|
||||||
tag: 'tproxy-in',
|
tag: TPROXY_INBOUND,
|
||||||
listen: '::',
|
listen: '::',
|
||||||
listen_port: settings.tproxyPort,
|
listen_port: settings.tproxyPort,
|
||||||
sniff: true,
|
sniff: true,
|
||||||
sniff_override_destination: true,
|
sniff_override_destination: true,
|
||||||
},
|
}] : []),
|
||||||
{
|
{
|
||||||
type: 'mixed',
|
type: 'mixed',
|
||||||
tag: 'mixed-in',
|
tag: MIXED_INBOUND,
|
||||||
listen: settings.bindIp,
|
listen: settings.bindIp,
|
||||||
listen_port: settings.proxyPort,
|
listen_port: settings.proxyPort,
|
||||||
sniff: true,
|
sniff: true,
|
||||||
set_system_proxy: false,
|
set_system_proxy: false,
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
|
const rules = clientMode
|
||||||
|
? [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }]
|
||||||
|
: [
|
||||||
|
{ inbound: [TPROXY_INBOUND], outbound: vpnOutbound.tag },
|
||||||
|
{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
log: { level: settings.logLevel, timestamp: true },
|
||||||
|
experimental: {
|
||||||
|
cache_file: { enabled: true, path: settings.cachePath },
|
||||||
|
},
|
||||||
|
dns: { independent_cache: true },
|
||||||
|
inbounds,
|
||||||
outbounds: [
|
outbounds: [
|
||||||
vpnOutbound,
|
vpnOutbound,
|
||||||
{ type: 'direct', tag: 'direct' },
|
{ type: 'direct', tag: 'direct' },
|
||||||
{ type: 'block', tag: 'block' },
|
{ type: 'block', tag: 'block' },
|
||||||
],
|
],
|
||||||
route: {
|
route: {
|
||||||
rule_set: ruleSets(),
|
rule_set: [],
|
||||||
rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
|
rules,
|
||||||
final: vpnOutbound.tag,
|
final: vpnOutbound.tag,
|
||||||
auto_detect_interface: true,
|
...(clientMode ? {} : { auto_detect_interface: true }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -173,3 +75,7 @@ export function writeSingboxConfig(config) {
|
|||||||
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
|
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
|
||||||
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8');
|
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeSingboxConfig() {
|
||||||
|
fs.rmSync(settings.configPath, { force: true });
|
||||||
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function parseSubscriptionBody(body) {
|
|||||||
const servers = outbounds
|
const servers = outbounds
|
||||||
.filter((outbound) => PROXY_TYPES.has(outbound.type))
|
.filter((outbound) => PROXY_TYPES.has(outbound.type))
|
||||||
.map((outbound) => ({
|
.map((outbound) => ({
|
||||||
tag: outbound.tag || `${outbound.type}-${outbound.server || 'server'}`,
|
tag: String(outbound.tag || `${outbound.type}-${outbound.server || 'server'}`).trim(),
|
||||||
type: outbound.type,
|
type: outbound.type,
|
||||||
server: outbound.server || 'unknown',
|
server: outbound.server || 'unknown',
|
||||||
server_port: outbound.server_port || 443,
|
server_port: outbound.server_port || 443,
|
||||||
@@ -136,7 +136,7 @@ export function parseSubscriptionBody(body) {
|
|||||||
return { config: parsedConfig, servers };
|
return { config: parsedConfig, servers };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubscription(url) {
|
async function requestSubscription(url) {
|
||||||
let parsedUrl;
|
let parsedUrl;
|
||||||
try {
|
try {
|
||||||
parsedUrl = new URL(url);
|
parsedUrl = new URL(url);
|
||||||
@@ -157,6 +157,21 @@ export async function fetchSubscription(url) {
|
|||||||
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscriptionInfo(url) {
|
||||||
|
const response = await requestSubscription(url);
|
||||||
|
await response.body?.cancel();
|
||||||
|
return {
|
||||||
|
userInfo: parseUserInfo(response.headers.get('subscription-userinfo')),
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscription(url) {
|
||||||
|
const response = await requestSubscription(url);
|
||||||
|
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
||||||
const parsed = parseSubscriptionBody(body);
|
const parsed = parseSubscriptionBody(body);
|
||||||
|
|||||||
467
src/web/App.jsx
467
src/web/App.jsx
@@ -1,450 +1,97 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
import { api } from './api.js';
|
||||||
function formatBytes(value) {
|
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
||||||
if (!value) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
let size = value;
|
|
||||||
let index = 0;
|
|
||||||
while (size >= 1024 && index < units.length - 1) {
|
|
||||||
size /= 1024;
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskUrl(value) {
|
|
||||||
if (!value) return '';
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
return `${url.hostname}/...`;
|
|
||||||
} catch {
|
|
||||||
return value.length > 48 ? `${value.slice(0, 48)}...` : value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [state, setState] = useState(null);
|
const [state, setState] = useState(null);
|
||||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
const [customRules, setCustomRules] = useState([]);
|
const [pendingTag, setPendingTag] = useState('');
|
||||||
const [selectedTag, setSelectedTag] = useState('');
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [log, setLog] = useState([]);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
|
|
||||||
const rulesDirtyRef = useRef(false);
|
|
||||||
const rulesSaveTimerRef = useRef(null);
|
|
||||||
const rulesRevisionRef = useRef(0);
|
|
||||||
|
|
||||||
const userTraffic = useMemo(() => {
|
|
||||||
const info = state?.userInfo;
|
|
||||||
if (!info) return 'нет данных';
|
|
||||||
const used = formatBytes((info.upload || 0) + (info.download || 0));
|
|
||||||
const total = info.total ? formatBytes(info.total) : 'без лимита';
|
|
||||||
return `${used} / ${total}`;
|
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
function addLog(message) {
|
|
||||||
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
|
||||||
setLog((items) => [{ time, message }, ...items].slice(0, 8));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadState() {
|
async function loadState() {
|
||||||
const response = await fetch('/api/state');
|
const data = await api.state();
|
||||||
const data = await response.json();
|
|
||||||
setState(data);
|
setState(data);
|
||||||
setServers(data.servers || []);
|
setServers(data.servers || []);
|
||||||
if (!rulesDirtyRef.current) {
|
setPendingTag((current) => current || data.selectedTag || '');
|
||||||
setCustomRules(data.customRules || []);
|
|
||||||
}
|
|
||||||
setSelectedTag(data.selectedTag || '');
|
|
||||||
if (data.subscriptionUrl && !subscriptionUrl) setSubscriptionUrl(data.subscriptionUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadState().catch(() => {});
|
loadState().catch((err) => setError(err.message));
|
||||||
const timer = setInterval(() => loadState().catch(() => {}), 5000);
|
const timer = setInterval(() => loadState().catch(() => {}), 5000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
async function run(action) {
|
||||||
return () => {
|
|
||||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function fetchServers() {
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
addLog(`SYNC ${maskUrl(subscriptionUrl)}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/subscription/fetch', {
|
await action();
|
||||||
method: 'POST',
|
await loadState();
|
||||||
headers: { 'content-type': 'application/json' },
|
} catch (err) {
|
||||||
body: JSON.stringify({ url: subscriptionUrl }),
|
setError(err.message);
|
||||||
});
|
throw err;
|
||||||
const data = await response.json();
|
} finally {
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || 'sync failed');
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSubscription() {
|
||||||
|
return run(async () => {
|
||||||
|
const data = await api.subscription.fetch(subscriptionUrl);
|
||||||
setServers(data.servers || []);
|
setServers(data.servers || []);
|
||||||
setSelectedTag(data.servers?.[0]?.tag || '');
|
setPendingTag('');
|
||||||
addLog(`FOUND ${data.servers.length} servers`);
|
|
||||||
await loadState();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
addLog(`ERROR ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyServer() {
|
|
||||||
setBusy(true);
|
|
||||||
setError('');
|
|
||||||
addLog(`APPLY ${selectedTag}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/apply', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ selectedTag }),
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || 'apply failed');
|
|
||||||
|
|
||||||
addLog(`SING-BOX ${data.singboxRunning ? 'RUNNING' : 'STOPPED'}`);
|
|
||||||
await loadState();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
addLog(`ERROR ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyRule() {
|
|
||||||
return {
|
|
||||||
id: `rule-${Date.now()}`,
|
|
||||||
name: 'Новый список',
|
|
||||||
enabled: true,
|
|
||||||
outbound: 'direct',
|
|
||||||
domains: [],
|
|
||||||
domainSuffixes: [],
|
|
||||||
domainKeywords: [],
|
|
||||||
ipCidrs: [],
|
|
||||||
ports: [],
|
|
||||||
networks: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function listToText(value) {
|
|
||||||
return Array.isArray(value) ? value.join('\n') : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function textToList(value) {
|
|
||||||
return value
|
|
||||||
.split(/\r?\n|,/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRule(id, patch) {
|
|
||||||
setCustomRules((rules) => {
|
|
||||||
const nextRules = rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule));
|
|
||||||
queueRulesSave(nextRules);
|
|
||||||
return nextRules;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueRulesSave(nextRules) {
|
async function refreshSubscriptionInfo() {
|
||||||
rulesDirtyRef.current = true;
|
const data = await api.subscription.refreshInfo();
|
||||||
const revision = rulesRevisionRef.current + 1;
|
setState((current) => current ? {
|
||||||
rulesRevisionRef.current = revision;
|
...current,
|
||||||
setRulesSaveStatus('pending');
|
userInfo: data.userInfo,
|
||||||
|
fetchedAt: data.fetchedAt,
|
||||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
} : current);
|
||||||
rulesSaveTimerRef.current = setTimeout(() => {
|
return data;
|
||||||
saveRules(nextRules, { silent: true, revision });
|
|
||||||
}, 700);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveRules(nextRules = customRules, options = {}) {
|
async function forgetSubscription() {
|
||||||
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
|
if (!confirm('Удалить подписку и остановить VPN?')) return;
|
||||||
if (!silent) setBusy(true);
|
return run(async () => {
|
||||||
setError('');
|
await api.subscription.forget();
|
||||||
if (!silent) addLog('SAVE ROUTING RULES');
|
setSubscriptionUrl('');
|
||||||
setRulesSaveStatus('saving');
|
setServers([]);
|
||||||
|
setPendingTag('');
|
||||||
try {
|
|
||||||
const response = await fetch('/api/rules', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ rules: nextRules }),
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || 'rules save failed');
|
|
||||||
|
|
||||||
if (rulesRevisionRef.current === revision) {
|
|
||||||
rulesDirtyRef.current = false;
|
|
||||||
setCustomRules(data.rules || []);
|
|
||||||
setRulesSaveStatus('saved');
|
|
||||||
addLog(`RULES SAVED ${data.rules.length}`);
|
|
||||||
await loadState();
|
|
||||||
} else {
|
|
||||||
setRulesSaveStatus('pending');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
setRulesSaveStatus('error');
|
|
||||||
addLog(`ERROR ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
if (!silent) setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveRulesNow() {
|
|
||||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
|
||||||
rulesDirtyRef.current = true;
|
|
||||||
const revision = rulesRevisionRef.current + 1;
|
|
||||||
rulesRevisionRef.current = revision;
|
|
||||||
saveRules(customRules, { silent: false, revision });
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRule() {
|
|
||||||
setCustomRules((rules) => {
|
|
||||||
const nextRules = [emptyRule(), ...rules];
|
|
||||||
queueRulesSave(nextRules);
|
|
||||||
return nextRules;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRule(id) {
|
if (!state) return <div className="app-loading">VPN</div>;
|
||||||
setCustomRules((rules) => {
|
|
||||||
const nextRules = rules.filter((rule) => rule.id !== id);
|
|
||||||
queueRulesSave(nextRules);
|
|
||||||
return nextRules;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<div className="app client-app">
|
||||||
<section className="hero panel">
|
<div className="app-body client-mode">
|
||||||
<div>
|
<main className="app-main">
|
||||||
<p className="eyebrow">VPN Proxy / Gateway Mode</p>
|
<ClientOverviewPage
|
||||||
<h1>Transparent gateway for the whole network</h1>
|
state={state}
|
||||||
<p className="lead">
|
busy={busy}
|
||||||
Вставь subscription URL, выбери outbound, и контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов.
|
error={error}
|
||||||
</p>
|
subscriptionUrl={subscriptionUrl}
|
||||||
</div>
|
setSubscriptionUrl={setSubscriptionUrl}
|
||||||
<div className="status-card">
|
servers={servers}
|
||||||
<span className={state?.singboxRunning ? 'dot on' : 'dot'} />
|
pendingTag={pendingTag}
|
||||||
<div>
|
setPendingTag={setPendingTag}
|
||||||
<strong>{state?.singboxRunning ? 'sing-box running' : 'sing-box standby'}</strong>
|
onFetchSubscription={fetchSubscription}
|
||||||
<small>{state?.selectedTag || 'сервер не выбран'}</small>
|
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
|
||||||
</div>
|
onForgetSubscription={forgetSubscription}
|
||||||
</div>
|
onApply={(tag) => run(() => api.apply(tag))}
|
||||||
</section>
|
onRestart={() => run(api.singbox.restart)}
|
||||||
|
onStop={() => run(api.singbox.stop)}
|
||||||
<section className="grid">
|
|
||||||
<div className="panel primary-flow">
|
|
||||||
<div className="section-title">
|
|
||||||
<span>1</span>
|
|
||||||
<h2>Subscription</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>Subscription URL</span>
|
|
||||||
<input
|
|
||||||
value={subscriptionUrl}
|
|
||||||
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
|
||||||
placeholder="https://provider.example/sub/..."
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
|
|
||||||
<button className="button" disabled={busy || !subscriptionUrl} onClick={fetchServers}>
|
|
||||||
{busy ? 'Working...' : 'Parse subscription'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="section-title compact">
|
|
||||||
<span>2</span>
|
|
||||||
<h2>Servers</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="server-list">
|
|
||||||
{servers.length === 0 && <div className="empty">Серверы еще не загружены</div>}
|
|
||||||
{servers.map((server) => (
|
|
||||||
<button
|
|
||||||
key={server.tag}
|
|
||||||
className={server.tag === selectedTag ? 'server active' : 'server'}
|
|
||||||
onClick={() => setSelectedTag(server.tag)}
|
|
||||||
>
|
|
||||||
<strong>{server.tag}</strong>
|
|
||||||
<small>{server.type} / {server.server}:{server.server_port}</small>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="button apply" disabled={busy || !selectedTag} onClick={applyServer}>
|
|
||||||
Apply selected gateway route
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="panel details">
|
|
||||||
<div className="section-title">
|
|
||||||
<span>3</span>
|
|
||||||
<h2>Gateway runtime</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl>
|
|
||||||
<div><dt>UI</dt><dd>:{state?.port || 3456}</dd></div>
|
|
||||||
<div><dt>Mixed proxy</dt><dd>:{state?.proxyPort || 8080}</dd></div>
|
|
||||||
<div><dt>TProxy</dt><dd>:{state?.tproxyPort || 7895}</dd></div>
|
|
||||||
<div><dt>RU direct</dt><dd>{state?.routingRuDirect ? 'enabled' : 'disabled'}</dd></div>
|
|
||||||
<div><dt>Traffic</dt><dd>{userTraffic}</dd></div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="route-card">
|
|
||||||
<span>Routing policy</span>
|
|
||||||
<p>private IP -> direct</p>
|
|
||||||
<p>geoip-ru/geosite-category-ru -> direct</p>
|
|
||||||
<p>everything else -> selected VPN outbound</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="logs">
|
|
||||||
{log.length === 0 && <p>Waiting for actions...</p>}
|
|
||||||
{log.map((entry) => (
|
|
||||||
<p key={`${entry.time}-${entry.message}`}><span>{entry.time}</span> {entry.message}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="panel rules-panel">
|
|
||||||
<div className="rules-header">
|
|
||||||
<div className="section-title">
|
|
||||||
<span>4</span>
|
|
||||||
<h2>Routing lists</h2>
|
|
||||||
</div>
|
|
||||||
<div className="rules-actions">
|
|
||||||
<button className="ghost-button" type="button" onClick={addRule}>Add list</button>
|
|
||||||
<button className="ghost-button solid" type="button" disabled={busy || rulesSaveStatus === 'saving'} onClick={saveRulesNow}>
|
|
||||||
{rulesSaveStatus === 'saving' ? 'Saving...' : rulesSaveStatus === 'pending' ? 'Save now' : rulesSaveStatus === 'error' ? 'Retry save' : 'Saved'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="rules-note">
|
|
||||||
Эти правила автосохраняются после изменений и вставляются после safety private-direct и до стандартного RU-direct. Для игр в gateway-режиме указывай домены, suffix, CIDR или порты: процесс на клиентском ПК gateway не видит.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="rule-grid">
|
|
||||||
{customRules.length === 0 && (
|
|
||||||
<div className="empty rule-empty">
|
|
||||||
Нет пользовательских списков. Добавь список, например `League direct`, и отправь его в `direct`.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{customRules.map((rule) => (
|
|
||||||
<article className="rule-card" key={rule.id}>
|
|
||||||
<div className="rule-top">
|
|
||||||
<input
|
|
||||||
value={rule.name}
|
|
||||||
onChange={(event) => updateRule(rule.id, { name: event.target.value })}
|
|
||||||
placeholder="Название списка"
|
|
||||||
/>
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={rule.enabled}
|
|
||||||
onChange={(event) => updateRule(rule.id, { enabled: event.target.checked })}
|
|
||||||
/>
|
|
||||||
enabled
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>Route to</span>
|
|
||||||
<select value={rule.outbound} onChange={(event) => updateRule(rule.id, { outbound: event.target.value })}>
|
|
||||||
<option value="direct">direct</option>
|
|
||||||
<option value="vpn">vpn</option>
|
|
||||||
<option value="block">block</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="rule-fields">
|
|
||||||
<label className="field">
|
|
||||||
<span>Domains exact</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.domains)}
|
|
||||||
onChange={(event) => updateRule(rule.id, { domains: textToList(event.target.value) })}
|
|
||||||
placeholder="riotgames.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>Domain suffixes</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.domainSuffixes)}
|
|
||||||
onChange={(event) => updateRule(rule.id, { domainSuffixes: textToList(event.target.value) })}
|
|
||||||
placeholder={'leagueoflegends.com\nriotcdn.net'}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>IP CIDR</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.ipCidrs)}
|
|
||||||
onChange={(event) => updateRule(rule.id, { ipCidrs: textToList(event.target.value) })}
|
|
||||||
placeholder="104.160.128.0/19"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>Ports</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.ports)}
|
|
||||||
onChange={(event) => updateRule(rule.id, { ports: textToList(event.target.value) })}
|
|
||||||
placeholder={'5000\n5223'}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rule-footer">
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(rule.networks || []).includes('tcp')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const set = new Set(rule.networks || []);
|
|
||||||
event.target.checked ? set.add('tcp') : set.delete('tcp');
|
|
||||||
updateRule(rule.id, { networks: Array.from(set) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
tcp
|
|
||||||
</label>
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(rule.networks || []).includes('udp')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const set = new Set(rule.networks || []);
|
|
||||||
event.target.checked ? set.add('udp') : set.delete('udp');
|
|
||||||
updateRule(rule.id, { networks: Array.from(set) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
udp
|
|
||||||
</label>
|
|
||||||
<button className="danger-button" type="button" onClick={() => removeRule(rule.id)}>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
src/web/api.js
Normal file
37
src/web/api.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
async function request(url, options = {}) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || data?.success === false) {
|
||||||
|
throw new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
state: () => request('/api/state'),
|
||||||
|
subscription: {
|
||||||
|
fetch: (url) => request('/api/subscription/fetch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
}),
|
||||||
|
refreshInfo: () => request('/api/subscription/refresh-info', { method: 'POST' }),
|
||||||
|
forget: () => request('/api/subscription', { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
apply: (selectedTag) => request('/api/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ selectedTag }),
|
||||||
|
}),
|
||||||
|
singbox: {
|
||||||
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
|
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||||
|
},
|
||||||
|
servers: {
|
||||||
|
pingAll: () => request('/api/servers/ping-all', { method: 'POST' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
423
src/web/components/ClientOverviewPage.jsx
Normal file
423
src/web/components/ClientOverviewPage.jsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import {
|
||||||
|
connectionAction,
|
||||||
|
formatConnectionDuration,
|
||||||
|
localProxyUrls,
|
||||||
|
subscriptionDomain,
|
||||||
|
subscriptionDaysLeft,
|
||||||
|
subscriptionUsage,
|
||||||
|
} from '../utils/clientControls.js';
|
||||||
|
import { formatBytes } from '../utils/format.js';
|
||||||
|
|
||||||
|
export function ClientOverviewPage({
|
||||||
|
state,
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
subscriptionUrl,
|
||||||
|
setSubscriptionUrl,
|
||||||
|
servers,
|
||||||
|
pendingTag,
|
||||||
|
setPendingTag,
|
||||||
|
onFetchSubscription,
|
||||||
|
onRefreshSubscriptionInfo,
|
||||||
|
onForgetSubscription,
|
||||||
|
onApply,
|
||||||
|
onRestart,
|
||||||
|
onStop,
|
||||||
|
}) {
|
||||||
|
const isGateway = state?.mode === 'gateway';
|
||||||
|
const connected = Boolean(state?.singboxRunning);
|
||||||
|
const hasSubscription = Boolean(state?.hasSubscription);
|
||||||
|
const selectedTag = pendingTag || state?.selectedTag || '';
|
||||||
|
const showPower = hasSubscription && Boolean(selectedTag);
|
||||||
|
const canStart = Boolean(selectedTag || state?.configExists);
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||||
|
const [pings, setPings] = useState({});
|
||||||
|
const [copiedProxy, setCopiedProxy] = useState('');
|
||||||
|
const [refreshingInfo, setRefreshingInfo] = useState(false);
|
||||||
|
const [usageUpdated, setUsageUpdated] = useState(false);
|
||||||
|
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||||
|
const [serversLeaving, setServersLeaving] = useState(false);
|
||||||
|
const subscriptionInputRef = useRef(null);
|
||||||
|
const subscriptionRef = useRef(null);
|
||||||
|
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||||
|
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||||
|
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||||
|
const usage = subscriptionUsage(state?.userInfo);
|
||||||
|
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||||||
|
const hasUsage = Boolean(
|
||||||
|
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
if (!connected || !state?.singboxStartedAt) return undefined;
|
||||||
|
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [connected, state?.singboxStartedAt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!servers.length) {
|
||||||
|
setPings({});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }])));
|
||||||
|
api.servers.pingAll()
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPings(Object.fromEntries((data.results || []).map((ping) => [
|
||||||
|
String(ping.tag || '').trim(),
|
||||||
|
ping,
|
||||||
|
])));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }])));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [serverKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingSubscription) subscriptionInputRef.current?.focus();
|
||||||
|
}, [editingSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasSubscription) setEditingSubscription(true);
|
||||||
|
}, [hasSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
|
||||||
|
const timer = setTimeout(() => setEditingSubscription(false), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingSubscription || !hasSubscription) return undefined;
|
||||||
|
const closeOnOutsideClick = (event) => {
|
||||||
|
if (subscriptionRef.current?.contains(event.target)) return;
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setEditingSubscription(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', closeOnOutsideClick);
|
||||||
|
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
|
||||||
|
}, [editingSubscription, hasSubscription, setSubscriptionUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state?.hasSubscription) return undefined;
|
||||||
|
const refresh = () => onRefreshSubscriptionInfo().catch(() => {});
|
||||||
|
refresh();
|
||||||
|
const timer = setInterval(refresh, 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [state?.hasSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const from = displayedUsed;
|
||||||
|
const to = usage.used;
|
||||||
|
if (from === to) return undefined;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let frame;
|
||||||
|
const tick = (now) => {
|
||||||
|
const progress = Math.min(1, (now - startedAt) / 900);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 4);
|
||||||
|
setDisplayedUsed(from + (to - from) * eased);
|
||||||
|
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [usage.used]);
|
||||||
|
|
||||||
|
async function toggleConnection() {
|
||||||
|
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||||||
|
if (action?.type === 'stop') return onStop();
|
||||||
|
if (action?.type === 'apply') return onApply(action.selectedTag);
|
||||||
|
if (action?.type === 'restart') return onRestart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectServer(tag) {
|
||||||
|
setPendingTag(tag);
|
||||||
|
if (connected && tag) onApply(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSubscription(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!subscriptionUrl.trim()) return;
|
||||||
|
await onFetchSubscription();
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setEditingSubscription(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyProxy(kind) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(kind === 'gateway' ? gatewayAddress : proxyUrls[kind]);
|
||||||
|
setCopiedProxy(kind);
|
||||||
|
setTimeout(() => setCopiedProxy(''), 800);
|
||||||
|
} catch {
|
||||||
|
setCopiedProxy('error');
|
||||||
|
setTimeout(() => setCopiedProxy(''), 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshInfo() {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
setRefreshingInfo(true);
|
||||||
|
try {
|
||||||
|
await onRefreshSubscriptionInfo();
|
||||||
|
setUsageUpdated(false);
|
||||||
|
requestAnimationFrame(() => setUsageUpdated(true));
|
||||||
|
setTimeout(() => setUsageUpdated(false), 900);
|
||||||
|
setServersLeaving(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 420 + Math.max(0, servers.length - 1) * 90));
|
||||||
|
setServerRevealVersion((version) => version + 1);
|
||||||
|
setServersLeaving(false);
|
||||||
|
} finally {
|
||||||
|
const elapsed = performance.now() - startedAt;
|
||||||
|
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
|
||||||
|
setRefreshingInfo(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="client-shell">
|
||||||
|
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
||||||
|
{showPower && (
|
||||||
|
<section className="client-power-section" aria-labelledby="connection-title">
|
||||||
|
<button
|
||||||
|
className="client-power"
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={connected}
|
||||||
|
aria-label={connected ? `Выключить ${isGateway ? 'Gateway' : 'VPN'}` : `Включить ${isGateway ? 'Gateway' : 'VPN'}`}
|
||||||
|
disabled={busy || (!connected && !canStart)}
|
||||||
|
onClick={toggleConnection}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="client-state-copy" aria-live="polite">
|
||||||
|
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
||||||
|
{isGateway
|
||||||
|
? connected ? 'Gateway включён' : 'Gateway выключен'
|
||||||
|
: connected ? 'VPN включён' : 'VPN выключен'}
|
||||||
|
</h2>
|
||||||
|
<div className="client-state-detail">
|
||||||
|
{connected ? (
|
||||||
|
<time key="duration" className="client-duration">
|
||||||
|
{formatConnectionDuration(state?.singboxStartedAt, now)}
|
||||||
|
</time>
|
||||||
|
) : (
|
||||||
|
<p key="hint">
|
||||||
|
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="client-proxies" aria-label={isGateway ? 'Адреса Gateway и Proxy' : 'Локальный прокси'}>
|
||||||
|
{isGateway && (
|
||||||
|
<div className="client-access-point">
|
||||||
|
<span className="client-proxy-label">Gateway</span>
|
||||||
|
<strong className="client-proxy-address">{gatewayAddress}</strong>
|
||||||
|
<button
|
||||||
|
className={copiedProxy === 'gateway' ? 'is-copied' : ''}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
|
||||||
|
onClick={() => copyProxy('gateway')}
|
||||||
|
>
|
||||||
|
<span className="client-copy-label">КОПИРОВАТЬ</span>
|
||||||
|
{copiedProxy === 'gateway' && <span className="client-copy-feedback">Copied</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="client-access-point">
|
||||||
|
<span className="client-proxy-label">{isGateway ? 'Proxy' : 'Адрес'}</span>
|
||||||
|
<strong className="client-proxy-address">
|
||||||
|
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||||
|
</strong>
|
||||||
|
<div className="client-proxy-actions">
|
||||||
|
{[
|
||||||
|
['socks5', 'SOCKS5'],
|
||||||
|
['http', 'HTTP'],
|
||||||
|
].map(([kind, label]) => (
|
||||||
|
<button
|
||||||
|
className={copiedProxy === kind ? 'is-copied' : ''}
|
||||||
|
type="button"
|
||||||
|
key={kind}
|
||||||
|
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
||||||
|
onClick={() => copyProxy(kind)}
|
||||||
|
>
|
||||||
|
<span className="client-copy-label">{label}</span>
|
||||||
|
{copiedProxy === kind && <span className="client-copy-feedback">Copied</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="client-error" role="alert">{error}</p>}
|
||||||
|
|
||||||
|
<div className="client-form">
|
||||||
|
<div
|
||||||
|
ref={subscriptionRef}
|
||||||
|
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="client-subscription-summary"
|
||||||
|
aria-hidden={editingSubscription}
|
||||||
|
inert={editingSubscription ? true : undefined}
|
||||||
|
>
|
||||||
|
<div className="client-subscription-heading">
|
||||||
|
<span>Ваша подписка</span>
|
||||||
|
<button
|
||||||
|
className="client-subscription-refresh"
|
||||||
|
type="button"
|
||||||
|
aria-label="Обновить статистику подписки"
|
||||||
|
title="Обновить статистику"
|
||||||
|
disabled={refreshingInfo}
|
||||||
|
onClick={refreshInfo}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="client-subscription-delete"
|
||||||
|
type="button"
|
||||||
|
aria-label="Удалить подписку"
|
||||||
|
title="Удалить подписку"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onForgetSubscription}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h16M9 7V4h6v3m-9 0 1 13h10l1-13M10 11v5M14 11v5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="client-subscription-domain-button"
|
||||||
|
type="button"
|
||||||
|
tabIndex={editingSubscription ? -1 : 0}
|
||||||
|
onClick={() => setEditingSubscription(true)}
|
||||||
|
>
|
||||||
|
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="client-subscription-edit"
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden={!editingSubscription}
|
||||||
|
inert={!editingSubscription ? true : undefined}
|
||||||
|
onSubmit={submitSubscription}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={subscriptionInputRef}
|
||||||
|
id="subscription-url"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
autoComplete="off"
|
||||||
|
tabIndex={editingSubscription ? 0 : -1}
|
||||||
|
aria-label="Ссылка подписки"
|
||||||
|
placeholder="Вставьте ссылку подписки"
|
||||||
|
className={subscriptionUrl ? 'has-value' : ''}
|
||||||
|
value={subscriptionUrl}
|
||||||
|
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape' && state?.hasSubscription) {
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setEditingSubscription(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{subscriptionUrl && (
|
||||||
|
<span className="client-subscription-domain">
|
||||||
|
{subscriptionDomain(subscriptionUrl)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{subscriptionUrl.trim() && (
|
||||||
|
<button type="submit" aria-label="Сохранить подписку" disabled={busy}>✓</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasSubscription && hasUsage && (
|
||||||
|
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
|
||||||
|
<span>Использовано</span>
|
||||||
|
<strong>
|
||||||
|
{formatBytes(displayedUsed)}
|
||||||
|
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
|
||||||
|
</strong>
|
||||||
|
{usage.percent !== null && (
|
||||||
|
<div
|
||||||
|
className="client-usage-bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Использованный трафик"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow={Math.round(usage.percent)}
|
||||||
|
>
|
||||||
|
<i style={{ width: `${usage.percent}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="client-usage-details">
|
||||||
|
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
|
||||||
|
<span>
|
||||||
|
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
|
||||||
|
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSubscription && (
|
||||||
|
<section className="client-servers" aria-label="Выберите сервер">
|
||||||
|
{!showPower && <span className="client-server-prompt">Выберите сервер</span>}
|
||||||
|
<div
|
||||||
|
className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`}
|
||||||
|
key={`${serverKey}:${serverRevealVersion}`}
|
||||||
|
>
|
||||||
|
{servers.map((server, index) => {
|
||||||
|
const ping = pings[server.tag];
|
||||||
|
const selected = server.tag === selectedTag;
|
||||||
|
const pingText = ping?.checking
|
||||||
|
? 'Проверка…'
|
||||||
|
: ping?.ok ? `${ping.latency} ms` : 'Недоступен';
|
||||||
|
const pingClass = ping?.ok
|
||||||
|
? ping.latency < 100 ? 'good' : ping.latency < 250 ? 'medium' : 'slow'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`client-server ${selected ? 'is-selected' : ''}`}
|
||||||
|
type="button"
|
||||||
|
key={server.tag}
|
||||||
|
disabled={busy}
|
||||||
|
aria-pressed={selected}
|
||||||
|
style={{ '--server-index': index }}
|
||||||
|
onClick={() => selectServer(server.tag)}
|
||||||
|
>
|
||||||
|
<strong>{server.tag}</strong>
|
||||||
|
<small className={pingClass}>{pingText}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1160
src/web/styles.css
1160
src/web/styles.css
File diff suppressed because it is too large
Load Diff
65
src/web/utils/clientControls.js
Normal file
65
src/web/utils/clientControls.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export function connectionAction({ connected, selectedTag, configExists }) {
|
||||||
|
if (connected) return { type: 'stop' };
|
||||||
|
if (selectedTag) return { type: 'apply', selectedTag };
|
||||||
|
if (configExists) return { type: 'restart' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatConnectionDuration(startedAt, now = Date.now()) {
|
||||||
|
const started = Date.parse(startedAt);
|
||||||
|
const totalSeconds = Number.isFinite(started)
|
||||||
|
? Math.max(0, Math.floor((now - started) / 1000))
|
||||||
|
: 0;
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
return [hours, minutes, seconds]
|
||||||
|
.map((part) => String(part).padStart(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionDomain(subscriptionHost) {
|
||||||
|
const value = String(subscriptionHost || '');
|
||||||
|
try {
|
||||||
|
return new URL(value).host;
|
||||||
|
} catch {
|
||||||
|
return value.split('/')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localProxyUrls(port = 8082, host = '127.0.0.1') {
|
||||||
|
const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
||||||
|
return {
|
||||||
|
socks5: `socks5://${urlHost}:${port}`,
|
||||||
|
http: `http://${urlHost}:${port}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionUsage(userInfo = {}) {
|
||||||
|
const upload = Math.max(0, Number(userInfo.upload) || 0);
|
||||||
|
const download = Math.max(0, Number(userInfo.download) || 0);
|
||||||
|
const total = Math.max(0, Number(userInfo.total) || 0);
|
||||||
|
const used = upload + download;
|
||||||
|
|
||||||
|
return {
|
||||||
|
upload,
|
||||||
|
download,
|
||||||
|
total,
|
||||||
|
used,
|
||||||
|
percent: total ? Math.min(100, (used / total) * 100) : null,
|
||||||
|
expiresAt: userInfo.expire ? new Date(Number(userInfo.expire) * 1000) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionDaysLeft(expiresAt, now = Date.now()) {
|
||||||
|
const days = Math.ceil((expiresAt?.getTime() - now) / 86_400_000);
|
||||||
|
if (!Number.isFinite(days)) return '';
|
||||||
|
if (days <= 0) return 'срок истёк';
|
||||||
|
const mod10 = days % 10;
|
||||||
|
const mod100 = days % 100;
|
||||||
|
const unit = mod10 === 1 && mod100 !== 11
|
||||||
|
? 'день'
|
||||||
|
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? 'дня' : 'дней';
|
||||||
|
return `${days === 1 ? 'остался' : 'осталось'} ${days} ${unit}`;
|
||||||
|
}
|
||||||
31
src/web/utils/format.js
Normal file
31
src/web/utils/format.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export function formatBytes(value) {
|
||||||
|
if (!value) return "0 Б";
|
||||||
|
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
|
||||||
|
let size = value;
|
||||||
|
let index = 0;
|
||||||
|
while (size >= 1024 && index < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const ts = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(ts)) return "";
|
||||||
|
const diff = Math.max(0, Date.now() - ts);
|
||||||
|
const sec = Math.floor(diff / 1000);
|
||||||
|
if (sec < 60) return `${sec} с назад`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
if (min < 60) return `${min} мин назад`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `${hr} ч назад`;
|
||||||
|
const days = Math.floor(hr / 24);
|
||||||
|
return `${days} дн назад`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
return new Date(iso).toLocaleTimeString("ru-RU", { hour12: false });
|
||||||
|
}
|
||||||
15
test/server/entrypoint-tproxy.test.js
Normal file
15
test/server/entrypoint-tproxy.test.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const entrypoint = fs.readFileSync(
|
||||||
|
path.resolve(import.meta.dirname, '../../entrypoint.sh'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('gateway intercepts all public TCP and UDP traffic without source bypasses', () => {
|
||||||
|
assert.match(entrypoint, /-p tcp -j TPROXY --on-port "\$TPROXY_PORT"/);
|
||||||
|
assert.match(entrypoint, /-p udp -j TPROXY --on-port "\$TPROXY_PORT"/);
|
||||||
|
assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/);
|
||||||
|
});
|
||||||
55
test/server/shared-proxy.test.js
Normal file
55
test/server/shared-proxy.test.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildSharedProxyInfo,
|
||||||
|
checkSharedProxyGateway,
|
||||||
|
} = await import("../../src/server/sharedProxy.js");
|
||||||
|
|
||||||
|
test("gateway shared proxy info exposes host and socks proxy when running", () => {
|
||||||
|
const info = buildSharedProxyInfo({
|
||||||
|
appMode: "gateway",
|
||||||
|
proxyPort: 8080,
|
||||||
|
running: true,
|
||||||
|
hostHeader: "192.168.50.111:3456",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(info.available, true);
|
||||||
|
assert.deepEqual(info.proxy, {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
httpUrl: "http://192.168.50.111:8080",
|
||||||
|
socksUrl: "socks5://192.168.50.111:8080",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client shared proxy check normalizes gateway response into settings patch", async () => {
|
||||||
|
const patch = await checkSharedProxyGateway(
|
||||||
|
"http://192.168.50.111:3456",
|
||||||
|
async (url) => {
|
||||||
|
assert.equal(url, "http://192.168.50.111:3456/api/shared-proxy");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
available: true,
|
||||||
|
proxy: {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(patch.sharedProxyEnabled, true);
|
||||||
|
assert.equal(patch.sharedProxyControlUrl, "http://192.168.50.111:3456");
|
||||||
|
assert.deepEqual(patch.sharedProxy, {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
});
|
||||||
|
});
|
||||||
34
test/server/singbox-client-mode.test.js
Normal file
34
test/server/singbox-client-mode.test.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
process.env.APP_MODE = 'client';
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-client-test-'));
|
||||||
|
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
|
||||||
|
|
||||||
|
const { buildGatewayConfig } = await import(`../../src/server/singbox.js?client=${Date.now()}`);
|
||||||
|
|
||||||
|
const subscriptionConfig = {
|
||||||
|
outbounds: [{
|
||||||
|
type: 'vless',
|
||||||
|
tag: 'test-vpn',
|
||||||
|
server: 'vpn.example.test',
|
||||||
|
server_port: 443,
|
||||||
|
uuid: '00000000-0000-4000-8000-000000000000',
|
||||||
|
tls: { enabled: true },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('client exposes one local proxy and routes it through the selected VPN', () => {
|
||||||
|
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
|
||||||
|
|
||||||
|
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ['mixed-in']);
|
||||||
|
assert.equal(config.inbounds[0].listen_port, 8082);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
||||||
|
]);
|
||||||
|
assert.equal(config.route.final, 'test-vpn');
|
||||||
|
assert.equal(config.route.auto_detect_interface, undefined);
|
||||||
|
});
|
||||||
33
test/server/singbox-gateway-mode.test.js
Normal file
33
test/server/singbox-gateway-mode.test.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
process.env.APP_MODE = 'gateway';
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-'));
|
||||||
|
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
|
||||||
|
|
||||||
|
const { buildGatewayConfig } = await import(`../../src/server/singbox.js?gateway=${Date.now()}`);
|
||||||
|
|
||||||
|
const subscriptionConfig = {
|
||||||
|
outbounds: [{
|
||||||
|
type: 'vless',
|
||||||
|
tag: 'test-vpn',
|
||||||
|
server: 'vpn.example.test',
|
||||||
|
server_port: 443,
|
||||||
|
uuid: '00000000-0000-4000-8000-000000000000',
|
||||||
|
tls: { enabled: true },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('gateway routes transparent and proxy traffic only through the selected VPN', () => {
|
||||||
|
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
|
||||||
|
|
||||||
|
assert.deepEqual(config.route.rule_set, []);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
|
||||||
|
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
||||||
|
]);
|
||||||
|
assert.equal(config.route.final, 'test-vpn');
|
||||||
|
});
|
||||||
12
test/server/subscription.test.js
Normal file
12
test/server/subscription.test.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { parseSubscriptionBody } from '../../src/server/subscription.js';
|
||||||
|
|
||||||
|
test('subscription server tags are trimmed for selection', () => {
|
||||||
|
const { servers } = parseSubscriptionBody(JSON.stringify({
|
||||||
|
outbounds: [{ type: 'vless', tag: 'de-frankfurt ', server: 'de.example', server_port: 443 }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(servers[0].tag, 'de-frankfurt');
|
||||||
|
});
|
||||||
73
test/web/client-controls.test.js
Normal file
73
test/web/client-controls.test.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
connectionAction,
|
||||||
|
formatConnectionDuration,
|
||||||
|
localProxyUrls,
|
||||||
|
subscriptionDomain,
|
||||||
|
subscriptionDaysLeft,
|
||||||
|
subscriptionUsage,
|
||||||
|
} from '../../src/web/utils/clientControls.js';
|
||||||
|
|
||||||
|
test('connection button chooses the only valid client action', () => {
|
||||||
|
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
|
||||||
|
assert.deepEqual(connectionAction({ selectedTag: 'nl-amsterdam' }), {
|
||||||
|
type: 'apply',
|
||||||
|
selectedTag: 'nl-amsterdam',
|
||||||
|
});
|
||||||
|
assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' });
|
||||||
|
assert.equal(connectionAction({}), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connection duration is derived from the backend start time', () => {
|
||||||
|
const startedAt = '2026-07-10T10:00:00.000Z';
|
||||||
|
const now = Date.parse('2026-07-11T12:03:04.900Z');
|
||||||
|
|
||||||
|
assert.equal(formatConnectionDuration(startedAt, now), '26:03:04');
|
||||||
|
assert.equal(formatConnectionDuration(null, now), '00:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saved subscription is reduced to its public domain', () => {
|
||||||
|
assert.equal(subscriptionDomain('sub.example.com/…'), 'sub.example.com');
|
||||||
|
assert.equal(subscriptionDomain(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local proxy exposes both supported URLs', () => {
|
||||||
|
assert.deepEqual(localProxyUrls(18080), {
|
||||||
|
socks5: 'socks5://127.0.0.1:18080',
|
||||||
|
http: 'http://127.0.0.1:18080',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local proxy defaults to the macOS client port', () => {
|
||||||
|
assert.deepEqual(localProxyUrls(), {
|
||||||
|
socks5: 'socks5://127.0.0.1:8082',
|
||||||
|
http: 'http://127.0.0.1:8082',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gateway proxy URLs use its network address', () => {
|
||||||
|
assert.deepEqual(localProxyUrls(8080, '192.168.50.111'), {
|
||||||
|
socks5: 'socks5://192.168.50.111:8080',
|
||||||
|
http: 'http://192.168.50.111:8080',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscription usage combines traffic and caps progress', () => {
|
||||||
|
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
|
||||||
|
upload: 30,
|
||||||
|
download: 80,
|
||||||
|
total: 100,
|
||||||
|
used: 110,
|
||||||
|
percent: 100,
|
||||||
|
expiresAt: new Date(2000),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscription expiry uses Russian day forms', () => {
|
||||||
|
const now = Date.parse('2026-07-11T00:00:00Z');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-12T00:00:00Z'), now), 'остался 1 день');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-13T00:00:00Z'), now), 'осталось 2 дня');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-16T00:00:00Z'), now), 'осталось 5 дней');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user