From a0f41baa36afd73d1b137bfd94148b20d8dac30a Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Thu, 4 Jun 2026 10:24:33 +0300 Subject: [PATCH] Simplify client proxy port handling --- README.md | 9 +- docker-compose.client.yml | 10 +- .../vpn-proxy-full-mock-prototype.html | 1752 +++++++++++++++++ .../vpn-proxy-route-console-redesign.html | 774 ++++++++ ...vpn-proxy-client-route-console-redesign.md | 470 +++++ scripts/install-macos-client.sh | 38 +- src/server/config.js | 23 +- src/server/singbox.js | 2 +- src/web/components/ClientOverviewPage.jsx | 8 +- test/server/client-config.test.js | 45 + test/server/singbox-client-mode.test.js | 5 +- 11 files changed, 3088 insertions(+), 48 deletions(-) create mode 100644 docs/design/open-design/vpn-proxy-full-mock-prototype.html create mode 100644 docs/design/open-design/vpn-proxy-route-console-redesign.html create mode 100644 docs/superpowers/plans/2026-05-24-vpn-proxy-client-route-console-redesign.md create mode 100644 test/server/client-config.test.js diff --git a/README.md b/README.md index 26516fd..3661c30 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/inst После запуска по умолчанию: - UI: `http://127.0.0.1:3456` -- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; в UI можно выбрать порт из Docker-диапазона `8080–8090` +- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся: @@ -376,9 +376,10 @@ UI доступен на `http://:3456`. | `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически | | `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` | | `VPN_PROXY_CLIENT_UI_PORT` | unset | UI-порт для macOS installer; записывается в `CLIENT_UI_PORT` | -| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT_START/END` | -| `CLIENT_PROXY_PORT_START` | `8080` | Первый host/container proxy-порт для `docker-compose.client.yml` | -| `CLIENT_PROXY_PORT_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` | +| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT` и single-port `CLIENT_PROXY_PORT_START/END` | +| `CLIENT_PROXY_PORT` | `8080` | Единственный host/container proxy-порт для `docker-compose.client.yml` | +| `CLIENT_PROXY_PORT_START` | `8080` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом | +| `CLIENT_PROXY_PORT_END` | same as start | Совместимость со старым env; по умолчанию не расширяет Docker-публикацию в диапазон | | `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса | | `PORT` | `3456` | Порт веб-интерфейса | | `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror | diff --git a/docker-compose.client.yml b/docker-compose.client.yml index 71720b8..55a07e9 100644 --- a/docker-compose.client.yml +++ b/docker-compose.client.yml @@ -9,9 +9,9 @@ services: environment: APP_MODE: client PORT: ${PORT:-3456} - PROXY_PORT: ${CLIENT_PROXY_PORT_START:-8080} - CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT_START:-8080} - CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT_END:-8090} + PROXY_PORT: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}} + CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}} + CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}} PROXY_BIND_IP: 0.0.0.0 DATA_DIR: /var/lib/vpn-proxy SING_BOX_CONFIG: /etc/sing-box/config.json @@ -29,13 +29,13 @@ services: 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_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}" + - "127.0.0.1:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}" volumes: - vpn-proxy-client-data:/var/lib/vpn-proxy - sing-box-client-cache:/var/lib/sing-box restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"] + test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"] interval: 30s timeout: 5s retries: 3 diff --git a/docs/design/open-design/vpn-proxy-full-mock-prototype.html b/docs/design/open-design/vpn-proxy-full-mock-prototype.html new file mode 100644 index 0000000..8962ccb --- /dev/null +++ b/docs/design/open-design/vpn-proxy-full-mock-prototype.html @@ -0,0 +1,1752 @@ + + + + + + VPN Proxy Gateway Mock Prototype + + + +
+
+
+
+ +
+

VPN Gateway Control

+ Server gateway for LAN transparent and explicit proxy traffic +
+
+
+ gateway running + 192.168.50.111, tproxy :7895, proxy :8080 +
+
+ + + +
+
+ +
+ +
+ +
+
+
+ +
+ + + + + + + + diff --git a/docs/design/open-design/vpn-proxy-route-console-redesign.html b/docs/design/open-design/vpn-proxy-route-console-redesign.html new file mode 100644 index 0000000..8e8a365 --- /dev/null +++ b/docs/design/open-design/vpn-proxy-route-console-redesign.html @@ -0,0 +1,774 @@ + + + + + + VPN Proxy Route Console Redesign + + + +
+
+
+
+ +
+

VPN Proxy Client

+ Local control panel, macOS Docker mode +
+
+
+ sing-box running + + +
+
+ +
+ + +
+
+
+

Текущий маршрут: приложения Mac идут через локальный VPN

+

+ Главный экран показывает не настройки как список, а фактический путь трафика: + от приложения до интернета, с портом, выбранным режимом и состоянием сервиса. +

+
+
+ 42 ms + последняя проверка маршрута +
+
+ +
+
+ Источник + Mac apps + браузер, Discord, Telegram +
+
+ Локальный proxy + 127.0.0.1:8082 + HTTP и SOCKS5 +
+
+ Режим + Local VPN + Finland 02 +
+
+ Выход + Internet + проверка 11:04 +
+
+ +
+ Mac apps>127.0.0.1:8082>sing-box>Finland 02>Internet +
+ +
+
+
+

Настройка выбранного режима

+ +
+
+
+ + +
+
+
+ +
+
+

Сводка

+
+
+
+
Servicerunning since 10:52
+
Configapplied 2 minutes ago
+
FallbackVPN by default
+
Quota18 GB left
+
+
+
+
+
+ + +
+
+
+ + diff --git a/docs/superpowers/plans/2026-05-24-vpn-proxy-client-route-console-redesign.md b/docs/superpowers/plans/2026-05-24-vpn-proxy-client-route-console-redesign.md new file mode 100644 index 0000000..b07fbe3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-vpn-proxy-client-route-console-redesign.md @@ -0,0 +1,470 @@ +# VPN Proxy Client Route Console Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the current macOS client overview with a route-first console that makes the active traffic path, local proxy address, selected mode, and next action obvious at a glance. + +**Architecture:** Keep `resolveClientRoute()` as the single source of truth and keep `ClientOverviewPage` as the orchestrator. Split the screen into small presentational components inside `src/web/components/ClientOverviewPage.jsx`, then replace only the client-mode CSS block in `src/web/styles.css` so gateway and Windows work stay untouched. + +**Tech Stack:** React 19, Vite, Node.js `node:test`, existing CSS variables, Open Design static HTML artifact. + +**Design Artifact:** `docs/design/open-design/vpn-proxy-route-console-redesign.html` + +--- + +## Current Findings + +- `src/web/components/ClientOverviewPage.jsx` already has the right model: one overview screen, mutually exclusive `Gateway`, `VPN`, and `Direct` modes, and route state from `resolveClientRoute()`. +- `src/web/styles.css` makes the client screen visually separate, but it uses a dark blue-green palette that reads as a monitoring dashboard rather than a macOS setup tool. +- The current status panel, route line, mode grid, and proxy panel have similar visual weight. The user must scan several boxes to answer the primary question: where does my traffic go right now? +- Copyable proxy addresses sit in the side panel. They are useful, but they are visually separated from the route story. +- The three mode buttons look like cards. They work, but they do not communicate that mode selection changes the middle segment of the route. + +## Target Design + +Use a light, restrained operational UI for a normal macOS desktop context: a user has Docker running, a browser open, and is checking why an app uses a certain proxy path. The interface should feel closer to a compact network control console than a server dashboard. + +The first viewport should show: + +- top status: service running, restart, apply route; +- left mode rail: Gateway, Local VPN, Direct; +- main route strip: `Mac apps > local proxy > selected route > Internet`; +- right utility panel: copy proxy addresses, proxy port, recent activity; +- settings below route: only the form for the selected mode. + +## File Structure + +- Modify `src/web/components/ClientOverviewPage.jsx`: reorganize render structure into route console subcomponents while preserving props and handlers. +- Modify `src/web/styles.css`: replace `.client-*` layout styles from `.client-mode .app-main` through the final client media query. +- Test `test/web/client-route.test.js`: extend route state coverage so UI changes do not hide incorrect mode/status combinations. +- Keep `docs/design/open-design/vpn-proxy-route-console-redesign.html`: reference artifact for visual decisions. + +--- + +### Task 1: Lock Route Contract Before UI Changes + +**Files:** +- Modify: `test/web/client-route.test.js` + +- [ ] **Step 1: Add tests for all user-visible route statuses** + +Add these cases to `test/web/client-route.test.js`: + +```js +test('resolves running local VPN route', () => { + const route = resolveClientRoute({ + state: { + singboxRunning: true, + configExists: true, + proxyPort: 8082, + selectedTag: 'finland-02', + clientSettings: { homeBypassEnabled: false, sharedProxyEnabled: false }, + }, + activeServer: { tag: 'finland-02' }, + }); + + assert.equal(route.mode, 'vpn'); + assert.equal(route.status, 'connected'); + assert.equal(route.localProxy, '127.0.0.1:8082'); + assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'VPN finland-02', 'Internet']); +}); + +test('resolves gateway route when shared proxy is enabled', () => { + const route = resolveClientRoute({ + state: { + singboxRunning: true, + configExists: true, + proxyPort: 8082, + clientSettings: { + sharedProxyEnabled: true, + sharedProxy: { host: '192.168.50.111', port: 8080 }, + }, + }, + }); + + assert.equal(route.mode, 'gateway'); + assert.equal(route.status, 'connected'); + assert.equal(route.target, '192.168.50.111:8080'); + assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'Gateway 192.168.50.111:8080', 'Internet']); +}); + +test('resolves direct route when home bypass is enabled', () => { + const route = resolveClientRoute({ + state: { + singboxRunning: true, + configExists: true, + clientSettings: { homeBypassEnabled: true, sharedProxyEnabled: false, proxyPort: 8084 }, + }, + }); + + assert.equal(route.mode, 'direct'); + assert.equal(route.status, 'connected'); + assert.equal(route.localProxy, '127.0.0.1:8084'); + assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8084', 'Direct', 'Internet']); +}); +``` + +- [ ] **Step 2: Run the route tests** + +Run: + +```bash +npm test -- test/web/client-route.test.js +``` + +Expected: all existing and new route tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add test/web/client-route.test.js +git commit -m "test: lock client route display contract" +``` + +--- + +### Task 2: Restructure Client Overview Markup + +**Files:** +- Modify: `src/web/components/ClientOverviewPage.jsx` + +- [ ] **Step 1: Replace the route line with route nodes** + +Replace `RouteLine` with: + +```jsx +function RouteStrip({ route }) { + const nodes = [ + { label: 'Источник', value: route.path[0], detail: 'приложения Mac' }, + { label: 'Локальный proxy', value: route.localProxy, detail: 'HTTP и SOCKS5' }, + { label: 'Режим', value: route.target, detail: route.targetDetail, active: route.status === 'connected' }, + { label: 'Выход', value: 'Internet', detail: route.status === 'connected' ? 'маршрут активен' : 'ожидает запуска' }, + ]; + + return ( +
+ {nodes.map((node) => ( +
+ {node.label} + {node.value} + {node.detail} +
+ ))} +
+ ); +} + +function RoutePath({ route }) { + return ( +
+ {route.path.map((item, index) => ( + + {item} + {index < route.path.length - 1 && {'>'}} + + ))} +
+ ); +} +``` + +- [ ] **Step 2: Add a mode rail component** + +Add: + +```jsx +function ModeRail({ route, setupMode, clientSettings, state, busy, onGateway, onVpn, onDirect }) { + const modes = [ + { + id: 'gateway', + title: 'Общий gateway', + subtitle: clientSettings?.sharedProxy + ? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}` + : 'серверная proxy', + onClick: onGateway, + }, + { + id: 'vpn', + title: 'Локальный VPN', + subtitle: state?.selectedTag || 'выбрать сервер', + onClick: onVpn, + }, + { + id: 'direct', + title: 'Напрямую', + subtitle: 'без VPN', + onClick: onDirect, + }, + ]; + + return ( + + ); +} +``` + +- [ ] **Step 3: Replace the top-level JSX** + +Use this layout in `ClientOverviewPage`: + +```jsx +return ( +
+ { + setSetupMode('direct'); + enableDirect(); + }} + /> + +
+ + + + +
+ {setupMode === 'gateway' && ( + + )} + {setupMode === 'vpn' && ( + + )} + {setupMode === 'direct' && } +
+
+ + +
+); +``` + +- [ ] **Step 4: Run build** + +Run: + +```bash +npm run build +``` + +Expected: Vite build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/components/ClientOverviewPage.jsx +git commit -m "refactor: reshape client overview around route console" +``` + +--- + +### Task 3: Replace Client Visual System + +**Files:** +- Modify: `src/web/styles.css` + +- [ ] **Step 1: Replace only the client CSS block** + +Replace the CSS from `.client-mode .app-main` through the client media query with the style direction from `docs/design/open-design/vpn-proxy-route-console-redesign.html`. Keep selectors scoped to `.client-*` so gateway screens keep the existing palette. + +Use these token values for the client block: + +```css +.app-body.client-mode { + grid-template-columns: 1fr; + background: oklch(0.965 0.008 232); +} + +.client-mode .topbar { + background: oklch(0.978 0.007 232); + border-bottom-color: oklch(0.835 0.018 232); +} + +.client-mode .app-main { + max-width: 1320px; + width: 100%; + margin: 0 auto; + padding: 18px; + color: oklch(0.238 0.028 238); +} + +.client-console { + min-height: calc(100vh - var(--topbar-h) - 36px); + display: grid; + grid-template-columns: 264px minmax(0, 1fr) 312px; + overflow: hidden; + background: oklch(0.986 0.006 232); + border: 1px solid oklch(0.835 0.018 232); + border-radius: 8px; + box-shadow: 0 18px 42px oklch(0.36 0.035 238 / 0.13); +} +``` + +- [ ] **Step 2: Add responsive behavior** + +Add: + +```css +@media (max-width: 1080px) { + .client-console { + grid-template-columns: 220px minmax(0, 1fr); + } + + .client-side-panel { + grid-column: 1 / -1; + border-left: 0; + border-top: 1px solid oklch(0.835 0.018 232); + } + + .client-route-strip { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 760px) { + .client-console, + .client-route-strip, + .client-inline-form, + .client-port-row { + grid-template-columns: 1fr; + } + + .client-mode-rail { + border-right: 0; + border-bottom: 1px solid oklch(0.835 0.018 232); + } +} +``` + +- [ ] **Step 3: Verify no banned patterns were introduced** + +Run: + +```bash +rg -n "background-clip:\\s*text|border-left:\\s*[2-9]|border-right:\\s*[2-9]|backdrop-filter|letter-spacing:\\s*-" src/web/styles.css +``` + +Expected: no matches. + +- [ ] **Step 4: Run build** + +Run: + +```bash +npm run build +``` + +Expected: Vite build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/styles.css +git commit -m "style: apply light route console client theme" +``` + +--- + +### Task 4: Browser Verification + +**Files:** +- No file changes expected. + +- [ ] **Step 1: Start the dev server** + +Run: + +```bash +npm run dev -- --host 127.0.0.1 --port 4567 +``` + +Expected: Vite listens on `http://127.0.0.1:4567`. + +- [ ] **Step 2: Open client mode with representative state** + +Use the browser to open: + +```text +http://127.0.0.1:4567 +``` + +Expected: the first viewport shows the mode rail, route strip, route path, selected-mode form, and copyable proxy addresses without overlap at desktop width. + +- [ ] **Step 3: Check mobile width** + +Resize to 390px wide. + +Expected: rail, route workspace, and proxy panel stack vertically; long proxy URLs truncate inside their containers; action buttons remain readable. + +- [ ] **Step 4: Run final verification** + +Run: + +```bash +npm test +npm run build +git diff --check +``` + +Expected: all commands pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/components/ClientOverviewPage.jsx src/web/styles.css test/web/client-route.test.js +git commit -m "feat: redesign client overview as route console" +``` + +--- + +## Self-Review + +Spec coverage: + +- Current UX assessment is captured in `Current Findings`. +- New design direction is captured in `Target Design`. +- Open Design artifact is referenced explicitly. +- Implementation tasks cover route contract, markup, scoped CSS, and browser verification. + +Placeholder scan: + +- No `TBD`, `TODO`, or unspecified validation steps remain. + +Type consistency: + +- Route fields match `resolveClientRoute()`: `mode`, `status`, `localProxy`, `target`, `targetDetail`, `path`. diff --git a/scripts/install-macos-client.sh b/scripts/install-macos-client.sh index c409665..0e73a56 100755 --- a/scripts/install-macos-client.sh +++ b/scripts/install-macos-client.sh @@ -59,15 +59,6 @@ ask_proxy_port() { printf '%s\n' "$DEFAULT_PROXY_PORT" } -port_range_end() { - local start="$1" - local end="$((start + 10))" - if [ "$end" -gt 65535 ]; then - end=65535 - fi - printf '%s\n' "$end" -} - published_port_conflicts() { local port="$1" local line @@ -82,18 +73,7 @@ published_port_conflicts() { } proxy_port_conflicts() { - local start="$1" - local end - local port - local conflicts - - end="$(port_range_end "$start")" - for port in $(seq "$start" "$end"); do - conflicts="$(published_port_conflicts "$port")" - if [ -n "$conflicts" ]; then - printf 'port %s: %s\n' "$port" "$conflicts" - fi - done + published_port_conflicts "$1" } assert_proxy_port_available() { @@ -105,8 +85,8 @@ assert_proxy_port_available() { return 0 fi - printf '[vpn-proxy-client] proxy port range %s-%s is already used:\n%s\n' \ - "$port" "$(port_range_end "$port")" "$conflicts" >&2 + 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= or stop the conflicting container" } @@ -170,8 +150,8 @@ choose_ui_port() { } assert_ui_outside_proxy_range() { - if [ "$UI_PORT" -ge "$PROXY_PORT" ] && [ "$UI_PORT" -le "$PROXY_PORT_END" ]; then - die "UI port ${UI_PORT} overlaps proxy port range ${PROXY_PORT}-${PROXY_PORT_END}" + if [ "$UI_PORT" = "$PROXY_PORT" ]; then + die "UI port ${UI_PORT} overlaps proxy port" fi } @@ -181,7 +161,7 @@ wait_for_client_ui() { local attempt for attempt in $(seq 1 30); do - if curl -fsS "$ui_url" >/dev/null 2>&1; then + if curl --noproxy "*" -fsS "$ui_url" >/dev/null 2>&1; then return 0 fi sleep 1 @@ -254,7 +234,7 @@ fi PROXY_PORT="$(ask_proxy_port)" assert_proxy_port_available "$PROXY_PORT" -PROXY_PORT_END="$(port_range_end "$PROXY_PORT")" +PROXY_PORT_END="$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")" @@ -268,7 +248,7 @@ set_env_value CLIENT_PROXY_PORT_END "$PROXY_PORT_END" 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} (reserved range ${PROXY_PORT}-${PROXY_PORT_END})" +log "proxy port: 127.0.0.1:${PROXY_PORT}" log "building and starting Docker client" docker compose -f "$COMPOSE_FILE" up -d --build @@ -283,7 +263,7 @@ UI: Proxy: HTTP/SOCKS5 127.0.0.1:${PROXY_PORT} - UI can switch proxy port within the Docker-published ${PROXY_PORT}-${PROXY_PORT_END} range. + This is the only Docker-published proxy port. Re-run the installer with VPN_PROXY_CLIENT_PORT= to change it. Useful commands: cd ~/.vpn-proxy-client diff --git a/src/server/config.js b/src/server/config.js index f64c6bf..c9fcf96 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -1,14 +1,27 @@ import path from "node:path"; 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, 8080); +const clientProxyPortStart = parsePort( + process.env.CLIENT_PROXY_PORT_START, + proxyPort, +); +const clientProxyPortEnd = parsePort( + process.env.CLIENT_PROXY_PORT_END, + clientProxyPortStart, +); export const settings = { appMode: process.env.APP_MODE === "client" ? "client" : "gateway", - port: Number(process.env.PORT || 3456), - proxyPort: Number(process.env.PROXY_PORT || 8080), - clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080), - clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090), - tproxyPort: Number(process.env.TPROXY_PORT || 7895), + port: parsePort(process.env.PORT, 3456), + proxyPort, + clientProxyPortStart, + clientProxyPortEnd, + tproxyPort: parsePort(process.env.TPROXY_PORT, 7895), tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY", tproxySourceBypassChain: process.env.TPROXY_SOURCE_BYPASS_CHAIN || "VPN_PROXY_SRC_BYPASS", diff --git a/src/server/singbox.js b/src/server/singbox.js index 1e6958d..d59448d 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -347,7 +347,7 @@ export function buildGatewayConfig( includeTransparent: !clientMode, }), final: "direct", - auto_detect_interface: true, + ...(clientMode ? {} : { auto_detect_interface: true }), }, }; } diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 7b0c5ee..440daed 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -203,6 +203,7 @@ function ProxySettings({ state, settings, busy, onSave }) { const parsed = Number.parseInt(draftPort, 10); const invalid = !Number.isInteger(parsed) || parsed < range.start || parsed > range.end; const dirty = !invalid && parsed !== port; + const singlePublishedPort = range.start === range.end; return ( ); diff --git a/test/server/client-config.test.js b/test/server/client-config.test.js new file mode 100644 index 0000000..49aa422 --- /dev/null +++ b/test/server/client-config.test.js @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +async function withEnv(patch, fn) { + const previous = {}; + for (const key of Object.keys(patch)) { + previous[key] = process.env[key]; + if (patch[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = patch[key]; + } + } + + try { + return await fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +test("client proxy range defaults to the single configured proxy port", async () => { + await withEnv( + { + PROXY_PORT: "8082", + CLIENT_PROXY_PORT_START: "8082", + CLIENT_PROXY_PORT_END: undefined, + }, + async () => { + const { settings } = await import( + `../../src/server/config.js?single-proxy-port=${Date.now()}` + ); + + assert.equal(settings.proxyPort, 8082); + assert.equal(settings.clientProxyPortStart, 8082); + assert.equal(settings.clientProxyPortEnd, 8082); + }, + ); +}); diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js index 566e2b9..1a1ff9d 100644 --- a/test/server/singbox-client-mode.test.js +++ b/test/server/singbox-client-mode.test.js @@ -44,6 +44,7 @@ test("client mode routes mixed proxy fallback to the selected VPN", () => { const config = buildGatewayConfig(subscriptionConfig, "test-vpn"); assert.deepEqual(config.route.rule_set, []); + assert.equal(config.route.auto_detect_interface, undefined); assert.deepEqual(config.route.rules, [ { inbound: ["mixed-in"], outbound: "test-vpn" }, ]); @@ -82,7 +83,7 @@ test("client home bypass can build direct proxy without local VPN", () => { ]); }); -test("client mode uses selected proxy port from client settings", () => { +test("client mode ignores saved proxy port outside the published single port", () => { fs.rmSync(clientSettingsPath, { force: true }); fs.writeFileSync( clientSettingsPath, @@ -91,7 +92,7 @@ test("client mode uses selected proxy port from client settings", () => { const config = buildGatewayConfig(subscriptionConfig, "test-vpn"); - assert.equal(config.inbounds[0].listen_port, 8085); + assert.equal(config.inbounds[0].listen_port, 8080); assert.deepEqual(config.route.rules, [ { inbound: ["mixed-in"], outbound: "test-vpn" }, ]);