diff --git a/.dockerignore b/.dockerignore index 8ef72ea..5d44064 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ node_modules .vpn-proxy +.runtime .git .gitea .github diff --git a/.env.example b/.env.example index b67accc..18468d0 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ PORT=3456 APP_MODE=gateway CLIENT_UI_PORT=3456 CLIENT_PROXY_PORT=8082 +HARBOR_GATEWAY_CONTROL_PORT=3456 BASE_IMAGE=debian:bookworm-slim SINGBOX_VERSION=1.12.13 INSTALL_RUNTIME_DEPS=true diff --git a/.gitignore b/.gitignore index 372f91d..9c52843 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.env.local data/ .vpn-proxy/ +.runtime/ .worktrees/ # Node/Vite diff --git a/README.md b/README.md index be294a8..4d87ad6 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ Proxy по умолчанию разрешён только из приватн По умолчанию интерфейс доступен на `http://127.0.0.1:3456`, локальный HTTP/SOCKS5 proxy — на `127.0.0.1:8082`. +Установщик также добавляет пользовательский LaunchAgent. Он раз в 5 секунд передаёт Connect текущий default gateway macOS. Если этот адрес отвечает как Harbor Gateway и подтверждает ту же подписку, Connect автоматически оставляет локальный proxy на `127.0.0.1`, но переключает его outbound на `direct`: дальнейший трафик обрабатывает системный Harbor Gateway. При смене сети или трёх ошибках проверки Connect возвращается к выбранному локальному VPN. + +Автоопределение не требует выбора домашней Wi-Fi сети. На Connect и Gateway должна быть настроена ссылка с одним и тем же секретным token длиной не менее 24 символов; token используется только для проверки presence-ответа и не передаётся между устройствами. + ## Проверка ```bash diff --git a/docker-compose.client.yml b/docker-compose.client.yml index d8e8b88..26f0a3d 100644 --- a/docker-compose.client.yml +++ b/docker-compose.client.yml @@ -14,6 +14,8 @@ services: DATA_DIR: /var/lib/vpn-proxy SING_BOX_CONFIG: /etc/sing-box/config.json SING_BOX_CACHE: /var/lib/sing-box/cache.db + HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json + HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456} LOG_LEVEL: ${LOG_LEVEL:-info} HTTP_PROXY: "" HTTPS_PROXY: "" @@ -29,6 +31,7 @@ services: volumes: - vpn-proxy-client-data:/var/lib/vpn-proxy - sing-box-client-cache:/var/lib/sing-box + - ./.runtime:/run/harbor-host:ro restart: unless-stopped healthcheck: test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"] diff --git a/scripts/harbor-network-monitor.sh b/scripts/harbor-network-monitor.sh new file mode 100644 index 0000000..375af62 --- /dev/null +++ b/scripts/harbor-network-monitor.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C + +RUNTIME_DIR="${HARBOR_RUNTIME_DIR:-$HOME/.vpn-proxy-client/.runtime}" +STATE_FILE="$RUNTIME_DIR/network.json" +ROUTE_BIN="${HARBOR_ROUTE_BIN:-/sbin/route}" +ARP_BIN="${HARBOR_ARP_BIN:-/usr/sbin/arp}" +NETSTAT_BIN="${HARBOR_NETSTAT_BIN:-/usr/sbin/netstat}" + +route_info="$($ROUTE_BIN -n get default 2>/dev/null || true)" +gateway="$(awk '/^[[:space:]]*gateway:/{print $2; exit}' <<<"$route_info")" +network_interface="$(awk '/^[[:space:]]*interface:/{print $2; exit}' <<<"$route_info")" + +if [[ -z "$gateway" || -z "$network_interface" ]]; then + read -r gateway network_interface < <( + "$NETSTAT_BIN" -rn -f inet 2>/dev/null \ + | awk '$1 == "default" && $2 ~ /^[0-9]+\./ { print $2, $4; exit }' + ) || true +fi + +if [[ ! "$gateway" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + gateway="" + network_interface="" +fi +if [[ ! "$network_interface" =~ ^[a-zA-Z0-9._-]{1,32}$ ]]; then + network_interface="" +fi + +mac="" +if [[ -n "$gateway" ]]; then + mac="$($ARP_BIN -n "$gateway" 2>/dev/null | awk '/ at /{print $4; exit}' || true)" + if [[ ! "$mac" =~ ^[a-fA-F0-9]{2}(:[a-fA-F0-9]{2}){5}$ ]]; then + mac="" + else + mac="$(printf '%s' "$mac" | tr '[:upper:]' '[:lower:]')" + fi +fi + +mkdir -p "$RUNTIME_DIR" +tmp="$(mktemp "${STATE_FILE}.XXXXXX")" +trap 'rm -f "$tmp"' EXIT +printf '{"gateway":"%s","interface":"%s","mac":"%s","observedAt":"%s"}\n' \ + "$gateway" "$network_interface" "$mac" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$tmp" +mv "$tmp" "$STATE_FILE" diff --git a/scripts/install-macos-client.sh b/scripts/install-macos-client.sh index 5577be6..fe24c72 100755 --- a/scripts/install-macos-client.sh +++ b/scripts/install-macos-client.sh @@ -9,6 +9,7 @@ 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" +NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network" log() { printf '[vpn-proxy-client] %s\n' "$*" @@ -175,6 +176,54 @@ wait_for_client_ui() { die "client UI is not ready; see Docker status and logs above" } +xml_escape() { + sed -e 's/&/\&/g' -e 's//\>/g' -e 's/"/\"/g' +} + +install_network_monitor() { + local launch_agents_dir="$HOME/Library/LaunchAgents" + local plist_path="$launch_agents_dir/${NETWORK_MONITOR_LABEL}.plist" + local escaped_script_path + local escaped_runtime_dir + local user_domain="gui/$(id -u)" + + escaped_script_path="$(printf '%s' "$INSTALL_DIR/scripts/harbor-network-monitor.sh" | xml_escape)" + escaped_runtime_dir="$(printf '%s' "$INSTALL_DIR/.runtime" | xml_escape)" + mkdir -p "$INSTALL_DIR/.runtime" "$launch_agents_dir" + + cat > "$plist_path" < + + + + Label + ${NETWORK_MONITOR_LABEL} + ProgramArguments + + /bin/bash + ${escaped_script_path} + + EnvironmentVariables + + HARBOR_RUNTIME_DIR + ${escaped_runtime_dir} + + RunAtLoad + + StartInterval + 5 + ProcessType + Background + + +EOF + + /bin/bash "$INSTALL_DIR/scripts/harbor-network-monitor.sh" + launchctl bootout "$user_domain" "$plist_path" >/dev/null 2>&1 || true + launchctl bootstrap "$user_domain" "$plist_path" + log "automatic Gateway detection enabled" +} + set_env_value() { local key="$1" local value="$2" @@ -247,6 +296,8 @@ 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}" +install_network_monitor + log "building and starting Docker client" docker compose -f "$COMPOSE_FILE" up -d --build wait_for_client_ui diff --git a/src/server/config.js b/src/server/config.js index ffe49fd..35a9668 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -25,6 +25,9 @@ export const settings = { statePath: path.join(dataDir, "state.json"), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), sharedProxyHost: process.env.SHARED_PROXY_HOST || "", + hostNetworkStatePath: + process.env.HARBOR_HOST_NETWORK_STATE || "/run/harbor-host/network.json", + gatewayPresencePort: parsePort(process.env.HARBOR_GATEWAY_CONTROL_PORT, 3456), hwidPath: path.join(dataDir, "hwid"), logLevel: process.env.LOG_LEVEL || "info", appName: "VPN Proxy Gateway", diff --git a/src/server/gatewayPresence.js b/src/server/gatewayPresence.js new file mode 100644 index 0000000..ded8ddd --- /dev/null +++ b/src/server/gatewayPresence.js @@ -0,0 +1,210 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; + +const NONCE_RE = /^[a-f0-9]{32}$/; +const PROOF_RE = /^[a-f0-9]{64}$/; +const INTERFACE_RE = /^[a-zA-Z0-9._-]{1,32}$/; +const MAC_RE = /^[a-f0-9]{2}(?::[a-f0-9]{2}){5}$/i; +const SECRET_QUERY_KEYS = new Set(['access_token', 'auth', 'key', 'secret', 'token', 'uuid']); + +function isIpv4(value) { + const parts = String(value || '').split('.'); + return parts.length === 4 && parts.every((part) => ( + /^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255 + )); +} + +function subscriptionSecret(subscriptionUrl) { + try { + const url = new URL(String(subscriptionUrl || '').trim()); + const pathSegments = url.pathname.split('/').filter(Boolean); + const candidates = [ + url.username, + url.password, + ...[...url.searchParams.entries()] + .filter(([key]) => SECRET_QUERY_KEYS.has(key.toLowerCase())) + .map(([, value]) => value), + pathSegments.at(-1), + ] + .map((value) => String(value || '').trim()) + .filter((value) => value.length >= 24); + if (!candidates.length) return ''; + + url.hash = ''; + url.searchParams.sort(); + return url.toString(); + } catch { + return ''; + } +} + +function presenceProof(subscriptionUrl, nonce, gatewayId) { + const credentialUrl = subscriptionSecret(subscriptionUrl); + if (!credentialUrl) return ''; + const key = crypto.createHash('sha256') + .update(`harbor-gateway-presence-key\n${credentialUrl}`) + .digest(); + return crypto.createHmac('sha256', key) + .update(`v1\n${nonce}\n${gatewayId}`) + .digest('hex'); +} + +export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }) { + if (!NONCE_RE.test(String(nonce || ''))) { + const error = new Error('Некорректный nonce'); + error.statusCode = 400; + throw error; + } + + const subscription = String(subscriptionUrl || '').trim(); + const id = String(gatewayId || '').trim(); + if (appMode !== 'gateway' || !subscriptionSecret(subscription) || !id) { + return { + success: true, + available: false, + product: 'harbor', + role: appMode, + protocolVersion: 1, + }; + } + + return { + success: true, + available: true, + product: 'harbor', + role: 'gateway', + protocolVersion: 1, + gatewayId: id, + transparentRouting: true, + proof: presenceProof(subscription, nonce, id), + }; +} + +export function verifyGatewayPresence(payload, { subscriptionUrl, nonce }) { + if ( + payload?.available !== true || + payload?.product !== 'harbor' || + payload?.role !== 'gateway' || + payload?.protocolVersion !== 1 || + payload?.transparentRouting !== true || + !payload.gatewayId || + !NONCE_RE.test(String(nonce || '')) || + !PROOF_RE.test(String(payload.proof || '')) + ) return false; + + const actual = Buffer.from(payload.proof, 'hex'); + const expectedProof = presenceProof(subscriptionUrl, nonce, String(payload.gatewayId)); + if (!expectedProof) return false; + const expected = Buffer.from(expectedProof, 'hex'); + return crypto.timingSafeEqual(actual, expected); +} + +export async function probeGatewayPresence({ + gateway, + subscriptionUrl, + port = 3456, + fetchImpl = fetch, + timeoutMs = 1000, + nonce = crypto.randomBytes(16).toString('hex'), +}) { + if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway'); + + const response = await fetchImpl( + `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`, + { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) }, + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !verifyGatewayPresence(payload, { subscriptionUrl, nonce })) { + throw new Error('Текущий default gateway не является доверенным Harbor Gateway'); + } + + return { gatewayId: payload.gatewayId }; +} + +export function normalizeHostNetworkState(value, { + now = Date.now(), + maxAgeMs = 15_000, +} = {}) { + const gateway = String(value?.gateway || '').trim(); + const networkInterface = String(value?.interface || '').trim(); + const mac = String(value?.mac || '').trim().toLowerCase(); + const observedAt = Date.parse(value?.observedAt || ''); + + // ponytail: IPv4-only matches the current Gateway; add IPv6 when its TProxy path supports it. + if ( + !isIpv4(gateway) || + !INTERFACE_RE.test(networkInterface) || + !MAC_RE.test(mac) || + !Number.isFinite(observedAt) || + observedAt > now + 5_000 || + now - observedAt > maxAgeMs + ) return null; + + return { gateway, interface: networkInterface, mac, observedAt }; +} + +export function readHostNetworkState(filePath, options) { + try { + return normalizeHostNetworkState( + JSON.parse(fs.readFileSync(filePath, 'utf8')), + options, + ); + } catch { + return null; + } +} + +export function sameGatewayRoute(previous, current) { + return Boolean( + previous && + current && + previous.gateway === current.gateway && + previous.interface === current.interface && + previous.mac === current.mac, + ); +} + +export function createGatewayAutoState() { + return { + mode: 'local-vpn', + failures: 0, + gateway: null, + gatewayId: '', + lastError: '', + }; +} + +export function nextGatewayAutoState(current, { + network, + verifiedGateway = null, + failureLimit = 3, + error = 'Gateway presence check failed', +}) { + if (!network) return createGatewayAutoState(); + + const routeChanged = !sameGatewayRoute(current.gateway, network); + const base = routeChanged + ? { ...createGatewayAutoState(), gateway: network } + : { ...current, gateway: network }; + + if (verifiedGateway?.gatewayId) { + return { + ...base, + mode: 'gateway-direct', + failures: 0, + gatewayId: verifiedGateway.gatewayId, + lastError: '', + }; + } + + const failures = base.failures + 1; + return { + ...base, + mode: base.mode === 'gateway-direct' && failures < failureLimit + ? 'gateway-direct' + : 'local-vpn', + failures, + gatewayId: failures < failureLimit ? base.gatewayId : '', + lastError: String(error || 'Gateway presence check failed'), + }; +} diff --git a/src/server/index.js b/src/server/index.js index 917e241..a14315e 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -4,6 +4,14 @@ import path from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; import { isDeepStrictEqual } from 'node:util'; import { settings } from './config.js'; +import { + buildGatewayPresence, + createGatewayAutoState, + nextGatewayAutoState, + probeGatewayPresence, + readHostNetworkState, + sameGatewayRoute, +} from './gatewayPresence.js'; import { setGatewayInterception } from './gatewayRouting.js'; import { tcpPing } from './ping.js'; import { buildSharedProxyInfo } from './sharedProxy.js'; @@ -12,10 +20,11 @@ import { removeSingboxConfig, writeSingboxConfig, } from './singbox.js'; -import { fetchSubscription, selectRefreshedServer } from './subscription.js'; +import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js'; const MAX_BODY_BYTES = 1_000_000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; +const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000; fs.mkdirSync(settings.dataDir, { recursive: true }); @@ -23,6 +32,10 @@ let singboxProcess = null; let singboxStartedAt = null; let subscriptionRefreshPromise = null; let subscriptionRefreshTimer = null; +let gatewayDiscoveryPromise = null; +let gatewayDiscoveryTimer = null; +let gatewayAutoState = createGatewayAutoState(); +let controlOperation = Promise.resolve(); function readJson(filePath, fallback) { try { @@ -39,6 +52,12 @@ function writeJson(filePath, value) { fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); } +function serializeControl(operation) { + const result = controlOperation.then(operation, operation); + controlOperation = result.catch(() => {}); + return result; +} + function sendJson(res, statusCode, payload) { res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(payload)); @@ -84,6 +103,12 @@ function subscriptionHost(url) { } } +function buildActiveConfig(subscriptionConfig, selectedTag) { + return buildGatewayConfig(subscriptionConfig, selectedTag, { + clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct', + }); +} + function checkSingboxConfig() { const result = spawnSync('sing-box', ['check', '-c', settings.configPath], { encoding: 'utf8', @@ -170,6 +195,13 @@ function publicState() { selectedTag: state.selectedTag || '', userInfo: state.userInfo || {}, fetchedAt: state.fetchedAt || null, + gatewayAuto: settings.appMode === 'client' ? { + mode: gatewayAutoState.mode, + address: gatewayAutoState.gateway?.gateway || '', + interface: gatewayAutoState.gateway?.interface || '', + failures: gatewayAutoState.failures, + lastError: gatewayAutoState.lastError, + } : null, servers: (state.servers || []).map((server) => ({ ...server, tag: String(server.tag || '').trim(), @@ -177,6 +209,120 @@ function publicState() { }; } +function writeCurrentConfig() { + const state = readJson(settings.statePath, {}); + const cached = readJson(settings.subscriptionCachePath, null); + if (!state.selectedTag || !cached?.config) return false; + writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag)); + return true; +} + +async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) { + const previousState = gatewayAutoState; + const modeChanged = previousState.mode !== nextState.mode; + gatewayAutoState = nextState; + if (!modeChanged) return; + + const previousConfig = fs.existsSync(settings.configPath) + ? fs.readFileSync(settings.configPath, 'utf8') + : null; + const wasRunning = Boolean(singboxProcess); + try { + const configured = writeCurrentConfig(); + if (reconfigure && configured && wasRunning) await startSingbox(); + } catch (error) { + gatewayAutoState = previousState; + if (previousConfig === null) removeSingboxConfig(); + else fs.writeFileSync(settings.configPath, previousConfig, 'utf8'); + throw error; + } + + const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : ''; + console.log(`[control] client route: ${nextState.mode}${route}`); +} + +function refreshGatewayAutoMode({ reconfigure = true } = {}) { + if (settings.appMode !== 'client') return Promise.resolve(gatewayAutoState); + if (gatewayDiscoveryPromise) return gatewayDiscoveryPromise; + + gatewayDiscoveryPromise = serializeControl(async () => { + const state = readJson(settings.statePath, {}); + const network = state.subscriptionUrl + ? readHostNetworkState(settings.hostNetworkStatePath) + : null; + + if (!network) { + const nextState = nextGatewayAutoState(gatewayAutoState, { network: null }); + if (state.subscriptionUrl) { + nextState.lastError = 'macOS default gateway недоступен или устарел'; + } + await applyGatewayAutoState( + nextState, + { reconfigure }, + ); + return gatewayAutoState; + } + + if ( + gatewayAutoState.mode === 'gateway-direct' && + !sameGatewayRoute(gatewayAutoState.gateway, network) + ) { + await applyGatewayAutoState( + nextGatewayAutoState(gatewayAutoState, { network }), + { reconfigure }, + ); + } + + try { + const verifiedGateway = await probeGatewayPresence({ + gateway: network.gateway, + port: settings.gatewayPresencePort, + subscriptionUrl: state.subscriptionUrl, + }); + const latestState = readJson(settings.statePath, {}); + const latestNetwork = latestState.subscriptionUrl + ? readHostNetworkState(settings.hostNetworkStatePath) + : null; + if ( + latestState.subscriptionUrl !== state.subscriptionUrl || + !sameGatewayRoute(network, latestNetwork) + ) { + await applyGatewayAutoState(createGatewayAutoState(), { reconfigure }); + return gatewayAutoState; + } + await applyGatewayAutoState( + nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }), + { reconfigure }, + ); + } catch (error) { + const reason = error?.message || 'Gateway presence check failed'; + const latestState = readJson(settings.statePath, {}); + const latestNetwork = latestState.subscriptionUrl + ? readHostNetworkState(settings.hostNetworkStatePath) + : null; + if ( + latestState.subscriptionUrl !== state.subscriptionUrl || + !sameGatewayRoute(network, latestNetwork) + ) { + await applyGatewayAutoState(createGatewayAutoState(), { reconfigure }); + return gatewayAutoState; + } + if (gatewayAutoState.lastError !== reason) { + console.warn(`[control] Gateway не используется: ${reason}`); + } + await applyGatewayAutoState( + nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }), + { reconfigure }, + ); + } + return gatewayAutoState; + }).finally(() => { + gatewayDiscoveryPromise = null; + }); + + return gatewayDiscoveryPromise; +} + async function applySelectedServer(selectedTag) { const cached = readJson(settings.subscriptionCachePath, null); if (!cached?.config) throw new Error('Сначала загрузите подписку'); @@ -184,7 +330,7 @@ async function applySelectedServer(selectedTag) { const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null; - writeSingboxConfig(buildGatewayConfig(cached.config, selectedTag)); + writeSingboxConfig(buildActiveConfig(cached.config, selectedTag)); try { await startSingbox(); } catch (error) { @@ -212,55 +358,57 @@ function refreshSavedSubscription() { const subscriptionUrl = initialState.subscriptionUrl; const parsed = await fetchSubscription(subscriptionUrl); - const currentState = readJson(settings.statePath, {}); - if (currentState.subscriptionUrl !== subscriptionUrl) { - throw new Error('Подписка была изменена во время обновления'); - } - - const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers); - const previousCache = readJson(settings.subscriptionCachePath, null); - const previousConfig = fs.existsSync(settings.configPath) - ? fs.readFileSync(settings.configPath, 'utf8') - : null; - const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && ( - !previousCache?.config || !isDeepStrictEqual( - buildGatewayConfig(previousCache.config, currentState.selectedTag), - buildGatewayConfig(parsed.config, selectedTag), - ) - ); - writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed }); - - try { - if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag); - else if (selectedTag) { - if (!singboxProcess) writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag)); - } else { - removeSingboxConfig(); + return serializeControl(async () => { + const currentState = readJson(settings.statePath, {}); + if (currentState.subscriptionUrl !== subscriptionUrl) { + throw new Error('Подписка была изменена во время обновления'); } - } catch (error) { - if (previousCache) writeJson(settings.subscriptionCachePath, previousCache); - else fs.rmSync(settings.subscriptionCachePath, { force: true }); - if (previousConfig === null) removeSingboxConfig(); - else fs.writeFileSync(settings.configPath, previousConfig, 'utf8'); - throw error; - } - writeJson(settings.statePath, { - ...readJson(settings.statePath, {}), - subscriptionUrl, - servers: parsed.servers, - userInfo: parsed.userInfo, - fetchedAt: parsed.fetchedAt, - selectedTag, + const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers); + const previousCache = readJson(settings.subscriptionCachePath, null); + const previousConfig = fs.existsSync(settings.configPath) + ? fs.readFileSync(settings.configPath, 'utf8') + : null; + const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && ( + !previousCache?.config || !isDeepStrictEqual( + buildActiveConfig(previousCache.config, currentState.selectedTag), + buildActiveConfig(parsed.config, selectedTag), + ) + ); + writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed }); + + try { + if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag); + else if (selectedTag) { + if (!singboxProcess) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag)); + } else { + removeSingboxConfig(); + } + } catch (error) { + if (previousCache) writeJson(settings.subscriptionCachePath, previousCache); + else fs.rmSync(settings.subscriptionCachePath, { force: true }); + if (previousConfig === null) removeSingboxConfig(); + else fs.writeFileSync(settings.configPath, previousConfig, 'utf8'); + throw error; + } + + writeJson(settings.statePath, { + ...readJson(settings.statePath, {}), + subscriptionUrl, + servers: parsed.servers, + userInfo: parsed.userInfo, + fetchedAt: parsed.fetchedAt, + selectedTag, + }); + + return { + success: true, + servers: parsed.servers, + userInfo: parsed.userInfo, + fetchedAt: parsed.fetchedAt, + selectedTag, + }; }); - - return { - success: true, - servers: parsed.servers, - userInfo: parsed.userInfo, - fetchedAt: parsed.fetchedAt, - selectedTag, - }; })().finally(() => { subscriptionRefreshPromise = null; }); @@ -283,6 +431,17 @@ async function handleApi(req, res) { })); } + const requestUrl = new URL(req.url, `http://localhost:${settings.port}`); + if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') { + const state = readJson(settings.statePath, {}); + return sendJson(res, 200, buildGatewayPresence({ + appMode: settings.appMode, + subscriptionUrl: state.subscriptionUrl, + gatewayId: getHwid(), + nonce: requestUrl.searchParams.get('nonce'), + })); + } + if (req.method === 'POST' && req.url === '/api/servers/ping-all') { const state = readJson(settings.statePath, {}); const results = await Promise.all((state.servers || []).map(async (server) => ({ @@ -297,15 +456,18 @@ async function handleApi(req, res) { const { url = '' } = await readBody(req); const normalizedUrl = String(url).trim(); const parsed = await fetchSubscription(normalizedUrl); - writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed }); - writeJson(settings.statePath, { - subscriptionUrl: normalizedUrl, - servers: parsed.servers, - userInfo: parsed.userInfo, - fetchedAt: parsed.fetchedAt, + await serializeControl(async () => { + writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed }); + writeJson(settings.statePath, { + subscriptionUrl: normalizedUrl, + servers: parsed.servers, + userInfo: parsed.userInfo, + fetchedAt: parsed.fetchedAt, + }); + await stopSingbox(); + removeSingboxConfig(); + gatewayAutoState = createGatewayAutoState(); }); - await stopSingbox(); - removeSingboxConfig(); return sendJson(res, 200, { success: true, ...parsed }); } @@ -320,10 +482,13 @@ async function handleApi(req, res) { } if (req.method === 'DELETE' && req.url === '/api/subscription') { - await stopSingbox(); - removeSingboxConfig(); - fs.rmSync(settings.subscriptionCachePath, { force: true }); - writeJson(settings.statePath, {}); + await serializeControl(async () => { + await stopSingbox(); + removeSingboxConfig(); + fs.rmSync(settings.subscriptionCachePath, { force: true }); + writeJson(settings.statePath, {}); + gatewayAutoState = createGatewayAutoState(); + }); return sendJson(res, 200, { success: true }); } @@ -331,20 +496,24 @@ async function handleApi(req, res) { const { selectedTag = '' } = await readBody(req); const tag = String(selectedTag).trim(); if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' }); - await applySelectedServer(tag); + await serializeControl(() => applySelectedServer(tag)); return sendJson(res, 200, { success: true, selectedTag: tag }); } if (req.method === 'POST' && req.url === '/api/singbox/stop') { - await stopSingbox(); + await serializeControl(() => 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 serializeControl(async () => { + if (!fs.existsSync(settings.configPath)) { + const error = new Error('Сначала выберите сервер'); + error.statusCode = 400; + throw error; + } + await startSingbox(); + }); return sendJson(res, 200, { success: true, singboxRunning: true }); } @@ -391,17 +560,18 @@ const server = http.createServer(async (req, res) => { async function shutdown() { clearInterval(subscriptionRefreshTimer); - await stopSingbox(); + clearInterval(gatewayDiscoveryTimer); + await serializeControl(() => stopSingbox()); process.exit(0); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); -const state = readJson(settings.statePath, {}); -const cached = readJson(settings.subscriptionCachePath, null); -if (!fs.existsSync(settings.configPath) && state.selectedTag && cached?.config) { - writeSingboxConfig(buildGatewayConfig(cached.config, state.selectedTag)); +await refreshGatewayAutoMode({ reconfigure: false }) + .catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`)); +if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) { + writeCurrentConfig(); } await startSingbox().catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`)); @@ -415,3 +585,9 @@ subscriptionRefreshTimer = setInterval(() => { .catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`)); }, SUBSCRIPTION_REFRESH_INTERVAL_MS); subscriptionRefreshTimer.unref(); + +gatewayDiscoveryTimer = setInterval(() => { + refreshGatewayAutoMode() + .catch((error) => console.warn(`[control] Gateway detection failed: ${error.message}`)); +}, GATEWAY_DISCOVERY_INTERVAL_MS); +gatewayDiscoveryTimer.unref(); diff --git a/src/server/sharedProxy.js b/src/server/sharedProxy.js index 3d43bf5..b9c1c66 100644 --- a/src/server/sharedProxy.js +++ b/src/server/sharedProxy.js @@ -1,17 +1,3 @@ -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 ""; @@ -22,17 +8,6 @@ function proxyHostFromHeader(hostHeader) { 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, @@ -67,28 +42,3 @@ export function buildSharedProxyInfo({ 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, - }; -} diff --git a/src/server/singbox.js b/src/server/singbox.js index 324d666..516f4ca 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -16,14 +16,18 @@ function findOutbound(subscriptionConfig, selectedTag) { )); } -export function buildGatewayConfig(subscriptionConfig, selectedTag) { +export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDirect = false } = {}) { const clientMode = settings.appMode === 'client'; - const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag)); - if (!vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`); - if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; - if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) { + const directClient = clientMode && clientDirect; + const vpnOutbound = directClient + ? null + : structuredClone(findOutbound(subscriptionConfig, selectedTag)); + if (!directClient && !vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`); + if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = 'vpn-out'; + if (vpnOutbound?.type === 'vless' && !vpnOutbound.packet_encoding) { vpnOutbound.packet_encoding = 'xudp'; } + const outboundTag = directClient ? 'direct' : vpnOutbound.tag; const inbounds = [ ...(!clientMode ? [{ @@ -44,10 +48,10 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) { }, ]; const rules = clientMode - ? [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }] + ? [{ inbound: [MIXED_INBOUND], outbound: outboundTag }] : [ - { inbound: [TPROXY_INBOUND], outbound: vpnOutbound.tag }, - { inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }, + { inbound: [TPROXY_INBOUND], outbound: outboundTag }, + { inbound: [MIXED_INBOUND], outbound: outboundTag }, ]; return { @@ -58,14 +62,14 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) { dns: { independent_cache: true }, inbounds, outbounds: [ - vpnOutbound, + ...(vpnOutbound ? [vpnOutbound] : []), { type: 'direct', tag: 'direct' }, { type: 'block', tag: 'block' }, ], route: { rule_set: [], rules, - final: vpnOutbound.tag, + final: outboundTag, ...(clientMode ? {} : { auto_detect_interface: true }), }, }; diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index e37e597..ddf7fdb 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -108,6 +108,7 @@ export function ClientOverviewPage({ onStop, }) { const isGateway = state?.mode === 'gateway'; + const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct'; const connected = Boolean(state?.singboxRunning); const hasSubscription = Boolean(state?.hasSubscription); const selectedTag = pendingTag || state?.selectedTag || ''; @@ -411,7 +412,7 @@ export function ClientOverviewPage({ type="button" role="switch" aria-checked={connected} - aria-label={connected ? 'Выключить VPN' : 'Включить VPN'} + aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'} disabled={busy || (!connected && !canStart)} onClick={toggleConnection} > @@ -421,7 +422,9 @@ export function ClientOverviewPage({

- {connected ? 'VPN включён' : 'VPN выключен'} + {connected + ? gatewayDirect ? 'Gateway подключён' : 'VPN включён' + : 'Подключение выключено'}

{connected ? ( @@ -505,7 +508,13 @@ export function ClientOverviewPage({
) : (
- {!isGateway && Адрес} + {!isGateway && ( + + {gatewayDirect + ? `Через Harbor Gateway · ${state.gatewayAuto.address}` + : 'Локальный VPN'} + + )} {proxyUrls.http.replace(/^https?:\/\//, '')} diff --git a/src/web/styles.css b/src/web/styles.css index 301132f..be5b7b0 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -1287,6 +1287,11 @@ p { text-transform: uppercase; } +.client-proxy-label.is-gateway { + color: var(--client-accent); + text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 42%, transparent); +} + .client-proxy-address { color: var(--client-text); font-size: 12px; diff --git a/test/server/gateway-presence.test.js b/test/server/gateway-presence.test.js new file mode 100644 index 0000000..5a0cf30 --- /dev/null +++ b/test/server/gateway-presence.test.js @@ -0,0 +1,109 @@ +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'; + +import { + buildGatewayPresence, + createGatewayAutoState, + nextGatewayAutoState, + probeGatewayPresence, + readHostNetworkState, + verifyGatewayPresence, +} from '../../src/server/gatewayPresence.js'; + +const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef'; +const nonce = '0123456789abcdef0123456789abcdef'; + +test('Gateway presence is authenticated by the shared subscription secret', async () => { + const payload = buildGatewayPresence({ + appMode: 'gateway', + subscriptionUrl, + gatewayId: 'gateway-1', + nonce, + }); + + assert.equal(verifyGatewayPresence(payload, { subscriptionUrl, nonce }), true); + assert.equal(verifyGatewayPresence(payload, { + subscriptionUrl: 'https://subscription.example/fedcba9876543210fedcba9876543210', + nonce, + }), false); + + const result = await probeGatewayPresence({ + gateway: '192.168.50.111', + subscriptionUrl, + nonce, + fetchImpl: async (url) => { + assert.equal( + url, + `http://192.168.50.111:3456/api/gateway-presence?nonce=${nonce}`, + ); + return { ok: true, json: async () => payload }; + }, + }); + assert.deepEqual(result, { gatewayId: 'gateway-1' }); + + assert.equal(buildGatewayPresence({ + appMode: 'gateway', + subscriptionUrl: 'https://subscription.example/public-feed', + gatewayId: 'gateway-1', + nonce, + }).available, false); + + const sharedPublicValue = 'https://public.example/sing-box-configuration-v1'; + const firstUrl = `${subscriptionUrl}?redirect=${encodeURIComponent(sharedPublicValue)}`; + const secondUrl = `https://subscription.example/fedcba9876543210fedcba9876543210?redirect=${encodeURIComponent(sharedPublicValue)}`; + const firstPayload = buildGatewayPresence({ + appMode: 'gateway', + subscriptionUrl: firstUrl, + gatewayId: 'gateway-1', + nonce, + }); + assert.equal(verifyGatewayPresence(firstPayload, { + subscriptionUrl: secondUrl, + nonce, + }), false); +}); + +test('host route freshness and Gateway failures drive a safe automatic fallback', () => { + const now = Date.now(); + const statePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-route-')), 'network.json'); + fs.writeFileSync(statePath, JSON.stringify({ + gateway: '192.168.50.111', + interface: 'en0', + mac: 'aa:bb:cc:dd:ee:ff', + observedAt: new Date(now).toISOString(), + })); + const network = readHostNetworkState(statePath, { now }); + assert.equal(network.gateway, '192.168.50.111'); + + let state = nextGatewayAutoState(createGatewayAutoState(), { + network, + verifiedGateway: { gatewayId: 'gateway-1' }, + }); + assert.equal(state.mode, 'gateway-direct'); + + state = nextGatewayAutoState(state, { network }); + state = nextGatewayAutoState(state, { network }); + assert.equal(state.mode, 'gateway-direct'); + state = nextGatewayAutoState(state, { network }); + assert.equal(state.mode, 'local-vpn'); + + const newNetwork = { ...network, mac: '11:22:33:44:55:66' }; + state = nextGatewayAutoState(nextGatewayAutoState(createGatewayAutoState(), { + network, + verifiedGateway: { gatewayId: 'gateway-1' }, + }), { network: newNetwork }); + assert.equal(state.mode, 'local-vpn'); + + assert.equal(readHostNetworkState(statePath, { now: now + 16_000 }), null); + + fs.writeFileSync(statePath, JSON.stringify({ + gateway: '192.168.50.111', + interface: 'en0', + mac: '', + observedAt: new Date(now).toISOString(), + })); + assert.equal(readHostNetworkState(statePath, { now }), null); +}); diff --git a/test/server/shared-proxy.test.js b/test/server/shared-proxy.test.js index 7deed31..30fb36b 100644 --- a/test/server/shared-proxy.test.js +++ b/test/server/shared-proxy.test.js @@ -3,7 +3,6 @@ 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", () => { @@ -23,33 +22,3 @@ test("gateway shared proxy info exposes host and socks proxy when running", () = 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", - }); -}); diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js index bd3e222..141f62b 100644 --- a/test/server/singbox-client-mode.test.js +++ b/test/server/singbox-client-mode.test.js @@ -32,3 +32,13 @@ test('client exposes one local proxy and routes it through the selected VPN', () assert.equal(config.route.final, 'test-vpn'); assert.equal(config.route.auto_detect_interface, undefined); }); + +test('client keeps its local proxy but routes directly when Harbor Gateway is ahead', () => { + const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', { clientDirect: true }); + + assert.deepEqual(config.route.rules, [ + { inbound: ['mixed-in'], outbound: 'direct' }, + ]); + assert.equal(config.route.final, 'direct'); + assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), ['direct', 'block']); +});