From 73488384e423061dfa120d2de341cc7444b43dd8 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Tue, 19 May 2026 16:31:33 +0300 Subject: [PATCH] feat: improve macos client proxy setup --- .env.example | 3 +- README.md | 15 ++- docker-compose.client.yml | 6 +- scripts/install-macos-client.sh | 117 ++++++++++++++++++++-- src/server/clientSettings.js | 20 ++++ src/server/config.js | 2 + src/server/index.js | 10 +- src/server/singbox.js | 3 +- src/web/components/ClientOverviewPage.jsx | 56 +++++++++-- test/server/singbox-client-mode.test.js | 21 +++- 10 files changed, 230 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index 94a2185..9edc26c 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ PORT=3456 APP_MODE=gateway CLIENT_UI_PORT=3456 -CLIENT_PROXY_PORT=8080 +CLIENT_PROXY_PORT_START=8080 +CLIENT_PROXY_PORT_END=8090 BASE_IMAGE=debian:bookworm-slim SINGBOX_VERSION=1.12.13 INSTALL_RUNTIME_DEPS=true diff --git a/README.md b/README.md index 5d50617..a5487ea 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,17 @@ 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` +- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; в UI можно выбрать порт из Docker-диапазона `8080–8090` -В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют `127.0.0.1:8080`, но весь proxy-трафик идёт напрямую без VPN. +Установщик интерактивно спросит proxy-порт. Для неинтерактивного запуска можно задать его заранее; тогда вопрос не появится: + +```bash +curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_PORT=18080 bash +``` + +После запуска скрипт проверяет, что UI реально ответил на `/api/state`. Если контейнер сразу упал или порт занят, он покажет `docker compose ps` и последние логи вместо ложного сообщения о готовности. + +В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют выбранный локальный proxy-порт, но весь proxy-трафик идёт напрямую без VPN. Ручной запуск из checkout: @@ -343,7 +351,8 @@ UI доступен на `http://:3456`. | ------------------- | -------------------- | -------------------------------------- | | `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически | | `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` | -| `CLIENT_PROXY_PORT` | `8080` | Host-порт proxy для `docker-compose.client.yml` | +| `CLIENT_PROXY_PORT_START` | `8080` | Первый host/container proxy-порт для `docker-compose.client.yml` | +| `CLIENT_PROXY_PORT_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` | | `PORT` | `3456` | Порт веб-интерфейса | | `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror | | `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build | diff --git a/docker-compose.client.yml b/docker-compose.client.yml index 91826f6..a6f9c10 100644 --- a/docker-compose.client.yml +++ b/docker-compose.client.yml @@ -9,7 +9,9 @@ services: environment: APP_MODE: client PORT: ${PORT:-3456} - PROXY_PORT: ${PROXY_PORT:-8080} + 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_BIND_IP: 0.0.0.0 DATA_DIR: /var/lib/vpn-proxy SING_BOX_CONFIG: /etc/sing-box/config.json @@ -19,7 +21,7 @@ services: LOG_LEVEL: ${LOG_LEVEL:-info} ports: - "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}" - - "127.0.0.1:${CLIENT_PROXY_PORT:-8080}:${PROXY_PORT:-8080}" + - "127.0.0.1:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}" volumes: - vpn-proxy-client-data:/var/lib/vpn-proxy - sing-box-client-cache:/var/lib/sing-box diff --git a/scripts/install-macos-client.sh b/scripts/install-macos-client.sh index c06f49d..69bffa8 100755 --- a/scripts/install-macos-client.sh +++ b/scripts/install-macos-client.sh @@ -5,6 +5,8 @@ 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="8080" +REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}" log() { printf '[vpn-proxy-client] %s\n' "$*" @@ -19,12 +21,98 @@ 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 + 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" +} + +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 -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" @@ -46,18 +134,35 @@ if [[ ! -f .env && -f .env.example ]]; then cp .env.example .env fi +PROXY_PORT="$(ask_proxy_port)" +PROXY_PORT_END="$((PROXY_PORT + 10))" +if [ "$PROXY_PORT_END" -gt 65535 ]; then + PROXY_PORT_END=65535 +fi +UI_PORT="${CLIENT_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}" +UI_PORT="${UI_PORT:-3456}" + +set_env_value APP_MODE client +set_env_value CLIENT_PROXY_PORT_START "$PROXY_PORT" +set_env_value CLIENT_PROXY_PORT_END "$PROXY_PORT_END" +set_env_value PROXY_PORT "$PROXY_PORT" + +log "proxy port: 127.0.0.1:${PROXY_PORT} (reserved range ${PROXY_PORT}-${PROXY_PORT_END})" + log "building and starting Docker client" docker compose -f "$COMPOSE_FILE" up -d --build +wait_for_client_ui -cat <<'EOF' +cat <= min && fallback <= max + ? fallback + : min; + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + return fallbackPort; + } + return parsed; +} + function readJson(filePath, fallback) { try { if (!fs.existsSync(filePath)) return fallback; @@ -23,12 +41,14 @@ function writeJson(filePath, value) { export function normalizeClientSettings(input = {}) { return { homeBypassEnabled: Boolean(input.homeBypassEnabled), + proxyPort: normalizeProxyPort(input.proxyPort), }; } export function readClientSettings() { return normalizeClientSettings({ ...DEFAULT_CLIENT_SETTINGS, + proxyPort: settings.proxyPort, ...readJson(settings.clientSettingsPath, {}), }); } diff --git a/src/server/config.js b/src/server/config.js index f87d94e..4886b27 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -6,6 +6,8 @@ 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), bindIp: process.env.PROXY_BIND_IP || "0.0.0.0", dataDir, diff --git a/src/server/index.js b/src/server/index.js index 519df8d..f535c15 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -598,15 +598,21 @@ function publicState() { const state = readJson(settings.statePath, {}); const customRules = readJson(settings.customRulesPath, []); const deviceProfiles = readDeviceProfiles(); + const clientSettings = readClientSettings(); const { subscriptionUrl, ...rest } = state; return { mode: settings.appMode, port: settings.port, - proxyPort: settings.proxyPort, + proxyPort: + settings.appMode === "client" ? clientSettings.proxyPort : settings.proxyPort, + clientProxyPortRange: { + start: settings.clientProxyPortStart, + end: settings.clientProxyPortEnd, + }, proxyBindIp: settings.bindIp, tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null, routingRuDirect: settings.routingRuDirect, - clientSettings: readClientSettings(), + clientSettings, configExists: fs.existsSync(settings.configPath), singboxRunning: Boolean(singboxProcess), singboxStartedAt, diff --git a/src/server/singbox.js b/src/server/singbox.js index 8ecc5cc..0f951e2 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -264,6 +264,7 @@ export function buildGatewayConfig( const clientOutbound = clientSettings?.homeBypassEnabled ? "direct" : vpnOutbound.tag; + const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort; const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }]; const inbounds = [ ...(clientMode @@ -282,7 +283,7 @@ export function buildGatewayConfig( type: "mixed", tag: "mixed-in", listen: settings.bindIp, - listen_port: settings.proxyPort, + listen_port: mixedProxyPort, sniff: true, set_system_proxy: false, }, diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index d456a54..afa8317 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { flagFor } from '../utils/country.js'; import { formatBytes, formatRelative } from '../utils/format.js'; @@ -166,8 +166,21 @@ function ClientSetup({ ); } -function ProxyCard({ state }) { - const port = state?.proxyPort || 8080; +function ProxyCard({ state, settings, busy, onSave }) { + const range = state?.clientProxyPortRange || { start: 8080, end: 8090 }; + const port = settings?.proxyPort || state?.proxyPort || 8080; + const [draftPort, setDraftPort] = useState(String(port)); + + useEffect(() => { + setDraftPort(String(port)); + }, [port]); + + const parsedDraftPort = Number.parseInt(draftPort, 10); + const portInvalid = + !Number.isInteger(parsedDraftPort) || + parsedDraftPort < range.start || + parsedDraftPort > range.end; + const portDirty = !portInvalid && parsedDraftPort !== port; const urls = useMemo(() => ({ http: `http://127.0.0.1:${port}`, socks: `socks5://127.0.0.1:${port}`, @@ -179,6 +192,29 @@ function ProxyCard({ state }) {

Локальный proxy

127.0.0.1:{port} +
+ +
+ setDraftPort(e.target.value)} + /> + +
+ + Доступный диапазон: {range.start}–{range.end} + +
@@ -187,8 +223,9 @@ function ProxyCard({ state }) { ); } -function HomeBypassCard({ settings, busy, onSave }) { +function HomeBypassCard({ state, settings, busy, onSave }) { const enabled = Boolean(settings?.homeBypassEnabled); + const port = settings?.proxyPort || state?.proxyPort || 8080; return (
@@ -199,7 +236,7 @@ function HomeBypassCard({ settings, busy, onSave }) {

- Включайте дома: приложения продолжают использовать 127.0.0.1:8080, но VPN не используется. + Включайте дома: приложения продолжают использовать 127.0.0.1:{port}, но VPN не используется.