4 Commits

Author SHA1 Message Date
ab44626a0f feat: simplify mac client interface
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 9s
Build and Deploy Gateway / deploy (push) Successful in 0s
2026-05-20 09:31:14 +03:00
95edefa84f feat: link mac client to shared gateway proxy
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 11s
Build and Deploy Gateway / deploy (push) Successful in 0s
2026-05-19 22:47:05 +03:00
f914c28bc5 fix: detect macos client port conflicts
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 10s
Build and Deploy Gateway / deploy (push) Successful in 0s
2026-05-19 16:51:40 +03:00
73488384e4 feat: improve macos client proxy setup
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 0s
2026-05-19 16:31:33 +03:00
17 changed files with 1381 additions and 356 deletions

View File

@@ -1,7 +1,8 @@
PORT=3456 PORT=3456
APP_MODE=gateway APP_MODE=gateway
CLIENT_UI_PORT=3456 CLIENT_UI_PORT=3456
CLIENT_PROXY_PORT=8080 CLIENT_PROXY_PORT_START=8080
CLIENT_PROXY_PORT_END=8090
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

View File

@@ -10,12 +10,34 @@
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | bash curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | bash
``` ```
После запуска: После запуска по умолчанию:
- UI: `http://127.0.0.1:3456` - UI: `http://127.0.0.1:3456`
- HTTP/SOCKS proxy: `127.0.0.1:8080` - HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; в UI можно выбрать порт из Docker-диапазона `80808090`
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют `127.0.0.1:8080`, но весь proxy-трафик идёт напрямую без VPN. Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся:
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_PORT=18080 bash
```
Если старый gateway/client уже занимает `3456` или выбранный proxy-порт, можно не трогать старый контейнер и поставить новый клиент на другие порты:
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_UI_PORT=3457 VPN_PROXY_CLIENT_PORT=18080 bash
```
После запуска скрипт проверяет, что UI реально ответил на `/api/state`. Если контейнер сразу упал или порт занят, он покажет `docker compose ps` и последние логи вместо ложного сообщения о готовности.
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют выбранный локальный proxy-порт, но весь proxy-трафик идёт напрямую без VPN.
Также Mac-клиент можно связать с серверным gateway. На gateway доступна ручка:
```bash
GET http://<gateway-ui-host>:3456/api/shared-proxy
```
Если gateway запущен и его mixed proxy работает, ручка вернёт `available: true` и SOCKS5 endpoint общего proxy. В Mac UI укажите адрес gateway UI, например `http://192.168.50.111:3456`. Клиент проверит ручку и переключит локальный `127.0.0.1:<proxy-port>` в режим upstream: весь proxy-трафик пойдёт через общий gateway, локальная VPN-подписка на Mac для этого режима не нужна.
Ручной запуск из checkout: Ручной запуск из checkout:
@@ -343,7 +365,11 @@ UI доступен на `http://<gateway-ip>:3456`.
| ------------------- | -------------------- | -------------------------------------- | | ------------------- | -------------------- | -------------------------------------- |
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически | | `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` | | `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT` | `8080` | Host-порт proxy для `docker-compose.client.yml` | | `VPN_PROXY_CLIENT_UI_PORT` | unset | UI-порт для macOS installer; записывается в `CLIENT_UI_PORT` |
| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT_START/END` |
| `CLIENT_PROXY_PORT_START` | `8080` | Первый host/container proxy-порт для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` |
| `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса |
| `PORT` | `3456` | Порт веб-интерфейса | | `PORT` | `3456` | Порт веб-интерфейса |
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror | | `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build | | `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
@@ -369,6 +395,7 @@ UI доступен на `http://<gateway-ip>:3456`.
| Метод | Путь | Описание | | Метод | Путь | Описание |
| --------- | ---------------------- | ------------------------------------ | | --------- | ---------------------- | ------------------------------------ |
| `GET` | `/api/state` | Полное состояние системы | | `GET` | `/api/state` | Полное состояние системы |
| `GET` | `/api/shared-proxy` | Проверка и параметры общего gateway proxy |
| `POST` | `/api/subscription` | Загрузить подписку по URL | | `POST` | `/api/subscription` | Загрузить подписку по URL |
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) | | `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
| `GET` | `/api/servers` | Список серверов из кэша | | `GET` | `/api/servers` | Список серверов из кэша |

View File

@@ -9,7 +9,9 @@ services:
environment: environment:
APP_MODE: client APP_MODE: client
PORT: ${PORT:-3456} PORT: ${PORT:-3456}
PROXY_PORT: ${PROXY_PORT:-8080} PROXY_PORT: ${CLIENT_PROXY_PORT_START:-8080}
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT_START:-8080}
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT_END:-8090}
PROXY_BIND_IP: 0.0.0.0 PROXY_BIND_IP: 0.0.0.0
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
@@ -17,9 +19,17 @@ services:
ROUTING_RU_DIRECT: ${ROUTING_RU_DIRECT:-true} ROUTING_RU_DIRECT: ${ROUTING_RU_DIRECT:-true}
RULE_SET_DOWNLOAD_DETOUR: ${RULE_SET_DOWNLOAD_DETOUR:-vpn} RULE_SET_DOWNLOAD_DETOUR: ${RULE_SET_DOWNLOAD_DETOUR:-vpn}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_PROXY: ""
HTTPS_PROXY: ""
ALL_PROXY: ""
http_proxy: ""
https_proxy: ""
all_proxy: ""
NO_PROXY: "localhost,127.0.0.1,host.docker.internal"
no_proxy: "localhost,127.0.0.1,host.docker.internal"
ports: ports:
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}" - "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
- "127.0.0.1:${CLIENT_PROXY_PORT:-8080}:${PROXY_PORT:-8080}" - "127.0.0.1:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}"
volumes: 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

View File

@@ -5,6 +5,10 @@ INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}" REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}"
BRANCH="${VPN_PROXY_BRANCH:-master}" BRANCH="${VPN_PROXY_BRANCH:-master}"
COMPOSE_FILE="docker-compose.client.yml" COMPOSE_FILE="docker-compose.client.yml"
DEFAULT_PROXY_PORT="8080"
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
CLIENT_CONTAINER_NAME="vpn-proxy-client"
log() { log() {
printf '[vpn-proxy-client] %s\n' "$*" printf '[vpn-proxy-client] %s\n' "$*"
@@ -19,12 +23,214 @@ need() {
command -v "$1" >/dev/null 2>&1 || die "$1 is required" command -v "$1" >/dev/null 2>&1 || die "$1 is required"
} }
is_valid_port() {
case "$1" in
''|*[!0-9]*) return 1 ;;
esac
[ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
}
ask_proxy_port() {
local value=""
if [ -n "$REQUESTED_PROXY_PORT" ]; then
if ! is_valid_port "$REQUESTED_PROXY_PORT"; then
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
fi
printf '%s\n' "$REQUESTED_PROXY_PORT"
return 0
fi
if [ -r /dev/tty ]; then
while true; do
printf 'Proxy port for local apps [%s]: ' "$DEFAULT_PROXY_PORT" >/dev/tty
IFS= read -r value </dev/tty || value=""
value="${value:-$DEFAULT_PROXY_PORT}"
if is_valid_port "$value"; then
printf '%s\n' "$value"
return 0
fi
printf 'Enter a port from 1024 to 65535.\n' >/dev/tty
done
fi
if ! is_valid_port "$DEFAULT_PROXY_PORT"; then
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
fi
printf '%s\n' "$DEFAULT_PROXY_PORT"
}
port_range_end() {
local start="$1"
local end="$((start + 10))"
if [ "$end" -gt 65535 ]; then
end=65535
fi
printf '%s\n' "$end"
}
published_port_conflicts() {
local port="$1"
local line
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in
"${CLIENT_CONTAINER_NAME}"$'\t'*) ;;
*) printf '%s\n' "$line" ;;
esac
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
}
proxy_port_conflicts() {
local start="$1"
local end
local port
local conflicts
end="$(port_range_end "$start")"
for port in $(seq "$start" "$end"); do
conflicts="$(published_port_conflicts "$port")"
if [ -n "$conflicts" ]; then
printf 'port %s: %s\n' "$port" "$conflicts"
fi
done
}
assert_proxy_port_available() {
local port="$1"
local conflicts
conflicts="$(proxy_port_conflicts "$port")"
if [ -z "$conflicts" ]; then
return 0
fi
printf '[vpn-proxy-client] proxy port range %s-%s is already used:\n%s\n' \
"$port" "$(port_range_end "$port")" "$conflicts" >&2
die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container"
}
assert_single_port_available() {
local label="$1"
local port="$2"
local conflicts
conflicts="$(published_port_conflicts "$port")"
if [ -z "$conflicts" ]; then
return 0
fi
printf '[vpn-proxy-client] %s port %s is already used:\n%s\n' \
"$label" "$port" "$conflicts" >&2
die "choose another ${label} port or stop the conflicting container"
}
first_free_port() {
local start="$1"
local port
for port in $(seq "$start" 65535); do
if [ -z "$(published_port_conflicts "$port")" ]; then
printf '%s\n' "$port"
return 0
fi
done
return 1
}
choose_ui_port() {
local value="$1"
local suggested
if ! is_valid_port "$value"; then
die "CLIENT_UI_PORT must be a port from 1024 to 65535"
fi
if [ -z "$(published_port_conflicts "$value")" ]; then
printf '%s\n' "$value"
return 0
fi
if [ -n "$REQUESTED_UI_PORT" ] || [ ! -r /dev/tty ]; then
assert_single_port_available "UI" "$value"
fi
suggested="$(first_free_port "$((value + 1))" || true)"
suggested="${suggested:-3457}"
while true; do
printf 'UI port %s is busy. Choose UI port [%s]: ' "$value" "$suggested" >/dev/tty
IFS= read -r value </dev/tty || value=""
value="${value:-$suggested}"
if is_valid_port "$value" && [ -z "$(published_port_conflicts "$value")" ]; then
printf '%s\n' "$value"
return 0
fi
printf 'Enter a free port from 1024 to 65535.\n' >/dev/tty
done
}
assert_ui_outside_proxy_range() {
if [ "$UI_PORT" -ge "$PROXY_PORT" ] && [ "$UI_PORT" -le "$PROXY_PORT_END" ]; then
die "UI port ${UI_PORT} overlaps proxy port range ${PROXY_PORT}-${PROXY_PORT_END}"
fi
}
wait_for_client_ui() {
local ui_port="${UI_PORT:-3456}"
local ui_url="http://127.0.0.1:${ui_port}/api/state"
local attempt
for attempt in $(seq 1 30); do
if curl -fsS "$ui_url" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
printf '\n[vpn-proxy-client] client did not become ready at %s\n' "$ui_url" >&2
printf '[vpn-proxy-client] docker compose status:\n' >&2
docker compose -f "$COMPOSE_FILE" ps >&2 || true
printf '\n[vpn-proxy-client] recent service logs:\n' >&2
docker compose -f "$COMPOSE_FILE" logs --tail=120 vpn-proxy-client >&2 || true
die "client UI is not ready; see Docker status and logs above"
}
set_env_value() {
local key="$1"
local value="$2"
local tmp
tmp="$(mktemp)"
if [ -f .env ] && grep -q "^${key}=" .env; then
awk -v key="$key" -v value="$value" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 { print key "=" value; next }
{ print }
' .env > "$tmp"
else
[ -f .env ] && cat .env > "$tmp"
printf '%s=%s\n' "$key" "$value" >> "$tmp"
fi
mv "$tmp" .env
}
get_env_value() {
local key="$1"
[ -f .env ] || return 0
awk -v key="$key" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 { print substr($0, length(prefix) + 1); exit }
' .env
}
if [[ "$(uname -s)" != "Darwin" ]]; then if [[ "$(uname -s)" != "Darwin" ]]; then
die "this installer is intended for macOS" die "this installer is intended for macOS"
fi fi
need git need git
need docker need docker
need curl
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required" docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
docker info >/dev/null 2>&1 || die "Docker Desktop is not running" docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
@@ -46,18 +252,38 @@ if [[ ! -f .env && -f .env.example ]]; then
cp .env.example .env cp .env.example .env
fi fi
PROXY_PORT="$(ask_proxy_port)"
assert_proxy_port_available "$PROXY_PORT"
PROXY_PORT_END="$(port_range_end "$PROXY_PORT")"
UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}"
UI_PORT="${UI_PORT:-3456}"
UI_PORT="$(choose_ui_port "$UI_PORT")"
assert_ui_outside_proxy_range
set_env_value APP_MODE client
set_env_value CLIENT_UI_PORT "$UI_PORT"
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
set_env_value CLIENT_PROXY_PORT_START "$PROXY_PORT"
set_env_value CLIENT_PROXY_PORT_END "$PROXY_PORT_END"
set_env_value PROXY_PORT "$PROXY_PORT"
log "UI port: http://127.0.0.1:${UI_PORT}"
log "proxy port: 127.0.0.1:${PROXY_PORT} (reserved range ${PROXY_PORT}-${PROXY_PORT_END})"
log "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
cat <<'EOF' cat <<EOF
VPN Proxy Client is running. VPN Proxy Client is running.
UI: UI:
http://127.0.0.1:3456 http://127.0.0.1:${UI_PORT}
Proxy: Proxy:
HTTP/SOCKS5 127.0.0.1:8080 HTTP/SOCKS5 127.0.0.1:${PROXY_PORT}
UI can switch proxy port within the Docker-published ${PROXY_PORT}-${PROXY_PORT_END} range.
Useful commands: Useful commands:
cd ~/.vpn-proxy-client cd ~/.vpn-proxy-client
@@ -66,9 +292,9 @@ Useful commands:
docker compose -f docker-compose.client.yml down docker compose -f docker-compose.client.yml down
Optional macOS system proxy example: Optional macOS system proxy example:
networksetup -setwebproxy Wi-Fi 127.0.0.1 8080 networksetup -setwebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 8080 networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 8080 networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
Disable later: Disable later:
networksetup -setwebproxystate Wi-Fi off networksetup -setwebproxystate Wi-Fi off

View File

@@ -4,8 +4,29 @@ import { settings } from "./config.js";
const DEFAULT_CLIENT_SETTINGS = { const DEFAULT_CLIENT_SETTINGS = {
homeBypassEnabled: false, homeBypassEnabled: false,
sharedProxyEnabled: false,
sharedProxyControlUrl: "",
sharedProxy: null,
}; };
function normalizeProxyPort(value, fallback = settings.proxyPort) {
const parsed = Number.parseInt(value, 10);
const min = Number.isInteger(settings.clientProxyPortStart)
? settings.clientProxyPortStart
: settings.proxyPort;
const max = Number.isInteger(settings.clientProxyPortEnd)
? settings.clientProxyPortEnd
: min;
const fallbackPort =
Number.isInteger(fallback) && fallback >= min && fallback <= max
? fallback
: min;
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
return fallbackPort;
}
return parsed;
}
function readJson(filePath, fallback) { function readJson(filePath, fallback) {
try { try {
if (!fs.existsSync(filePath)) return fallback; if (!fs.existsSync(filePath)) return fallback;
@@ -20,15 +41,52 @@ function writeJson(filePath, value) {
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8"); fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
} }
function normalizeUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
try {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol)) return "";
url.hash = "";
url.search = "";
return url.toString().replace(/\/$/, "");
} catch {
return "";
}
}
function normalizeSharedProxy(value) {
if (!value || typeof value !== "object") return null;
const host = String(value.host || "").trim();
const port = Number.parseInt(value.port, 10);
const protocol = value.protocol === "http" ? "http" : "socks5";
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
return null;
}
return {
host,
port,
protocol,
checkedAt: value.checkedAt || null,
};
}
export function normalizeClientSettings(input = {}) { export function normalizeClientSettings(input = {}) {
const sharedProxy = normalizeSharedProxy(input.sharedProxy);
const sharedProxyEnabled = Boolean(input.sharedProxyEnabled && sharedProxy);
return { return {
homeBypassEnabled: Boolean(input.homeBypassEnabled), homeBypassEnabled: Boolean(input.homeBypassEnabled),
proxyPort: normalizeProxyPort(input.proxyPort),
sharedProxyEnabled,
sharedProxyControlUrl: normalizeUrl(input.sharedProxyControlUrl),
sharedProxy,
}; };
} }
export function readClientSettings() { export function readClientSettings() {
return normalizeClientSettings({ return normalizeClientSettings({
...DEFAULT_CLIENT_SETTINGS, ...DEFAULT_CLIENT_SETTINGS,
proxyPort: settings.proxyPort,
...readJson(settings.clientSettingsPath, {}), ...readJson(settings.clientSettingsPath, {}),
}); });
} }

View File

@@ -6,6 +6,8 @@ export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway", appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
port: Number(process.env.PORT || 3456), port: Number(process.env.PORT || 3456),
proxyPort: Number(process.env.PROXY_PORT || 8080), proxyPort: Number(process.env.PROXY_PORT || 8080),
clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090),
tproxyPort: Number(process.env.TPROXY_PORT || 7895), tproxyPort: Number(process.env.TPROXY_PORT || 7895),
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0", bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
dataDir, dataDir,
@@ -20,6 +22,7 @@ export const settings = {
devicesPath: path.join(dataDir, "devices.json"), devicesPath: path.join(dataDir, "devices.json"),
deviceRulesPath: path.join(dataDir, "device-rules.json"), deviceRulesPath: path.join(dataDir, "device-rules.json"),
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
hwidPath: path.join(dataDir, "hwid"), hwidPath: path.join(dataDir, "hwid"),
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false", routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn", ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",

View File

@@ -21,6 +21,10 @@ import {
readClientSettings, readClientSettings,
writeClientSettings, writeClientSettings,
} from "./clientSettings.js"; } from "./clientSettings.js";
import {
buildSharedProxyInfo,
checkSharedProxyGateway,
} from "./sharedProxy.js";
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js"; import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
import { tcpPing, resolveHost } from "./ping.js"; import { tcpPing, resolveHost } from "./ping.js";
@@ -598,15 +602,21 @@ function publicState() {
const state = readJson(settings.statePath, {}); const state = readJson(settings.statePath, {});
const customRules = readJson(settings.customRulesPath, []); const customRules = readJson(settings.customRulesPath, []);
const deviceProfiles = readDeviceProfiles(); const deviceProfiles = readDeviceProfiles();
const clientSettings = readClientSettings();
const { subscriptionUrl, ...rest } = state; const { subscriptionUrl, ...rest } = state;
return { return {
mode: settings.appMode, mode: settings.appMode,
port: settings.port, port: settings.port,
proxyPort: settings.proxyPort, proxyPort:
settings.appMode === "client" ? clientSettings.proxyPort : settings.proxyPort,
clientProxyPortRange: {
start: settings.clientProxyPortStart,
end: settings.clientProxyPortEnd,
},
proxyBindIp: settings.bindIp, proxyBindIp: settings.bindIp,
tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null, tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null,
routingRuDirect: settings.routingRuDirect, routingRuDirect: settings.routingRuDirect,
clientSettings: readClientSettings(), clientSettings,
configExists: fs.existsSync(settings.configPath), configExists: fs.existsSync(settings.configPath),
singboxRunning: Boolean(singboxProcess), singboxRunning: Boolean(singboxProcess),
singboxStartedAt, singboxStartedAt,
@@ -716,6 +726,36 @@ async function applySelectedServer(selectedTag) {
}); });
} }
async function applyClientSharedProxy() {
const clientSettings = readClientSettings();
if (!clientSettings.sharedProxyEnabled || !clientSettings.sharedProxy) {
return false;
}
const generated = buildGatewayConfig(
{ outbounds: [], customRules: [] },
"",
);
writeSingboxConfig(generated);
await startSingbox();
pushLog(
"info",
`Mac client uses shared gateway proxy ${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}`,
);
return true;
}
async function applyClientDirectProxy() {
const generated = buildGatewayConfig(
{ outbounds: [], customRules: [] },
"",
);
writeSingboxConfig(generated);
await startSingbox();
pushLog("info", "Mac client routes local proxy directly");
return true;
}
function handleLogsStream(req, res) { function handleLogsStream(req, res) {
res.writeHead(200, { res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8", "content-type": "text/event-stream; charset=utf-8",
@@ -750,6 +790,20 @@ async function handleApi(req, res) {
return sendJson(res, 200, publicState()); return sendJson(res, 200, publicState());
} }
if (req.method === "GET" && req.url === "/api/shared-proxy") {
return sendJson(
res,
200,
buildSharedProxyInfo({
appMode: settings.appMode,
proxyPort: settings.proxyPort,
running: Boolean(singboxProcess),
hostHeader: req.headers.host,
sharedProxyHost: settings.sharedProxyHost,
}),
);
}
if (req.method === "GET" && req.url === "/api/config") { if (req.method === "GET" && req.url === "/api/config") {
const config = readSingboxConfig(); const config = readSingboxConfig();
return sendJson(res, 200, { success: true, config }); return sendJson(res, 200, { success: true, config });
@@ -941,12 +995,48 @@ async function handleApi(req, res) {
const clientSettings = writeClientSettings(body.clientSettings || body); const clientSettings = writeClientSettings(body.clientSettings || body);
const prevState = readJson(settings.statePath, {}); const prevState = readJson(settings.statePath, {});
if ( if (settings.appMode === "client") {
settings.appMode === "client" && if (clientSettings.sharedProxyEnabled) {
await applyClientSharedProxy();
} else if (clientSettings.homeBypassEnabled) {
await applyClientDirectProxy();
} else if (
prevState.selectedTag && prevState.selectedTag &&
readJson(settings.subscriptionCachePath, null)?.config readJson(settings.subscriptionCachePath, null)?.config
) { ) {
await applySelectedServer(prevState.selectedTag); await applySelectedServer(prevState.selectedTag);
} else {
await stopSingbox();
removeSingboxConfig();
}
}
return sendJson(res, 200, {
success: true,
clientSettings,
singboxRunning: Boolean(singboxProcess),
});
}
if (req.method === "POST" && req.url === "/api/client-settings/shared-proxy/check") {
const body = await readBody(req);
const url = String(body.url || "").trim();
if (!url) {
return sendJson(res, 400, {
success: false,
error: "Укажите адрес gateway",
});
}
const patch = await checkSharedProxyGateway(url);
const clientSettings = writeClientSettings({
...readClientSettings(),
...patch,
homeBypassEnabled: false,
});
if (settings.appMode === "client") {
await applyClientSharedProxy();
} }
return sendJson(res, 200, { return sendJson(res, 200, {
@@ -1342,6 +1432,14 @@ async function handleApi(req, res) {
error: "selectedTag обязателен", error: "selectedTag обязателен",
}); });
if (settings.appMode === "client") {
writeClientSettings({
...readClientSettings(),
homeBypassEnabled: false,
sharedProxyEnabled: false,
});
}
await applySelectedServer(selectedTag); await applySelectedServer(selectedTag);
return sendJson(res, 200, { return sendJson(res, 200, {
success: true, success: true,

94
src/server/sharedProxy.js Normal file
View File

@@ -0,0 +1,94 @@
function normalizeControlUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
const url = new URL(withProtocol);
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("Gateway URL must use http or https");
}
url.hash = "";
url.search = "";
url.pathname = url.pathname.replace(/\/api\/shared-proxy\/?$/, "") || "/";
return url.toString().replace(/\/$/, "");
}
function proxyHostFromHeader(hostHeader) {
const raw = String(hostHeader || "").trim();
if (!raw) return "";
if (raw.startsWith("[")) {
const end = raw.indexOf("]");
return end > 0 ? raw.slice(1, end) : "";
}
return raw.split(":")[0];
}
function normalizeProxyInfo(proxy) {
if (!proxy || typeof proxy !== "object") return null;
const host = String(proxy.host || "").trim();
const port = Number.parseInt(proxy.port, 10);
const protocol = proxy.protocol === "http" ? "http" : "socks5";
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
return null;
}
return { host, port, protocol };
}
export function buildSharedProxyInfo({
appMode,
proxyPort,
running,
hostHeader,
sharedProxyHost,
}) {
const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader);
const port = Number.parseInt(proxyPort, 10);
const available =
appMode === "gateway" &&
Boolean(running) &&
host &&
Number.isInteger(port) &&
port > 0 &&
port <= 65535;
const proxy = available
? {
host,
port,
protocol: "socks5",
httpUrl: `http://${host}:${port}`,
socksUrl: `socks5://${host}:${port}`,
}
: null;
return {
success: true,
available,
mode: appMode,
proxy,
};
}
export async function checkSharedProxyGateway(controlUrl, fetchImpl = fetch) {
const baseUrl = normalizeControlUrl(controlUrl);
const response = await fetchImpl(`${baseUrl}/api/shared-proxy`, {
headers: { accept: "application/json" },
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || `Gateway returned ${response.status}`);
}
if (!data.available) {
throw new Error("Gateway shared proxy is not available");
}
const sharedProxy = normalizeProxyInfo(data.proxy);
if (!sharedProxy) {
throw new Error("Gateway returned invalid shared proxy settings");
}
return {
sharedProxyEnabled: true,
sharedProxyControlUrl: baseUrl,
sharedProxy,
};
}

View File

@@ -242,28 +242,57 @@ function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
return rules; return rules;
} }
function sharedProxyOutbound(sharedProxy) {
if (!sharedProxy?.host || !sharedProxy?.port) return null;
if (sharedProxy.protocol === "http") {
return {
type: "http",
tag: "shared-proxy",
server: sharedProxy.host,
server_port: sharedProxy.port,
};
}
return {
type: "socks",
tag: "shared-proxy",
server: sharedProxy.host,
server_port: sharedProxy.port,
version: "5",
};
}
export function buildGatewayConfig( export function buildGatewayConfig(
subscriptionConfig, subscriptionConfig,
selectedTag, selectedTag,
{ bypassAll = false } = {}, { bypassAll = false } = {},
) { ) {
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
if (!selectedOutbound) {
throw new Error(`Outbound не найден: ${selectedTag}`);
}
const vpnOutbound = clone(selectedOutbound);
if (!vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
if (vpnOutbound.type === "vless" && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = "xudp";
}
const customRuleSets = readCustomRuleSets(); const customRuleSets = readCustomRuleSets();
const clientMode = settings.appMode === "client"; const clientMode = settings.appMode === "client";
const clientSettings = clientMode ? readClientSettings() : null; const clientSettings = clientMode ? readClientSettings() : null;
const clientOutbound = clientSettings?.homeBypassEnabled const sharedOutbound =
clientMode && clientSettings?.sharedProxyEnabled
? sharedProxyOutbound(clientSettings.sharedProxy)
: null;
const directOnlyClient = clientMode && clientSettings?.homeBypassEnabled;
const selectedOutbound = sharedOutbound
? null
: findOutbound(subscriptionConfig, selectedTag);
if (!sharedOutbound && !directOnlyClient && !selectedOutbound) {
throw new Error(`Outbound не найден: ${selectedTag}`);
}
const vpnOutbound = selectedOutbound ? clone(selectedOutbound) : null;
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
if (vpnOutbound?.type === "vless" && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = "xudp";
}
const clientOutbound = sharedOutbound
? sharedOutbound.tag
: clientSettings?.homeBypassEnabled
? "direct" ? "direct"
: vpnOutbound.tag; : vpnOutbound.tag;
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }]; const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
const inbounds = [ const inbounds = [
...(clientMode ...(clientMode
@@ -282,7 +311,7 @@ export function buildGatewayConfig(
type: "mixed", type: "mixed",
tag: "mixed-in", tag: "mixed-in",
listen: settings.bindIp, listen: settings.bindIp,
listen_port: settings.proxyPort, listen_port: mixedProxyPort,
sniff: true, sniff: true,
set_system_proxy: false, set_system_proxy: false,
}, },
@@ -304,7 +333,7 @@ export function buildGatewayConfig(
}, },
inbounds, inbounds,
outbounds: [ outbounds: [
vpnOutbound, ...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
{ type: "direct", tag: "direct" }, { type: "direct", tag: "direct" },
{ type: "block", tag: "block" }, { type: "block", tag: "block" },
], ],

View File

@@ -35,6 +35,7 @@ function App() {
}); });
const [clientSettings, setClientSettings] = useState({ const [clientSettings, setClientSettings] = useState({
homeBypassEnabled: false, homeBypassEnabled: false,
sharedProxyEnabled: false,
}); });
const [selectedTag, setSelectedTag] = useState(''); const [selectedTag, setSelectedTag] = useState('');
const [pendingTag, setPendingTag] = useState(''); const [pendingTag, setPendingTag] = useState('');
@@ -81,7 +82,7 @@ function App() {
proxyDefaultMode: 'vpn', proxyDefaultMode: 'vpn',
devices: data.devices || [], devices: data.devices || [],
}); });
setClientSettings(data.clientSettings || { homeBypassEnabled: false }); setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
setSelectedTag((prev) => prev || data.selectedTag || ''); setSelectedTag((prev) => prev || data.selectedTag || '');
setPendingTag((prev) => prev || data.selectedTag || ''); setPendingTag((prev) => prev || data.selectedTag || '');
} }
@@ -268,11 +269,19 @@ function App() {
async function saveClientSettings(nextSettings) { async function saveClientSettings(nextSettings) {
return withBusy(null, async () => { return withBusy(null, async () => {
const data = await api.clientSettings.save(nextSettings); const data = await api.clientSettings.save(nextSettings);
setClientSettings(data.clientSettings || { homeBypassEnabled: false }); setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
await loadState(); await loadState();
}, { quiet: true }); }, { quiet: true });
} }
async function checkSharedProxy(url) {
return withBusy('Общий proxy подключён', async () => {
const data = await api.clientSettings.checkSharedProxy(url);
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
await loadState();
});
}
// === Rules CRUD === // === Rules CRUD ===
function emptyRule() { function emptyRule() {
return { return {
@@ -418,6 +427,7 @@ function App() {
setPendingTag={setPendingTag} setPendingTag={setPendingTag}
clientSettings={clientSettings} clientSettings={clientSettings}
onSaveClientSettings={saveClientSettings} onSaveClientSettings={saveClientSettings}
onCheckSharedProxy={checkSharedProxy}
onFetchSubscription={fetchSubscription} onFetchSubscription={fetchSubscription}
onApply={applyServer} onApply={applyServer}
onRestart={restartSingbox} onRestart={restartSingbox}

View File

@@ -51,6 +51,11 @@ export const api = {
method: "PUT", method: "PUT",
body: JSON.stringify({ clientSettings }), body: JSON.stringify({ clientSettings }),
}), }),
checkSharedProxy: (url) =>
request("/api/client-settings/shared-proxy/check", {
method: "POST",
body: JSON.stringify({ url }),
}),
}, },
ruleSets: { ruleSets: {

View File

@@ -1,104 +1,126 @@
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { flagFor } from '../utils/country.js'; import { flagFor } from '../utils/country.js';
import { formatBytes, formatRelative } from '../utils/format.js'; import { formatRelative } from '../utils/format.js';
import { resolveClientRoute } from '../utils/clientRoute.js';
function CopyField({ label, value }) { function CopyValue({ value }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
async function copy() { async function copy() {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1400); setTimeout(() => setCopied(false), 1200);
} }
return ( return (
<div className="copy-field"> <button className="client-copy" type="button" onClick={copy}>
<div> <span>{value}</span>
<small className="muted">{label}</small> <strong>{copied ? 'OK' : 'Copy'}</strong>
<div className="text-mono">{value}</div>
</div>
<button className="btn btn-secondary sm" onClick={copy}>
{copied ? 'Скопировано' : 'Копировать'}
</button> </button>
</div>
); );
} }
function ClientHero({ state, status, activeServer }) { function StatusPanel({ route, state }) {
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled); const statusLabel = {
const cfg = { connected: 'Работает',
running: { stopped: 'Остановлен',
title: 'Локальный proxy работает', empty: 'Не настроен',
hint: activeServer ? `Подключен сервер ${activeServer.tag}` : 'Сервер применён', }[route.status];
badge: 'Готов',
kind: 'success',
},
applying: {
title: 'Применяем сервер',
hint: 'sing-box перезапускается',
badge: 'Применяем',
kind: 'warning',
},
error: {
title: 'Нужна проверка',
hint: 'Откройте логи и config',
badge: 'Ошибка',
kind: 'danger',
},
stopped: {
title: 'Proxy остановлен',
hint: 'Конфиг есть, sing-box не запущен',
badge: 'Остановлен',
kind: 'warning',
},
no_config: {
title: 'Proxy ещё не настроен',
hint: 'Загрузите подписку и выберите сервер',
badge: 'Не настроен',
kind: 'neutral',
},
}[status] || {};
const view = homeBypass
? {
...cfg,
title: 'Домашний режим: VPN выключен',
hint: 'Локальный proxy работает напрямую',
badge: 'Напрямую',
kind: 'info',
}
: cfg;
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
: 'нет данных';
return ( return (
<section className="client-hero"> <section className={`client-status-panel ${route.status}`}>
<div className="client-hero-main"> <div className="client-status-main">
<span className={`badge ${view.kind}`}>{view.badge}</span> <span className={`client-status-dot ${route.status}`} />
<h1>{view.title}</h1>
<p>{view.hint}</p>
</div>
<div className="client-hero-meta">
<div> <div>
<small className="muted">Активный сервер</small> <div className="client-eyebrow">Текущий маршрут</div>
<strong>{homeBypass ? 'Не используется дома' : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong> <h1>{route.title}</h1>
<p>{route.description}</p>
</div>
</div>
<div className="client-status-facts">
<div>
<small>Куда</small>
<strong>{route.target}</strong>
<span>{route.targetDetail}</span>
</div> </div>
<div> <div>
<small className="muted">Трафик</small> <small>Локальный proxy</small>
<strong>{traffic}</strong> <strong>{route.localProxy}</strong>
<span>HTTP и SOCKS5</span>
</div> </div>
<div> <div>
<small className="muted">Применено</small> <small>Сервис</small>
<strong>{state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'}</strong> <strong>{statusLabel}</strong>
<span>{state?.appliedAt ? `применено ${formatRelative(state.appliedAt)}` : 'нет примененного config'}</span>
</div> </div>
</div> </div>
</section> </section>
); );
} }
function ClientSetup({ function RouteLine({ route }) {
return (
<div className="client-route-line">
{route.path.map((item, index) => (
<React.Fragment key={`${item}-${index}`}>
<span>{item}</span>
{index < route.path.length - 1 && <b></b>}
</React.Fragment>
))}
</div>
);
}
function ModeButton({ active, selected, title, subtitle, onClick, disabled }) {
return (
<button
type="button"
className={`client-mode-button ${selected ? 'selected' : ''} ${active ? 'active' : ''}`}
disabled={disabled}
onClick={onClick}
>
<strong>{title}</strong>
<span>{subtitle}</span>
</button>
);
}
function GatewaySettings({ settings, busy, onCheck }) {
const [draftUrl, setDraftUrl] = useState(settings?.sharedProxyControlUrl || '');
const sharedProxy = settings?.sharedProxy;
useEffect(() => {
setDraftUrl(settings?.sharedProxyControlUrl || '');
}, [settings?.sharedProxyControlUrl]);
return (
<div className="client-mode-settings">
<div className="field">
<label className="field-label">Адрес gateway UI</label>
<div className="client-inline-form">
<input
className="input"
placeholder="http://192.168.50.111:3456"
value={draftUrl}
onChange={(e) => setDraftUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && draftUrl && onCheck(draftUrl)}
/>
<button className="btn btn-primary" disabled={busy || !draftUrl} onClick={() => onCheck(draftUrl)}>
Подключить
</button>
</div>
</div>
{sharedProxy && (
<div className="client-current-target">
<small>Найден общий proxy</small>
<strong>{sharedProxy.host}:{sharedProxy.port}</strong>
</div>
)}
</div>
);
}
function VpnSettings({
state, state,
servers, servers,
subscriptionUrl, subscriptionUrl,
@@ -110,19 +132,13 @@ function ClientSetup({
onApply, onApply,
}) { }) {
const selected = pendingTag || state?.selectedTag || ''; const selected = pendingTag || state?.selectedTag || '';
const canApply = selected && selected !== state?.selectedTag; const activeServer = servers.find((server) => server.tag === selected);
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
return ( return (
<div className="card client-setup"> <div className="client-mode-settings">
<div className="card-header">
<h2>Подключение</h2>
{state?.hasSubscription && <span className="badge success">Подписка загружена</span>}
</div>
<div className="field"> <div className="field">
<label className="field-label">URL подписки или VLESS-ссылка</label> <label className="field-label">Подписка или VLESS</label>
<div className="subscription-input"> <div className="client-inline-form">
<input <input
className="input" className="input"
placeholder="https://… или vless://…" placeholder="https://… или vless://…"
@@ -130,15 +146,14 @@ function ClientSetup({
onChange={(e) => setSubscriptionUrl(e.target.value)} onChange={(e) => setSubscriptionUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()} onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()}
/> />
<button className="btn btn-primary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}> <button className="btn btn-secondary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
Загрузить Загрузить
</button> </button>
</div> </div>
</div> </div>
<div className="field"> <div className="field">
<label className="field-label">VPN-сервер</label> <label className="field-label">VPN-сервер</label>
<div className="subscription-input"> <div className="client-inline-form">
<select <select
className="select" className="select"
value={selected} value={selected}
@@ -152,113 +167,79 @@ function ClientSetup({
</option> </option>
))} ))}
</select> </select>
<button className="btn btn-secondary" disabled={busy || !canApply} onClick={() => onApply(selected)}> <button className="btn btn-primary" disabled={busy || !selected} onClick={() => onApply(selected)}>
Применить Подключить
</button> </button>
</div> </div>
<small className="field-hint"> {activeServer && <small className="field-hint">Выбран {flagFor(activeServer)} {activeServer.tag}</small>}
{homeBypass
? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.'
: 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
</small>
</div> </div>
</div> </div>
); );
} }
function ProxyCard({ state }) { function DirectSettings({ busy, onEnable }) {
const port = state?.proxyPort || 8080;
const urls = useMemo(() => ({
http: `http://127.0.0.1:${port}`,
socks: `socks5://127.0.0.1:${port}`,
}), [port]);
return ( return (
<div className="card"> <div className="client-mode-settings direct">
<div className="card-header"> <div>
<h2>Локальный proxy</h2> <strong>Прямой режим</strong>
<span className="badge info">127.0.0.1:{port}</span> <p className="muted">Приложения продолжают использовать локальный proxy, но трафик идет без VPN и без gateway.</p>
</div>
<div className="copy-stack">
<CopyField label="HTTP / HTTPS" value={urls.http} />
<CopyField label="SOCKS5" value={urls.socks} />
</div> </div>
<button className="btn btn-primary" disabled={busy} onClick={onEnable}>
Включить напрямую
</button>
</div> </div>
); );
} }
function HomeBypassCard({ settings, busy, onSave }) { function ProxySettings({ state, settings, busy, onSave }) {
const enabled = Boolean(settings?.homeBypassEnabled); const range = state?.clientProxyPortRange || { start: 8080, end: 8090 };
const port = settings?.proxyPort || state?.proxyPort || 8080;
const [draftPort, setDraftPort] = useState(String(port));
useEffect(() => {
setDraftPort(String(port));
}, [port]);
const parsed = Number.parseInt(draftPort, 10);
const invalid = !Number.isInteger(parsed) || parsed < range.start || parsed > range.end;
const dirty = !invalid && parsed !== port;
return ( return (
<div className="card"> <aside className="client-side-panel">
<div className="card-header"> <div>
<h2>Домашний режим</h2> <div className="client-panel-title">Адрес для приложений</div>
<span className={`badge ${enabled ? 'info' : 'neutral'}`}> <div className="client-copy-stack">
{enabled ? 'Напрямую' : 'Через VPN'} <CopyValue value={`http://127.0.0.1:${port}`} />
</span> <CopyValue value={`socks5://127.0.0.1:${port}`} />
</div> </div>
<p className="muted"> </div>
Включайте дома: приложения продолжают использовать <code>127.0.0.1:8080</code>, но VPN не используется. <div className="field">
</p> <label className="field-label">Порт proxy</label>
<label className="switch-row"> <div className="client-port-row">
<span>
<strong>Я дома</strong>
<small>{enabled ? 'Весь proxy-трафик идёт напрямую' : 'Весь proxy-трафик идёт через VPN'}</small>
</span>
<input <input
type="checkbox" className="input"
checked={enabled} type="number"
disabled={busy} min={range.start}
onChange={(e) => onSave({ ...settings, homeBypassEnabled: e.target.checked })} max={range.end}
value={draftPort}
onChange={(e) => setDraftPort(e.target.value)}
/> />
</label> <button
</div> className="btn btn-secondary"
); disabled={busy || !dirty}
} onClick={() => onSave({ ...settings, proxyPort: parsed })}
>
function ClientFlow({ state, activeServer }) { Save
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled); </button>
const steps = [
{ label: 'Mac', value: 'приложения' },
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
{ label: homeBypass ? 'Домашняя сеть' : 'VPN-сервер', value: homeBypass ? 'напрямую' : activeServer?.tag || state?.selectedTag || 'не выбран' },
{ label: 'Интернет', value: state?.singboxRunning ? homeBypass ? 'без VPN' : 'через VPN' : 'ожидает' },
];
return (
<div className="card">
<div className="card-header"><h2>Цепочка подключения</h2></div>
<div className="client-flow">
{steps.map((step, index) => (
<React.Fragment key={step.label}>
<div className="flow-node">
<small>{step.label}</small>
<strong>{step.value}</strong>
</div>
{index < steps.length - 1 && <span className="flow-arrow"></span>}
</React.Fragment>
))}
</div>
</div>
);
}
function ClientActions({ state, busy, onRestart, onStop }) {
return (
<div className="card">
<div className="card-header"><h2>Управление</h2></div>
<div className="btn-group">
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>Перезапустить</button>
<button className="btn btn-ghost" disabled={busy || !state?.singboxRunning} onClick={onStop}>Остановить</button>
</div> </div>
<small className={invalid ? 'field-error' : 'field-hint'}>{range.start}{range.end}</small>
</div> </div>
</aside>
); );
} }
export function ClientOverviewPage({ export function ClientOverviewPage({
state, state,
status,
activeServer, activeServer,
busy, busy,
subscriptionUrl, subscriptionUrl,
@@ -268,15 +249,94 @@ export function ClientOverviewPage({
setPendingTag, setPendingTag,
clientSettings, clientSettings,
onSaveClientSettings, onSaveClientSettings,
onCheckSharedProxy,
onFetchSubscription, onFetchSubscription,
onApply, onApply,
onRestart,
onStop,
}) { }) {
const route = useMemo(
() => resolveClientRoute({ state, activeServer }),
[state, activeServer],
);
const [setupMode, setSetupMode] = useState(route.mode === 'none' ? 'gateway' : route.mode);
useEffect(() => {
if (route.mode !== 'none') setSetupMode(route.mode);
}, [route.mode]);
function enableDirect() {
return onSaveClientSettings({
...clientSettings,
homeBypassEnabled: true,
sharedProxyEnabled: false,
});
}
function selectGateway() {
setSetupMode('gateway');
if (clientSettings?.sharedProxyControlUrl) {
return onCheckSharedProxy(clientSettings.sharedProxyControlUrl);
}
return null;
}
function selectVpn() {
setSetupMode('vpn');
if (state?.selectedTag) {
return onApply(state.selectedTag);
}
return onSaveClientSettings({
...clientSettings,
homeBypassEnabled: false,
sharedProxyEnabled: false,
});
}
return ( return (
<div className="section-stack"> <div className="client-dashboard">
<ClientHero state={state} status={status} activeServer={activeServer} /> <StatusPanel route={route} state={state} />
<ClientSetup <RouteLine route={route} />
<section className="client-workspace">
<div className="client-main-panel">
<div className="client-mode-grid">
<ModeButton
active={route.mode === 'gateway'}
selected={setupMode === 'gateway'}
title="Общий gateway"
subtitle={clientSettings?.sharedProxy ? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}` : 'серверная proxy'}
disabled={busy}
onClick={selectGateway}
/>
<ModeButton
active={route.mode === 'vpn'}
selected={setupMode === 'vpn'}
title="Локальный VPN"
subtitle={state?.selectedTag || 'выбрать сервер'}
disabled={busy}
onClick={selectVpn}
/>
<ModeButton
active={route.mode === 'direct'}
selected={setupMode === 'direct'}
title="Напрямую"
subtitle="без VPN"
disabled={busy}
onClick={() => {
setSetupMode('direct');
enableDirect();
}}
/>
</div>
{setupMode === 'gateway' && (
<GatewaySettings
settings={clientSettings}
busy={busy}
onCheck={onCheckSharedProxy}
/>
)}
{setupMode === 'vpn' && (
<VpnSettings
state={state} state={state}
servers={servers} servers={servers}
subscriptionUrl={subscriptionUrl} subscriptionUrl={subscriptionUrl}
@@ -287,19 +347,19 @@ export function ClientOverviewPage({
onFetchSubscription={onFetchSubscription} onFetchSubscription={onFetchSubscription}
onApply={onApply} onApply={onApply}
/> />
<div className="grid-2"> )}
<ProxyCard state={state} /> {setupMode === 'direct' && (
<HomeBypassCard settings={clientSettings} busy={busy} onSave={onSaveClientSettings} /> <DirectSettings busy={busy} onEnable={enableDirect} />
)}
</div> </div>
<div className="grid-2">
<ClientActions <ProxySettings
state={state} state={state}
settings={clientSettings}
busy={busy} busy={busy}
onRestart={onRestart} onSave={onSaveClientSettings}
onStop={onStop}
/> />
</div> </section>
<ClientFlow state={state} activeServer={activeServer} />
</div> </div>
); );
} }

View File

@@ -118,6 +118,9 @@ code, .mono {
} }
.app-body.client-mode { .app-body.client-mode {
grid-template-columns: 1fr; grid-template-columns: 1fr;
background:
radial-gradient(circle at 10% 0%, rgba(142, 212, 255, 0.08), transparent 28rem),
linear-gradient(180deg, #07110f 0%, #070d11 60%, #06090d 100%);
} }
.app-main { .app-main {
@@ -827,141 +830,269 @@ code, .mono {
/* ============ Client overview ============ */ /* ============ Client overview ============ */
.client-hero {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.8fr);
gap: var(--space-4);
align-items: stretch;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-card);
padding: var(--space-6);
box-shadow: var(--shadow-card);
}
.client-mode .app-main { .client-mode .app-main {
max-width: 1120px; max-width: 1180px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding-top: 18px;
} }
.client-hero-main {
.client-dashboard {
display: grid;
gap: 12px;
}
.client-status-panel {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(420px, 0.8fr);
gap: 16px;
padding: 18px;
background: #101820;
border: 1px solid #263442;
border-radius: 8px;
}
.client-status-panel.connected { border-color: rgba(109, 255, 157, 0.46); }
.client-status-panel.stopped { border-color: rgba(255, 209, 102, 0.42); }
.client-status-panel.empty { border-color: rgba(142, 212, 255, 0.32); }
.client-status-main {
min-width: 0;
display: flex; display: flex;
flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: var(--space-3); gap: 14px;
} }
.client-hero-main h1 { .client-status-dot {
font-size: 28px; width: 12px;
height: 12px;
margin-top: 9px;
border-radius: 50%;
background: var(--subtle);
box-shadow: 0 0 0 6px rgba(111, 140, 124, 0.12);
flex: 0 0 12px;
}
.client-status-dot.connected {
background: var(--success);
box-shadow: 0 0 0 6px rgba(109, 255, 157, 0.12);
}
.client-status-dot.stopped {
background: var(--warning);
box-shadow: 0 0 0 6px rgba(255, 209, 102, 0.12);
}
.client-status-dot.empty {
background: var(--info);
box-shadow: 0 0 0 6px rgba(142, 212, 255, 0.12);
}
.client-eyebrow {
color: var(--subtle);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.client-status-main h1 {
margin: 2px 0 4px;
font-size: 30px;
line-height: 1.08;
letter-spacing: 0; letter-spacing: 0;
} }
.client-hero-main p { .client-status-main p {
color: var(--muted); margin: 0;
}
.client-hero-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
margin-top: var(--space-2);
}
.client-hero-meta {
display: grid;
gap: var(--space-3);
align-content: center;
}
.client-hero-meta > div {
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-input);
background: var(--surface-2);
}
.client-hero-meta strong {
display: block;
margin-top: 4px;
overflow-wrap: anywhere;
}
.copy-stack {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.client-setup {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.switch-row {
margin-top: var(--space-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-input);
background: var(--surface-2);
}
.switch-row span {
display: flex;
flex-direction: column;
gap: 2px;
}
.switch-row input {
width: 44px;
height: 24px;
flex: 0 0 44px;
accent-color: var(--accent);
}
.copy-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
}
.copy-field .text-mono {
margin-top: 4px;
overflow-wrap: anywhere;
}
.client-flow {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
gap: var(--space-3);
align-items: stretch;
}
.flow-node {
min-width: 0;
padding: var(--space-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-input);
}
.flow-node strong {
display: block;
margin-top: 4px;
overflow-wrap: anywhere;
}
.flow-arrow {
display: flex;
align-items: center;
color: var(--muted); color: var(--muted);
} }
@media (max-width: 900px) { .client-status-facts {
.client-hero { display: grid;
grid-template-columns: 1fr; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
} }
.client-flow { .client-status-facts > div {
grid-template-columns: 1fr; min-width: 0;
padding: 12px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
} }
.flow-arrow { .client-status-facts small,
justify-content: center; .client-current-target small,
transform: rotate(90deg); .client-panel-title {
display: block;
color: var(--subtle);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
} }
.copy-field { .client-status-facts strong,
align-items: flex-start; .client-current-target strong {
display: block;
margin: 3px 0;
overflow-wrap: anywhere;
}
.client-status-facts span {
color: var(--muted);
font-size: 12px;
}
.client-route-line {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
color: var(--muted);
overflow-x: auto;
white-space: nowrap;
}
.client-route-line span {
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
}
.client-route-line b {
color: var(--subtle);
font-weight: 600;
}
.client-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 12px;
align-items: start;
}
.client-main-panel,
.client-side-panel {
background: #101820;
border: 1px solid #263442;
border-radius: 8px;
padding: 14px;
}
.client-main-panel {
display: flex;
flex-direction: column; flex-direction: column;
gap: 14px;
}
.client-mode-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.client-mode-button {
min-width: 0;
text-align: left;
padding: 12px;
border: 1px solid #2a3948;
border-radius: 8px;
background: #0b1219;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease;
}
.client-mode-button:hover:not(:disabled) {
border-color: #4c6d88;
background: #101c27;
}
.client-mode-button.selected {
border-color: var(--info);
background: rgba(142, 212, 255, 0.08);
}
.client-mode-button.active {
border-color: var(--success);
background: rgba(109, 255, 157, 0.11);
}
.client-mode-button strong,
.client-mode-button span {
display: flex;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-mode-button strong {
font-size: 14px;
}
.client-mode-button span {
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.client-mode-settings {
display: grid;
gap: 12px;
}
.client-mode-settings.direct {
grid-template-columns: minmax(0, 1fr) auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.client-mode-settings.direct p {
margin: 4px 0 0;
}
.client-inline-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.client-current-target {
padding: 10px 12px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
}
.client-side-panel {
display: grid;
gap: 16px;
}
.client-copy-stack {
display: grid;
gap: 8px;
margin-top: 8px;
}
.client-copy {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 10px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
cursor: pointer;
}
.client-copy span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 12px;
}
.client-copy strong {
color: var(--accent);
font-size: 11px;
}
.client-port-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
@media (max-width: 980px) {
.client-status-panel,
.client-workspace {
grid-template-columns: 1fr;
}
.client-status-facts,
.client-mode-grid {
grid-template-columns: 1fr;
}
.client-mode-settings.direct,
.client-inline-form {
grid-template-columns: 1fr;
} }
} }

View File

@@ -0,0 +1,57 @@
export function resolveClientRoute({ state, activeServer } = {}) {
const settings = state?.clientSettings || {};
const localProxy = `127.0.0.1:${state?.proxyPort || settings.proxyPort || 8080}`;
const running = Boolean(state?.singboxRunning);
const hasConfig = Boolean(state?.configExists);
let mode = "none";
let target = "выберите режим";
let targetDetail = "Gateway, локальный VPN или напрямую";
let title = "Не подключено";
let description = "Выберите режим подключения и примените его.";
let pathTarget = "не выбран";
if (settings.sharedProxyEnabled && settings.sharedProxy) {
mode = "gateway";
target = `${settings.sharedProxy.host}:${settings.sharedProxy.port}`;
targetDetail = "общий gateway proxy";
title = running ? "Подключено к gateway" : "Gateway настроен, но остановлен";
description = "Локальный proxy на Mac отправляет трафик на серверный gateway.";
pathTarget = `Gateway ${target}`;
} else if (settings.homeBypassEnabled) {
mode = "direct";
target = "без VPN";
targetDetail = "прямое подключение";
title = running ? "Подключено напрямую" : "Direct настроен, но остановлен";
description = "Приложения используют локальный proxy, но трафик идет напрямую.";
pathTarget = "Direct";
} else if (state?.selectedTag) {
mode = "vpn";
target = activeServer?.tag || state.selectedTag;
targetDetail = "локальный VPN";
title = running ? "Подключено через VPN" : "VPN настроен, но остановлен";
description = "Локальный proxy на Mac отправляет трафик через выбранный VPN-сервер.";
pathTarget = `VPN ${target}`;
}
const status = running
? "connected"
: hasConfig && mode !== "none"
? "stopped"
: "empty";
if (status === "empty") {
title = "Не подключено";
}
return {
mode,
status,
localProxy,
title,
target,
targetDetail,
description,
path: ["Mac apps", localProxy, pathTarget, "Internet"],
};
}

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
const {
buildSharedProxyInfo,
checkSharedProxyGateway,
} = await import("../../src/server/sharedProxy.js");
test("gateway shared proxy info exposes host and socks proxy when running", () => {
const info = buildSharedProxyInfo({
appMode: "gateway",
proxyPort: 8080,
running: true,
hostHeader: "192.168.50.111:3456",
});
assert.equal(info.available, true);
assert.deepEqual(info.proxy, {
host: "192.168.50.111",
port: 8080,
protocol: "socks5",
httpUrl: "http://192.168.50.111:8080",
socksUrl: "socks5://192.168.50.111:8080",
});
});
test("client shared proxy check normalizes gateway response into settings patch", async () => {
const patch = await checkSharedProxyGateway(
"http://192.168.50.111:3456",
async (url) => {
assert.equal(url, "http://192.168.50.111:3456/api/shared-proxy");
return {
ok: true,
status: 200,
json: async () => ({
success: true,
available: true,
proxy: {
host: "192.168.50.111",
port: 8080,
protocol: "socks5",
},
}),
};
},
);
assert.equal(patch.sharedProxyEnabled, true);
assert.equal(patch.sharedProxyControlUrl, "http://192.168.50.111:3456");
assert.deepEqual(patch.sharedProxy, {
host: "192.168.50.111",
port: 8080,
protocol: "socks5",
});
});

View File

@@ -11,6 +11,7 @@ process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, "cache.db");
const { buildGatewayConfig } = await import( const { buildGatewayConfig } = await import(
`../../src/server/singbox.js?client-mode=${Date.now()}` `../../src/server/singbox.js?client-mode=${Date.now()}`
); );
const clientSettingsPath = path.join(process.env.DATA_DIR, "client-settings.json");
const subscriptionConfig = { const subscriptionConfig = {
outbounds: [ outbounds: [
@@ -27,6 +28,7 @@ const subscriptionConfig = {
}; };
test("client mode exposes only the local mixed proxy inbound", () => { test("client mode exposes only the local mixed proxy inbound", () => {
fs.rmSync(clientSettingsPath, { force: true });
const config = buildGatewayConfig(subscriptionConfig, "test-vpn"); const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
assert.deepEqual( assert.deepEqual(
@@ -38,6 +40,7 @@ test("client mode exposes only the local mixed proxy inbound", () => {
}); });
test("client mode routes mixed proxy fallback to the selected VPN", () => { test("client mode routes mixed proxy fallback to the selected VPN", () => {
fs.rmSync(clientSettingsPath, { force: true });
const config = buildGatewayConfig(subscriptionConfig, "test-vpn"); const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
assert.deepEqual(config.route.rule_set, []); assert.deepEqual(config.route.rule_set, []);
@@ -47,8 +50,9 @@ test("client mode routes mixed proxy fallback to the selected VPN", () => {
}); });
test("client home bypass routes the local proxy directly", () => { test("client home bypass routes the local proxy directly", () => {
fs.rmSync(clientSettingsPath, { force: true });
fs.writeFileSync( fs.writeFileSync(
path.join(process.env.DATA_DIR, "client-settings.json"), clientSettingsPath,
JSON.stringify({ homeBypassEnabled: true }), JSON.stringify({ homeBypassEnabled: true }),
); );
@@ -59,3 +63,68 @@ test("client home bypass routes the local proxy directly", () => {
{ inbound: ["mixed-in"], outbound: "direct" }, { inbound: ["mixed-in"], outbound: "direct" },
]); ]);
}); });
test("client home bypass can build direct proxy without local VPN", () => {
fs.rmSync(clientSettingsPath, { force: true });
fs.writeFileSync(
clientSettingsPath,
JSON.stringify({ homeBypassEnabled: true }),
);
const config = buildGatewayConfig({ outbounds: [], customRules: [] }, "");
assert.deepEqual(config.outbounds, [
{ type: "direct", tag: "direct" },
{ type: "block", tag: "block" },
]);
assert.deepEqual(config.route.rules, [
{ inbound: ["mixed-in"], outbound: "direct" },
]);
});
test("client mode uses selected proxy port from client settings", () => {
fs.rmSync(clientSettingsPath, { force: true });
fs.writeFileSync(
clientSettingsPath,
JSON.stringify({ proxyPort: 8085 }),
);
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
assert.equal(config.inbounds[0].listen_port, 8085);
assert.deepEqual(config.route.rules, [
{ inbound: ["mixed-in"], outbound: "test-vpn" },
]);
});
test("client shared proxy mode routes local proxy to gateway socks outbound", () => {
fs.rmSync(clientSettingsPath, { force: true });
fs.writeFileSync(
clientSettingsPath,
JSON.stringify({
sharedProxyEnabled: true,
sharedProxy: {
host: "192.168.50.111",
port: 8080,
protocol: "socks5",
},
}),
);
const config = buildGatewayConfig({ outbounds: [], customRules: [] }, "");
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ["mixed-in"]);
assert.deepEqual(
config.outbounds.find((outbound) => outbound.tag === "shared-proxy"),
{
type: "socks",
tag: "shared-proxy",
server: "192.168.50.111",
server_port: 8080,
version: "5",
},
);
assert.deepEqual(config.route.rules, [
{ inbound: ["mixed-in"], outbound: "shared-proxy" },
]);
});

View File

@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveClientRoute } from "../../src/web/utils/clientRoute.js";
test("shows gateway route as the active Mac connection", () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
proxyPort: 18080,
clientSettings: {
sharedProxyEnabled: true,
sharedProxy: { host: "192.168.50.111", port: 8080, protocol: "socks5" },
},
},
});
assert.equal(route.mode, "gateway");
assert.equal(route.status, "connected");
assert.equal(route.title, "Подключено к gateway");
assert.equal(route.target, "192.168.50.111:8080");
assert.deepEqual(route.path, [
"Mac apps",
"127.0.0.1:18080",
"Gateway 192.168.50.111:8080",
"Internet",
]);
});
test("shows local VPN route with selected server", () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
proxyPort: 8082,
selectedTag: "nl-amsterdam",
clientSettings: {},
},
activeServer: { tag: "nl-amsterdam", country: "NL" },
});
assert.equal(route.mode, "vpn");
assert.equal(route.status, "connected");
assert.equal(route.title, "Подключено через VPN");
assert.equal(route.target, "nl-amsterdam");
});
test("shows direct route when home mode is enabled", () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
proxyPort: 8082,
clientSettings: { homeBypassEnabled: true },
},
});
assert.equal(route.mode, "direct");
assert.equal(route.status, "connected");
assert.equal(route.title, "Подключено напрямую");
assert.equal(route.target, "без VPN");
});
test("shows configured but stopped route clearly", () => {
const route = resolveClientRoute({
state: {
singboxRunning: false,
configExists: true,
proxyPort: 8082,
selectedTag: "nl-amsterdam",
clientSettings: {},
},
});
assert.equal(route.mode, "vpn");
assert.equal(route.status, "stopped");
assert.equal(route.title, "VPN настроен, но остановлен");
});
test("shows missing setup when nothing is configured", () => {
const route = resolveClientRoute({
state: {
singboxRunning: false,
configExists: false,
proxyPort: 8082,
clientSettings: {},
},
});
assert.equal(route.mode, "none");
assert.equal(route.status, "empty");
assert.equal(route.title, "Не подключено");
assert.equal(route.target, "выберите режим");
});