# VPN Proxy

Локальный Docker-клиент для Mac и прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).

## macOS: локальный Docker-клиент

Самый простой режим: контейнер работает как обычный локальный HTTP/SOCKS proxy без TProxy, iptables, `network_mode: host` и прав `NET_ADMIN`.

```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | bash
```

После запуска по умолчанию:

- UI: `http://127.0.0.1:3456`
- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его

Установщик интерактивно спросит 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:

```bash
docker compose -f docker-compose.client.yml up -d --build
```

Перезапуск и логи:

```bash
cd ~/.vpn-proxy-client
docker compose -f docker-compose.client.yml logs -f
docker compose -f docker-compose.client.yml restart
```

## Windows: standalone desktop client direction

Windows app routing lives in a separate Tauri 2 desktop utility, not as
`APP_MODE=windows` inside the current Node gateway/client server. The active
workspace slice is `apps/windows-client`.

Active design documents:

- Product/tech brief: `docs/windows-client-product-tech-brief.md`
- Execution plan: `docs/goals/windows-modular-client/PLAN.md`
- Windows client README: `apps/windows-client/README.md`

Target shape:

- Control App: compact Windows UI for status, profiles, targets, components,
  logs, and diagnostics.
- Proxyfier Layer: adapter boundary with ProxiFyre as the first engine for
  per-app TCP/UDP routing.
- Local sing-box: optional local runtime; external SOCKS5/HTTP targets must
  work without it.

Development checks:

```powershell
cd apps/windows-client
npm install
npm run build
npm run tauri -- info

cd src-tauri
cargo test
```

Native Tauri build requires WebView2, Rust/rustup, and Visual Studio Build
Tools with MSVC and Windows SDK components. In the current checkpoint, frontend
builds pass, while native Rust/Tauri tests require that Windows toolchain.

The three Windows pieces are installed and operated separately:

```powershell
cd apps/windows-client
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
```

`-PlanOnly` returns structured JSON without install side effects. Real install
or service operations must be explicit; profile apply must not silently install
Proxyfier or Local sing-box.

Windows source configuration is owned by JSON under
`C:\ProgramData\VpnProxy\config`. Generated ProxiFyre and sing-box files under
`C:\ProgramData\VpnProxy\generated` are derived artifacts.

---

# VPN Proxy Gateway

Самохостируемый прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).  
Разворачивается в Docker (LXC, VPS), перехватывает трафик всей локальной сети через iptables TProxy — без клиентов на устройствах.

Веб-интерфейс на React даёт полное управление: подписки, выбор сервера, кастомные правила маршрутизации, просмотр трафика в реальном времени.

---

## Архитектура

```
Клиент (ПК/телефон)
    │  TCP/UDP трафик
    ▼
[Роутер] → маршрут по умолчанию → LXC/VPS (gateway)
    │
    ▼
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
                    │
                    ▼
              sing-box (tproxy inbound :7895)
                    │
            роутинг по правилам
                    │
         ┌──────────┼──────────┐
         ▼          ▼          ▼
      direct     VPN out    block
```

ПК-приложения, которым нужен VPN явно:

```
Windows app → ProxiFyre/Proxifier → gateway:8080 → sing-box mixed-in → global rules → default VPN
```

**Node.js API-сервер** (`src/server/index.js`) работает внутри того же контейнера:  
управляет процессом sing-box, парсит его логи, экспортирует REST API и SSE-стримы для веб-интерфейса.

---

## Стек

| Слой             | Технология                                                    |
| ---------------- | ------------------------------------------------------------- |
| Контейнер        | Docker, `network_mode: host`, `CAP_NET_ADMIN` + `CAP_NET_RAW` |
| Перехват трафика | iptables TProxy + iproute2 policy routing                     |
| Bypass-кэш       | опциональный ipset `hash:ip` с TTL                            |
| VPN-ядро         | sing-box (VLESS/VLESS-Reality/VMess/Trojan/Hysteria2/SS)      |
| API-сервер       | Node.js 18, plain `http` (без фреймворков)                    |
| Веб-интерфейс    | React 18 + Vite 7, SPA                                        |

---

## Как работает прозрачное проксирование

### 1. TProxy и policy routing

При старте контейнера `entrypoint.sh` настраивает ядро:

```bash
# Policy routing: пакеты с меткой TPROXY_MARK уходят через loopback
ip rule add fwmark 1 table 100
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
  -p udp                                → TPROXY :7895 mark 1
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

Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:

| Приоритет | Условие                                     | Действие                                 |
| --------- | ------------------------------------------- | ---------------------------------------- |
| 1         | `ip_is_private: true`                       | `direct` (защита LAN)                    |
| 2         | Global custom rules                         | `direct` / VPN / `block` для всех inbound |
| 3         | `rule_set: [geoip-ru, geosite-category-ru]` | `direct`                                  |
| 4         | Device defaults для `tproxy-in`             | `direct` / VPN / `block`                 |
| 5         | Proxy default для `mixed-in`                | по умолчанию VPN                         |
| 6         | Transparent default для unknown devices     | по умолчанию VPN                         |
| 7         | Всё остальное (`final`)                     | `direct`                                 |

Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`.

### 3. Bypass Mode (весь трафик напрямую)

Кнопка "Весь трафик напрямую" в дашборде. При активации `buildGatewayConfig()` вызывается с `{ bypassAll: true }` — в конфиге убираются все rule_set, `final: "direct"`. Удобно для диагностики или когда VPN не нужен.

---

## Direct Bypass Cache (ipset)

Оптимизация выключена по умолчанию: `DIRECT_BYPASS_CACHE=false`. Причина — dst-IP cache обходит sing-box до проверки global rules, а значит может нарушить требования вида `AI → VPN` или `blocked → block`.

Если явно включить `DIRECT_BYPASS_CACHE=true`, IP-адреса, которые sing-box уже отправил напрямую, кэшируются в ядре и больше не проходят через userspace.

**Цепочка событий:**

1. sing-box маршрутизирует соединение как `direct`, пишет в лог:  
   `[TCP] 192.168.1.5:54321 --> 203.0.113.10:443 outbound/direct[direct]`

2. Node.js парсит строку (regex `-->` + `outbound/`). Если `category === "direct"` и назначение — IPv4-адрес:

   ```bash
   ipset add vpn_direct_bypass 203.0.113.10 timeout 3600 -exist
   ```

3. Следующий пакет к `203.0.113.10` обрабатывается iptables **до** передачи в sing-box:

   ```
   -m set --match-set vpn_direct_bypass dst → RETURN
   ```

   Пакет уходит напрямую на уровне ядра — нулевые накладные расходы userspace sing-box.

4. Запись истекает через TTL (по умолчанию 1 час).

```
DIRECT_BYPASS_CACHE=false             # безопасное значение по умолчанию
DIRECT_BYPASS_SET=vpn_direct_bypass   # имя ipset
DIRECT_BYPASS_TTL=3600                # TTL в секундах
```

## Профили устройств

Управляются из UI на вкладке **Маршрутизация** и сохраняются в `devices.json`:

```json
{
  "defaultTransparentMode": "vpn",
  "proxyDefaultMode": "vpn",
  "devices": [
    {
      "id": "gaming-pc",
      "name": "Gaming PC",
      "ip": "192.168.1.50",
      "mac": "",
      "mode": "direct",
      "enabled": true
    },
    {
      "id": "phone",
      "name": "Phone",
      "ip": "192.168.1.60",
      "mode": "vpn",
      "enabled": true
    }
  ]
}
```

| Mode     | Что делает                                                        |
| -------- | ----------------------------------------------------------------- |
| `direct` | fallback устройства после global rules → `direct`                 |
| `vpn`    | fallback устройства после global rules → выбранный VPN            |
| `block`  | fallback устройства после global rules → `block`                  |
| `rules`  | не задаёт fallback устройства; используется transparent default   |

`mixed-in` не зависит от режима устройства: если приложение явно пошло на `gateway:8080`, сначала применяются global rules, затем `proxyDefaultMode` (по умолчанию VPN).

---

## Кастомные правила маршрутизации

Управляются из вкладки **Маршрутизация**. Сохраняются в `custom-rules.json`.
Правила применяются в порядке отображения в UI — **first match wins**. Custom rules являются global rules: они применяются для `tproxy-in`, `mixed-in`, ПК, телефона и unknown devices до любых fallback-режимов.

| Поле             | Тип                          | Описание                                    |
| ---------------- | ---------------------------- | ------------------------------------------- |
| `name`           | string                       | Название правила                            |
| `enabled`        | bool                         | Вкл/выкл                                    |
| `outbound`       | `direct` \| `vpn` \| `block` | Куда отправить трафик                       |
| `domains`        | string[]                     | Точные домены (`example.com`)               |
| `domainSuffixes` | string[]                     | Суффикс домена (`.example.com` + поддомены) |
| `domainKeywords` | string[]                     | Keyword в имени хоста                       |
| `ipCidrs`        | string[]                     | IP-диапазоны CIDR                           |
| `ports`          | string[]                     | Порты или диапазоны (`443`, `8000-9000`)    |
| `networks`       | `tcp` \| `udp`               | Протокол                                    |
| `ruleSets`       | string[]                     | Ссылки на remote rule-set                   |

UI автоматически детектирует конфликты — когда правило полностью перекрывается предыдущим.

### Remote Rule Sets

В **Настройках** можно добавить произвольные rule-set:

```json
{ "tag": "gaming-servers", "url": "https://...", "format": "binary" }
```

sing-box скачивает их при старте, кэширует в `cache.db`. Ключ кэша — SHA-1 от URL.

---

## Подписки

Поддерживаемые форматы:

- **JSON-конфиг sing-box** — объект с полем `outbounds[]`
- **Base64-список VLESS-ссылок** — декодируется, каждая ссылка парсится
- **Прямые VLESS URI** (`vless://uuid@host:port?...#tag`)

После загрузки пользователь выбирает сервер → генерируется конфиг → `sing-box check` → перезапуск.

Подписка кэшируется в `subscription-cache.json` — при рестарте контейнера конфиг автоматически пересоздаётся из кэша без повторного скачивания.

---

## Просмотр трафика

Вкладка **Трафик** в разделе Логи. Данные приходят через SSE (`/api/traffic/stream`).

### Парсинг логов sing-box

Node.js читает stderr sing-box и извлекает трафик двумя шагами:

```
[router] match[2][my-rule] => outbound/direct[direct]   ← имя правила
[TCP] 192.168.1.5:PORT --> example.com:443 outbound/vpn[tag]   ← соединение
```

1. `[router]`-строка → имя правила сохраняется с TTL 500 мс
2. Следующая строка с `-->` подхватывает имя в поле `matchedRule`
3. Тип трафика: `direct` / `vpn` / `block` по outbound
4. Direct + IPv4 → добавление в ipset bypass-кэш, только если `DIRECT_BYPASS_CACHE=true`

### Группировка и сортировка

`(category, host, port, matchedRule)` объединяются в группу с счётчиком:

- **По частоте** — самые частые наверху (по умолчанию)
- **По времени** — последние наверху

---

## Проверка маршрута

Вкладка **Проверка** позволяет узнать, по какому правилу пойдёт трафик к хосту/IP/порту — без реального подключения. Node.js (`routeMatcher.js`) симулирует ту же логику, что и sing-box:

1. private IP → direct
2. global custom rules
3. geoip-ru / geosite-category-ru → direct
4. `tproxy-in` + device default
5. `mixed-in` + proxy default
6. final → direct

---

## Быстрый старт

```bash
# Сборка фронтенда
npm install && npm run build

# Запуск контейнера
docker compose -f docker-compose.gateway.yml up -d
```

Если Docker Hub отвечает таймаутом на `debian:bookworm-slim`, можно собрать через read-through mirror:

```bash
BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim \
docker compose -f docker-compose.gateway.yml build

docker compose -f docker-compose.gateway.yml up -d
```

Если сборку нужно выполнять на контейнере/хосте, который уже ходит через рабочий gateway, а запускать image на другом:

```bash
BUILD_HOST=107 DEPLOY_HOST=111 ./scripts/build-on-107-deploy-111.sh
```

Скрипт собирает image на `BUILD_HOST`, переносит его на `DEPLOY_HOST` через `docker save | docker load` и запускает без `docker pull`. Если `107`/`111` не являются SSH-алиасами, укажите реальные адреса, например `BUILD_HOST=root@192.168.1.107 DEPLOY_HOST=root@192.168.1.111`.

Чтобы не получать циклическую зависимость "собрать gateway можно только через уже работающий gateway", подготовьте runtime base на `107` один раз:

```bash
./scripts/build-runtime-base.sh
```

После этого CI и `build-on-107-deploy-111.sh` используют локальный `vpn-proxy-runtime-base:bookworm-slim`: основная сборка gateway больше не делает `apt-get`, не качает sing-box и не обращается к Docker Hub за base image.

UI доступен на `http://<gateway-ip>:3456`.

На роутере указать шлюз по умолчанию (или нужные подсети) на IP контейнера.

---

## Переменные окружения

| Переменная          | По умолчанию         | Описание                               |
| ------------------- | -------------------- | -------------------------------------- |
| `APP_MODE`          | `gateway`            | `gateway` или `client`; compose клиента задаёт `client` автоматически |
| `CLIENT_UI_PORT`    | `3456`               | Host-порт UI для `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       |
| `INSTALL_RUNTIME_DEPS` | `true`            | Устанавливать runtime-пакеты в Docker build; `false` для подготовленного runtime base |
| `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                 |
| `DIRECT_BYPASS_CACHE` | `false`            | Включить dst-IP bypass cache; по умолчанию выключен |
| `DIRECT_BYPASS_SET` | `vpn_direct_bypass`  | Имя ipset bypass-кэша                  |
| `DIRECT_BYPASS_TTL` | `3600`               | TTL записей (секунды)                  |
| `RULE_SET_DOWNLOAD_DETOUR` | `vpn`          | Через какой outbound sing-box скачивает remote rule-set; `vpn` = выбранный сервер |
| `PROXY_BIND_IP`     | `0.0.0.0`            | Bind для HTTP/SOCKS в LAN; можно сузить до IP gateway |
| `PROXY_FIREWALL`    | `true`               | Закрыть `PROXY_PORT` не из allowed CIDR |
| `PROXY_ALLOWED_CIDRS` | `10.0.0.0/8 172.16.0.0/12 192.168.0.0/16` | Кто может подключаться к mixed proxy |

---

## REST API

| Метод     | Путь                   | Описание                             |
| --------- | ---------------------- | ------------------------------------ |
| `GET`     | `/api/state`           | Полное состояние системы             |
| `GET`     | `/api/shared-proxy`    | Проверка и параметры общего gateway proxy |
| `POST`    | `/api/subscription`    | Загрузить подписку по URL            |
| `POST`    | `/api/apply`           | Применить сервер (`{ selectedTag }`) |
| `GET`     | `/api/servers`         | Список серверов из кэша              |
| `GET/PUT` | `/api/rules`           | Кастомные правила                    |
| `GET/PUT` | `/api/devices`         | Профили устройств и default fallback |
| `GET/PUT` | `/api/rule-sets`       | Кастомные remote rule-set            |
| `POST`    | `/api/singbox/start`   | Запустить sing-box                   |
| `POST`    | `/api/singbox/stop`    | Остановить sing-box                  |
| `POST`    | `/api/singbox/restart` | Перезапустить sing-box               |
| `POST`    | `/api/bypass`          | `{ enabled }` — bypass mode          |
| `GET`     | `/api/direct-cache`    | Состояние ipset bypass-кэша          |
| `DELETE`  | `/api/direct-cache`    | Сбросить bypass-кэш                  |
| `POST`    | `/api/route/check`     | Симулировать маршрут                 |
| `POST`    | `/api/servers/ping`    | TCP-пинг до хоста                    |
| `GET`     | `/api/logs/stream`     | SSE системных логов                  |
| `GET`     | `/api/traffic/stream`  | SSE трафика                          |

---

## Структура проекта

```
├── Dockerfile                  # debian + sing-box + ipset + node
├── entrypoint.sh               # iptables/ipset setup → запуск node
├── docker-compose.gateway.yml
├── src/
│   ├── server/
│   │   ├── index.js            # HTTP-сервер, управление sing-box, SSE
│   │   ├── singbox.js          # генерация конфига sing-box
│   │   ├── subscription.js     # парсинг подписок (JSON/VLESS/base64)
│   │   ├── routeMatcher.js     # симулятор маршрутизации
│   │   ├── ping.js             # TCP-пинг и DNS-resolve
│   │   └── config.js           # настройки из env
│   └── web/
│       ├── App.jsx             # корневой компонент, глобальный state
│       ├── api.js              # обёртка fetch для API
│       └── components/
│           ├── OverviewPage.jsx    # дашборд, bypass-toggle
│           ├── LogsPage.jsx        # трафик + системные логи
│           ├── RoutingPage.jsx     # кастомные правила
│           ├── ServersPage.jsx     # подписка и выбор сервера
│           ├── SettingsPage.jsx    # rule-sets и настройки
│           └── RouteChecker.jsx    # проверка маршрута
└── docs/
    └── roadmap.md
```

## Ограничения

- TProxy только IPv4. IPv6 — в roadmap.
- DNS-перехват не включён; выдавайте клиентам DNS через DHCP роутера.
- Gateway не видит имя процесса на клиентском ПК — правила для игр задаются через домены, CIDR и порты.
