# 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` по умолчанию; в UI можно выбрать порт из Docker-диапазона `8080–8090` Установщик интерактивно спросит 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://: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:` в режим 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 ``` --- # 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 │ ├─ 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 -m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box -m mark --mark 1 → RETURN # уже помеченные пакеты -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. ### 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://: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_START/END` | | `CLIENT_PROXY_PORT_START` | `8080` | Первый host/container proxy-порт для `docker-compose.client.yml` | | `CLIENT_PROXY_PORT_END` | `8090` | Последний host/container proxy-порт для `docker-compose.client.yml` | | `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса | | `PORT` | `3456` | Порт веб-интерфейса | | `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror | | `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build | | `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 | | `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 и порты.