451 lines
26 KiB
Markdown
451 lines
26 KiB
Markdown
# 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://<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
|
||
```
|
||
|
||
---
|
||
|
||
# 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://<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_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 и порты.
|