Add Harbor Gateway auto-detection for client routing
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.vpn-proxy
|
.vpn-proxy
|
||||||
|
.runtime
|
||||||
.git
|
.git
|
||||||
.gitea
|
.gitea
|
||||||
.github
|
.github
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ PORT=3456
|
|||||||
APP_MODE=gateway
|
APP_MODE=gateway
|
||||||
CLIENT_UI_PORT=3456
|
CLIENT_UI_PORT=3456
|
||||||
CLIENT_PROXY_PORT=8082
|
CLIENT_PROXY_PORT=8082
|
||||||
|
HARBOR_GATEWAY_CONTROL_PORT=3456
|
||||||
BASE_IMAGE=debian:bookworm-slim
|
BASE_IMAGE=debian:bookworm-slim
|
||||||
SINGBOX_VERSION=1.12.13
|
SINGBOX_VERSION=1.12.13
|
||||||
INSTALL_RUNTIME_DEPS=true
|
INSTALL_RUNTIME_DEPS=true
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
*.env.local
|
*.env.local
|
||||||
data/
|
data/
|
||||||
.vpn-proxy/
|
.vpn-proxy/
|
||||||
|
.runtime/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
# Node/Vite
|
# Node/Vite
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ Proxy по умолчанию разрешён только из приватн
|
|||||||
|
|
||||||
По умолчанию интерфейс доступен на `http://127.0.0.1:3456`, локальный HTTP/SOCKS5 proxy — на `127.0.0.1:8082`.
|
По умолчанию интерфейс доступен на `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
|
```bash
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ services:
|
|||||||
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
|
||||||
|
HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json
|
||||||
|
HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
HTTP_PROXY: ""
|
HTTP_PROXY: ""
|
||||||
HTTPS_PROXY: ""
|
HTTPS_PROXY: ""
|
||||||
@@ -29,6 +31,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
||||||
- sing-box-client-cache:/var/lib/sing-box
|
- sing-box-client-cache:/var/lib/sing-box
|
||||||
|
- ./.runtime:/run/harbor-host:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||||
|
|||||||
46
scripts/harbor-network-monitor.sh
Normal file
46
scripts/harbor-network-monitor.sh
Normal file
@@ -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"
|
||||||
@@ -9,6 +9,7 @@ DEFAULT_PROXY_PORT="8082"
|
|||||||
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||||
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||||
CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||||
|
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[vpn-proxy-client] %s\n' "$*"
|
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"
|
die "client UI is not ready; see Docker status and logs above"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
xml_escape() {
|
||||||
|
sed -e 's/&/\&/g' -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" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>${NETWORK_MONITOR_LABEL}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/bin/bash</string>
|
||||||
|
<string>${escaped_script_path}</string>
|
||||||
|
</array>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>HARBOR_RUNTIME_DIR</key>
|
||||||
|
<string>${escaped_runtime_dir}</string>
|
||||||
|
</dict>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>StartInterval</key>
|
||||||
|
<integer>5</integer>
|
||||||
|
<key>ProcessType</key>
|
||||||
|
<string>Background</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
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() {
|
set_env_value() {
|
||||||
local key="$1"
|
local key="$1"
|
||||||
local value="$2"
|
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 "UI port: http://127.0.0.1:${UI_PORT}"
|
||||||
log "proxy port: 127.0.0.1:${PROXY_PORT}"
|
log "proxy port: 127.0.0.1:${PROXY_PORT}"
|
||||||
|
|
||||||
|
install_network_monitor
|
||||||
|
|
||||||
log "building and starting Docker client"
|
log "building and starting Docker client"
|
||||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
wait_for_client_ui
|
wait_for_client_ui
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export const settings = {
|
|||||||
statePath: path.join(dataDir, "state.json"),
|
statePath: path.join(dataDir, "state.json"),
|
||||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||||
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
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"),
|
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",
|
||||||
|
|||||||
210
src/server/gatewayPresence.js
Normal file
210
src/server/gatewayPresence.js
Normal file
@@ -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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,14 @@ import path from 'node:path';
|
|||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import { settings } from './config.js';
|
import { settings } from './config.js';
|
||||||
|
import {
|
||||||
|
buildGatewayPresence,
|
||||||
|
createGatewayAutoState,
|
||||||
|
nextGatewayAutoState,
|
||||||
|
probeGatewayPresence,
|
||||||
|
readHostNetworkState,
|
||||||
|
sameGatewayRoute,
|
||||||
|
} from './gatewayPresence.js';
|
||||||
import { setGatewayInterception } from './gatewayRouting.js';
|
import { setGatewayInterception } from './gatewayRouting.js';
|
||||||
import { tcpPing } from './ping.js';
|
import { tcpPing } from './ping.js';
|
||||||
import { buildSharedProxyInfo } from './sharedProxy.js';
|
import { buildSharedProxyInfo } from './sharedProxy.js';
|
||||||
@@ -12,10 +20,11 @@ import {
|
|||||||
removeSingboxConfig,
|
removeSingboxConfig,
|
||||||
writeSingboxConfig,
|
writeSingboxConfig,
|
||||||
} from './singbox.js';
|
} from './singbox.js';
|
||||||
import { fetchSubscription, selectRefreshedServer } from './subscription.js';
|
import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js';
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 1_000_000;
|
const MAX_BODY_BYTES = 1_000_000;
|
||||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
|
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
|
||||||
@@ -23,6 +32,10 @@ let singboxProcess = null;
|
|||||||
let singboxStartedAt = null;
|
let singboxStartedAt = null;
|
||||||
let subscriptionRefreshPromise = null;
|
let subscriptionRefreshPromise = null;
|
||||||
let subscriptionRefreshTimer = null;
|
let subscriptionRefreshTimer = null;
|
||||||
|
let gatewayDiscoveryPromise = null;
|
||||||
|
let gatewayDiscoveryTimer = null;
|
||||||
|
let gatewayAutoState = createGatewayAutoState();
|
||||||
|
let controlOperation = Promise.resolve();
|
||||||
|
|
||||||
function readJson(filePath, fallback) {
|
function readJson(filePath, fallback) {
|
||||||
try {
|
try {
|
||||||
@@ -39,6 +52,12 @@ function writeJson(filePath, value) {
|
|||||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
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) {
|
function sendJson(res, statusCode, payload) {
|
||||||
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
res.end(JSON.stringify(payload));
|
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() {
|
function checkSingboxConfig() {
|
||||||
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], {
|
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
@@ -170,6 +195,13 @@ function publicState() {
|
|||||||
selectedTag: state.selectedTag || '',
|
selectedTag: state.selectedTag || '',
|
||||||
userInfo: state.userInfo || {},
|
userInfo: state.userInfo || {},
|
||||||
fetchedAt: state.fetchedAt || null,
|
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) => ({
|
servers: (state.servers || []).map((server) => ({
|
||||||
...server,
|
...server,
|
||||||
tag: String(server.tag || '').trim(),
|
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) {
|
async function applySelectedServer(selectedTag) {
|
||||||
const cached = readJson(settings.subscriptionCachePath, null);
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
if (!cached?.config) throw new Error('Сначала загрузите подписку');
|
if (!cached?.config) throw new Error('Сначала загрузите подписку');
|
||||||
@@ -184,7 +330,7 @@ async function applySelectedServer(selectedTag) {
|
|||||||
const previousConfig = fs.existsSync(settings.configPath)
|
const previousConfig = fs.existsSync(settings.configPath)
|
||||||
? fs.readFileSync(settings.configPath, 'utf8')
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
: null;
|
: null;
|
||||||
writeSingboxConfig(buildGatewayConfig(cached.config, selectedTag));
|
writeSingboxConfig(buildActiveConfig(cached.config, selectedTag));
|
||||||
try {
|
try {
|
||||||
await startSingbox();
|
await startSingbox();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -212,6 +358,7 @@ function refreshSavedSubscription() {
|
|||||||
|
|
||||||
const subscriptionUrl = initialState.subscriptionUrl;
|
const subscriptionUrl = initialState.subscriptionUrl;
|
||||||
const parsed = await fetchSubscription(subscriptionUrl);
|
const parsed = await fetchSubscription(subscriptionUrl);
|
||||||
|
return serializeControl(async () => {
|
||||||
const currentState = readJson(settings.statePath, {});
|
const currentState = readJson(settings.statePath, {});
|
||||||
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
||||||
throw new Error('Подписка была изменена во время обновления');
|
throw new Error('Подписка была изменена во время обновления');
|
||||||
@@ -224,8 +371,8 @@ function refreshSavedSubscription() {
|
|||||||
: null;
|
: null;
|
||||||
const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && (
|
const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && (
|
||||||
!previousCache?.config || !isDeepStrictEqual(
|
!previousCache?.config || !isDeepStrictEqual(
|
||||||
buildGatewayConfig(previousCache.config, currentState.selectedTag),
|
buildActiveConfig(previousCache.config, currentState.selectedTag),
|
||||||
buildGatewayConfig(parsed.config, selectedTag),
|
buildActiveConfig(parsed.config, selectedTag),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
|
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
|
||||||
@@ -233,7 +380,7 @@ function refreshSavedSubscription() {
|
|||||||
try {
|
try {
|
||||||
if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag);
|
if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag);
|
||||||
else if (selectedTag) {
|
else if (selectedTag) {
|
||||||
if (!singboxProcess) writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag));
|
if (!singboxProcess) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag));
|
||||||
} else {
|
} else {
|
||||||
removeSingboxConfig();
|
removeSingboxConfig();
|
||||||
}
|
}
|
||||||
@@ -261,6 +408,7 @@ function refreshSavedSubscription() {
|
|||||||
fetchedAt: parsed.fetchedAt,
|
fetchedAt: parsed.fetchedAt,
|
||||||
selectedTag,
|
selectedTag,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
subscriptionRefreshPromise = null;
|
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') {
|
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
|
||||||
const state = readJson(settings.statePath, {});
|
const state = readJson(settings.statePath, {});
|
||||||
const results = await Promise.all((state.servers || []).map(async (server) => ({
|
const results = await Promise.all((state.servers || []).map(async (server) => ({
|
||||||
@@ -297,6 +456,7 @@ async function handleApi(req, res) {
|
|||||||
const { url = '' } = await readBody(req);
|
const { url = '' } = await readBody(req);
|
||||||
const normalizedUrl = String(url).trim();
|
const normalizedUrl = String(url).trim();
|
||||||
const parsed = await fetchSubscription(normalizedUrl);
|
const parsed = await fetchSubscription(normalizedUrl);
|
||||||
|
await serializeControl(async () => {
|
||||||
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
|
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
|
||||||
writeJson(settings.statePath, {
|
writeJson(settings.statePath, {
|
||||||
subscriptionUrl: normalizedUrl,
|
subscriptionUrl: normalizedUrl,
|
||||||
@@ -306,6 +466,8 @@ async function handleApi(req, res) {
|
|||||||
});
|
});
|
||||||
await stopSingbox();
|
await stopSingbox();
|
||||||
removeSingboxConfig();
|
removeSingboxConfig();
|
||||||
|
gatewayAutoState = createGatewayAutoState();
|
||||||
|
});
|
||||||
return sendJson(res, 200, { success: true, ...parsed });
|
return sendJson(res, 200, { success: true, ...parsed });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,10 +482,13 @@ async function handleApi(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||||
|
await serializeControl(async () => {
|
||||||
await stopSingbox();
|
await stopSingbox();
|
||||||
removeSingboxConfig();
|
removeSingboxConfig();
|
||||||
fs.rmSync(settings.subscriptionCachePath, { force: true });
|
fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||||
writeJson(settings.statePath, {});
|
writeJson(settings.statePath, {});
|
||||||
|
gatewayAutoState = createGatewayAutoState();
|
||||||
|
});
|
||||||
return sendJson(res, 200, { success: true });
|
return sendJson(res, 200, { success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,20 +496,24 @@ async function handleApi(req, res) {
|
|||||||
const { selectedTag = '' } = await readBody(req);
|
const { selectedTag = '' } = await readBody(req);
|
||||||
const tag = String(selectedTag).trim();
|
const tag = String(selectedTag).trim();
|
||||||
if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' });
|
if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' });
|
||||||
await applySelectedServer(tag);
|
await serializeControl(() => applySelectedServer(tag));
|
||||||
return sendJson(res, 200, { success: true, selectedTag: tag });
|
return sendJson(res, 200, { success: true, selectedTag: tag });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||||
await stopSingbox();
|
await serializeControl(() => stopSingbox());
|
||||||
return sendJson(res, 200, { success: true, singboxRunning: false });
|
return sendJson(res, 200, { success: true, singboxRunning: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||||
|
await serializeControl(async () => {
|
||||||
if (!fs.existsSync(settings.configPath)) {
|
if (!fs.existsSync(settings.configPath)) {
|
||||||
return sendJson(res, 400, { success: false, error: 'Сначала выберите сервер' });
|
const error = new Error('Сначала выберите сервер');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
await startSingbox();
|
await startSingbox();
|
||||||
|
});
|
||||||
return sendJson(res, 200, { success: true, singboxRunning: true });
|
return sendJson(res, 200, { success: true, singboxRunning: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,17 +560,18 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
clearInterval(subscriptionRefreshTimer);
|
clearInterval(subscriptionRefreshTimer);
|
||||||
await stopSingbox();
|
clearInterval(gatewayDiscoveryTimer);
|
||||||
|
await serializeControl(() => stopSingbox());
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGTERM', shutdown);
|
process.on('SIGTERM', shutdown);
|
||||||
process.on('SIGINT', shutdown);
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
const state = readJson(settings.statePath, {});
|
await refreshGatewayAutoMode({ reconfigure: false })
|
||||||
const cached = readJson(settings.subscriptionCachePath, null);
|
.catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`));
|
||||||
if (!fs.existsSync(settings.configPath) && state.selectedTag && cached?.config) {
|
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
||||||
writeSingboxConfig(buildGatewayConfig(cached.config, state.selectedTag));
|
writeCurrentConfig();
|
||||||
}
|
}
|
||||||
await startSingbox().catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
await startSingbox().catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
||||||
|
|
||||||
@@ -415,3 +585,9 @@ subscriptionRefreshTimer = setInterval(() => {
|
|||||||
.catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
|
.catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
|
||||||
}, SUBSCRIPTION_REFRESH_INTERVAL_MS);
|
}, SUBSCRIPTION_REFRESH_INTERVAL_MS);
|
||||||
subscriptionRefreshTimer.unref();
|
subscriptionRefreshTimer.unref();
|
||||||
|
|
||||||
|
gatewayDiscoveryTimer = setInterval(() => {
|
||||||
|
refreshGatewayAutoMode()
|
||||||
|
.catch((error) => console.warn(`[control] Gateway detection failed: ${error.message}`));
|
||||||
|
}, GATEWAY_DISCOVERY_INTERVAL_MS);
|
||||||
|
gatewayDiscoveryTimer.unref();
|
||||||
|
|||||||
@@ -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) {
|
function proxyHostFromHeader(hostHeader) {
|
||||||
const raw = String(hostHeader || "").trim();
|
const raw = String(hostHeader || "").trim();
|
||||||
if (!raw) return "";
|
if (!raw) return "";
|
||||||
@@ -22,17 +8,6 @@ function proxyHostFromHeader(hostHeader) {
|
|||||||
return raw.split(":")[0];
|
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({
|
export function buildSharedProxyInfo({
|
||||||
appMode,
|
appMode,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
@@ -67,28 +42,3 @@ export function buildSharedProxyInfo({
|
|||||||
proxy,
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 clientMode = settings.appMode === 'client';
|
||||||
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
const directClient = clientMode && clientDirect;
|
||||||
if (!vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`);
|
const vpnOutbound = directClient
|
||||||
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
|
? null
|
||||||
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
|
: 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';
|
vpnOutbound.packet_encoding = 'xudp';
|
||||||
}
|
}
|
||||||
|
const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
|
||||||
|
|
||||||
const inbounds = [
|
const inbounds = [
|
||||||
...(!clientMode ? [{
|
...(!clientMode ? [{
|
||||||
@@ -44,10 +48,10 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const rules = clientMode
|
const rules = clientMode
|
||||||
? [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }]
|
? [{ inbound: [MIXED_INBOUND], outbound: outboundTag }]
|
||||||
: [
|
: [
|
||||||
{ inbound: [TPROXY_INBOUND], outbound: vpnOutbound.tag },
|
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },
|
||||||
{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag },
|
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -58,14 +62,14 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
|||||||
dns: { independent_cache: true },
|
dns: { independent_cache: true },
|
||||||
inbounds,
|
inbounds,
|
||||||
outbounds: [
|
outbounds: [
|
||||||
vpnOutbound,
|
...(vpnOutbound ? [vpnOutbound] : []),
|
||||||
{ type: 'direct', tag: 'direct' },
|
{ type: 'direct', tag: 'direct' },
|
||||||
{ type: 'block', tag: 'block' },
|
{ type: 'block', tag: 'block' },
|
||||||
],
|
],
|
||||||
route: {
|
route: {
|
||||||
rule_set: [],
|
rule_set: [],
|
||||||
rules,
|
rules,
|
||||||
final: vpnOutbound.tag,
|
final: outboundTag,
|
||||||
...(clientMode ? {} : { auto_detect_interface: true }),
|
...(clientMode ? {} : { auto_detect_interface: true }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export function ClientOverviewPage({
|
|||||||
onStop,
|
onStop,
|
||||||
}) {
|
}) {
|
||||||
const isGateway = state?.mode === 'gateway';
|
const isGateway = state?.mode === 'gateway';
|
||||||
|
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
|
||||||
const connected = Boolean(state?.singboxRunning);
|
const connected = Boolean(state?.singboxRunning);
|
||||||
const hasSubscription = Boolean(state?.hasSubscription);
|
const hasSubscription = Boolean(state?.hasSubscription);
|
||||||
const selectedTag = pendingTag || state?.selectedTag || '';
|
const selectedTag = pendingTag || state?.selectedTag || '';
|
||||||
@@ -411,7 +412,7 @@ export function ClientOverviewPage({
|
|||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={connected}
|
aria-checked={connected}
|
||||||
aria-label={connected ? 'Выключить VPN' : 'Включить VPN'}
|
aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
|
||||||
disabled={busy || (!connected && !canStart)}
|
disabled={busy || (!connected && !canStart)}
|
||||||
onClick={toggleConnection}
|
onClick={toggleConnection}
|
||||||
>
|
>
|
||||||
@@ -421,7 +422,9 @@ export function ClientOverviewPage({
|
|||||||
</button>
|
</button>
|
||||||
<div className="client-state-copy" aria-live="polite">
|
<div className="client-state-copy" aria-live="polite">
|
||||||
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
||||||
{connected ? 'VPN включён' : 'VPN выключен'}
|
{connected
|
||||||
|
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
|
||||||
|
: 'Подключение выключено'}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="client-state-detail">
|
<div className="client-state-detail">
|
||||||
{connected ? (
|
{connected ? (
|
||||||
@@ -505,7 +508,13 @@ export function ClientOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="client-access-point" role={isGateway ? 'tabpanel' : undefined} key="proxy">
|
<div className="client-access-point" role={isGateway ? 'tabpanel' : undefined} key="proxy">
|
||||||
{!isGateway && <span className="client-proxy-label">Адрес</span>}
|
{!isGateway && (
|
||||||
|
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
|
||||||
|
{gatewayDirect
|
||||||
|
? `Через Harbor Gateway · ${state.gatewayAuto.address}`
|
||||||
|
: 'Локальный VPN'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<strong className="client-proxy-address">
|
<strong className="client-proxy-address">
|
||||||
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||||
</strong>
|
</strong>
|
||||||
|
|||||||
@@ -1287,6 +1287,11 @@ p {
|
|||||||
text-transform: uppercase;
|
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 {
|
.client-proxy-address {
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
109
test/server/gateway-presence.test.js
Normal file
109
test/server/gateway-presence.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -3,7 +3,6 @@ import test from "node:test";
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
buildSharedProxyInfo,
|
buildSharedProxyInfo,
|
||||||
checkSharedProxyGateway,
|
|
||||||
} = await import("../../src/server/sharedProxy.js");
|
} = await import("../../src/server/sharedProxy.js");
|
||||||
|
|
||||||
test("gateway shared proxy info exposes host and socks proxy when running", () => {
|
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",
|
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",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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.final, 'test-vpn');
|
||||||
assert.equal(config.route.auto_detect_interface, undefined);
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user