Compare commits
4 Commits
2.0.0
...
ab44626a0f
| Author | SHA1 | Date | |
|---|---|---|---|
| ab44626a0f | |||
| 95edefa84f | |||
| f914c28bc5 | |||
| 73488384e4 |
@@ -1,7 +1,8 @@
|
||||
PORT=3456
|
||||
APP_MODE=gateway
|
||||
CLIENT_UI_PORT=3456
|
||||
CLIENT_PROXY_PORT=8080
|
||||
CLIENT_PROXY_PORT_START=8080
|
||||
CLIENT_PROXY_PORT_END=8090
|
||||
BASE_IMAGE=debian:bookworm-slim
|
||||
SINGBOX_VERSION=1.12.13
|
||||
INSTALL_RUNTIME_DEPS=true
|
||||
|
||||
35
README.md
35
README.md
@@ -10,12 +10,34 @@
|
||||
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`
|
||||
- HTTP/SOCKS proxy: `127.0.0.1:8080`
|
||||
- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; в UI можно выбрать порт из Docker-диапазона `8080–8090`
|
||||
|
||||
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют `127.0.0.1:8080`, но весь proxy-трафик идёт напрямую без VPN.
|
||||
Установщик интерактивно спросит proxy-порт. Если стандартный 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:
|
||||
|
||||
@@ -343,7 +365,11 @@ UI доступен на `http://<gateway-ip>:3456`.
|
||||
| ------------------- | -------------------- | -------------------------------------- |
|
||||
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
|
||||
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
|
||||
| `CLIENT_PROXY_PORT` | `8080` | Host-порт proxy для `docker-compose.client.yml` |
|
||||
| `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` | Порт веб-интерфейса |
|
||||
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
||||
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
|
||||
@@ -369,6 +395,7 @@ UI доступен на `http://<gateway-ip>:3456`.
|
||||
| Метод | Путь | Описание |
|
||||
| --------- | ---------------------- | ------------------------------------ |
|
||||
| `GET` | `/api/state` | Полное состояние системы |
|
||||
| `GET` | `/api/shared-proxy` | Проверка и параметры общего gateway proxy |
|
||||
| `POST` | `/api/subscription` | Загрузить подписку по URL |
|
||||
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
|
||||
| `GET` | `/api/servers` | Список серверов из кэша |
|
||||
|
||||
@@ -9,7 +9,9 @@ services:
|
||||
environment:
|
||||
APP_MODE: client
|
||||
PORT: ${PORT:-3456}
|
||||
PROXY_PORT: ${PROXY_PORT:-8080}
|
||||
PROXY_PORT: ${CLIENT_PROXY_PORT_START:-8080}
|
||||
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT_START:-8080}
|
||||
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT_END:-8090}
|
||||
PROXY_BIND_IP: 0.0.0.0
|
||||
DATA_DIR: /var/lib/vpn-proxy
|
||||
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||
@@ -17,9 +19,17 @@ services:
|
||||
ROUTING_RU_DIRECT: ${ROUTING_RU_DIRECT:-true}
|
||||
RULE_SET_DOWNLOAD_DETOUR: ${RULE_SET_DOWNLOAD_DETOUR:-vpn}
|
||||
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:
|
||||
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
|
||||
- "127.0.0.1:${CLIENT_PROXY_PORT:-8080}:${PROXY_PORT:-8080}"
|
||||
- "127.0.0.1:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}:${CLIENT_PROXY_PORT_START:-8080}-${CLIENT_PROXY_PORT_END:-8090}"
|
||||
volumes:
|
||||
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
||||
- sing-box-client-cache:/var/lib/sing-box
|
||||
|
||||
@@ -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}"
|
||||
BRANCH="${VPN_PROXY_BRANCH:-master}"
|
||||
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() {
|
||||
printf '[vpn-proxy-client] %s\n' "$*"
|
||||
@@ -19,12 +23,214 @@ need() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
|
||||
}
|
||||
|
||||
is_valid_port() {
|
||||
case "$1" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
[ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
|
||||
}
|
||||
|
||||
ask_proxy_port() {
|
||||
local value=""
|
||||
if [ -n "$REQUESTED_PROXY_PORT" ]; then
|
||||
if ! is_valid_port "$REQUESTED_PROXY_PORT"; then
|
||||
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
|
||||
fi
|
||||
printf '%s\n' "$REQUESTED_PROXY_PORT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -r /dev/tty ]; then
|
||||
while true; do
|
||||
printf 'Proxy port for local apps [%s]: ' "$DEFAULT_PROXY_PORT" >/dev/tty
|
||||
IFS= read -r value </dev/tty || 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
|
||||
die "this installer is intended for macOS"
|
||||
fi
|
||||
|
||||
need git
|
||||
need docker
|
||||
need curl
|
||||
|
||||
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
||||
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
||||
@@ -46,18 +252,38 @@ if [[ ! -f .env && -f .env.example ]]; then
|
||||
cp .env.example .env
|
||||
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"
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
wait_for_client_ui
|
||||
|
||||
cat <<'EOF'
|
||||
cat <<EOF
|
||||
|
||||
VPN Proxy Client is running.
|
||||
|
||||
UI:
|
||||
http://127.0.0.1:3456
|
||||
http://127.0.0.1:${UI_PORT}
|
||||
|
||||
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:
|
||||
cd ~/.vpn-proxy-client
|
||||
@@ -66,9 +292,9 @@ Useful commands:
|
||||
docker compose -f docker-compose.client.yml down
|
||||
|
||||
Optional macOS system proxy example:
|
||||
networksetup -setwebproxy Wi-Fi 127.0.0.1 8080
|
||||
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 8080
|
||||
networksetup -setsocksfirewallproxy 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 ${PROXY_PORT}
|
||||
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||
|
||||
Disable later:
|
||||
networksetup -setwebproxystate Wi-Fi off
|
||||
|
||||
@@ -4,8 +4,29 @@ import { settings } from "./config.js";
|
||||
|
||||
const DEFAULT_CLIENT_SETTINGS = {
|
||||
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) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
@@ -20,15 +41,52 @@ function writeJson(filePath, value) {
|
||||
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 = {}) {
|
||||
const sharedProxy = normalizeSharedProxy(input.sharedProxy);
|
||||
const sharedProxyEnabled = Boolean(input.sharedProxyEnabled && sharedProxy);
|
||||
return {
|
||||
homeBypassEnabled: Boolean(input.homeBypassEnabled),
|
||||
proxyPort: normalizeProxyPort(input.proxyPort),
|
||||
sharedProxyEnabled,
|
||||
sharedProxyControlUrl: normalizeUrl(input.sharedProxyControlUrl),
|
||||
sharedProxy,
|
||||
};
|
||||
}
|
||||
|
||||
export function readClientSettings() {
|
||||
return normalizeClientSettings({
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
proxyPort: settings.proxyPort,
|
||||
...readJson(settings.clientSettingsPath, {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export const settings = {
|
||||
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||
port: Number(process.env.PORT || 3456),
|
||||
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
||||
clientProxyPortStart: Number(process.env.CLIENT_PROXY_PORT_START || 8080),
|
||||
clientProxyPortEnd: Number(process.env.CLIENT_PROXY_PORT_END || 8090),
|
||||
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
||||
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
||||
dataDir,
|
||||
@@ -20,6 +22,7 @@ export const settings = {
|
||||
devicesPath: path.join(dataDir, "devices.json"),
|
||||
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||
hwidPath: path.join(dataDir, "hwid"),
|
||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
readClientSettings,
|
||||
writeClientSettings,
|
||||
} from "./clientSettings.js";
|
||||
import {
|
||||
buildSharedProxyInfo,
|
||||
checkSharedProxyGateway,
|
||||
} from "./sharedProxy.js";
|
||||
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
||||
import { tcpPing, resolveHost } from "./ping.js";
|
||||
|
||||
@@ -598,15 +602,21 @@ function publicState() {
|
||||
const state = readJson(settings.statePath, {});
|
||||
const customRules = readJson(settings.customRulesPath, []);
|
||||
const deviceProfiles = readDeviceProfiles();
|
||||
const clientSettings = readClientSettings();
|
||||
const { subscriptionUrl, ...rest } = state;
|
||||
return {
|
||||
mode: settings.appMode,
|
||||
port: settings.port,
|
||||
proxyPort: settings.proxyPort,
|
||||
proxyPort:
|
||||
settings.appMode === "client" ? clientSettings.proxyPort : settings.proxyPort,
|
||||
clientProxyPortRange: {
|
||||
start: settings.clientProxyPortStart,
|
||||
end: settings.clientProxyPortEnd,
|
||||
},
|
||||
proxyBindIp: settings.bindIp,
|
||||
tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null,
|
||||
routingRuDirect: settings.routingRuDirect,
|
||||
clientSettings: readClientSettings(),
|
||||
clientSettings,
|
||||
configExists: fs.existsSync(settings.configPath),
|
||||
singboxRunning: Boolean(singboxProcess),
|
||||
singboxStartedAt,
|
||||
@@ -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) {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
@@ -750,6 +790,20 @@ async function handleApi(req, res) {
|
||||
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") {
|
||||
const config = readSingboxConfig();
|
||||
return sendJson(res, 200, { success: true, config });
|
||||
@@ -941,12 +995,48 @@ async function handleApi(req, res) {
|
||||
const clientSettings = writeClientSettings(body.clientSettings || body);
|
||||
const prevState = readJson(settings.statePath, {});
|
||||
|
||||
if (
|
||||
settings.appMode === "client" &&
|
||||
if (settings.appMode === "client") {
|
||||
if (clientSettings.sharedProxyEnabled) {
|
||||
await applyClientSharedProxy();
|
||||
} else if (clientSettings.homeBypassEnabled) {
|
||||
await applyClientDirectProxy();
|
||||
} else if (
|
||||
prevState.selectedTag &&
|
||||
readJson(settings.subscriptionCachePath, null)?.config
|
||||
) {
|
||||
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, {
|
||||
@@ -1342,6 +1432,14 @@ async function handleApi(req, res) {
|
||||
error: "selectedTag обязателен",
|
||||
});
|
||||
|
||||
if (settings.appMode === "client") {
|
||||
writeClientSettings({
|
||||
...readClientSettings(),
|
||||
homeBypassEnabled: false,
|
||||
sharedProxyEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
await applySelectedServer(selectedTag);
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
|
||||
94
src/server/sharedProxy.js
Normal file
94
src/server/sharedProxy.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -242,28 +242,57 @@ function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
|
||||
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(
|
||||
subscriptionConfig,
|
||||
selectedTag,
|
||||
{ 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 clientMode = settings.appMode === "client";
|
||||
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"
|
||||
: vpnOutbound.tag;
|
||||
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
||||
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
||||
const inbounds = [
|
||||
...(clientMode
|
||||
@@ -282,7 +311,7 @@ export function buildGatewayConfig(
|
||||
type: "mixed",
|
||||
tag: "mixed-in",
|
||||
listen: settings.bindIp,
|
||||
listen_port: settings.proxyPort,
|
||||
listen_port: mixedProxyPort,
|
||||
sniff: true,
|
||||
set_system_proxy: false,
|
||||
},
|
||||
@@ -304,7 +333,7 @@ export function buildGatewayConfig(
|
||||
},
|
||||
inbounds,
|
||||
outbounds: [
|
||||
vpnOutbound,
|
||||
...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
|
||||
{ type: "direct", tag: "direct" },
|
||||
{ type: "block", tag: "block" },
|
||||
],
|
||||
|
||||
@@ -35,6 +35,7 @@ function App() {
|
||||
});
|
||||
const [clientSettings, setClientSettings] = useState({
|
||||
homeBypassEnabled: false,
|
||||
sharedProxyEnabled: false,
|
||||
});
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [pendingTag, setPendingTag] = useState('');
|
||||
@@ -81,7 +82,7 @@ function App() {
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||
}
|
||||
@@ -268,11 +269,19 @@ function App() {
|
||||
async function saveClientSettings(nextSettings) {
|
||||
return withBusy(null, async () => {
|
||||
const data = await api.clientSettings.save(nextSettings);
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
|
||||
setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false });
|
||||
await loadState();
|
||||
}, { 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 ===
|
||||
function emptyRule() {
|
||||
return {
|
||||
@@ -418,6 +427,7 @@ function App() {
|
||||
setPendingTag={setPendingTag}
|
||||
clientSettings={clientSettings}
|
||||
onSaveClientSettings={saveClientSettings}
|
||||
onCheckSharedProxy={checkSharedProxy}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onApply={applyServer}
|
||||
onRestart={restartSingbox}
|
||||
|
||||
@@ -51,6 +51,11 @@ export const api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ clientSettings }),
|
||||
}),
|
||||
checkSharedProxy: (url) =>
|
||||
request("/api/client-settings/shared-proxy/check", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
},
|
||||
|
||||
ruleSets: {
|
||||
|
||||
@@ -1,104 +1,126 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
import { formatBytes, formatRelative } from '../utils/format.js';
|
||||
import { formatRelative } from '../utils/format.js';
|
||||
import { resolveClientRoute } from '../utils/clientRoute.js';
|
||||
|
||||
function CopyField({ label, value }) {
|
||||
function CopyValue({ value }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1400);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="copy-field">
|
||||
<div>
|
||||
<small className="muted">{label}</small>
|
||||
<div className="text-mono">{value}</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary sm" onClick={copy}>
|
||||
{copied ? 'Скопировано' : 'Копировать'}
|
||||
<button className="client-copy" type="button" onClick={copy}>
|
||||
<span>{value}</span>
|
||||
<strong>{copied ? 'OK' : 'Copy'}</strong>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientHero({ state, status, activeServer }) {
|
||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||
const cfg = {
|
||||
running: {
|
||||
title: 'Локальный proxy работает',
|
||||
hint: activeServer ? `Подключен сервер ${activeServer.tag}` : 'Сервер применён',
|
||||
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) : ''}`
|
||||
: 'нет данных';
|
||||
function StatusPanel({ route, state }) {
|
||||
const statusLabel = {
|
||||
connected: 'Работает',
|
||||
stopped: 'Остановлен',
|
||||
empty: 'Не настроен',
|
||||
}[route.status];
|
||||
|
||||
return (
|
||||
<section className="client-hero">
|
||||
<div className="client-hero-main">
|
||||
<span className={`badge ${view.kind}`}>{view.badge}</span>
|
||||
<h1>{view.title}</h1>
|
||||
<p>{view.hint}</p>
|
||||
</div>
|
||||
<div className="client-hero-meta">
|
||||
<section className={`client-status-panel ${route.status}`}>
|
||||
<div className="client-status-main">
|
||||
<span className={`client-status-dot ${route.status}`} />
|
||||
<div>
|
||||
<small className="muted">Активный сервер</small>
|
||||
<strong>{homeBypass ? 'Не используется дома' : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
|
||||
<div className="client-eyebrow">Текущий маршрут</div>
|
||||
<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>
|
||||
<small className="muted">Трафик</small>
|
||||
<strong>{traffic}</strong>
|
||||
<small>Локальный proxy</small>
|
||||
<strong>{route.localProxy}</strong>
|
||||
<span>HTTP и SOCKS5</span>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Применено</small>
|
||||
<strong>{state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'}</strong>
|
||||
<small>Сервис</small>
|
||||
<strong>{statusLabel}</strong>
|
||||
<span>{state?.appliedAt ? `применено ${formatRelative(state.appliedAt)}` : 'нет примененного config'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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,
|
||||
servers,
|
||||
subscriptionUrl,
|
||||
@@ -110,19 +132,13 @@ function ClientSetup({
|
||||
onApply,
|
||||
}) {
|
||||
const selected = pendingTag || state?.selectedTag || '';
|
||||
const canApply = selected && selected !== state?.selectedTag;
|
||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||
const activeServer = servers.find((server) => server.tag === selected);
|
||||
|
||||
return (
|
||||
<div className="card client-setup">
|
||||
<div className="card-header">
|
||||
<h2>Подключение</h2>
|
||||
{state?.hasSubscription && <span className="badge success">Подписка загружена</span>}
|
||||
</div>
|
||||
|
||||
<div className="client-mode-settings">
|
||||
<div className="field">
|
||||
<label className="field-label">URL подписки или VLESS-ссылка</label>
|
||||
<div className="subscription-input">
|
||||
<label className="field-label">Подписка или VLESS</label>
|
||||
<div className="client-inline-form">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="https://… или vless://…"
|
||||
@@ -130,15 +146,14 @@ function ClientSetup({
|
||||
onChange={(e) => setSubscriptionUrl(e.target.value)}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">VPN-сервер</label>
|
||||
<div className="subscription-input">
|
||||
<div className="client-inline-form">
|
||||
<select
|
||||
className="select"
|
||||
value={selected}
|
||||
@@ -152,113 +167,79 @@ function ClientSetup({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-secondary" disabled={busy || !canApply} onClick={() => onApply(selected)}>
|
||||
Применить
|
||||
<button className="btn btn-primary" disabled={busy || !selected} onClick={() => onApply(selected)}>
|
||||
Подключить
|
||||
</button>
|
||||
</div>
|
||||
<small className="field-hint">
|
||||
{homeBypass
|
||||
? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.'
|
||||
: 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
|
||||
</small>
|
||||
{activeServer && <small className="field-hint">Выбран {flagFor(activeServer)} {activeServer.tag}</small>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyCard({ state }) {
|
||||
const port = state?.proxyPort || 8080;
|
||||
const urls = useMemo(() => ({
|
||||
http: `http://127.0.0.1:${port}`,
|
||||
socks: `socks5://127.0.0.1:${port}`,
|
||||
}), [port]);
|
||||
|
||||
function DirectSettings({ busy, onEnable }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Локальный proxy</h2>
|
||||
<span className="badge info">127.0.0.1:{port}</span>
|
||||
</div>
|
||||
<div className="copy-stack">
|
||||
<CopyField label="HTTP / HTTPS" value={urls.http} />
|
||||
<CopyField label="SOCKS5" value={urls.socks} />
|
||||
<div className="client-mode-settings direct">
|
||||
<div>
|
||||
<strong>Прямой режим</strong>
|
||||
<p className="muted">Приложения продолжают использовать локальный proxy, но трафик идет без VPN и без gateway.</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={onEnable}>
|
||||
Включить напрямую
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeBypassCard({ settings, busy, onSave }) {
|
||||
const enabled = Boolean(settings?.homeBypassEnabled);
|
||||
function ProxySettings({ state, settings, busy, onSave }) {
|
||||
const range = state?.clientProxyPortRange || { start: 8080, end: 8090 };
|
||||
const port = settings?.proxyPort || state?.proxyPort || 8080;
|
||||
const [draftPort, setDraftPort] = useState(String(port));
|
||||
|
||||
useEffect(() => {
|
||||
setDraftPort(String(port));
|
||||
}, [port]);
|
||||
|
||||
const parsed = Number.parseInt(draftPort, 10);
|
||||
const invalid = !Number.isInteger(parsed) || parsed < range.start || parsed > range.end;
|
||||
const dirty = !invalid && parsed !== port;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Домашний режим</h2>
|
||||
<span className={`badge ${enabled ? 'info' : 'neutral'}`}>
|
||||
{enabled ? 'Напрямую' : 'Через VPN'}
|
||||
</span>
|
||||
<aside className="client-side-panel">
|
||||
<div>
|
||||
<div className="client-panel-title">Адрес для приложений</div>
|
||||
<div className="client-copy-stack">
|
||||
<CopyValue value={`http://127.0.0.1:${port}`} />
|
||||
<CopyValue value={`socks5://127.0.0.1:${port}`} />
|
||||
</div>
|
||||
<p className="muted">
|
||||
Включайте дома: приложения продолжают использовать <code>127.0.0.1:8080</code>, но VPN не используется.
|
||||
</p>
|
||||
<label className="switch-row">
|
||||
<span>
|
||||
<strong>Я дома</strong>
|
||||
<small>{enabled ? 'Весь proxy-трафик идёт напрямую' : 'Весь proxy-трафик идёт через VPN'}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Порт proxy</label>
|
||||
<div className="client-port-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => onSave({ ...settings, homeBypassEnabled: e.target.checked })}
|
||||
className="input"
|
||||
type="number"
|
||||
min={range.start}
|
||||
max={range.end}
|
||||
value={draftPort}
|
||||
onChange={(e) => setDraftPort(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientFlow({ state, activeServer }) {
|
||||
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
|
||||
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>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={busy || !dirty}
|
||||
onClick={() => onSave({ ...settings, proxyPort: parsed })}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<small className={invalid ? 'field-error' : 'field-hint'}>{range.start}–{range.end}</small>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientOverviewPage({
|
||||
state,
|
||||
status,
|
||||
activeServer,
|
||||
busy,
|
||||
subscriptionUrl,
|
||||
@@ -268,15 +249,94 @@ export function ClientOverviewPage({
|
||||
setPendingTag,
|
||||
clientSettings,
|
||||
onSaveClientSettings,
|
||||
onCheckSharedProxy,
|
||||
onFetchSubscription,
|
||||
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 (
|
||||
<div className="section-stack">
|
||||
<ClientHero state={state} status={status} activeServer={activeServer} />
|
||||
<ClientSetup
|
||||
<div className="client-dashboard">
|
||||
<StatusPanel route={route} state={state} />
|
||||
<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}
|
||||
servers={servers}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
@@ -287,19 +347,19 @@ export function ClientOverviewPage({
|
||||
onFetchSubscription={onFetchSubscription}
|
||||
onApply={onApply}
|
||||
/>
|
||||
<div className="grid-2">
|
||||
<ProxyCard state={state} />
|
||||
<HomeBypassCard settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
|
||||
)}
|
||||
{setupMode === 'direct' && (
|
||||
<DirectSettings busy={busy} onEnable={enableDirect} />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<ClientActions
|
||||
|
||||
<ProxySettings
|
||||
state={state}
|
||||
settings={clientSettings}
|
||||
busy={busy}
|
||||
onRestart={onRestart}
|
||||
onStop={onStop}
|
||||
onSave={onSaveClientSettings}
|
||||
/>
|
||||
</div>
|
||||
<ClientFlow state={state} activeServer={activeServer} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +118,9 @@ code, .mono {
|
||||
}
|
||||
.app-body.client-mode {
|
||||
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 {
|
||||
@@ -827,141 +830,269 @@ code, .mono {
|
||||
|
||||
/* ============ 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 {
|
||||
max-width: 1120px;
|
||||
max-width: 1180px;
|
||||
width: 100%;
|
||||
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;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
gap: 14px;
|
||||
}
|
||||
.client-hero-main h1 {
|
||||
font-size: 28px;
|
||||
.client-status-dot {
|
||||
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;
|
||||
}
|
||||
.client-hero-main p {
|
||||
color: var(--muted);
|
||||
}
|
||||
.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;
|
||||
.client-status-main p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.client-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.client-flow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.flow-arrow {
|
||||
justify-content: center;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.copy-field {
|
||||
align-items: flex-start;
|
||||
.client-status-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.client-status-facts > div {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
background: #0b1219;
|
||||
border: 1px solid #253341;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.client-status-facts small,
|
||||
.client-current-target small,
|
||||
.client-panel-title {
|
||||
display: block;
|
||||
color: var(--subtle);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.client-status-facts strong,
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
src/web/utils/clientRoute.js
Normal file
57
src/web/utils/clientRoute.js
Normal 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"],
|
||||
};
|
||||
}
|
||||
55
test/server/shared-proxy.test.js
Normal file
55
test/server/shared-proxy.test.js
Normal 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",
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, "cache.db");
|
||||
const { buildGatewayConfig } = await import(
|
||||
`../../src/server/singbox.js?client-mode=${Date.now()}`
|
||||
);
|
||||
const clientSettingsPath = path.join(process.env.DATA_DIR, "client-settings.json");
|
||||
|
||||
const subscriptionConfig = {
|
||||
outbounds: [
|
||||
@@ -27,6 +28,7 @@ const subscriptionConfig = {
|
||||
};
|
||||
|
||||
test("client mode exposes only the local mixed proxy inbound", () => {
|
||||
fs.rmSync(clientSettingsPath, { force: true });
|
||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||
|
||||
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", () => {
|
||||
fs.rmSync(clientSettingsPath, { force: true });
|
||||
const config = buildGatewayConfig(subscriptionConfig, "test-vpn");
|
||||
|
||||
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", () => {
|
||||
fs.rmSync(clientSettingsPath, { force: true });
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.DATA_DIR, "client-settings.json"),
|
||||
clientSettingsPath,
|
||||
JSON.stringify({ homeBypassEnabled: true }),
|
||||
);
|
||||
|
||||
@@ -59,3 +63,68 @@ test("client home bypass routes the local proxy directly", () => {
|
||||
{ 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" },
|
||||
]);
|
||||
});
|
||||
|
||||
92
test/web/client-route.test.js
Normal file
92
test/web/client-route.test.js
Normal 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, "выберите режим");
|
||||
});
|
||||
Reference in New Issue
Block a user