12 Commits

Author SHA1 Message Date
a0f41baa36 Simplify client proxy port handling
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-06-04 10:24:33 +03:00
c3d3aaa699 Add kernel forwarding for bypassed devices 2026-05-24 14:00:09 +03:00
301b76c03e Shorten tproxy source bypass chain name 2026-05-24 13:46:04 +03:00
ab6de6996f Add UI-controlled TProxy bypass for devices 2026-05-24 13:38:52 +03:00
0092ec4cde Add source CIDR bypass for TProxy 2026-05-24 13:26:03 +03:00
12ad0c8b78 chore: ignore local worktrees 2026-05-21 20:13:18 +03:00
b5d4c61783 docs: add windows client implementation plan
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-21 20:04:51 +03:00
f4990a4f55 docs: add windows client design 2026-05-21 19:55:08 +03:00
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
32 changed files with 7393 additions and 378 deletions

View File

@@ -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

2
.gitignore vendored
View File

@@ -6,6 +6,8 @@ _archive/
*.env.local
data/
.vpn-proxy/
.superpowers/
.worktrees/
# Node/Vite
node_modules/

View File

@@ -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` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его
В 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:
@@ -53,6 +75,8 @@ docker compose -f docker-compose.client.yml restart
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
├─ source bypass chain → ACCEPT ← устройства мимо sing-box
│ └─ FORWARD + MASQUERADE → обычный internet path
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш
├─ приватные CIDR (RFC1918, ...) → RETURN
└─ TCP/UDP → TPROXY :7895
@@ -104,8 +128,12 @@ ip route replace local 0.0.0.0/0 dev lo table 100
# Цепочка iptables (порядок правил — критичен)
iptables -t mangle -N VPN_PROXY_TPROXY
iptables -t mangle -N VPN_PROXY_SRC_BYPASS
iptables -N VPN_PROXY_FWD_BYPASS
iptables -t nat -N VPN_PROXY_NAT_BYPASS
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
-m mark --mark 1 → RETURN # уже помеченные пакеты
-j VPN_PROXY_SRC_BYPASS → ACCEPT # source bypass до sing-box
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
-p tcp → TPROXY :7895 mark 1
@@ -116,6 +144,10 @@ iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
Устройства можно исключить из transparent-перехвата в интерфейсе: **Routing → Устройства → Mode → bypass TProxy**.
Такой source IP обходит `tproxy-in` и не попадает в `sing-box`; для него gateway включает обычный kernel forwarding + `MASQUERADE`.
Ручной HTTP/SOCKS proxy на `gateway:8080` остаётся доступен для выбранных программ.
### 2. Маршрутизация внутри sing-box
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
@@ -343,7 +375,12 @@ 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` и single-port `CLIENT_PROXY_PORT_START/END` |
| `CLIENT_PROXY_PORT` | `8080` | Единственный host/container proxy-порт для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT_START` | `8080` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом |
| `CLIENT_PROXY_PORT_END` | same as start | Совместимость со старым env; по умолчанию не расширяет Docker-публикацию в диапазон |
| `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 |
@@ -351,6 +388,10 @@ UI доступен на `http://<gateway-ip>:3456`.
| `INSTALL_SINGBOX` | `true` | Скачивать sing-box в Docker build; `false` для подготовленного runtime base |
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
| `TPROXY_BYPASS_SOURCE_CIDRS` | unset | Source CIDR устройств, которые должны идти напрямую мимо TProxy/sing-box, например `192.168.50.25/32` |
| `TPROXY_SOURCE_BYPASS_CHAIN` | `VPN_PROXY_SRC_BYPASS` | Управляемая iptables-цепочка для UI source-bypass |
| `TPROXY_SOURCE_FORWARD_CHAIN` | `VPN_PROXY_FWD_BYPASS` | Управляемая filter/FORWARD цепочка для UI source-bypass |
| `TPROXY_SOURCE_NAT_CHAIN` | `VPN_PROXY_NAT_BYPASS` | Управляемая nat/POSTROUTING цепочка для UI source-bypass |
| `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) |
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
| `LOG_LEVEL` | `info` | Уровень логов sing-box |
@@ -369,6 +410,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` | Список серверов из кэша |

View File

@@ -9,7 +9,9 @@ services:
environment:
APP_MODE: client
PORT: ${PORT:-3456}
PROXY_PORT: ${PROXY_PORT:-8080}
PROXY_PORT: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
PROXY_BIND_IP: 0.0.0.0
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /etc/sing-box/config.json
@@ -17,15 +19,23 @@ 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:-${CLIENT_PROXY_PORT_START:-8080}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}"
volumes:
- vpn-proxy-client-data:/var/lib/vpn-proxy
- sing-box-client-cache:/var/lib/sing-box
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
interval: 30s
timeout: 5s
retries: 3

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,774 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VPN Proxy Route Console Redesign</title>
<style>
:root {
color-scheme: light;
--bg: oklch(0.965 0.008 232);
--surface: oklch(0.986 0.006 232);
--surface-2: oklch(0.948 0.009 232);
--surface-3: oklch(0.918 0.014 232);
--ink: oklch(0.238 0.028 238);
--muted: oklch(0.47 0.028 238);
--subtle: oklch(0.62 0.022 238);
--line: oklch(0.835 0.018 232);
--line-strong: oklch(0.72 0.032 232);
--blue: oklch(0.56 0.14 244);
--blue-soft: oklch(0.915 0.045 244);
--green: oklch(0.61 0.13 153);
--green-soft: oklch(0.915 0.052 153);
--amber: oklch(0.72 0.13 74);
--amber-soft: oklch(0.93 0.07 74);
--red: oklch(0.58 0.15 27);
--radius: 8px;
--shadow: 0 18px 42px oklch(0.36 0.035 238 / 0.13);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font-size: 14px;
line-height: 1.45;
}
button,
input,
select {
font: inherit;
}
.page {
min-height: 100vh;
padding: 24px;
}
.shell {
max-width: 1320px;
min-height: calc(100vh - 48px);
margin: 0 auto;
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 64px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: oklch(0.978 0.007 232);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mark {
width: 32px;
height: 32px;
border-radius: 7px;
background:
linear-gradient(135deg, oklch(0.52 0.13 244), oklch(0.62 0.12 153));
position: relative;
}
.mark::after {
content: "";
position: absolute;
inset: 8px;
border: 2px solid oklch(0.985 0.005 232);
border-left-color: transparent;
border-radius: 50%;
}
.brand h1 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.brand span {
color: var(--muted);
font-size: 12px;
}
.top-actions {
display: flex;
align-items: center;
gap: 8px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 11px;
border: 1px solid oklch(0.73 0.05 153);
border-radius: 999px;
background: var(--green-soft);
color: oklch(0.34 0.08 153);
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.status-pill::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
}
.btn {
min-height: 34px;
padding: 0 13px;
border-radius: 7px;
border: 1px solid var(--line-strong);
background: var(--surface);
color: var(--ink);
font-weight: 650;
cursor: default;
}
.btn.primary {
border-color: oklch(0.49 0.13 244);
background: var(--blue);
color: oklch(0.985 0.005 232);
}
.workspace {
display: grid;
grid-template-columns: 264px minmax(0, 1fr) 312px;
gap: 0;
min-height: 0;
}
.rail {
padding: 18px 14px;
border-right: 1px solid var(--line);
background: oklch(0.956 0.009 232);
}
.nav-title,
.panel-label,
.field-label {
margin: 0;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.mode-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.mode {
width: 100%;
padding: 12px;
display: grid;
grid-template-columns: auto 1fr;
gap: 11px;
text-align: left;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.mode.active {
border-color: oklch(0.68 0.08 244);
background: var(--blue-soft);
}
.mode-dot {
width: 11px;
height: 11px;
margin-top: 4px;
border-radius: 50%;
background: var(--subtle);
}
.mode.active .mode-dot {
background: var(--blue);
}
.mode strong {
display: block;
font-size: 14px;
}
.mode span {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 12px;
}
.rail-section {
margin-top: 24px;
}
.mini-list {
display: grid;
gap: 7px;
margin-top: 10px;
}
.mini-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
font-size: 12px;
}
.mini-row span {
color: var(--muted);
}
.main {
min-width: 0;
padding: 22px;
overflow: auto;
}
.route-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: start;
margin-bottom: 18px;
}
.route-head h2 {
margin: 0;
font-size: 28px;
line-height: 1.12;
}
.route-head p {
max-width: 68ch;
margin: 8px 0 0;
color: var(--muted);
}
.health {
min-width: 210px;
padding: 12px 14px;
border: 1px solid oklch(0.76 0.07 153);
border-radius: var(--radius);
background: var(--green-soft);
}
.health strong {
display: block;
font-size: 18px;
}
.health span {
color: oklch(0.38 0.07 153);
font-size: 12px;
}
.route-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.node {
min-height: 126px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
position: relative;
}
.node.active {
border-color: oklch(0.72 0.075 153);
background: var(--green-soft);
}
.node.pending {
border-color: oklch(0.8 0.09 74);
background: var(--amber-soft);
}
.node small {
color: var(--muted);
font-weight: 700;
}
.node strong {
display: block;
margin-top: 9px;
font-size: 18px;
}
.node span {
display: block;
margin-top: 5px;
color: var(--muted);
font-size: 12px;
}
.flow-line {
display: flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 13px;
margin-bottom: 20px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
overflow-x: auto;
white-space: nowrap;
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
}
.flow-line b {
color: var(--blue);
}
.flow-line span {
color: var(--muted);
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr);
gap: 14px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 50px;
padding: 0 14px;
border-bottom: 1px solid var(--line);
}
.panel-head h3 {
margin: 0;
font-size: 15px;
}
.panel-body {
padding: 14px;
}
.form-grid {
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.control-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.input,
.select {
width: 100%;
min-height: 38px;
border: 1px solid var(--line-strong);
border-radius: 7px;
background: oklch(0.992 0.004 232);
color: var(--ink);
padding: 0 11px;
}
.summary-list {
display: grid;
gap: 8px;
}
.summary-row {
display: grid;
grid-template-columns: 94px minmax(0, 1fr);
gap: 10px;
align-items: baseline;
padding: 9px 0;
border-bottom: 1px solid var(--line);
}
.summary-row:last-child {
border-bottom: 0;
}
.summary-row small {
color: var(--muted);
font-weight: 700;
}
.summary-row strong {
overflow-wrap: anywhere;
}
.side {
padding: 18px 14px;
border-left: 1px solid var(--line);
background: oklch(0.956 0.009 232);
overflow: auto;
}
.copy-stack {
display: grid;
gap: 8px;
margin-top: 10px;
}
.copy-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 40px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
}
.copy-row button {
border: 0;
background: transparent;
color: var(--blue);
font-weight: 700;
}
.side-panel {
margin-top: 18px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.activity {
display: grid;
gap: 0;
}
.activity-row {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.activity-row:last-child {
border-bottom: 0;
}
.activity-row time {
color: var(--muted);
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11px;
}
.activity-row strong {
display: block;
font-size: 13px;
}
.activity-row span {
display: block;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1080px) {
.workspace {
grid-template-columns: 220px minmax(0, 1fr);
}
.side {
grid-column: 1 / -1;
border-left: 0;
border-top: 1px solid var(--line);
}
.route-strip,
.settings-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.page {
padding: 10px;
}
.shell {
min-height: calc(100vh - 20px);
}
.topbar,
.route-head,
.workspace {
grid-template-columns: 1fr;
}
.topbar {
align-items: flex-start;
padding: 14px;
}
.top-actions {
flex-wrap: wrap;
}
.rail {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.route-strip,
.settings-grid,
.control-row {
grid-template-columns: 1fr;
}
.main,
.side {
padding: 14px;
}
}
</style>
</head>
<body>
<main class="page">
<section class="shell" aria-label="VPN Proxy redesign preview">
<header class="topbar">
<div class="brand">
<div class="mark" aria-hidden="true"></div>
<div>
<h1>VPN Proxy Client</h1>
<span>Local control panel, macOS Docker mode</span>
</div>
</div>
<div class="top-actions">
<span class="status-pill">sing-box running</span>
<button class="btn">Restart</button>
<button class="btn primary">Apply route</button>
</div>
</header>
<div class="workspace">
<aside class="rail" aria-label="Connection modes">
<p class="nav-title">Режим подключения</p>
<div class="mode-list">
<button class="mode" type="button">
<span class="mode-dot"></span>
<span>
<strong>Общий gateway</strong>
<span>192.168.50.111:8080</span>
</span>
</button>
<button class="mode active" type="button">
<span class="mode-dot"></span>
<span>
<strong>Локальный VPN</strong>
<span>Finland 02 selected</span>
</span>
</button>
<button class="mode" type="button">
<span class="mode-dot"></span>
<span>
<strong>Напрямую</strong>
<span>без VPN и gateway</span>
</span>
</button>
</div>
<section class="rail-section">
<p class="nav-title">Проверки</p>
<div class="mini-list">
<div class="mini-row"><strong>Docker</strong><span>ready</span></div>
<div class="mini-row"><strong>Ports</strong><span>8082 open</span></div>
<div class="mini-row"><strong>Config</strong><span>valid</span></div>
</div>
</section>
</aside>
<section class="main">
<div class="route-head">
<div>
<h2>Текущий маршрут: приложения Mac идут через локальный VPN</h2>
<p>
Главный экран показывает не настройки как список, а фактический путь трафика:
от приложения до интернета, с портом, выбранным режимом и состоянием сервиса.
</p>
</div>
<div class="health">
<strong>42 ms</strong>
<span>последняя проверка маршрута</span>
</div>
</div>
<div class="route-strip" aria-label="Active route">
<div class="node">
<small>Источник</small>
<strong>Mac apps</strong>
<span>браузер, Discord, Telegram</span>
</div>
<div class="node active">
<small>Локальный proxy</small>
<strong>127.0.0.1:8082</strong>
<span>HTTP и SOCKS5</span>
</div>
<div class="node active">
<small>Режим</small>
<strong>Local VPN</strong>
<span>Finland 02</span>
</div>
<div class="node pending">
<small>Выход</small>
<strong>Internet</strong>
<span>проверка 11:04</span>
</div>
</div>
<div class="flow-line" aria-label="Route path">
<b>Mac apps</b><span>></span><b>127.0.0.1:8082</b><span>></span><b>sing-box</b><span>></span><b>Finland 02</b><span>></span><b>Internet</b>
</div>
<div class="settings-grid">
<section class="panel">
<div class="panel-head">
<h3>Настройка выбранного режима</h3>
<button class="btn">Load subscription</button>
</div>
<div class="panel-body">
<div class="form-grid">
<label class="field">
<span class="field-label">Подписка или VLESS</span>
<div class="control-row">
<input class="input" value="https://provider.example/subscription" />
<button class="btn">Refresh</button>
</div>
</label>
<label class="field">
<span class="field-label">VPN-сервер</span>
<div class="control-row">
<select class="select">
<option>Finland 02, 42 ms, 18 GB left</option>
</select>
<button class="btn primary">Connect</button>
</div>
</label>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h3>Сводка</h3>
</div>
<div class="panel-body">
<div class="summary-list">
<div class="summary-row"><small>Service</small><strong>running since 10:52</strong></div>
<div class="summary-row"><small>Config</small><strong>applied 2 minutes ago</strong></div>
<div class="summary-row"><small>Fallback</small><strong>VPN by default</strong></div>
<div class="summary-row"><small>Quota</small><strong>18 GB left</strong></div>
</div>
</div>
</section>
</div>
</section>
<aside class="side" aria-label="Local proxy details">
<p class="panel-label">Адреса для приложений</p>
<div class="copy-stack">
<div class="copy-row"><span>http://127.0.0.1:8082</span><button>Copy</button></div>
<div class="copy-row"><span>socks5://127.0.0.1:8082</span><button>Copy</button></div>
</div>
<section class="side-panel">
<div class="panel-head">
<h3>Порт proxy</h3>
</div>
<div class="panel-body">
<div class="control-row">
<input class="input" value="8082" />
<button class="btn">Save</button>
</div>
</div>
</section>
<section class="side-panel">
<div class="panel-head">
<h3>Активность</h3>
</div>
<div class="panel-body">
<div class="activity">
<div class="activity-row">
<time>11:04</time>
<span><strong>Route check passed</strong><span>Finland 02 returned 42 ms</span></span>
</div>
<div class="activity-row">
<time>11:02</time>
<span><strong>Port changed</strong><span>8080 was busy, 8082 selected</span></span>
</div>
<div class="activity-row">
<time>10:59</time>
<span><strong>Subscription refreshed</strong><span>12 servers available</span></span>
</div>
</div>
</div>
</section>
</aside>
</div>
</section>
</main>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,470 @@
# VPN Proxy Client Route Console Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the current macOS client overview with a route-first console that makes the active traffic path, local proxy address, selected mode, and next action obvious at a glance.
**Architecture:** Keep `resolveClientRoute()` as the single source of truth and keep `ClientOverviewPage` as the orchestrator. Split the screen into small presentational components inside `src/web/components/ClientOverviewPage.jsx`, then replace only the client-mode CSS block in `src/web/styles.css` so gateway and Windows work stay untouched.
**Tech Stack:** React 19, Vite, Node.js `node:test`, existing CSS variables, Open Design static HTML artifact.
**Design Artifact:** `docs/design/open-design/vpn-proxy-route-console-redesign.html`
---
## Current Findings
- `src/web/components/ClientOverviewPage.jsx` already has the right model: one overview screen, mutually exclusive `Gateway`, `VPN`, and `Direct` modes, and route state from `resolveClientRoute()`.
- `src/web/styles.css` makes the client screen visually separate, but it uses a dark blue-green palette that reads as a monitoring dashboard rather than a macOS setup tool.
- The current status panel, route line, mode grid, and proxy panel have similar visual weight. The user must scan several boxes to answer the primary question: where does my traffic go right now?
- Copyable proxy addresses sit in the side panel. They are useful, but they are visually separated from the route story.
- The three mode buttons look like cards. They work, but they do not communicate that mode selection changes the middle segment of the route.
## Target Design
Use a light, restrained operational UI for a normal macOS desktop context: a user has Docker running, a browser open, and is checking why an app uses a certain proxy path. The interface should feel closer to a compact network control console than a server dashboard.
The first viewport should show:
- top status: service running, restart, apply route;
- left mode rail: Gateway, Local VPN, Direct;
- main route strip: `Mac apps > local proxy > selected route > Internet`;
- right utility panel: copy proxy addresses, proxy port, recent activity;
- settings below route: only the form for the selected mode.
## File Structure
- Modify `src/web/components/ClientOverviewPage.jsx`: reorganize render structure into route console subcomponents while preserving props and handlers.
- Modify `src/web/styles.css`: replace `.client-*` layout styles from `.client-mode .app-main` through the final client media query.
- Test `test/web/client-route.test.js`: extend route state coverage so UI changes do not hide incorrect mode/status combinations.
- Keep `docs/design/open-design/vpn-proxy-route-console-redesign.html`: reference artifact for visual decisions.
---
### Task 1: Lock Route Contract Before UI Changes
**Files:**
- Modify: `test/web/client-route.test.js`
- [ ] **Step 1: Add tests for all user-visible route statuses**
Add these cases to `test/web/client-route.test.js`:
```js
test('resolves running local VPN route', () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
configExists: true,
proxyPort: 8082,
selectedTag: 'finland-02',
clientSettings: { homeBypassEnabled: false, sharedProxyEnabled: false },
},
activeServer: { tag: 'finland-02' },
});
assert.equal(route.mode, 'vpn');
assert.equal(route.status, 'connected');
assert.equal(route.localProxy, '127.0.0.1:8082');
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'VPN finland-02', 'Internet']);
});
test('resolves gateway route when shared proxy is enabled', () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
configExists: true,
proxyPort: 8082,
clientSettings: {
sharedProxyEnabled: true,
sharedProxy: { host: '192.168.50.111', port: 8080 },
},
},
});
assert.equal(route.mode, 'gateway');
assert.equal(route.status, 'connected');
assert.equal(route.target, '192.168.50.111:8080');
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'Gateway 192.168.50.111:8080', 'Internet']);
});
test('resolves direct route when home bypass is enabled', () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
configExists: true,
clientSettings: { homeBypassEnabled: true, sharedProxyEnabled: false, proxyPort: 8084 },
},
});
assert.equal(route.mode, 'direct');
assert.equal(route.status, 'connected');
assert.equal(route.localProxy, '127.0.0.1:8084');
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8084', 'Direct', 'Internet']);
});
```
- [ ] **Step 2: Run the route tests**
Run:
```bash
npm test -- test/web/client-route.test.js
```
Expected: all existing and new route tests pass.
- [ ] **Step 3: Commit**
```bash
git add test/web/client-route.test.js
git commit -m "test: lock client route display contract"
```
---
### Task 2: Restructure Client Overview Markup
**Files:**
- Modify: `src/web/components/ClientOverviewPage.jsx`
- [ ] **Step 1: Replace the route line with route nodes**
Replace `RouteLine` with:
```jsx
function RouteStrip({ route }) {
const nodes = [
{ label: 'Источник', value: route.path[0], detail: 'приложения Mac' },
{ label: 'Локальный proxy', value: route.localProxy, detail: 'HTTP и SOCKS5' },
{ label: 'Режим', value: route.target, detail: route.targetDetail, active: route.status === 'connected' },
{ label: 'Выход', value: 'Internet', detail: route.status === 'connected' ? 'маршрут активен' : 'ожидает запуска' },
];
return (
<div className="client-route-strip">
{nodes.map((node) => (
<div className={`client-route-node ${node.active ? 'active' : ''}`} key={node.label}>
<small>{node.label}</small>
<strong>{node.value}</strong>
<span>{node.detail}</span>
</div>
))}
</div>
);
}
function RoutePath({ route }) {
return (
<div className="client-route-path">
{route.path.map((item, index) => (
<React.Fragment key={`${item}-${index}`}>
<strong>{item}</strong>
{index < route.path.length - 1 && <span>{'>'}</span>}
</React.Fragment>
))}
</div>
);
}
```
- [ ] **Step 2: Add a mode rail component**
Add:
```jsx
function ModeRail({ route, setupMode, clientSettings, state, busy, onGateway, onVpn, onDirect }) {
const modes = [
{
id: 'gateway',
title: 'Общий gateway',
subtitle: clientSettings?.sharedProxy
? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}`
: 'серверная proxy',
onClick: onGateway,
},
{
id: 'vpn',
title: 'Локальный VPN',
subtitle: state?.selectedTag || 'выбрать сервер',
onClick: onVpn,
},
{
id: 'direct',
title: 'Напрямую',
subtitle: 'без VPN',
onClick: onDirect,
},
];
return (
<aside className="client-mode-rail">
<div className="client-section-label">Режим подключения</div>
<div className="client-mode-list">
{modes.map((mode) => (
<button
key={mode.id}
type="button"
className={`client-rail-mode ${setupMode === mode.id ? 'selected' : ''} ${route.mode === mode.id ? 'active' : ''}`}
disabled={busy}
onClick={mode.onClick}
>
<span className="client-mode-dot" />
<span>
<strong>{mode.title}</strong>
<small>{mode.subtitle}</small>
</span>
</button>
))}
</div>
</aside>
);
}
```
- [ ] **Step 3: Replace the top-level JSX**
Use this layout in `ClientOverviewPage`:
```jsx
return (
<div className="client-console">
<ModeRail
route={route}
setupMode={setupMode}
clientSettings={clientSettings}
state={state}
busy={busy}
onGateway={selectGateway}
onVpn={selectVpn}
onDirect={() => {
setSetupMode('direct');
enableDirect();
}}
/>
<section className="client-route-workspace">
<StatusPanel route={route} state={state} />
<RouteStrip route={route} />
<RoutePath route={route} />
<section className="client-mode-panel">
{setupMode === 'gateway' && (
<GatewaySettings settings={clientSettings} busy={busy} onCheck={onCheckSharedProxy} />
)}
{setupMode === 'vpn' && (
<VpnSettings
state={state}
servers={servers}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
busy={busy}
onFetchSubscription={onFetchSubscription}
onApply={onApply}
/>
)}
{setupMode === 'direct' && <DirectSettings busy={busy} onEnable={enableDirect} />}
</section>
</section>
<ProxySettings state={state} settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
</div>
);
```
- [ ] **Step 4: Run build**
Run:
```bash
npm run build
```
Expected: Vite build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/web/components/ClientOverviewPage.jsx
git commit -m "refactor: reshape client overview around route console"
```
---
### Task 3: Replace Client Visual System
**Files:**
- Modify: `src/web/styles.css`
- [ ] **Step 1: Replace only the client CSS block**
Replace the CSS from `.client-mode .app-main` through the client media query with the style direction from `docs/design/open-design/vpn-proxy-route-console-redesign.html`. Keep selectors scoped to `.client-*` so gateway screens keep the existing palette.
Use these token values for the client block:
```css
.app-body.client-mode {
grid-template-columns: 1fr;
background: oklch(0.965 0.008 232);
}
.client-mode .topbar {
background: oklch(0.978 0.007 232);
border-bottom-color: oklch(0.835 0.018 232);
}
.client-mode .app-main {
max-width: 1320px;
width: 100%;
margin: 0 auto;
padding: 18px;
color: oklch(0.238 0.028 238);
}
.client-console {
min-height: calc(100vh - var(--topbar-h) - 36px);
display: grid;
grid-template-columns: 264px minmax(0, 1fr) 312px;
overflow: hidden;
background: oklch(0.986 0.006 232);
border: 1px solid oklch(0.835 0.018 232);
border-radius: 8px;
box-shadow: 0 18px 42px oklch(0.36 0.035 238 / 0.13);
}
```
- [ ] **Step 2: Add responsive behavior**
Add:
```css
@media (max-width: 1080px) {
.client-console {
grid-template-columns: 220px minmax(0, 1fr);
}
.client-side-panel {
grid-column: 1 / -1;
border-left: 0;
border-top: 1px solid oklch(0.835 0.018 232);
}
.client-route-strip {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.client-console,
.client-route-strip,
.client-inline-form,
.client-port-row {
grid-template-columns: 1fr;
}
.client-mode-rail {
border-right: 0;
border-bottom: 1px solid oklch(0.835 0.018 232);
}
}
```
- [ ] **Step 3: Verify no banned patterns were introduced**
Run:
```bash
rg -n "background-clip:\\s*text|border-left:\\s*[2-9]|border-right:\\s*[2-9]|backdrop-filter|letter-spacing:\\s*-" src/web/styles.css
```
Expected: no matches.
- [ ] **Step 4: Run build**
Run:
```bash
npm run build
```
Expected: Vite build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/web/styles.css
git commit -m "style: apply light route console client theme"
```
---
### Task 4: Browser Verification
**Files:**
- No file changes expected.
- [ ] **Step 1: Start the dev server**
Run:
```bash
npm run dev -- --host 127.0.0.1 --port 4567
```
Expected: Vite listens on `http://127.0.0.1:4567`.
- [ ] **Step 2: Open client mode with representative state**
Use the browser to open:
```text
http://127.0.0.1:4567
```
Expected: the first viewport shows the mode rail, route strip, route path, selected-mode form, and copyable proxy addresses without overlap at desktop width.
- [ ] **Step 3: Check mobile width**
Resize to 390px wide.
Expected: rail, route workspace, and proxy panel stack vertically; long proxy URLs truncate inside their containers; action buttons remain readable.
- [ ] **Step 4: Run final verification**
Run:
```bash
npm test
npm run build
git diff --check
```
Expected: all commands pass.
- [ ] **Step 5: Commit**
```bash
git add src/web/components/ClientOverviewPage.jsx src/web/styles.css test/web/client-route.test.js
git commit -m "feat: redesign client overview as route console"
```
---
## Self-Review
Spec coverage:
- Current UX assessment is captured in `Current Findings`.
- New design direction is captured in `Target Design`.
- Open Design artifact is referenced explicitly.
- Implementation tasks cover route contract, markup, scoped CSS, and browser verification.
Placeholder scan:
- No `TBD`, `TODO`, or unspecified validation steps remain.
Type consistency:
- Route fields match `resolveClientRoute()`: `mode`, `status`, `localProxy`, `target`, `targetDetail`, `path`.

View File

@@ -0,0 +1,229 @@
# Windows Client Design
## Goal
Restore the old Windows workflow in a cleaner product shape: a one-command PowerShell installer can install either a full local `sing-box` + ProxiFyre setup or ProxiFyre-only routing to an existing proxy, then expose a small local web UI for profiles, folders, executable files, status, and logs.
## Product Shape
The Windows mode is script-first and UI-assisted. The installer remains the durable entrypoint because Windows driver/service setup needs administrator rights and must stay easy to debug from PowerShell. The web UI is a local control surface on top of the same scripts, not a separate desktop app in the first version.
The installer supports two paths:
- **Full install:** install native `sing-box.exe`, configure a local SOCKS/HTTP proxy on `127.0.0.1:1080`, install WinPacketFilter and ProxiFyre, then route selected Windows apps through the local proxy.
- **ProxiFyre only:** install WinPacketFilter and ProxiFyre, then point selected Windows apps to an existing proxy target such as `127.0.0.1:8080`, `192.168.50.111:8080`, or another reachable SOCKS5 endpoint.
The default UI direction is the approved cleaner mockup: one route status, profiles as the main object, selected profile details on the right, and a short recent activity/log section below. The first screen should answer: what is the active proxy target, whether services are running, and which profiles are currently enabled.
## User Flows
### Install
The user opens PowerShell 7 as Administrator and runs:
```powershell
irm https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-windows-client.ps1 | iex
```
The installer checks administrator rights, PowerShell version, architecture, internet access, and required paths. It installs under `C:\Tools\vpn-proxy-windows` and keeps third-party runtime files in focused subdirectories:
- `C:\Tools\vpn-proxy-windows\app` for this project checkout or archive.
- `C:\Tools\vpn-proxy-windows\runtime\node` for portable Node.js when no suitable Node is installed.
- `C:\Tools\vpn-proxy-windows\runtime\sing-box` for `sing-box.exe`, config, and logs.
- `C:\Tools\ProxiFyre` for ProxiFyre, matching the legacy script path.
If the user chooses Full install, the installer asks for a subscription or VLESS link, parses it through the existing subscription logic where possible, lets the user select a server, writes the native `sing-box` config, installs a scheduled task for `sing-box`, and starts it.
If the user chooses ProxiFyre only, the installer asks for a SOCKS5 proxy target and verifies TCP connectivity before writing ProxiFyre config.
After setup, the installer starts the local control UI on `http://127.0.0.1:3456` and prints recovery commands:
```powershell
& "C:\Tools\vpn-proxy-windows\manage.ps1"
& "C:\Tools\vpn-proxy-windows\manage.ps1" -OpenUi
& "C:\Tools\vpn-proxy-windows\manage.ps1" -Status
```
### Profile Management
Profiles are the central unit. A profile contains a name, enabled flag, proxy target, protocol list, and app items. Supported item types:
- `process`: process name such as `Discord`, `Update`, or `Vesktop`.
- `folder`: folder path; the system scans `.exe` files and converts them to routable entries.
- `exe`: explicit executable file path; the system resolves it to the executable name for ProxiFyre and keeps the full path for display and diagnostics.
Folder and exe entries are intentionally stored as user-facing source items, while the generated ProxiFyre config is derived. This keeps the UI understandable and makes future ProxiFyre/Proxifier adapter changes possible without changing the profile model.
When a profile changes, the UI marks it as pending. The user applies changes once. Apply regenerates ProxiFyre `app-config.json`, restarts the ProxiFyre service, then writes an activity entry showing what changed.
### Runtime Operations
The UI exposes these actions:
- start, stop, restart `sing-box` when local mode is installed;
- start, stop, restart ProxiFyre;
- switch a profile between `local-singbox` and an external proxy target;
- add process, folder, or exe entries;
- scan folder entries again;
- copy diagnostics for support/debugging;
- open logs.
The UI does not auto-change global Windows proxy settings. Routing happens only through ProxiFyre profiles.
## Architecture
The active project already has a plain Node API server, React/Vite UI, subscription parser, `sing-box` config generator, logs, traffic parsing, and client/gateway modes. Windows mode should reuse those pieces and add a Windows helper boundary.
### App Mode
Add `APP_MODE=windows`. In Windows mode:
- the HTTP server binds to `127.0.0.1`;
- the UI uses Windows labels and hides gateway-only TProxy/device controls;
- config generation is proxy-only like client mode, but it targets native `sing-box.exe` rather than Docker;
- service and driver actions go through the PowerShell helper, not direct Node assumptions.
### Windows Helper Boundary
Create a PowerShell helper module that owns privileged Windows operations:
- install/update `sing-box.exe`;
- install/start/stop scheduled tasks;
- install/update WinPacketFilter;
- install/update ProxiFyre;
- write ProxiFyre config;
- query service/task status;
- read recent log files;
- test proxy connectivity;
- manage firewall rules for local proxy ports.
The Node server calls the helper with explicit command names and JSON input/output. The helper returns structured JSON for every operation:
```json
{
"success": true,
"action": "proxies.apply",
"changed": true,
"message": "ProxiFyre config applied and service restarted"
}
```
Errors use the same shape with `success: false`, `error`, and optional `details`. The UI never parses raw PowerShell text.
### Data Files
Windows mode stores state under `C:\Tools\vpn-proxy-windows\data`:
- `windows-profiles.json` for profile source data.
- `proxy-targets.json` for `local-singbox` and external proxy targets.
- `windows-state.json` for last applied profile revision and UI status.
- `subscription-cache.json` and `state.json` stay compatible with existing subscription/server selection logic.
Profile shape:
```json
{
"id": "discord-vesktop",
"name": "Discord + Vesktop",
"enabled": true,
"proxyTargetId": "local-singbox",
"protocols": ["TCP", "UDP"],
"items": [
{ "type": "process", "value": "Discord" },
{ "type": "process", "value": "Update" },
{
"type": "folder",
"value": "%LOCALAPPDATA%\\vesktop",
"recursive": true
},
{
"type": "exe",
"value": "C:\\Games\\SomeGame\\game.exe"
}
]
}
```
Generated ProxiFyre config is not edited directly. It is derived from enabled profiles and proxy targets, then written to `C:\Tools\ProxiFyre\app-config.json`.
### API Surface
Add Windows-specific endpoints:
- `GET /api/windows/status`: returns install mode, `sing-box` status, ProxiFyre status, active target, pending changes, and recent activity.
- `GET /api/windows/profiles`: returns profile source data with resolved executable counts.
- `PUT /api/windows/profiles`: saves profiles without applying.
- `POST /api/windows/profiles/apply`: generates ProxiFyre config and restarts service.
- `POST /api/windows/profiles/scan`: resolves folder and exe entries for preview.
- `GET /api/windows/targets`: returns `local-singbox` and external proxy targets.
- `PUT /api/windows/targets`: saves external proxy targets after validation.
- `POST /api/windows/service`: start, stop, or restart `sing-box`, ProxiFyre, or the UI service.
- `GET /api/windows/logs`: returns recent helper, `sing-box`, and ProxiFyre logs.
Existing generic endpoints for subscription fetch, server selection, apply, logs, and config validation should be reused where the behavior matches Windows mode.
## UI Design
The approved direction is a restrained operational UI:
- top bar: product name, restart/stop/add profile actions;
- left nav: Overview, Profiles, Targets, Logs, Settings;
- main status panel: one sentence describing the active route, plus a compact route line such as `Selected apps -> ProxiFyre -> sing-box -> VPN`;
- main workspace: profile list on the left, selected profile details on the right;
- profile details: target selector, add process/folder/exe input, resolved items list, save/apply controls;
- activity panel: recent traffic/service events, not a full noisy log dump.
Avoid duplicate status blocks. Avoid showing raw implementation concepts like WinPacketFilter unless the user is in diagnostics/settings. The primary terms should be `Profile`, `Proxy target`, `Local sing-box`, `Existing proxy`, `App/folder/exe`, and `Apply changes`.
## Safety And Constraints
The UI binds only to `127.0.0.1`. Windows actions that require elevation stay in the installer/helper path. The installer must not delete existing `C:\Tools\vpn-proxy` legacy folders without confirmation.
Folder and exe routing needs a clear diagnostic note: ProxiFyre routing ultimately depends on what the installed ProxiFyre version accepts. The first implementation should resolve folders and exe paths to executable names for compatibility, while preserving full paths in profile data and diagnostics. If direct path matching is verified in ProxiFyre, the adapter can emit full paths without changing the UI model.
The installer should be idempotent:
- re-running it updates project files;
- existing subscriptions and profiles are preserved unless the user chooses reset;
- existing ProxiFyre config is backed up before overwrite;
- failed applies leave the previous generated config available for rollback.
## Testing And Verification
Use focused tests for pure logic:
- profile normalization;
- folder/exe item resolution;
- ProxiFyre config generation;
- proxy target validation;
- Windows helper JSON command contract;
- `APP_MODE=windows` public state and config generation.
Use manual Windows verification for privileged operations:
- fresh Full install;
- fresh ProxiFyre-only install;
- re-run installer over existing install;
- add process profile;
- add folder profile;
- add explicit exe profile;
- switch a profile from local sing-box to external proxy;
- restart ProxiFyre and verify service status;
- copy diagnostics after a failed proxy target check.
Local non-Windows development should still run `npm test` and `npm run build`. Windows-only helper commands should have dry-run or mockable modes so logic can be tested without installing drivers on the development machine.
## Non-Goals For First Version
- No Electron or Tauri wrapper.
- No global Windows system proxy changes.
- No transparent routing without ProxiFyre.
- No remote multi-device management.
- No automatic uninstall of unrelated WinPacketFilter users.
- No Proxifier support until ProxiFyre behavior is stable.
## Implementation Defaults
- The UI server runs when opened by `manage.ps1 -OpenUi` in the first version. An at-logon scheduled UI task can be added later after the helper and UI are stable.
- Full install uses portable Node/npm when the machine has no suitable Node.js. The installer builds the React UI locally for MVP; a prebuilt release artifact can replace that later without changing user-facing behavior.
- ProxiFyre generation emits process names in the first version for compatibility. Full folder and exe paths remain in profile data and diagnostics; the adapter can start emitting full paths later if ProxiFyre path matching is verified.

View File

@@ -5,11 +5,15 @@ TPROXY_PORT="${TPROXY_PORT:-7895}"
TPROXY_MARK="${TPROXY_MARK:-1}"
TPROXY_TABLE="${TPROXY_TABLE:-100}"
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
TPROXY_SOURCE_BYPASS_CHAIN="${TPROXY_SOURCE_BYPASS_CHAIN:-VPN_PROXY_SRC_BYPASS}"
TPROXY_SOURCE_FORWARD_CHAIN="${TPROXY_SOURCE_FORWARD_CHAIN:-VPN_PROXY_FWD_BYPASS}"
TPROXY_SOURCE_NAT_CHAIN="${TPROXY_SOURCE_NAT_CHAIN:-VPN_PROXY_NAT_BYPASS}"
PROXY_PORT="${PROXY_PORT:-8080}"
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
TPROXY_BYPASS_SOURCE_CIDRS="${TPROXY_BYPASS_SOURCE_CIDRS:-}"
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
@@ -36,13 +40,32 @@ cleanup_proxy_firewall() {
cleanup_tproxy() {
log "cleanup tproxy rules"
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
ipt -D FORWARD -j "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -t nat -D POSTROUTING -j "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
ipt -t mangle -F "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
ipt -t mangle -X "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
ipt -F "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -X "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -t nat -F "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
ipt -t nat -X "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
# ipset не чистим при завершении — TTL сам истечёт
}
enable_ip_forwarding() {
log "enable IPv4 forwarding for source bypass"
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
printf '1' > /proc/sys/net/ipv4/ip_forward || true
return
fi
if command -v sysctl >/dev/null 2>&1; then
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
fi
}
setup_direct_bypass_set() {
if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then
export DIRECT_BYPASS_CACHE
@@ -76,14 +99,32 @@ setup_proxy_firewall() {
setup_tproxy() {
log "setup tproxy on port ${TPROXY_PORT}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
cleanup_tproxy
enable_ip_forwarding
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
ipt -t mangle -N "$TPROXY_CHAIN"
ipt -t mangle -N "$TPROXY_SOURCE_BYPASS_CHAIN"
ipt -N "$TPROXY_SOURCE_FORWARD_CHAIN"
ipt -t nat -N "$TPROXY_SOURCE_NAT_CHAIN"
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
ipt -t mangle -A "$TPROXY_CHAIN" -j "$TPROXY_SOURCE_BYPASS_CHAIN"
ipt -I FORWARD 1 -j "$TPROXY_SOURCE_FORWARD_CHAIN"
ipt -t nat -I POSTROUTING 1 -j "$TPROXY_SOURCE_NAT_CHAIN"
for cidr in $BYPASS_CIDRS; do
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -d "$cidr" -j RETURN
done
for cidr in $TPROXY_BYPASS_SOURCE_CIDRS; do
ipt -t mangle -A "$TPROXY_SOURCE_BYPASS_CHAIN" -s "$cidr" -j ACCEPT
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -s "$cidr" -j ACCEPT
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -s "$cidr" -j MASQUERADE
done
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box.

View File

@@ -47,6 +47,10 @@ TPROXY_PORT=7895
TPROXY_MARK=1
TPROXY_TABLE=100
TPROXY_CHAIN=VPN_PROXY_TPROXY
TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_SRC_BYPASS
TPROXY_SOURCE_FORWARD_CHAIN=VPN_PROXY_FWD_BYPASS
TPROXY_SOURCE_NAT_CHAIN=VPN_PROXY_NAT_BYPASS
TPROXY_BYPASS_SOURCE_CIDRS=
ROUTING_RU_DIRECT=true
LOG_LEVEL=info
EOF

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}"
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,194 @@ 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"
}
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() {
published_port_conflicts "$1"
}
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 %s is already used:\n%s\n' \
"$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" = "$PROXY_PORT" ]; then
die "UI port ${UI_PORT} overlaps proxy port"
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 --noproxy "*" -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 +232,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="$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}"
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}
This is the only Docker-published proxy port. Re-run the installer with VPN_PROXY_CLIENT_PORT=<port> to change it.
Useful commands:
cd ~/.vpn-proxy-client
@@ -66,9 +272,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

View File

@@ -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, {}),
});
}

View File

@@ -1,12 +1,34 @@
import path from "node:path";
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
const parsePort = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? parsed : fallback;
};
const proxyPort = parsePort(process.env.PROXY_PORT, 8080);
const clientProxyPortStart = parsePort(
process.env.CLIENT_PROXY_PORT_START,
proxyPort,
);
const clientProxyPortEnd = parsePort(
process.env.CLIENT_PROXY_PORT_END,
clientProxyPortStart,
);
export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
port: Number(process.env.PORT || 3456),
proxyPort: Number(process.env.PROXY_PORT || 8080),
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
port: parsePort(process.env.PORT, 3456),
proxyPort,
clientProxyPortStart,
clientProxyPortEnd,
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
tproxySourceBypassChain:
process.env.TPROXY_SOURCE_BYPASS_CHAIN || "VPN_PROXY_SRC_BYPASS",
tproxySourceForwardChain:
process.env.TPROXY_SOURCE_FORWARD_CHAIN || "VPN_PROXY_FWD_BYPASS",
tproxySourceNatChain:
process.env.TPROXY_SOURCE_NAT_CHAIN || "VPN_PROXY_NAT_BYPASS",
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
dataDir,
distDir: process.env.DIST_DIR || "/app/dist",
@@ -20,6 +42,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",

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { settings } from "./config.js";
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block"]);
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block", "bypass"]);
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
export const DEFAULT_DEVICE_MODE = "vpn";
export const DEFAULT_PROXY_MODE = "vpn";
@@ -27,7 +27,6 @@ function writeJson(filePath, value) {
function normalizeDeviceMode(mode, fallback = "rules") {
const value = String(mode || "").trim().toLowerCase();
if (value === "bypass") return "direct";
return DEVICE_MODES.has(value) ? value : fallback;
}

View File

@@ -21,6 +21,14 @@ import {
readClientSettings,
writeClientSettings,
} from "./clientSettings.js";
import {
buildSharedProxyInfo,
checkSharedProxyGateway,
} from "./sharedProxy.js";
import {
sourceBypassCidrs,
syncTproxySourceBypass,
} from "./tproxySourceBypass.js";
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
import { tcpPing, resolveHost } from "./ping.js";
@@ -598,15 +606,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,
@@ -624,6 +638,7 @@ function publicState() {
directBypassCount,
directBypassEnabled: DIRECT_BYPASS_CACHE,
directBypassAvailable: IPSET_AVAILABLE,
sourceBypassCidrs: sourceBypassCidrs(deviceProfiles),
...rest,
};
}
@@ -716,6 +731,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 +795,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 });
@@ -916,6 +975,13 @@ async function handleApi(req, res) {
devices: body.devices,
};
const profiles = writeDeviceProfiles(input);
const sourceBypassResult = syncTproxySourceBypass(profiles);
if (!sourceBypassResult.success) {
pushLog(
"warning",
`Не удалось применить bypass устройств в iptables: ${sourceBypassResult.error}`,
);
}
const prevState = readJson(settings.statePath, {});
const devicesUpdatedAt = new Date().toISOString();
writeJson(settings.statePath, {
@@ -925,6 +991,8 @@ async function handleApi(req, res) {
return sendJson(res, 200, {
success: true,
...profiles,
sourceBypassCidrs: sourceBypassCidrs(profiles),
sourceBypassResult,
devicesUpdatedAt,
});
}
@@ -941,12 +1009,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 +1446,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,
@@ -1443,6 +1555,14 @@ process.on("SIGINT", async () => {
process.exit(0);
});
const sourceBypassStartup = syncTproxySourceBypass(readDeviceProfiles());
if (!sourceBypassStartup.success) {
pushLog(
"warning",
`Не удалось применить bypass устройств в iptables: ${sourceBypassStartup.error}`,
);
}
// При старте пробуем подхватить уже запущенный sing-box
const existingPid = readSingboxPid();
if (existingPid && isPidAlive(existingPid)) {

View File

@@ -159,6 +159,21 @@ export function matchRoute(target, customRules, options = {}) {
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
);
if (
inbound === TPROXY_INBOUND &&
matchedDevice &&
matchedDevice.mode === "bypass"
) {
return {
matched: "kernel-bypass",
ruleIndex: -1,
ruleId: matchedDevice.id,
ruleName: `${matchedDevice.name} -> bypass TProxy`,
outbound: "direct",
reason: "Source IP исключён на уровне iptables до попадания в sing-box",
};
}
// 1. private IP → direct
if (target.ip && isPrivateIp(target.ip)) {
return {

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;
}
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" },
],
@@ -318,7 +347,7 @@ export function buildGatewayConfig(
includeTransparent: !clientMode,
}),
final: "direct",
auto_detect_interface: true,
...(clientMode ? {} : { auto_detect_interface: true }),
},
};
}

View File

@@ -0,0 +1,124 @@
import { spawnSync } from "node:child_process";
import { settings } from "./config.js";
import { deviceCidrs, normalizeCidr } from "./devices.js";
const DEFAULT_NAT_BYPASS_CIDRS =
"0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4";
function splitCidrs(value) {
return String(value || "")
.split(/[\s,]+/)
.map((item) => normalizeCidr(item))
.filter(Boolean);
}
function unique(list) {
return [...new Set(list)];
}
export function sourceBypassCidrs(
profiles,
envCidrs = process.env.TPROXY_BYPASS_SOURCE_CIDRS || "",
) {
return unique([
...splitCidrs(envCidrs),
...deviceCidrs(profiles?.devices || [], "bypass"),
]);
}
export function buildSourceBypassIptablesCommands(
cidrs,
{
chain = settings.tproxySourceBypassChain,
forwardChain = settings.tproxySourceForwardChain,
natChain = settings.tproxySourceNatChain,
natBypassCidrs = splitCidrs(
process.env.BYPASS_CIDRS || DEFAULT_NAT_BYPASS_CIDRS,
),
} = {},
) {
return [
["-w", "-t", "mangle", "-F", chain],
["-w", "-F", forwardChain],
["-w", "-t", "nat", "-F", natChain],
...cidrs.map((cidr) => [
"-w",
"-t",
"mangle",
"-A",
chain,
"-s",
cidr,
"-j",
"ACCEPT",
]),
...cidrs.flatMap((cidr) => [
["-w", "-A", forwardChain, "-s", cidr, "-j", "ACCEPT"],
[
"-w",
"-A",
forwardChain,
"-d",
cidr,
"-m",
"conntrack",
"--ctstate",
"RELATED,ESTABLISHED",
"-j",
"ACCEPT",
],
]),
...natBypassCidrs.map((cidr) => [
"-w",
"-t",
"nat",
"-A",
natChain,
"-d",
cidr,
"-j",
"RETURN",
]),
...cidrs.map((cidr) => [
"-w",
"-t",
"nat",
"-A",
natChain,
"-s",
cidr,
"-j",
"MASQUERADE",
]),
];
}
export function syncTproxySourceBypass(profiles, options = {}) {
if (settings.appMode !== "gateway") {
return { success: true, skipped: true, cidrs: [] };
}
const cidrs = sourceBypassCidrs(
profiles,
options.envCidrs ?? process.env.TPROXY_BYPASS_SOURCE_CIDRS,
);
const commands = buildSourceBypassIptablesCommands(cidrs, options);
for (const args of commands) {
const result = spawnSync("iptables", args, {
encoding: "utf8",
timeout: 1000,
});
if (result.error || result.status !== 0) {
return {
success: false,
cidrs,
error:
result.error?.message ||
(result.stderr || result.stdout || "iptables command failed").trim(),
};
}
}
return { success: true, cidrs };
}

View File

@@ -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 || '');
}
@@ -223,7 +224,19 @@ function App() {
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
devices: data.devices || [],
});
setState((prev) => prev ? { ...prev, devicesUpdatedAt: data.devicesUpdatedAt } : prev);
setState((prev) => prev ? {
...prev,
devicesUpdatedAt: data.devicesUpdatedAt,
sourceBypassCidrs: data.sourceBypassCidrs,
} : prev);
if (data.sourceBypassResult && data.sourceBypassResult.success === false) {
pushToast({
kind: 'warning',
title: 'Bypass сохранён, но не применён',
message: data.sourceBypassResult.error,
duration: 7000,
});
}
} catch (err) {
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
}
@@ -268,11 +281,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 +439,7 @@ function App() {
setPendingTag={setPendingTag}
clientSettings={clientSettings}
onSaveClientSettings={saveClientSettings}
onCheckSharedProxy={checkSharedProxy}
onFetchSubscription={fetchSubscription}
onApply={applyServer}
onRestart={restartSingbox}

View File

@@ -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: {

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 { 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,83 @@ 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.'}
{activeServer && <small className="field-hint">Выбран {flagFor(activeServer)} {activeServer.tag}</small>}
</div>
</div>
);
}
function DirectSettings({ busy, onEnable }) {
return (
<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 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;
const singlePublishedPort = range.start === range.end;
return (
<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>
</div>
<div className="field">
<label className="field-label">Порт proxy</label>
<div className="client-port-row">
<input
className="input"
type="number"
min={range.start}
max={range.end}
value={draftPort}
disabled={singlePublishedPort}
onChange={(e) => setDraftPort(e.target.value)}
/>
<button
className="btn btn-secondary"
disabled={busy || singlePublishedPort || !dirty}
onClick={() => onSave({ ...settings, proxyPort: parsed })}
>
Save
</button>
</div>
<small className={invalid ? 'field-error' : 'field-hint'}>
{singlePublishedPort ? 'Порт задаётся установщиком' : `${range.start}${range.end}`}
</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]);
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>
</div>
);
}
function HomeBypassCard({ settings, busy, onSave }) {
const enabled = Boolean(settings?.homeBypassEnabled);
return (
<div className="card">
<div className="card-header">
<h2>Домашний режим</h2>
<span className={`badge ${enabled ? 'info' : 'neutral'}`}>
{enabled ? 'Напрямую' : 'Через VPN'}
</span>
</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>
<input
type="checkbox"
checked={enabled}
disabled={busy}
onChange={(e) => onSave({ ...settings, homeBypassEnabled: e.target.checked })}
/>
</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>
</div>
</div>
</aside>
);
}
export function ClientOverviewPage({
state,
status,
activeServer,
busy,
subscriptionUrl,
@@ -268,15 +253,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 +351,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>
);
}

View File

@@ -19,6 +19,7 @@ const OUTBOUND_KIND = {
};
const DEVICE_MODES = {
bypass: { kind: 'warning', label: 'bypass TProxy', hint: 'мимо sing-box; ручной proxy отдельно' },
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
@@ -28,6 +29,7 @@ const DEVICE_MODES = {
function DeviceModeSelect({ value, onChange }) {
return (
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
<option value="bypass">bypass TProxy</option>
<option value="direct">direct</option>
<option value="vpn">VPN</option>
<option value="rules">default</option>
@@ -46,7 +48,7 @@ function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemov
<div className="card-header">
<div>
<h2>Устройства</h2>
<small className="muted">Global rules применяются первыми. Эти значения fallback после них.</small>
<small className="muted">bypass TProxy применяется до sing-box. Остальные режимы fallback после global rules.</small>
</div>
<div className="btn-group">
<label className="field" style={{ minWidth: 180, margin: 0 }}>

View File

@@ -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-status-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.client-flow {
grid-template-columns: 1fr;
.client-status-facts > div {
min-width: 0;
padding: 12px;
background: #0b1219;
border: 1px solid #253341;
border-radius: 8px;
}
.flow-arrow {
justify-content: center;
transform: rotate(90deg);
.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;
}
.copy-field {
align-items: flex-start;
.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;
}
}

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,45 @@
import assert from "node:assert/strict";
import test from "node:test";
async function withEnv(patch, fn) {
const previous = {};
for (const key of Object.keys(patch)) {
previous[key] = process.env[key];
if (patch[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = patch[key];
}
}
try {
return await fn();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
test("client proxy range defaults to the single configured proxy port", async () => {
await withEnv(
{
PROXY_PORT: "8082",
CLIENT_PROXY_PORT_START: "8082",
CLIENT_PROXY_PORT_END: undefined,
},
async () => {
const { settings } = await import(
`../../src/server/config.js?single-proxy-port=${Date.now()}`
);
assert.equal(settings.proxyPort, 8082);
assert.equal(settings.clientProxyPortStart, 8082);
assert.equal(settings.clientProxyPortEnd, 8082);
},
);
});

View File

@@ -0,0 +1,167 @@
import assert from "node:assert/strict";
import test from "node:test";
const {
deviceCidrs,
normalizeDeviceProfiles,
} = await import("../../src/server/devices.js");
const { matchRoute } = await import("../../src/server/routeMatcher.js");
const {
sourceBypassCidrs,
buildSourceBypassIptablesCommands,
} = await import("../../src/server/tproxySourceBypass.js");
const { settings } = await import("../../src/server/config.js");
test("default source bypass chain name fits iptables chain length limit", () => {
assert.equal(settings.tproxySourceBypassChain, "VPN_PROXY_SRC_BYPASS");
assert.equal(settings.tproxySourceForwardChain, "VPN_PROXY_FWD_BYPASS");
assert.equal(settings.tproxySourceNatChain, "VPN_PROXY_NAT_BYPASS");
assert.ok(settings.tproxySourceBypassChain.length <= 28);
assert.ok(settings.tproxySourceForwardChain.length <= 28);
assert.ok(settings.tproxySourceNatChain.length <= 28);
});
test("device profiles preserve bypass mode for kernel-level TProxy bypass", () => {
const profiles = normalizeDeviceProfiles({
devices: [
{
id: "pc",
name: "PC",
enabled: true,
ip: "192.168.50.25",
mode: "bypass",
},
],
});
assert.equal(profiles.devices[0].mode, "bypass");
assert.deepEqual(deviceCidrs(profiles.devices, "bypass"), [
"192.168.50.25/32",
]);
});
test("route checker reports transparent bypass before sing-box rules", () => {
const result = matchRoute(
{
host: "example.com",
ip: "93.184.216.34",
sourceIp: "192.168.50.25",
inbound: "tproxy-in",
},
[
{
id: "vpn-all",
enabled: true,
name: "VPN all",
domains: ["example.com"],
outbound: "vpn",
},
],
{
vpnTag: "test-vpn",
deviceProfiles: {
defaultTransparentMode: "vpn",
proxyDefaultMode: "vpn",
devices: [
{
id: "pc",
name: "PC",
enabled: true,
ip: "192.168.50.25",
mode: "bypass",
},
],
},
},
);
assert.equal(result.matched, "kernel-bypass");
assert.equal(result.ruleName, "PC -> bypass TProxy");
assert.equal(result.outbound, "direct");
});
test("source bypass sync combines env CIDRs and bypass-mode devices", () => {
const cidrs = sourceBypassCidrs(
{
devices: [
{ enabled: true, ip: "192.168.50.25", mode: "bypass" },
{ enabled: false, ip: "192.168.50.26", mode: "bypass" },
{ enabled: true, ip: "192.168.50.27", mode: "direct" },
],
},
"192.168.50.30/32",
);
assert.deepEqual(cidrs, ["192.168.50.30/32", "192.168.50.25/32"]);
});
test("source bypass iptables commands use ACCEPT inside the managed subchain", () => {
assert.deepEqual(
buildSourceBypassIptablesCommands(["192.168.50.25/32"], {
chain: "VPN_PROXY_SOURCE_BYPASS",
forwardChain: "VPN_PROXY_FWD_BYPASS",
natChain: "VPN_PROXY_NAT_BYPASS",
natBypassCidrs: ["10.0.0.0/8"],
}),
[
["-w", "-t", "mangle", "-F", "VPN_PROXY_SOURCE_BYPASS"],
["-w", "-F", "VPN_PROXY_FWD_BYPASS"],
["-w", "-t", "nat", "-F", "VPN_PROXY_NAT_BYPASS"],
[
"-w",
"-t",
"mangle",
"-A",
"VPN_PROXY_SOURCE_BYPASS",
"-s",
"192.168.50.25/32",
"-j",
"ACCEPT",
],
[
"-w",
"-A",
"VPN_PROXY_FWD_BYPASS",
"-s",
"192.168.50.25/32",
"-j",
"ACCEPT",
],
[
"-w",
"-A",
"VPN_PROXY_FWD_BYPASS",
"-d",
"192.168.50.25/32",
"-m",
"conntrack",
"--ctstate",
"RELATED,ESTABLISHED",
"-j",
"ACCEPT",
],
[
"-w",
"-t",
"nat",
"-A",
"VPN_PROXY_NAT_BYPASS",
"-d",
"10.0.0.0/8",
"-j",
"RETURN",
],
[
"-w",
"-t",
"nat",
"-A",
"VPN_PROXY_NAT_BYPASS",
"-s",
"192.168.50.25/32",
"-j",
"MASQUERADE",
],
],
);
});

View File

@@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const ROOT = path.resolve(import.meta.dirname, "../..");
const ENTRYPOINT = path.join(ROOT, "entrypoint.sh");
function writeExecutable(filePath, contents) {
fs.writeFileSync(filePath, contents, { mode: 0o755 });
}
test("entrypoint bypasses configured source CIDRs before TProxy interception", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vpn-entrypoint-"));
const commandLog = path.join(tmp, "commands.log");
writeExecutable(
path.join(tmp, "iptables"),
`#!/usr/bin/env bash
printf 'iptables %s\\n' "$*" >> "$COMMAND_LOG"
exit 0
`,
);
writeExecutable(
path.join(tmp, "ip"),
`#!/usr/bin/env bash
printf 'ip %s\\n' "$*" >> "$COMMAND_LOG"
exit 0
`,
);
writeExecutable(
path.join(tmp, "ipset"),
`#!/usr/bin/env bash
printf 'ipset %s\\n' "$*" >> "$COMMAND_LOG"
exit 0
`,
);
writeExecutable(
path.join(tmp, "sysctl"),
`#!/usr/bin/env bash
printf 'sysctl %s\\n' "$*" >> "$COMMAND_LOG"
exit 0
`,
);
writeExecutable(
path.join(tmp, "node"),
`#!/usr/bin/env bash
printf 'node %s\\n' "$*" >> "$COMMAND_LOG"
exit 0
`,
);
const result = spawnSync("bash", [ENTRYPOINT], {
cwd: ROOT,
env: {
...process.env,
PATH: `${tmp}${path.delimiter}${process.env.PATH}`,
COMMAND_LOG: commandLog,
TPROXY_BYPASS_SOURCE_CIDRS: "192.168.50.25/32 192.168.50.26/32",
DIRECT_BYPASS_CACHE: "true",
BYPASS_CIDRS: "10.0.0.0/8",
},
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
const commands = fs.readFileSync(commandLog, "utf8").trim().split("\n");
const sourceBypassIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -t mangle -A VPN_PROXY_SRC_BYPASS -s 192.168.50.25/32 -j ACCEPT",
),
);
const secondSourceBypassIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -t mangle -A VPN_PROXY_SRC_BYPASS -s 192.168.50.26/32 -j ACCEPT",
),
);
const sourceBypassJumpIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -t mangle -A VPN_PROXY_TPROXY -j VPN_PROXY_SRC_BYPASS",
),
);
const directCacheIndex = commands.findIndex((line) =>
line.includes("-m set --match-set vpn_direct_bypass dst -j RETURN"),
);
const tproxyIndex = commands.findIndex((line) =>
line.includes("-p tcp -j TPROXY --on-port 7895"),
);
const ipForwardIndex = commands.findIndex((line) =>
line.includes("sysctl -w net.ipv4.ip_forward=1"),
);
const forwardAcceptIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -A VPN_PROXY_FWD_BYPASS -s 192.168.50.25/32 -j ACCEPT",
),
);
const forwardReturnIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -A VPN_PROXY_FWD_BYPASS -d 192.168.50.25/32 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT",
),
);
const natMasqueradeIndex = commands.findIndex((line) =>
line.includes(
"iptables -w -t nat -A VPN_PROXY_NAT_BYPASS -s 192.168.50.25/32 -j MASQUERADE",
),
);
assert.notEqual(sourceBypassIndex, -1);
assert.notEqual(secondSourceBypassIndex, -1);
assert.notEqual(sourceBypassJumpIndex, -1);
assert.notEqual(directCacheIndex, -1);
assert.notEqual(tproxyIndex, -1);
assert.notEqual(ipForwardIndex, -1);
assert.notEqual(forwardAcceptIndex, -1);
assert.notEqual(forwardReturnIndex, -1);
assert.notEqual(natMasqueradeIndex, -1);
assert.ok(sourceBypassJumpIndex < directCacheIndex);
assert.ok(sourceBypassJumpIndex < tproxyIndex);
});

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(
`../../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,17 +40,20 @@ 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, []);
assert.equal(config.route.auto_detect_interface, undefined);
assert.deepEqual(config.route.rules, [
{ inbound: ["mixed-in"], outbound: "test-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 +64,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 ignores saved proxy port outside the published single port", () => {
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, 8080);
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, "выберите режим");
});