platform: modularize api/gui, add docs-tests-web foundation, and refresh root config

This commit is contained in:
beckline
2026-03-26 22:40:54 +03:00
parent 0e2d7f61ea
commit 6a56d734c2
562 changed files with 70151 additions and 16423 deletions

5
.gitignore vendored
View File

@@ -23,3 +23,8 @@ selective-vpn-api/_backups/
# Local archive / old copies (kept out of repo root)
_legacy/
# Web prototype
selective-vpn-web/node_modules/
selective-vpn-web/dist/
selective-vpn-web/.env.local

View File

@@ -15,6 +15,7 @@ Repo layout:
- `selective-vpn-api/` - Go backend API (localhost, default `127.0.0.1:8080`).
- `selective-vpn-gui/` - PySide6 GUI (`vpn_dashboard_qt.py`).
- `selective-vpn-gui/svpn_run_profile.py` - headless launcher used by profile shortcuts.
- `selective-vpn-web/` - Vite + React + TypeScript web prototype foundation (SPA, read-only at current stage).
Requirements (high level):
- Linux with `systemd`, `nftables`, `iproute2`, cgroup v2.

1166
docs/EXECUTION_TRACKER.md Normal file

File diff suppressed because it is too large Load Diff

54
docs/README.md Normal file
View File

@@ -0,0 +1,54 @@
# Selective VPN Dashboard — Документация
## О проекте
- Go-ядро (`selective-vpn-api/`) управляет nftables, policy routing, DNS, SmartDNS, VPN и trace. Всё взаимодействие идет через локальный REST/SSE сервер (`127.0.0.1:8080`).
- GUI (`selective-vpn-gui/`) использует `api_client.py` и `dashboard_controller.py`, не дублируя бизнес-логику.
- Задача текущего этапа — проверить ядро, задокументировать API и подготовить почву для будущего веб-прототипа.
- Архитектурный ориентир: один API-контракт для всех клиентов (`web + iOS + Android`) без развилки бизнес-логики в ядре.
- Принятое направление web prototype: `Vite + React + TypeScript` (SPA); `Next.js` не используется на MVP-этапе.
- Фокус текущего desktop-этапа: `SingBox` вкладка + `SingBox` Go API (`profiles/validate/render/apply`); `DNSTT/Phoenix` остаются backend-ready треком без UI-расширения на этом шаге.
## Структура фаз
- Phase A — аудит API, реестр маршрутов и подтверждение, что все операции раскрыты в Go.
- Phase B — проверка внешних зависимостей (nftables, systemd, cgroup, SmartDNS) и фиксация требований к привилегиям/состоянию.
- Phase C — анализ готовности к веб-доступу (binding, SSE, авторизация, CORS, long-running команды).
- Phase D — документирование результатов (матрицы, чеклистов, критериев готовности) и запуск multi-client подхода.
- Phase E — дизайн multi-client PBR: удобное управление несколькими transport-клиентами, изоляция маршрутов и anti-conflict защита.
- Integration Track — подключение внешних transport-клиентов (`sing-box`, `dnstt-client`, `phoenix->slipstream`) через единый API-контракт ядра.
## Быстрый доступ к артефактам
- `docs/phase-a/A1_API_AUDIT.md` — цели и критерии фазы A.
- `docs/phase-b/B1_CORE_VERIFICATION.md` — набор задач по ядру и зависимостям.
- `docs/phase-b/B2_RESOLVER_DIFF_AND_IMPROVEMENT_PLAN.md` — различия `system resolver` vs `sing-box DNS` и roadmap улучшений нашего resolver.
- `docs/phase-b/B4_RUNTIME_DEPENDENCIES_AND_PREFLIGHT.md` — разделение `go.mod` зависимостей и runtime/bin/service зависимостей + preflight-check скрипт.
- `docs/phase-b/B5_SINGBOX_TEMPLATE_MIGRATION_ROLLBACK_RUNBOOK.md` — runbook миграции `legacy singbox-*.service` -> `singbox@<id>.service` и аварийного rollback-плана.
- `docs/phase-c/C1_WEB_READINESS.md` — возможности и ограничения REST/SSE доступа.
- `docs/phase-c/C2_WEB_STACK_DECISION.md` — зафиксированное решение по веб-стеку (`Vite + React + TypeScript`) и условия для возможного перехода на `Next.js`.
- `docs/phase-d/D1_GO_READINESS_DOCS.md` — матрицы, чеклисты и тревоги перед веб-прототипом.
- `docs/phase-d/D4_PLATFORM_COMPATIBILITY_MATRIX.md` — совместимость transport-контракта для `web + iOS + Android` и платформенные ограничения runtime.
- `docs/phase-d/D5_NETNS_RUNTIME_CASE.md` — готовый netns-case для `SingBox` (runtime-check, exec-mode, refactor map GUI/API).
- `docs/phase-e/E1_MULTI_CLIENT_PBR_DESIGN.md` — целевая архитектура multi-client маршрутизации, mark/table allocator, conflict-guardrails и UX-поток.
- `docs/phase-e/E2_TRANSPORT_API_CONTRACT.md` — контракт `/api/v1/transport/*` с DTO, примерами запросов/ответов и workflow `validate -> apply`.
- `docs/phase-e/E4_VALIDATE_CONFIRM_APPLY_UX.md` — UX-сценарии предупреждений и подтверждения для конфликтного apply.
- `docs/phase-e/E5_SINGBOX_PROTOCOLS_REQUIREMENTS.md` — требования к вкладке протоколов `SingBox` и target Go API для `singbox profiles`.
- `docs/phase-e/E5_2_SINGBOX_DESKTOP_DASHBOARD_SPEC.md` — зафиксированный desktop-дизайн вкладки `SingBox`: runtime card + profile settings + global defaults.
- `docs/phase-f/F1_REFACTOR_MODULARITY_PLAN.md` — план декомпозиции крупных файлов GUI/API/Go без изменения поведения.
- `docs/EXECUTION_TRACKER.md` — статус фаз и текущие шаги.
- `tests/` — smoke-скрипты для API (sanity, SSE, VPN login, trace append) + общий запуск через `tests/run_all.sh`.
- `selective-vpn-api/cmd/` — явные Go entrypoints (`selective-vpn-api`, `selective-vpn-routes-update`, `selective-vpn-routes-clear`, `selective-vpn-autoloop`), legacy root `main.go` сохранён для совместимости.
- `selective-vpn-api/app/cli/` и `selective-vpn-api/app/bootstrap/` — вынесенные runtime-раннеры (CLI и HTTP bootstrap) при сохранении фасадов `Run*` в `app`.
- `selective-vpn-api/app/transporttoken/` — вынесенный confirm-token store для transport policy apply/force-override lifecycle.
- `selective-vpn-gui/api/` — пакетизированный API-клиент GUI (base `client.py` + domain mixin-модули `status/routes/traffic/dns/domains/vpn/trace/transport_*`) с сохранением legacy-фасада `api_client.py`.
- `selective-vpn-gui/controllers/` — пакетизированный слой `DashboardController` (domain mixin-модули + `views.py`), при этом `dashboard_controller.py` сохранён как facade для совместимости импорта в UI.
- `selective-vpn-gui/main_window/` — модульные части GUI-окна (`constants`, `workers`, `ui_shell_mixin`, `ui_tabs_*`, `ui_tabs_singbox_{layout,editor}`, `runtime_actions_mixin`, `runtime_{state,refresh,auth,ops}`) и подпакет `main_window/singbox/*` (`editor/cards/links/runtime` + split `links_*`/`runtime_*`) при сохранении `vpn_dashboard_qt.py` как thin-bootstrap/wiring слоя.
- `scripts/transport_runbook.py` — операционный helper для lifecycle transport-клиентов через API (`create/provision/start/health/metrics/restart/stop/delete`).
- `scripts/transport_recovery_runbook.py` — runbook восстановления transport-клиента (`health -> restart -> provision/start fallback -> diagnostics`).
- `scripts/check_runtime_dependencies.sh` — preflight-check runtime зависимостей среды (`required/optional`, `--strict` режим).
- `scripts/transport-packaging/` — manual+pinned updater (`update.sh`), opt-in scheduler (`auto_update.sh`) и rollback (`rollback.sh`) для companion-бинарей (`runtime_mode=exec`), включая `manifest.production.json` и `source_policy.production.json`.
- `selective-vpn-web/` — web foundation (`Vite + React + TypeScript`) для будущего control-plane UI.
## Как использовать план
1. Следите за статусом фаз в `docs/EXECUTION_TRACKER.md`.
2. Обновляйте соответствующие `phase-*` документы результатами проверок (замены `[~]` на `[x]`).
3. После окончания фаз AD приступить к прототипу веб-интерфейса, опираясь на собранную матрицу endpoint → handler.
4. Интеграционный трек выполнять параллельно с завершением ядра: новые клиенты подключаются через backend-адаптеры, UI остаётся тонким слоем вызовов API.

View File

@@ -0,0 +1,144 @@
# A1 Аудит API и HTTP-маршрутов
Дата: 2026-02-27
Статус: draft
Владелец: Engineering
## 1) Цель
- Подтвердить, что все пользовательские сценарии (статусы, смена режимов, управление маршрутами, DNS, VPN, SmartDNS, trace) реализованы в Go-ядре и доступны через единую REST-SSE оболочку.
- Зафиксировать текущую структуру `/api/v1/*`, SSE `/api/v1/events/stream` и CLI-режимов (`routes-update`, `routes-clear`, `autoloop`) как основу для будущего веб-интерфейса.
## 1.1) Факт по коду (точный baseline)
- Источник: `selective-vpn-api/app/server.go`.
- Зарегистрировано `61` `mux.HandleFunc(...)`.
- Из них:
- `60` endpoint под `/api/v1/*`.
- `1` сервисный endpoint `/healthz`.
- CLI режимы в `Run()`: `routes-update`, `routes-clear`, `autoloop`.
- Изменение от 2026-03-07: в transport добавлены `8` base route (`/api/v1/transport/*`) и action-subpath `GET /api/v1/transport/clients/{id}/metrics`.
## 2) Критерии завершения
- Таблица endpoint → handler-файл / CLI mode составлена и вынесена в документ (см. Phase A).
- Подтверждено, что GUI не содержит альтернативных реализаций бизнес-логики (всё идет через Go API).
- Описаны ответы (CmdResult, dataclasses) и форматы, которые нужно сохранять для веба.
## 3) Задачи
- Выписать все маршруты из `selective-vpn-api/app/server.go` и сопоставить их с файлами-обработчиками (`routes_handlers.go`, `traffic_mode.go`, `dns_settings.go`, `vpn_handlers.go`, `smartdns_runtime.go`, `trace_handlers.go`, `traffic_appmarks.go` и др.).
- Отметить CLI-флаги и специальные пути (`/routes/update`, `/routes/timer`, `/routes/rollback`, trace, SmartDNS, VPN login) и их привязку к Go-механикам.
- Проверить `selective-vpn-gui/api_client.py` на факт наличия всех URL/методов/форматов и зафиксировать зависимости.
- Подготовить краткое описание (для фазы A) о том, какие маршруты уже готовы к прямому вызову чере веб (без правок).
## 4) Состояние (черновик таблицы)
| Endpoint | HTTP | Handler file | Краткая логика | Требования |
| --- | --- | --- | --- | --- |
| `/api/v1/status`, `/api/v1/routes/status` | GET | `routes_handlers.go` | Чтение `status.json`, вычисление policy-route через nftables | Файл `status.json`, доступ к nftables |
| `/api/v1/routes/service[*]` | POST | `routes_handlers.go` | `systemctl start|stop|restart` service unit | systemd unit name, root или capability |
| `/api/v1/routes/update` | POST | `routes_handlers.go``routes_update.go` | Сообщает о ручном апдейте через Go-реализацию вместо bash → обновляет set и policy | NFT/state dirs, `stateDir`, `preferredIface` |
| `/api/v1/routes/timer`, `/routes/timer/toggle` | GET/POST | `routes_handlers.go` | Чтение/запись enable flag для таймера, использует `runRoutesTimerSet` | Файл таймера, systemd |
| `/api/v1/traffic/mode`, `/traffic/mode/test` | GET/POST | `traffic_mode.go` | Переключает режимы Selective/Full/Direct, включает auto bypass, проверка `TrafficModeState` | Состояние в `stateDir`, доступ к nftables/systemd |
| `/api/v1/dns-upstreams`, `/api/v1/dns/mode`, `/api/v1/dns/benchmark` | GET/POST | `dns_settings.go` | Управление DNS upstreams, режимом и benchmark (проверка резольвера) | `resolv.conf`, `dnsmaestro`, `SmartDNS` |
| `/api/v1/vpn/status`, `/api/v1/vpn/autoloop*` | GET/POST | `vpn_handlers.go` | Статусы VPN, управление autoloop/autoconnect (через AdGuard VPN API) | Доступ к AdGuard VPN API, PTY |
| `/api/v1/events/stream` | GET SSE | `events_handlers.go` / `events_bus.go` | SSE-поток статусов и логов для GUI | Доступ к event bus, `ctx` |
| `/api/v1/trace`, `/api/v1/trace-json`, `/api/v1/trace/append` | GET/POST | `trace_handlers.go` | tail/JSON/append лог-файла `trace.log` | Доступ к `trace.log`, root |
| `/api/v1/traffic/advanced/reset` | POST | `traffic_mode.go` | Сброс расширенных bypass-опций (auto-local, ingress) | Доступ к traffic state |
| `/api/v1/traffic/interfaces` | GET | `traffic_mode.go` | Список интерфейсов и предпочитаемый iface | Просмотр `iproute2` interfaces |
| `/api/v1/traffic/candidates` | GET | `traffic_candidates.go` | Выборка потенциальных подсетей для bypass | `nft`/routing discovery |
| `/api/v1/traffic/appmarks` | POST | `traffic_appmarks.go` | Управление cgroup mark (vpn/direct) | `cgroup v2`, systemd scopes |
| `/api/v1/traffic/appmarks/items` | GET | `traffic_appmarks.go` | Список runtime marks для UI | `cgroup v2` info |
| `/api/v1/traffic/app-profiles` | GET/POST/DELETE | `traffic_app_profiles.go` | CRUD профилей запуска приложений | Хранение в `stateDir` |
| `/api/v1/traffic/audit` | GET | `traffic_audit.go` | Проверки консистентности nft/route | Доступ к nft\logs |
| `/api/v1/vpn/autoloop-status` | GET | `vpn_handlers.go` | Статус autoloop watcher | AdGuard VPN API |
| `/api/v1/vpn/status` | GET | `vpn_handlers.go` | Общий статус VPN/traffic | AdGuard VPN API |
| `/api/v1/vpn/autoconnect` | POST | `vpn_handlers.go` | Переключение autoconnect | AdGuard VPN API |
| `/api/v1/vpn/locations` | GET | `vpn_handlers.go` | Список стран/регионов | AdGuard VPN API (locations) |
| `/api/v1/vpn/location` | POST | `vpn_handlers.go` | Установка нового location | AdGuard VPN API |
| `/api/v1/vpn/login/session/*` | POST/GET | `vpn_login_session.go` | Установка интерактивной PTY сессии, проверки, действия | PTY, systemd user session |
| `/api/v1/vpn/logout` | POST | `vpn_handlers.go` | Выход VPN | AdGuard VPN API |
| `/api/v1/dns/upstream-pool` | GET/POST | `dns_settings.go` | pool upstreams (итоги) | `dns_upstreams` state |
| `/api/v1/dns/status` | GET | `dns_settings.go` | Текущий upstreams и режим | `resolver` состояние |
| `/api/v1/dns/smartdns-service` | POST | `dns_settings.go` | Старт/стоп SmartDNS сервис | `smartdns` бинарь, systemd unit |
| `/api/v1/smartdns/service` | POST | `smartdns_runtime.go` | Управление SmartDNS runtime | systemd + config |
| `/api/v1/smartdns/runtime` | GET | `smartdns_runtime.go` | Получение runtime stats | СмартДНС статус |
| `/api/v1/smartdns/prewarm` | POST | `smartdns_runtime.go` | Прогрев wildcard-базы | smartdns runtime |
| `/api/v1/domains/table` | GET/POST | `domains_handlers.go` | Загрузка/сохранение таблицы доменов | Файл доменов |
| `/api/v1/domains/file` | GET | `domains_handlers.go` | Скачать raw файл доменов | Доступ к `domains.json` |
| `/api/v1/smartdns/wildcards` | GET/POST | `smartdns_wildcards_store.go` | CRUD wildcard-списка | `wildcards.json` |
Это рабочая матрица для ключевых сценариев. Полный реестр endpointов из `server.go` приведён ниже.
## 5) GUI → API
- `selective-vpn-gui/api_client.py` инкапсулирует все URL/методы/JSON-форматы (например, `TrafficModeStatus`, `TrafficAppMarkItem`, `CmdResult`), поэтому при замене фронта достаточно реализовать аналогичные dataclasses в новой веб-части.
- `dashboard_controller.py` использует эти модели, подписывается на SSE и отображает текущий статус/trace. Нужно подтвердить, что в GUI нет logic-branch (например, расчёт маршрутов), которая должна перейти в веб отдельно.
- Задача анализа: пробежать по `api_client.py` и `dashboard_controller.py` и отметить все вызовы; в Phase D перенести их в колонку “UI usage” для проверки совместимости.
## 6) CLI-режимы и API parity
- `routes-update` / `selective-vpn-api routes-update` — запускает ту же логику, что и `POST /api/v1/routes/update`, но без HTTP-запросов; используется для cron/автообновления.
- `routes-clear` / `/api/v1/routes/rollback` — очищает nftables/statestatus; веб должен предоставить rollback-кнопку, чтобы совпадал функционал CLI.
- `autoloop` — опрашивает AdGuard VPN API и пишет `autoloop.log`; веб потребляет статус через `/api/v1/vpn/autoloop-status` и SSE события watchers.
## 7) Полный реестр endpoint (из `server.go`)
- `/healthz`
- `/api/v1/events/stream`
- `/api/v1/status`
- `/api/v1/routes/status`
- `/api/v1/vpn/login-state`
- `/api/v1/systemd/state`
- `/api/v1/routes/service/start`
- `/api/v1/routes/service/stop`
- `/api/v1/routes/service/restart`
- `/api/v1/routes/service`
- `/api/v1/routes/update`
- `/api/v1/routes/timer`
- `/api/v1/routes/timer/toggle`
- `/api/v1/routes/rollback`
- `/api/v1/routes/clear`
- `/api/v1/routes/cache/restore`
- `/api/v1/routes/precheck/debug`
- `/api/v1/routes/fix-policy-route`
- `/api/v1/routes/fix-policy`
- `/api/v1/traffic/mode`
- `/api/v1/traffic/mode/test`
- `/api/v1/traffic/advanced/reset`
- `/api/v1/traffic/interfaces`
- `/api/v1/traffic/candidates`
- `/api/v1/traffic/appmarks`
- `/api/v1/traffic/appmarks/items`
- `/api/v1/traffic/app-profiles`
- `/api/v1/traffic/audit`
- `/api/v1/trace`
- `/api/v1/trace-json`
- `/api/v1/trace/append`
- `/api/v1/dns-upstreams`
- `/api/v1/dns/upstream-pool`
- `/api/v1/dns/status`
- `/api/v1/dns/mode`
- `/api/v1/dns/benchmark`
- `/api/v1/dns/smartdns-service`
- `/api/v1/smartdns/service`
- `/api/v1/smartdns/runtime`
- `/api/v1/smartdns/prewarm`
- `/api/v1/domains/table`
- `/api/v1/domains/file`
- `/api/v1/smartdns/wildcards`
- `/api/v1/vpn/autoloop-status`
- `/api/v1/vpn/status`
- `/api/v1/vpn/autoconnect`
- `/api/v1/vpn/locations`
- `/api/v1/vpn/location`
- `/api/v1/vpn/login/session/start`
- `/api/v1/vpn/login/session/state`
- `/api/v1/vpn/login/session/action`
- `/api/v1/vpn/login/session/stop`
- `/api/v1/vpn/logout`
- `/api/v1/transport/clients`
- `/api/v1/transport/clients/{id}`
- `/api/v1/transport/clients/{id}/start`
- `/api/v1/transport/clients/{id}/stop`
- `/api/v1/transport/clients/{id}/restart`
- `/api/v1/transport/clients/{id}/health`
- `/api/v1/transport/policies`
- `/api/v1/transport/policies/validate`
- `/api/v1/transport/policies/apply`
- `/api/v1/transport/policies/rollback`
- `/api/v1/transport/conflicts`
- `/api/v1/transport/capabilities`

View File

@@ -0,0 +1,100 @@
# B1 Проверка ядра и внешних зависимостей
Дата: 2026-02-27
Статус: draft
Владелец: Engineering
## 1) Цель
- Убедиться, что Go-ядро имеет централизованную логику для работы с nftables, policy routing, systemd, cgroup и SmartDNS, и что эти зависимости отражены в конфигурациях/документации.
- Выявить потенциальные отверстия (например, части логики, которые напрямую обращаются к shell-скриптам `routes_update`, `autoloop`, `trace`) перед тем, как стартовать задачи для веба.
## 2) Критерии завершения
- Описание работы ключевых модулей ({`routes_update.go`, `traffic_mode.go`, `events_bus.go`, `watchers.go`, `resolver.go`, `smartdns_runtime.go`, `vpn_handlers.go`}) подготовлено, включая внешние зависимости (nftables, SmartDNS, systemd, AdGuard VPN API).
- Сформулирован список требований (root, обязательные сервисы, долгие операции, возможные блокировки) на случай запуска через веб-консоль.
- Уточнено, какие данные уже сохраняются в `stateDir`, `appmark` и `routes` файлах, чтобы веб мог отображать информацию без дополнительного API.
## 3) Задачи
- Пройти `routes_update.go`, `routes_units.go`, `routes_cache.go`, `nft_update.go` и описать, как обновляются маршруты и кэшируются состояния.
- Проанализировать `traffic_mode.go`, `traffic_appmarks.go`, `traffic_app_profiles.go`, `traffic_audit.go`: что именно меняют (fwmark, cgroup, nft sets) и какие проверки выполняются.
- Прописать взаимодействие с DNS/SmartDNS (`dns_settings.go`, `smartdns_runtime.go`, `smartdns_wildcards_store.go`, `resolver.go`), включая init/restore/caching жизненный цикл.
- Проверить `watchers.go`, `events_bus.go`, `trace_handlers.go`: какие события стримятся, какие веб-интерфейсы они смогут потреблять, и нужны ли дополнительные фильтры или буферизация.
- Отметить, какие части кода требуют root-доступ и как это влияет на веб (например, API будет работать под сервис-аккаунтом и выполнять nft/update через internal API, но сама служба должна запускаться с нужными привилегиями).
- Собрать стартовый список фактов по каждому модулю: с какими `systemd` unit'ами, `nft` set'ами, SmartDNS/AdGuard VPN API и `stateDir` работает, и какие ограничения это накладывает.
- Задокументировать потенциальные точки отказа (недоступность nftables/systemd, SmartDNS/AdGuard API) и предложить стратегию контроля ошибок для веб-интерфейса.
## 4) Модули и зависимости
### `routes_update.go`
- Основной контроллер обновления маршрутов: строит policy routes, nftables-цепочки `agvpn`/`agvpn4`/`agvpn_dyn4`, запускает `runResolverJob`, пишет список доменов/IP в `stateDir` и сохраняет `status.json`.
- Поддерживает прогресс через `events.push("routes_nft_progress", ...)`, пишет heartbeat-файлы и требует доступа к `iproute2`, `nft`, `stateDir`, `trace.log` и воркеру резольвера.
- Обрабатывает `force`/`auto` конфигурации из `TrafficModeState` и фиксирует `trafficEval` в статусе, что важно для отображения в вебе.
### `routes_units.go`, `routes_cache.go`, `nft_update.go`
- `routes_units.go` разрешает `systemd` имя (`routesServiceUnitName`, `routesTimerUnitName`) и используется в `/routes/service`/`timer`.
- `routes_cache.go` кеширует `domains`, `ips`, `domains` state; можно использовать для расчёта прогресса и восстановления.
- `nft_update.go` держит “умный” апдейтер, который управляет правилами и sets; важно документировать, какие команды выполняются и как проверяется успех (`runNFTUpdate`, `progressCb`) для веба.
### `traffic_mode.go` + `traffic_appmarks*`
- Управляют состоянием (`stateDir/state-traffic-mode.json`), применяют флаги fwmark и policy rules (`applyTrafficMode`), читают/пишут список принудительных субнетов/UID/cgroup.
- `traffic_appmarks.go` работает с cgroup v2, создаёт маркеры `MARK_APP`/`MARK_DIRECT` и задаёт TTL (через systemd scopes) для runtime управления per-app traffic.
- `traffic_app_profiles.go` хранит профили в `stateDir` и предоставляет CRUD, что пригодится вебу для создания shortcut-профилей.
- `traffic_audit.go` проверяет состояние nft/route и формирует `TrafficAudit` issues, полезные для мониторинга.
### `dns_settings.go` + `resolver.go`
- Хранит конфигурацию `dns-upstreams.conf`, режим (`dns_mode.json`) и pool upstreams; отвечает за benchmark, SmartDNS control (`smartdns` service start/stop) и режим `ViaSmartDNS`.
- `resolver.go` запускает Go-резольвер, читает `domains.txt`, `meta-special.txt`, `static-ips.txt`, использует кеши (`domain-cache.json`, `ptr-cache.json`) и пишет результат в `stateDir`.
- Сервисы SmartDNS (`smartdns_runtime.go`, `smartdns_wildcards_store.go`) управляют дополнительными wildcard-базами, runtime stats и prewarm, что потребует отображения статуса SmartDNS в веб.
### `vpn_handlers.go` + `vpn_login_session.go`
- Интеграция с AdGuard VPN API: `autoloop`, `autoconnect`, `locations`, `set location`, `logout` и status založený na HTTP-запросах к локальному `adguardvpn` сервису.
- `vpn_login_session.go` создаёт PTY-сессию (через `runCommand` + `systemd-run --user`?), сохраняет состояния в `loginStatePath` и выпускает события (`events.push`) для SSE; вебу потребуется поддерживать эти пользователи.
### `events_bus.go`, `watchers.go`, `trace_handlers.go`
- `startWatchers` запускает наблюдение за `status.json`, `loginState`, autoloop логом, `trace.log`, state traffic appmarks TTL и systemd unitами (routes service/timer, VPN unit, SmartDNS).
- Все watcher-изменения отправляются в `events` и поступают клиенту через `handleEventsStream`, что даёт вебу источник realtime данных.
- `trace_handlers.go` читает `trace.log`, `trace-json` и принимает append, что позволяет вебу показывать live trace и записывать дополнительные строки.
### `routes_handlers.go`
- Управление systemd-unit'ами (`routes_service`, `routes_timer`), ручной rollback/clear, fixing policy route, переключение режимов/advanced config.
- Служит фасадом для CLI (как `routes-update`, `routes-clear`, `autoloop`), следовательно API может использоваться как `POST /api/v1/routes/service` и `POST /api/v1/routes/update`.
### Корневые ограничения и привилегии
- `routes_update`/`routes_handlers` запускаются как root (nft, ip, systemctl); веб-интерфейс должен вызывать API, а не повторять команды, сохраняя сервис privileged.
- SmartDNS требует запуска `smartdns-local.service`; VPN команды ждут доступ к AdGuard VPN (возможно, `adguardvpn.service`).
- `stateDir` и `domains` файлы должны быть доступны API, а вебу важно понять, какие endpoints кешируют/читают эти файлы. Например, `routes timer enable` хранится в `routes_timer_state.json`.
- Потенциальные точки отказа:
- `nft` команды падут, если kernel не поддерживает nftables или service не запущен — API должен возвращать `CmdResult` с `stderr` и `exitCode`.
- `systemd` unit может быть недоступна (не установлен `routes-service`), мониторинг через watchers должен отправлять `unit_state_changed`.
- SmartDNS runtime может не стартовать: нужно отразить ошибку через `events.push("smartdns_error"... )` и вернуть `CmdResult.ok=false`.
- AdGuard VPN API или PTY могут быть недоступны; API должен возвращать ошибки и веб должен приступать к повторной попытке.
## 5) Матрица зависимостей
| Модуль | http/cli | Зависимости | Состояния/файлы | Потенциальные ошибки |
| --- | --- | --- | --- | --- |
| `events_bus.go`, `watchers.go`, `events_handlers.go` | `/api/v1/events/stream` | in-memory queue, watcher goroutines | `status.json`, `login_state.json`, `trace.log`, systemd unit polling, `autoloop.log` | SSE disconnected, poll errors, buffer overflow |
| `routes_update.go` | `POST /api/v1/routes/update`, CLI `routes-update` | `iproute2`, `nft`, `runResolverJob`, `SmartDNS` | `stateDir/{domains,ips}*`, `status.json`, `trace.log`, `heartbeat` | Нет VPN-интерфейса, резольвер падает, `nft` пишет ошибки |
| `routes_units.go` | `/api/v1/routes/service`, `/routes/timer`, CLI `routes-clear` | `systemd` (service/timer names) | `routes_timer_state.json`, unit name env | Unit не установлена, systemctl возвращает error |
| `routes_cache.go`, `nft_update.go` | внутренняя логика routes update | `nft`, temp files | `/var/run/selective-vpn` temp files | `nft` прогресс в stderr, temp-файлы не создаются |
| `traffic_mode.go` + `traffic_appmarks*` | `/api/v1/traffic/*` | `nft`, policy routing, `cgroup v2`, `systemd` | `stateDir/traffic-mode.json`, `appmarks.json`, cgroup scopes | Некорректная конфигурация, `nft` не применяет правила |
| `traffic_app_profiles.go` | `/api/v1/traffic/app-profiles` | файловые операции | `stateDir/app-profiles.json` | Файл повреждён, не сохраняется |
| `dns_settings.go` + `resolver.go` | `/api/v1/dns-*` | `resolv.conf`, SmartDNS binary | `dns-upstreams.conf`, `dns_mode.json`, `domain-cache.json`, `ptr-cache.json` | Нет upstream, benchmark таймаут |
| `smartdns_runtime.go`, `smartdns_wildcards_store.go` | `/api/v1/smartdns/*` | `smartdns-local.service`, wildcard files | `smartdns.conf`, `wildcards.json` | Service fail, wildcard file read-only |
| `vpn_handlers.go`, `vpn_login_session.go` | `/api/v1/vpn/*` | AdGuard VPN API, PTY, systemd-user | `login_state.json`, `autoloop.log` | API недоступен, PTY не стартует |
| `trace_handlers.go` | `/api/v1/trace*` | доступ к `trace.log` | `trace.log` | Файл недоступен, append write error |
Эта матрица показывает, какие компоненты требуют привилегий и как ошибка отражается на UI. В Phase D можно перенести эти строки в таблицу `web-ready` и отметить `status` (готово/требует/blocked).
### Технические state-файлы и кеши
- `stateDir` (по умолчанию `/var/lib/selective-vpn`) содержит:
- `domains.txt`, `ips-*.txt`, `ipmap-*.txt` — сериализация выходных данных resolver, используются trace/expand logic.
- `state-traffic-mode.json`, `appmarks.json`, `app-profiles.json` — основные состояния traffic mode/appmarks/profiles, читаются `traffic_mode.go`, `traffic_appmarks.go`, `traffic_app_profiles.go`.
- `domain-cache.json`, `ptr-cache.json` — кеш resolver, обновляется из `runResolverJob` и влияет на `routes_update` (limit/ttl).
- `status.json`, `heartbeat` — пишутся `routes_update`. `watchStatusFile` читает file, `events.push("status_changed")` доставляет web info (`iface`, `table`, `healthy`).
- `login_state.json` — создаётся `vpn_login_session.go`, SSE `login_state_changed` отражает current state; при `CmdResult.ok=false` пишется `error` в file/event.
- `trace.log` — задействован `trace_handlers.go` и `watchFileChange`, SSE `trace_changed` парсит tail. `trace_append` endpoint пишет новые строки (CmdResult). Web должна ограничить size (<=1<<20) и показывать stderr/exitCode.
### Watchers и события
- `watchStatusFile`, `watchLoginFile`, `watchAutoloop`, `watchTrafficAppMarksTTL`, `watchSystemdUnit*` запускаются на `startWatchers` и публикуют события (`status_changed`, `login_state_changed`, `autoloop_status_changed`, `unit_state_changed`, `appmarks_ttl`).
- `events_bus` буферизует последние `SVPN_EVENTS_CAP` событий (config via env). SSE `events/stream` читает их `since`/`Last-Event-ID`. При ошибок (blocked mutex, dropped events) нужно логировать `selective-vpn-api` и отображать `status_error`.
- `events.push` используется для `routes_nft_progress`, `smartdns`, `trace_changed`. Вебу важно знать ключи, чтобы фильтровать события по статус/trace/health.

View File

@@ -0,0 +1,76 @@
# B2 Resolver: разница ролей и план улучшения
Дата: 2026-03-07
Статус: planned
Владелец: Engineering
## 1) Цель
- Зафиксировать, чем отличается текущий системный resolver selective-vpn от DNS-модуля `sing-box`.
- Зафиксировать roadmap доработки нашего resolver, чтобы он оставался основой для `selective`-маршрутизации.
## 2) Роли компонентов
- `System resolver` (Go + SmartDNS + nftset):
- источник истины для PBR (`routes/update`, nft sets, wildcard/static fallback),
- системный контроль резолва для selective-режима.
- `SingBox DNS`:
- DNS-часть transport-профиля `sing-box`,
- управляет резолвом внутри конкретного engine/profile.
Правило слоя:
- В текущей архитектуре authoritative для маршрутизации остаётся системный resolver.
- `SingBox DNS` используется как transport-level capability и не заменяет системный PBR resolver.
## 3) Матрица различий
| Область | System resolver (текущий) | SingBox DNS |
| --- | --- | --- |
| Основная роль | Системная selective-маршрутизация | DNS внутри transport engine |
| Связь с nft/PBR | Прямая (`agvpn4/agvpn_dyn4`, `routes/update`) | Непрямая, через поведение engine |
| Wildcard runtime | Да (SmartDNS nftset + merge в `routes/update`) | Нет прямого контроля системных nft sets |
| Static fallback | Да (`static-ips.txt`, policy merge) | Через raw/typed config профиля |
| Единая картина для всех клиентов | Да, через Go state/API | Нет, scoped к конкретному профилю |
| Риск рассинхрона | Низкий при single source of truth | Высокий, если использовать как отдельный source of truth |
## 4) Основные gap-ы текущего resolver
- Недостаточная наблюдаемость по качеству резолва (latency/error-rate/NX/servfail per upstream).
- Нет отдельного endpoint'а с полным health snapshot resolver pipeline.
- Нужна более строгая TTL/refresh-стратегия для mixed источников (cache + wildcard runtime + static).
- Нужен формализованный negative caching и rate-limit на проблемные домены при деградации upstream.
- Нужна формализация owner-map для доменов в multi-client сценариях (без дублей и гонок).
## 5) План улучшения resolver (приоритеты)
### R1 Надёжность и консистентность
- Ввести unified resolver state snapshot (`updated_at`, `stale`, `refresh_in_progress`, `last_error`, `next_retry_at`, counters).
- Усилить single-flight на expensive refresh-path и добавить доменный backoff.
- Нормализовать merge-порядок источников: `static fallback -> wildcard runtime -> resolver cache`.
### R2 Качество резолва
- Добавить adaptive upstream scoring (latency/success/NX/timeout/servfail).
- Добавить sticky-preferred upstream с безопасным failover.
- Ввести отдельную политику negative cache TTL для NXDOMAIN/SERVFAIL.
### R3 Наблюдаемость и API
- Добавить API snapshot для resolver health:
- `GET /api/v1/resolver/status` (target),
- `POST /api/v1/resolver/refresh` (target trigger),
- `GET /api/v1/resolver/upstreams` (target metrics).
- Добавить SSE-события:
- `resolver_status_changed`,
- `resolver_upstream_degraded`,
- `resolver_refresh_completed`.
### R4 Multi-client безопасность
- Ввести `domain_owner_map` и conflict-detection до применения policy.
- Гарантировать, что один domain-selector не получает двух владельцев без explicit override.
- Синхронизировать resolver ownership с transport policy revision.
## 6) Критерии готовности B2
- Есть документированная и реализуемая разница ролей `system resolver` vs `sing-box DNS`.
- Resolver имеет прозрачный health/status API и наблюдаемость по upstream.
- При деградации upstream UI получает stale-safe состояние, а не "пустой провал".
- В multi-client режиме домены не уходят в двойное владение без подтверждённого override.
## 7) Ограничения этапа
- В этом этапе не переводим управление маршрутизацией на `SingBox DNS`.
- `DNSTT/Phoenix` DNS-аспекты не расширяются в UI до завершения `SingBox` API/GUI трека.

View File

@@ -0,0 +1,50 @@
# B4 Runtime Dependencies And Preflight
## Важно
- `go.mod` хранит только Go-модули (библиотеки, которые импортируются в коде).
- Внешние сервисы/бинарники (`systemd`, `nft`, `ip`, `sing-box`, `dnstt-client`, `phoenix-client`) не являются Go-модулями и не должны фиксироваться в `go.mod`.
## Текущие Go-зависимости (по `go.mod`)
- `github.com/cenkalti/backoff/v4`
- `github.com/creack/pty`
## Runtime-зависимости ядра (вне go.mod)
### Required (core path)
- `systemctl`
- `nft`
- `ip`
- `curl`
- `/usr/local/bin/adguardvpn-cli-root`
### Required service units (current production path)
- `singbox@.service`
### Recommended / optional
- `nsenter` (предпочтительный exec-mode для netns)
- `wget` (fallback для части egress probe)
- `ps`
- `ipset`
### Optional by enabled transport kind
- `sing-box` (`/usr/local/bin/sing-box` или `/usr/bin/sing-box`)
- `dnstt-client` (`/usr/local/bin/dnstt-client` или `/usr/bin/dnstt-client`)
- `phoenix-client` (`/usr/local/bin/phoenix-client` или `/usr/bin/phoenix-client`)
### Optional service units (зависят от deployment-профиля)
- `adguardvpn-autoconnect.service`
- `smartdns-local.service`
- `selective-vpn2@.service`
- `sing-box.service` (legacy/compat)
- `dnstt-client.service`
- `phoenix-client.service`
## Preflight-check
- Скрипт: `scripts/check_runtime_dependencies.sh`
- Режимы:
- `scripts/check_runtime_dependencies.sh` — проверка required + warning по optional.
- `scripts/check_runtime_dependencies.sh --strict` — fail если есть missing/warning.
## Зачем это перед рефакторингом
- Убираем смешение понятий: Go-зависимости отдельно, runtime/system-зависимости отдельно.
- Перед декомпозицией (`F1.*`) быстро проверяем среду и снижаем ложные регрессии.

View File

@@ -0,0 +1,61 @@
# B5 SingBox Template Migration And Rollback Runbook
## Цель
Операционный runbook для миграции `legacy singbox-<id>.service` к template-модели `singbox@<id>.service` и для аварийного отката при инциденте деплоя.
## Контекст текущей модели
- Целевой runtime: `singbox@.service` + per-instance drop-in `singbox@<id>.service.d/10-selective-vpn.conf`.
- One-shot миграция legacy unit выполняется автоматически в pre-action (`start/restart`) для `kind=singbox`.
- Safety guard: legacy unit обрабатывается только при ownership marker `Environment=SVPN_TRANSPORT_ID=<client_id>`.
## Preflight перед деплоем
1. Проверить runtime-зависимости:
- `scripts/check_runtime_dependencies.sh`
2. Проверить strict-режим (опционально для CI/релиза):
- `scripts/check_runtime_dependencies.sh --strict`
3. Проверить наличие template unit:
- `systemctl list-unit-files 'singbox@.service' --no-legend`
## Управление миграцией
- Отключить авто-миграцию для профиля:
- `config.singbox_legacy_unit_migrate=false`
- Включить dry-run без изменений unit-файлов:
- `config.singbox_legacy_unit_migrate_dry_run=true`
- Поведение dry-run:
- backend пишет trace/stdout о планируемом `legacy -> template` переходе,
- `stop/disable/remove` не выполняются.
## Нормальный migrate-путь
1. Обновить API-бинарь и перезапустить `selective-vpn-api.service`.
2. На `start/restart` конкретного SingBox-клиента backend:
- валидирует ownership legacy unit,
- выполняет `stop + disable + remove` legacy unit,
- выполняет `daemon-reload + reset-failed` для legacy unit,
- запускает template instance `singbox@<id>.service`.
3. Проверить:
- `systemctl status 'singbox@<id>.service'`
- `systemctl list-unit-files 'singbox-*' --no-legend` (legacy не должен появляться как managed unit)
## Аварийный rollback (template -> legacy)
Важно: текущий прод-код нормализует SingBox clients к `config.unit=singbox@.service`, поэтому rollback делается на уровне release rollback (предыдущий API build + восстановление unit/state).
1. Зафиксировать инцидент и остановить изменения policy:
- временно не выполнять `start/restart/switch` через GUI/API.
2. Откатить API до предыдущего релиза (где legacy path поддерживался штатно).
3. Восстановить `transport-clients` state из backup/снапшота релиза (если в инциденте state был изменён).
4. Восстановить legacy unit-файлы `singbox-*.service` из backup среды (если удалены).
5. Выполнить:
- `systemctl daemon-reload`
- `systemctl reset-failed`
6. Поднять нужные legacy unit и проверить egress/health.
## Проверка после rollback
- `systemctl is-active selective-vpn-api.service`
- `systemctl is-active 'singbox-<id>.service'` (для rollback-релиза)
- API smoke:
- `curl -fsS http://127.0.0.1:8080/healthz`
- `curl -fsS http://127.0.0.1:8080/api/v1/transport/clients`
## Примечание
- Legacy `sing-box.service` остаётся только как compat-артефакт окружения.
- Production path для новых профилей: только `singbox@.service`.

View File

@@ -0,0 +1,72 @@
# C1 Подготовка ядра к веб-совместимости
Дата: 2026-02-27
Статус: draft
Владелец: Engineering
## 1) Цель
- Зафиксировать, какие компоненты Go-ядра уже готовы к внешнему доступу (SSE, REST, локальный API) и что нужно изменить/документировать для будущего веб-прототипа.
- Убедиться, что веб-интерфейс сможет работать с ядром через ожидаемые договоренности (binding, CORS, авторизация, long-polling/SSE).
- Зафиксировать архитектурный принцип повторного использования одного API-контракта для `web + iOS + Android`, без форков бизнес-логики по платформам.
- Выбор UI-стека вынесен в `docs/phase-c/C2_WEB_STACK_DECISION.md` (принято: `Vite + React + TypeScript` для MVP).
## 2) Критерии завершения
- Описан binding (`127.0.0.1:8080`), механизм SSE (`/api/v1/events/stream`), стандартные ответы (e.g. `CmdResult`, dataclasses) и авторизация через `vpn_login_session`.
- Зафиксированы необходимые изменения инфраструктуры (proxy/unix socket, CORS/CSRF, дополнительная аутентификация или токены).
- Собраны критические endpointы для веба (routes service/update/rollback, traffic mode/appmarks/profiles, DNS/SmartDNS, VPN, trace) и их характеристики (async операции, payload, ответы).
- Для мобильных клиентов описаны условия transport/auth/retry (TLS, токены, refresh, polling fallback к SSE, device session control).
## 3) Задачи
- Подтвердить, что SSE поток (`events/stream`) предоставляет status/login/trace/autoloop updates и определить, какие события нужно визуализировать (policy route, unit state, autoloop, SmartDNS, routes progress).
- Задокументировать payload/методы/JSON форматы для ключевых endpointов (`routes/service`, `routes/update`, `traffic/mode`, `dns/benchmark`, `smartdns/prewarm`, `vpn/login/session`), чтобы новый фронт мог повторить вызовы.
- Определить ограничения текущего binding (`127.0.0.1:8080`) и описать безопасный путь для проксирования (unix socket + nginx/systemd socket/reverse proxy).
- Проработать механизм авторизации: как web UI вызывает `/api/v1/vpn/login/session/start`, получает `login_state`, делает action, и как `/api/v1/auth/status`/`login/session/state` поддерживают health.
- Уточнить, какие операции требуют async-статуса (например, `routes update`, `routes/service restart`) и как отображать прогресс (SSE event `routes_nft_progress`, `status_changed`).
- Задокументировать headers/CORS (`allowed origin`, `Authorization`, `Content-Type`, `X-Requested-With`, `GET/POST`) и сценарии CSRF/preflight при использовании cookie.
- Утвердить единый API-versioning подход (`/api/v1`) и правила обратной совместимости для всех клиентов (веб и мобильных).
- Зафиксировать контракт ошибок (`ok`, `message`, `exitCode`, `stderr`) и политику idempotency/retry для мобильных сетевых условий.
## 4) SSE, watchers, trace
- `events_bus.go` собирает события `{ID, Kind, Ts, Data}` и поддерживает replay по `Last-Event-ID`/`since`. Web должен подписаться на `status_changed`, `login_state_changed`, `routes_nft_progress`, `unit_state_changed`, `autoloop_status_changed` и восстанавливать соединение при reconnect.
- `watchers.go` poll-ит `status.json`, `login_state.json`, `trace.log`, `autoloop.log`, state traffic appmarks TTL и systemd unitы (`routes_service`, `routes_timer`, `vpn_unit`, `smartdns_unit`). Эти события идут в SSE и дают realtime-статус на UI (health, trace, unit status).
- `trace_handlers.go` предоставляет `GET /api/v1/trace` (tail), `/trace-json` (structured), `/trace/append`. Убедиться, что web ограничивает JSON size (1<<20) и отображает `CmdResult` (stdout/stderr) при append.
- Нужно описать, какие watcher-события и trace-последствия критичны для UI (policy route check, SmartDNS runtime, traffic appmarks TTL) и как SSE clients обрабатывают disconnect/replay.
## 5) Проксирование и безопасность
- API должен быть доступен через безопасный proxy (unix socket + nginx, systemd socket или reverse proxy) и/или на изолированном интерфейсе, чтобы не экспонировать `127.0.0.1:8080` наружу.
- Proxy configuration:
- Systemd socket unit (`selective-vpn-api.socket`) слушает `/run/selective-vpn.sock`; nginx проксирует `location /api/``proxy_pass http://unix:/run/selective-vpn.sock:`.
- В nginx включаете `proxy_buffering off`, `proxy_http_version 1.1`, `proxy_set_header Connection keep-alive`, `proxy_set_header Host $host`, `proxy_set_header X-Real-IP $remote_addr`, чтобы SSE `/api/v1/events/stream` работал без задержек.
- Альтернатива — nginx на localhost:8080 с firewall, разрешающий соединения только от веб-интерфейса; при этом добавляется `allow 127.0.0.1` и `deny all`.
- CORS/CSRF:
- `Access-Control-Allow-Origin: https://selective-ui.local` (или переменная окружения). `Access-Control-Allow-Credentials: true` при использовании cookie.
- `Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With`, `Access-Control-Allow-Methods: GET, POST`, `Access-Control-Expose-Headers: X-Request-Id`.
- Preflight (`OPTIONS`) обрабатывается proxy (nginx) или Go кодом, возвращая нужные заголовки и `204`.
- Авторизация:
- Web UI вызывает `POST /api/v1/vpn/login/session/start`, backend пишет `login_state.json` и возвращает `LoginState` (включая `state`/`msg`/`email`).
- Web сохраняет `login_state_id`, прикладывает его как `Authorization: Bearer $login_state_id` либо использует session cookie от proxy. Go middleware проверяет токен перед вызовом критичных endpointов.
- `/api/v1/auth/status` (или расширение `/api/v1/vpn/login/session/state`) сообщают, активна ли сессия; если нет, UI перенаправляет пользователя к login modal.
- `CmdResult` с `ok=false`, SSE `login_state_changed` или события `status_error` должна отображаться как alert и давать кнопку retry.
- Async операции и прогресс:
- `routes update` и `routes/service restart` занимают до 60 секунд. Web запускает POST `/api/v1/routes/update`, включает бесперебойный SSE и показывает progress bar, пока не придёт `CmdResult.ok=true` + event `routes_nft_progress`/`status_changed`.
- `dns/benchmark`, `smartdns/prewarm` и `traffic/mode/test` тоже могут длиться минуты — UI ожидает SSE `progress`/`status` и отключает повторные вызовы до завершения.
- Trace append (`/api/v1/trace/append`) возвращает `CmdResult`; UI показывает `stderr`/`exitCode` и ограничивает длину payload (<=1<<20).
## 6) Кросс-платформенная переиспользуемость (Web + iOS + Android)
- Backend-слой остается единым: бизнес-логика только в Go-ядре, клиенты используют одинаковые endpointы и payload-контракты.
- DTO/модели (`Status`, `CmdResult`, `TrafficModeStatus`, `LoginState`, `TrafficAppMarkItem`) считаются каноничными; изменение полей только с backward-compatible стратегией.
- Реaltime:
- Web: SSE как основной канал.
- Mobile: SSE при активном приложении + fallback polling для background/нестабильной сети.
- Авторизация:
- Веб-клиент может использовать cookie/token через proxy.
- Мобильные клиенты используют bearer access token + refresh token, хранят секреты в secure storage (iOS Keychain / Android Keystore).
- Надежность:
- Для mutating POST операций вводить `Idempotency-Key`, чтобы избежать дублей при ретраях на мобильной сети.
- Добавить request correlation (`X-Request-Id`) в ответы/логи для расследования инцидентов на всех клиентах.
- Транспорт:
- Только HTTPS, поддержка certificate pinning для мобильных приложений.
- Никаких прямых подключений к `127.0.0.1:8080` с клиентов; только через контролируемый gateway/proxy.
- Transport backends:
- Поддержать модель pluggable backend-клиентов (`sing-box`, `dnstt-client`, `phoenix->slipstream`) с единым API-контрактом ядра.
- UI-слой (web/iOS/Android) не должен зависеть от конкретного transport backend; различия покрываются capability endpoint/матрицей в Go.

View File

@@ -0,0 +1,37 @@
# C2 Решение по стеку web prototype
Дата: 2026-03-07
Статус: approved
Владелец: Engineering
## 1) Принятое решение
- Для web prototype используется `Vite + React + TypeScript` (SPA).
- Бэкенд-ядро остаётся в Go (`/api/v1` + SSE `/api/v1/events/stream`).
- `Next.js` на MVP-этапе не используется.
## 2) Почему так
- В проекте уже есть готовый Go API-контракт; отдельный Node/SSR-слой не обязателен.
- Цель текущего этапа: рабочая control-plane панель, а не SEO-публичный сайт.
- `Vite` даёт быстрый старт, простой билд и минимальные операционные риски.
## 3) Базовый frontend stack
- `react` + `typescript`
- `vite`
- `react-router`
- `@tanstack/react-query` для REST
- `EventSource` (SSE) для realtime
- lightweight UI state (например, `zustand`) только для локального состояния интерфейса
## 4) Когда рассматривать переход на Next.js
- Появляется требование SSR/SEO.
- Нужен встроенный BFF/edge middleware в самом фронтенд-проекте.
- Требуется server-side session orchestration, которую нецелесообразно держать в Go gateway.
## 5) Архитектурный инвариант
- Независимо от UI-фреймворка, источник истины остаётся в Go-ядре.
- UI не дублирует бизнес-логику маршрутизации/transport/policy.
- Контракт для web/iOS/Android остаётся единым: `/api/v1`.
## 6) Статус внедрения
- Создан модуль `selective-vpn-web/` (foundation level).
- На текущем этапе включены read-only проверки и SSE connectivity; mutating controls будут подключаться по фазам P1/E4.

View File

@@ -0,0 +1,184 @@
# D1 Документирование готовности ядра
Дата: 2026-03-02
Статус: in-progress
Владелец: Engineering
## 1) Цель
- Дать точный endpoint-level срез: что уже можно подключать в веб, что требует инфраструктурных условий, и где нужен отдельный UX-поток.
- Зафиксировать критерии перехода к веб-прототипу на базе фактического кода `selective-vpn-api`.
## 2) Легенда статусов
- `ready-read` — готово для веба как read endpoint.
- `ready-write` — готово для веба как write endpoint, но обязательно через auth/rbac/audit/proxy.
- `ready-async` — write endpoint готов, но требуется async UX (SSE progress/polling).
- `ready-interactive` — endpoint готов, но требует интерактивного flow (session state/action).
## 3) Глобальные условия для веба
- API должен ходить через proxy (unix socket + nginx/systemd socket) и не быть публично открыт напрямую на `127.0.0.1:8080`.
- Для всех mutating endpoints обязательны auth/rbac и аудит вызовов.
- Для SSE (`/api/v1/events/stream`) нужен корректный proxy режим (`proxy_buffering off`).
- Для async операций (`routes update`, `smartdns prewarm`, `dns benchmark`) веб должен слушать SSE/статусы и блокировать повторные конкурирующие действия.
- Архитектурный инвариант: один контракт `/api/v1` для `web + iOS + Android`, без платформенных форков backend-логики.
## 4) Матрица endpoint -> web-ready
| Endpoint | Method | Handler | Статус | Комментарий для веба |
| --- | --- | --- | --- | --- |
| `/healthz` | GET | `handleHealthz` | `ready-read` | Базовый liveness probe. |
| `/api/v1/events/stream` | GET | `handleEventsStream` | `ready-read` | SSE source для realtime UI. |
| `/api/v1/status` | GET | `handleGetStatus` | `ready-read` | Карточка статуса маршрутов. |
| `/api/v1/routes/status` | GET | `handleGetStatus` | `ready-read` | Алиас статуса. |
| `/api/v1/vpn/login-state` | GET | `handleVPNLoginState` | `ready-read` | Текущий state логина VPN. |
| `/api/v1/systemd/state` | GET | `handleSystemdState` | `ready-read` | Проверка unit state по query `unit`. |
| `/api/v1/routes/service/start` | POST | `makeRoutesServiceActionHandler("start")` | `ready-write` | Чувствительная операция systemd. |
| `/api/v1/routes/service/stop` | POST | `makeRoutesServiceActionHandler("stop")` | `ready-write` | Чувствительная операция systemd. |
| `/api/v1/routes/service/restart` | POST | `makeRoutesServiceActionHandler("restart")` | `ready-write` | Чувствительная операция systemd. |
| `/api/v1/routes/service` | POST | `handleRoutesService` | `ready-write` | Универсальный action endpoint. |
| `/api/v1/routes/update` | POST | `handleRoutesUpdate` | `ready-async` | Запуск async job; прогресс через SSE/events. |
| `/api/v1/routes/timer` | GET/POST | `handleRoutesTimer` | `ready-write` | GET состояние, POST переключение. |
| `/api/v1/routes/timer/toggle` | POST | `handleRoutesTimerToggle` | `ready-write` | Legacy toggle для совместимости. |
| `/api/v1/routes/rollback` | POST | `handleRoutesClear` | `ready-write` | Очистка nft/routes с риском влияния на трафик. |
| `/api/v1/routes/clear` | POST | `handleRoutesClear` | `ready-write` | Алиас rollback. |
| `/api/v1/routes/cache/restore` | POST | `handleRoutesCacheRestore` | `ready-write` | Восстановление из clear-cache. |
| `/api/v1/routes/precheck/debug` | POST | `handleRoutesPrecheckDebug` | `ready-async` | Debug flow + опциональный async restart. |
| `/api/v1/routes/fix-policy-route` | POST | `handleFixPolicyRoute` | `ready-write` | Ремонт policy route по status.json. |
| `/api/v1/routes/fix-policy` | POST | `handleFixPolicyRoute` | `ready-write` | Алиас fix-policy-route. |
| `/api/v1/traffic/mode` | GET/POST | `handleTrafficMode` | `ready-write` | Ключевой control-plane endpoint. |
| `/api/v1/traffic/mode/test` | GET/POST | `handleTrafficModeTest` | `ready-read` | Проверка health/probe для UI. |
| `/api/v1/traffic/advanced/reset` | POST | `handleTrafficAdvancedReset` | `ready-write` | Сброс advanced bypass state. |
| `/api/v1/traffic/interfaces` | GET | `handleTrafficInterfaces` | `ready-read` | Список интерфейсов и active iface. |
| `/api/v1/traffic/candidates` | GET | `handleTrafficCandidates` | `ready-read` | Подсказки subnet/unit/uid. |
| `/api/v1/traffic/appmarks` | GET/POST | `handleTrafficAppMarks` | `ready-write` | Runtime app marking, требует RBAC. |
| `/api/v1/traffic/appmarks/items` | GET | `handleTrafficAppMarksItems` | `ready-read` | Список текущих runtime marks. |
| `/api/v1/traffic/app-profiles` | GET/POST/DELETE | `handleTrafficAppProfiles` | `ready-write` | CRUD профилей приложений. |
| `/api/v1/traffic/audit` | GET | `handleTrafficAudit` | `ready-read` | Sanity-check и issues report. |
| `/api/v1/trace` | GET | `handleTraceTailPlain` | `ready-read` | Plain tail trace. |
| `/api/v1/trace-json` | GET | `handleTraceJSON` | `ready-read` | Structured trace view. |
| `/api/v1/trace/append` | POST | `handleTraceAppend` | `ready-write` | Write в trace; ограничить доступ и размер body. |
| `/api/v1/dns-upstreams` | GET/POST | `handleDNSUpstreams` | `ready-write` | Конфиг upstreams. |
| `/api/v1/dns/upstream-pool` | GET/POST | `handleDNSUpstreamPool` | `ready-write` | Конфиг pool. |
| `/api/v1/dns/status` | GET | `handleDNSStatus` | `ready-read` | Состояние DNS mode/runtime. |
| `/api/v1/dns/mode` | POST | `handleDNSModeSet` | `ready-write` | Переключение DNS resolver mode. |
| `/api/v1/dns/benchmark` | POST | `handleDNSBenchmark` | `ready-async` | Долгий бенчмарк; нужен UX ожидания. |
| `/api/v1/dns/smartdns-service` | POST | `handleDNSSmartdnsService` | `ready-write` | Start/stop/restart smartdns unit. |
| `/api/v1/smartdns/service` | GET/POST | `handleSmartdnsService` | `ready-write` | GET state + POST action. |
| `/api/v1/smartdns/runtime` | GET/POST | `handleSmartdnsRuntime` | `ready-write` | Runtime nftset hook toggle. |
| `/api/v1/smartdns/prewarm` | POST | `handleSmartdnsPrewarm` | `ready-async` | Потенциально долгий prewarm. |
| `/api/v1/domains/table` | GET | `handleDomainsTable` | `ready-read` | Таблица nft/ipset dump. |
| `/api/v1/domains/file` | GET/POST | `handleDomainsFile` | `ready-write` | File-backed editor; нужен ACL по name. |
| `/api/v1/smartdns/wildcards` | GET/POST | `handleSmartdnsWildcards` | `ready-write` | CRUD wildcard list. |
| `/api/v1/vpn/autoloop-status` | GET | `handleVPNAutoloopStatus` | `ready-read` | Статус autoloop parser. |
| `/api/v1/vpn/status` | GET | `handleVPNStatus` | `ready-read` | VPN state + unit state. |
| `/api/v1/vpn/autoconnect` | POST | `handleVPNAutoconnect` | `ready-write` | Управление adgvpn unit. |
| `/api/v1/vpn/locations` | GET | `handleVPNListLocations` | `ready-read` | Список location options. |
| `/api/v1/vpn/location` | POST | `handleVPNSetLocation` | `ready-write` | Смена desired location + unit restart. |
| `/api/v1/vpn/login/session/start` | POST | `handleVPNLoginSessionStart` | `ready-interactive` | Старт интерактивной PTY login session. |
| `/api/v1/vpn/login/session/state` | GET | `handleVPNLoginSessionState` | `ready-interactive` | Poll состояния и линий с `since`. |
| `/api/v1/vpn/login/session/action` | POST | `handleVPNLoginSessionAction` | `ready-interactive` | Команды open/check/cancel. |
| `/api/v1/vpn/login/session/stop` | POST | `handleVPNLoginSessionStop` | `ready-interactive` | Cleanup/stop interactive session. |
| `/api/v1/vpn/logout` | POST | `handleVPNLogout` | `ready-write` | Logout + refresh login state. |
## 5) Скриптовые smoke-тесты
- `tests/api_sanity.sh` — read endpoints + method guard + SSE headers.
- `tests/trace_append.sh` — append + readback (`/trace`, `/trace-json`).
- `tests/events_stream.py` — SSE stream + активный триггер `trace_append`.
- `tests/vpn_login_flow.py` — start/state/action/stop для login session.
- `tests/transport_flow_smoke.py` — API-flow `draft/validate/confirm/apply/rollback` для transport policy.
- `tests/transport_platform_compatibility_smoke.py` — проверка кроссплатформенного transport-контракта (`web/iOS/Android`) через capabilities + policy endpoints.
- `tests/transport_runbook_cli_smoke.sh` — smoke операционного runbook helper (`scripts/transport_runbook.py`) по lifecycle transport-клиента.
- `tests/transport_recovery_runbook_smoke.sh` — smoke recovery runbook (`scripts/transport_recovery_runbook.py`) для сценария `health->restart->fallback->diagnostics`.
- `tests/transport_systemd_real_e2e.py` — real-systemd e2e для `singbox/dnstt(+ssh)/phoenix` + проверка cleanup unit artifacts.
- `tests/transport_production_like_e2e.py` — production-like e2e для template-команд и `packaging_profile=bundled` на `systemd` backend.
- `tests/transport_singbox_e2e.py` — backend-first e2e для `singbox` lifecycle/guards.
- `tests/transport_dnstt_e2e.py` — backend-first e2e для `dnstt` lifecycle/guards (`ssh overlay`, template checks).
- `tests/transport_phoenix_e2e.py` — backend-first e2e для `phoenix` lifecycle/guards.
- `tests/run_all.sh` — общий запуск набора.
## 6) Факт прогона (2026-03-02)
- Команда: `API_URL=http://127.0.0.1:8080 ./tests/run_all.sh`.
- Результат: `api_sanity`, `trace_append`, `events_stream`, `vpn_login_flow``passed`.
- Дополнение (2026-03-07):
- Расширенный `run_all` с transport smoke/e2e и packaging smoke проходит `passed`;
- включён и проходит `tests/transport_platform_compatibility_smoke.py` (capabilities + policy contract);
- при запуске старого backend-процесса `transport_*_e2e` могут завершаться `SKIP` (без `provision/metrics`);
- после пересборки и перезапуска `selective-vpn-api` из текущего кода `transport_singbox_e2e`, `transport_dnstt_e2e`, `transport_phoenix_e2e` прошли без `SKIP`.
- в `run_all` включены и проходят `transport_systemd_real_e2e` + `transport_production_like_e2e` (template-commands + packaging profile checks на `runner=systemd`).
## 7) Вывод для веб-прототипа
- Срез по legacy-матрице (53 endpoint из Phase D):
- `ready-read`: 18
- `ready-write`: 27
- `ready-async`: 4
- `ready-interactive`: 4
- Дополнение 2026-03-07:
- В ядро добавлены `8` базовых mux-route `/api/v1/transport/*` + action-subpath `GET /api/v1/transport/clients/{id}/metrics` (контракт D4.1).
- Их контракт и rollout описаны в `docs/phase-e/E2_TRANSPORT_API_CONTRACT.md`.
- По API-ядру нет endpoint, который блокирует веб-прототип на уровне контракта.
- Ограничения не в ручках, а в обвязке: auth/rbac, proxy/CORS/CSRF, и UX для async/interactive сценариев.
- Практический старт веба можно делать сразу: read панели + write действия под admin scope + SSE realtime слой.
## 8) Условия переиспользования для смартфонов (iOS/Android)
- Серверная обвязка:
- gateway/proxy с TLS и токен-авторизацией;
- refresh/access токены и управление device sessions;
- аудит и rate-limit для mutating endpoints.
- Контракт:
- стабильные DTO и error contract (`ok`, `message`, `exitCode`, `stderr`);
- versioning через `/api/v1` и backward-compatible изменения.
- Realtime и фоновые ограничения:
- веб использует SSE постоянно;
- mobile использует SSE в foreground и polling fallback в background.
- Надежность мобильной сети:
- idempotency key для POST операций;
- retry policy с экспоненциальной паузой;
- correlation id для трассировки запросов.
- Безопасность клиента:
- iOS Keychain / Android Keystore для токенов;
- certificate pinning для production приложений.
## 9) D3: Последовательность запуска multi-client
- D3.1: Завершить gateway/auth слой (TLS, токены, RBAC, audit).
- D3.2: Подключить web-клиент к уже готовой матрице endpoint и SSE.
- D3.3: Ввести mobile transport profile (polling fallback + retry/idempotency).
- D3.4: Запустить iOS/Android клиенты на том же `/api/v1` без изменения бизнес-логики ядра.
## 10) D4: Transport Integration Backlog
- D4.1 `sing-box client`:
- Роль: универсальный клиент-транспорт для сценариев proxy/tun.
- Требование к ядру: запуск/остановка/health через backend-адаптер, без переноса логики в UI.
- Статус: `in-progress` (2026-03-07: в API введён минимальный общий backend-контракт lifecycle/health/metrics/errors, добавлен `GET /api/v1/transport/clients/{id}/metrics`).
- E2E шаг (backend-first): `tests/transport_singbox_e2e.py`:
- проверяет успешный lifecycle на `runner=mock` (`provision/start/health/restart/stop/metrics`);
- проверяет guard `runtime_mode=embedded` -> `TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED`;
- проверяет fail-fast `require_binary=true` для отсутствующего `singbox_bin`.
- Примечание: если в рантайме запущен старый процесс API без `provision/metrics`, тест завершится `SKIP`; для полного прогона нужен перезапуск backend из актуального кода.
- D4.2 `dnstt-client`:
- Роль: отдельный клиент для DNSTT-протокола (как самостоятельный backend).
- Требование к ядру: управление lifecycle + состояние/ошибки через общий API слой.
- Уточнение: поддержать режим `dnstt + ssh overlay` (туннель поверх SSH как единая система, аналогично phoenix UX-модели).
- Статус: `in-progress` (2026-03-07: в Go добавлены adapter foundation, `POST /api/v1/transport/clients/{id}/provision`, template-команды запуска, systemd restart/watchdog tuning, unit hardening-профили, `runtime_mode` foundation `exec|embedded|sidecar`, packaging profiles (`system|bundled`) и manual pinned updater/rollback scripts; `config.exec_start` работает как optional override).
- E2E шаг (backend-first): `tests/transport_dnstt_e2e.py`:
- проверяет успешный lifecycle на `runner=mock`;
- проверяет guard `ssh_overlay` конфигурации (`ssh_host` обязателен, `ssh_unit` валидируется);
- проверяет template-валидацию DNSTT при неполном config.
- Примечание: как и для singbox, при старом API-процессе без `provision` тест завершится `SKIP`; нужен перезапуск backend из текущего кода.
- D4.3 `phoenix -> slipstream`:
- Роль: отдельный backend-клиент для подключения к удаленному `slipstream` серверу.
- Требование к ядру: конфиг/статус/управление сессией через backend-адаптер и общий контракт.
- Статус: `in-progress` (2026-03-07: добавлен backend-first e2e тест `tests/transport_phoenix_e2e.py` для lifecycle/guards на API-контракте).
- E2E шаг (backend-first): `tests/transport_phoenix_e2e.py`:
- проверяет успешный lifecycle на `runner=mock` (`provision/start/health/restart/stop/metrics`);
- проверяет guard `runtime_mode=embedded` -> `TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED`;
- проверяет fail-fast `require_binary=true` для отсутствующего `phoenix_bin`.
- Примечание: при старом API-процессе без `provision/metrics` тест завершится `SKIP`; для полного прогона нужен перезапуск backend из актуального кода.
- D4.4 Общий инвариант:
- Для `web + iOS + Android` UI не знает о внутренней реализации backend-клиента; он работает с единым API ядра.
- Все transport-backend различия прячутся в Go-слое (feature flags, capability matrix, unified error contract).
## 11) D4.3: Матрица совместимости web + iOS + Android
- Отдельный артефакт: `docs/phase-d/D4_PLATFORM_COMPATIBILITY_MATRIX.md`.
- Зафиксировано:
- единый control-plane контракт `/api/v1` для всех платформ;
- transport-runtime работает только в backend (`exec`), UI работает через capabilities/policies flow;
- mobile использует тот же API, с fallback `polling` при недоступном SSE в background.

View File

@@ -0,0 +1,59 @@
# D4.3 Матрица совместимости (Web + iOS + Android)
Дата: 2026-03-07
Статус: done
Владелец: Engineering
## 1) Цель
- Зафиксировать единый контракт, чтобы `web`, `iOS`, `Android` переиспользовали одно Go-ядро (`/api/v1`) без платформенных форков бизнес-логики.
- Явно обозначить ограничения transport-runtime и ожидания к клиентам.
## 2) Инвариант архитектуры
- Все transport-клиенты (`singbox`, `dnstt`, `phoenix`) запускаются на стороне хоста с `selective-vpn-api` через backend-адаптеры.
- `web/iOS/Android` являются control-plane клиентами: вызывают API, читают состояние, запускают validate/apply flow.
- Платформы не должны управлять локальными `systemd`/бинарями напрямую.
## 3) Платформенная матрица API-контракта
| Контур | Web (`Vite + React + TS`) | iOS | Android |
| --- | --- | --- | --- |
| Базовый API | `GET/POST /api/v1/*` | `GET/POST /api/v1/*` | `GET/POST /api/v1/*` |
| Realtime | SSE (`/api/v1/events/stream`) в foreground | SSE в foreground, polling fallback в background | SSE в foreground, polling fallback в background |
| Transport capabilities | `GET /api/v1/transport/capabilities` | тот же endpoint | тот же endpoint |
| Policy flow | `GET /policies`, `POST /validate`, `POST /apply`, `POST /rollback` | тот же flow | тот же flow |
| Auth/TLS | gateway/proxy + token | gateway/proxy + token + secure storage | gateway/proxy + token + secure storage |
| Error contract | `ok/code/message/exitCode/stderr` | идентично | идентично |
## 4) Матрица transport backend vs платформы
| Transport backend (в Go-ядре) | Web UI | iOS UI | Android UI | Ограничения |
| --- | --- | --- | --- | --- |
| `singbox` | Поддержан через API | Поддержан через API | Поддержан через API | runtime на сервере, не в мобильном UI |
| `dnstt` (+ `ssh_overlay`) | Поддержан через API | Поддержан через API | Поддержан через API | SSH overlay оркестрируется backend-ом |
| `phoenix` -> `slipstream` | Поддержан через API | Поддержан через API | Поддержан через API | подключение/health/metrics только через backend |
## 5) Runtime/packaging ограничения
- Поддерживаемый runtime mode на текущем этапе: `exec=true`.
- `embedded=false`, `sidecar=false` (зарезервировано, без silent fallback).
- Packaging profiles: `system=true`, `bundled=true`.
- Практический вывод для платформ:
- UI показывает capabilities и ограничения из backend.
- UI не должен предполагать поддержку `embedded/sidecar` до явного включения в capabilities.
## 6) Проверка совместимости (smoke)
- Скрипт: `tests/transport_platform_compatibility_smoke.py`.
- Проверяет:
- наличие `singbox/dnstt/phoenix` в `/transport/capabilities`,
- `runtime_modes.exec=true`,
- `packaging_profiles.system=true`, `packaging_profiles.bundled=true`,
- рабочий policy-контракт (`/transport/policies`, `/validate`, `/conflicts`).
Команда запуска:
```bash
API_URL=http://127.0.0.1:8080 ./tests/transport_platform_compatibility_smoke.py
```
## 7) Итог D4.3
- Совместимость `web + iOS + Android` зафиксирована как единый API-контракт control-plane.
- Все transport-runtime различия остаются в Go-ядре.
- Это позволяет продолжать backend-first реализацию и затем переиспользовать ту же логику в desktop/web/mobile UI.

View File

@@ -0,0 +1,34 @@
# D5: NetNS Runtime Case (Ready)
Дата: 2026-03-09
## Что зафиксировано
- `SingBox` может работать в отдельном `netns` без влияния на основной VPN-контур.
- Для запуска внутри namespace используется адаптивный exec-режим:
- default: `nsenter --net=/var/run/netns/<name> -- ...`
- fallback: `ip netns exec <name> ...`
- Подготовка `netns` (veth/route/NAT) и запуск runtime используют единый механизм выбора exec-mode.
- GUI debug toggle (`Debug netns`) изменяет `netns_enabled` через API, делает `provision` и `restart` для активных клиентов.
## Рефакторинг модулей
- Go:
- `selective-vpn-api/app/transport_netns.go` — setup/cleanup/NAT/spec.
- `selective-vpn-api/app/transport_netns_exec.go` — exec-mode selection (`nsenter|ip`) и wrappers.
- GUI:
- `selective-vpn-gui/netns_debug.py` — вычисление состояния netns-кнопки и общий toggle pipeline (`patch -> provision -> restart`).
- `selective-vpn-gui/vpn_dashboard_qt.py` — только UI-обвязка и вызов helper-модуля.
## Минимальный runtime-check
1. `POST /api/v1/transport/clients/<id>/restart` -> `ok=true`.
2. `GET /api/v1/transport/clients/<id>/health` -> `status=up`.
3. `systemctl status <unit>` -> `active (running)`.
4. `systemctl show <unit> -p ExecStart` -> команда через `nsenter` (или `ip netns exec` при fallback).
## Полезные конфиг-ключи клиента
- `netns_enabled: bool`
- `netns_name: string`
- `netns_exec_mode: auto|nsenter|ip` (optional)
- `netns_nsenter_bin: /usr/bin/nsenter` (optional)
- `netns_ip_bin: /sbin/ip` (optional)
- `netns_setup_strict: bool` (optional)
- `netns_auto_cleanup: bool` (optional)

View File

@@ -0,0 +1,188 @@
# E1 Дизайн multi-client маршрутизации (PBR)
Дата: 2026-03-04
Статус: draft
Владелец: Engineering
## 1) Цель
- Спроектировать расширение текущего ядра так, чтобы несколько transport-клиентов (`sing-box`, `dnstt-client`, `phoenix->slipstream`) работали под единым control-plane API.
- Сохранить один источник истины для маршрутизации: только Go-ядро управляет PBR/nft/ip rule, UI только вызывает API.
- Обеспечить безопасную многоклиентную схему без конфликтов: один трафик/сайт не должен одновременно идти через два интерфейса.
## 2) Текущий baseline (по коду)
- Сейчас модель маршрутизации бинарная: `vpn|direct` + глобальные `MARK`/`MARK_APP`/`MARK_DIRECT`/`MARK_INGRESS`.
- Runtime app routing хранится в `traffic-appmarks.json`, persistent launcher-профили в `traffic-app-profiles.json`.
- Центр оркестрации: `traffic_mode.go`, `traffic_appmarks.go`, `routes_update.go`.
- Ограничение: нет сущности "клиент-транспорт" как объекта с собственным iface/table/mark.
## 3) Архитектурный инвариант
- `PBR Engine` в Go-ядре остается единственным writer для:
- `ip rule`,
- `ip route table`,
- `nft chains/sets/rules`,
- `conntrack mark policy`.
- Transport backends (sing-box/dnstt/phoenix) подключаются через backend-адаптеры и не пишут маршруты напрямую.
- UI (desktop/web/iOS/android) оперирует одинаковым API-контрактом, не знает о внутреннем устройстве backend-клиентов.
## 4) Целевая модель данных
### 4.1 TransportClient
- `id`: стабильный ключ (`sb-main`, `dnstt-home`, `phoenix-eu`).
- `kind`: `singbox | dnstt | phoenix`.
- `enabled`: bool.
- `status`: `starting | up | degraded | down`.
- `iface`: фактический интерфейс/туннель.
- `routing_table`: имя таблицы (`agvpn_<id>`).
- `mark_hex`: выделенная fwmark клиента.
- `priority_base`: базовый диапазон `ip rule pref` для клиента.
- `capabilities`: `tcp`, `udp`, `dns_tunnel`, `ssh_tunnel`.
- `health`: last_check, latency, last_error.
### 4.2 RouteIntent
- Нормализованная запись назначения трафика к клиенту:
- `selector_type`: `domain | cidr | app_key | cgroup | uid`.
- `selector_value`: значение селектора.
- `client_id`: целевой клиент.
- `priority`: порядок применения.
- `mode`: `strict | fallback`.
### 4.3 ConflictRecord
- Запись о конфликте маршрутизации:
- `key`: нормализованный ключ пересечения.
- `owners`: список `client_id`.
- `severity`: `warn | block`.
- `reason`: человеко-читаемая причина.
- `suggested_resolution`: автоматическая подсказка.
## 5) Схема марков и таблиц (без конфликтов)
### 5.1 Mark allocator
- Ввести менеджер выделения марков из пула, например:
- `0x100-0x1FF` для client-specific route marks,
- `0x66/0x67/0x68/0x69` оставить для legacy/системных сценариев.
- Для каждого `client_id` выделяется:
- `client_mark`,
- `client_reply_mark` (если нужен отдельный ingress stickiness).
### 5.2 Table allocator
- Для каждого клиента заводится отдельная routing table:
- `agvpn_sb_main`, `agvpn_dnstt_home`, `agvpn_phoenix_eu`.
- В таблице только default route через iface клиента + локальные bypass-правила.
### 5.3 Rule priority allocator
- Для каждого клиента выделяется непересекаемый диапазон `pref`:
- пример: `13000-13049` клиент A, `13050-13099` клиент B.
- Это исключает перетирание правил между клиентами при apply/reconcile.
## 6) Защита от "мешанины" трафика
### 6.1 Destination ownership lock
- Один домен/cidr/app_key в активной конфигурации может иметь только одного владельца (`client_id`).
- При пересечении:
- по умолчанию `block` (HTTP `409` на apply),
- опционально `force_override` с явным подтверждением пользователя.
### 6.2 Flow stickiness (conntrack)
- Для первого пакета потока проставляется `ct mark = client_mark`.
- Для последующих пакетов mark восстанавливается из `ct mark`, чтобы один и тот же flow не перескакивал между интерфейсами.
- Правило действует в `output` и `prerouting`, аналогично текущему ingress-reply подходу.
### 6.3 DNS/IP coherence
- Для domain-based маршрутизации вводится owner-cache:
- `domain -> client_id -> ip set` с TTL.
- Один и тот же домен в активной политике не может одновременно резолвиться в разные клиентские set-цепочки.
### 6.4 Audit/guardrail
- Расширить `traffic_audit` на multi-client проверки:
- duplicate destination ownership,
- overlap CIDR между клиентами,
- app_key на двух клиентах одновременно,
- nft/rule drift по client chains.
## 7) UX дизайн (удобное добавление/переключение)
### 7.1 Экран "Клиенты"
- Список клиентов: имя, тип, статус, интерфейс, health.
- Действия: `Добавить`, `Включить/Выключить`, `Перезапустить`, `Удалить`.
- Мастер добавления:
- Шаг 1: тип клиента (`sing-box`, `dnstt`, `phoenix`),
- Шаг 2: параметры подключения,
- Шаг 3: health-check,
- Шаг 4: назначение default policy.
- Подпункты UX для engine:
- единый селектор `Active engine` с вариантами `singbox|dnstt|phoenix`;
- быстрые действия `Connect`, `Disconnect`, `Switch to ...`;
- явное отображение `desired_engine` vs `active_engine`;
- при деградации показывать `last_error` и action `Rollback to previous engine`.
### 7.2 Экран "Маршрутизация"
- Матрица `Селектор -> Клиент`.
- Массовое назначение списков IP/CIDR/доменов.
- Быстрый переключатель "перенести селектор на другой клиент" с dry-run проверкой конфликтов.
### 7.3 UX предупреждения
- Перед apply показывать diff:
- какие селекторы сменят владельца,
- какие потоки могут быть прерваны,
- какие конфликты заблокируют применение.
- При `force_override` обязательное подтверждение пользователя с явным риском:
- "Один и тот же сайт может потерять стабильность при частой смене интерфейса".
- При switch/connect engine:
- показывать предупреждение о кратковременном разрыве активных сессий;
- запрещать параллельные mutating-операции до завершения текущего switch;
- при failed switch предлагать rollback на предыдущий engine.
## 8) API-контракт (новые ручки, проект)
- `GET /api/v1/transport/clients`
- `POST /api/v1/transport/clients`
- `POST /api/v1/transport/clients/{id}/start`
- `POST /api/v1/transport/clients/{id}/stop`
- `GET /api/v1/transport/clients/{id}/health`
- `GET /api/v1/transport/policies`
- `POST /api/v1/transport/policies/validate`
- `POST /api/v1/transport/policies/apply`
- `GET /api/v1/transport/conflicts`
- `GET /api/v1/transport/capabilities`
Принцип:
- Все операции изменения policy идут через `validate -> apply`.
- `apply` атомарный: либо вся новая политика применена, либо rollback на предыдущую snapshot-конфигурацию.
## 9) Реализация по шагам
### E1.1 Контракты и состояние
- Ввести state-файлы:
- `transport-clients.json`,
- `transport-policies.json`,
- `transport-conflicts.json`.
- Добавить DTO и минимальные read endpoints.
### E1.2 PBR compiler v2
- Реализовать компиляцию RouteIntent в:
- nft sets/chains per client,
- ip rule pref ranges per client,
- table route entries per client.
### E1.3 Guardrails
- Валидация ownership/overlap до apply.
- Conntrack stickiness rules для стабильности flow.
### E1.4 UX-ready слой
- API предупреждений + dry-run diff.
- SSE события:
- `transport_client_state_changed`,
- `transport_policy_applied`,
- `transport_conflict_detected`.
## 10) Критерии готовности дизайна
- Можно добавить 2+ клиентов и поднять 2+ интерфейса без перезаписи чужих rule/table.
- Нельзя назначить один и тот же selector двум клиентам без explicit override.
- `traffic_audit` показывает целостную картину конфликтов и drift.
- UI получает понятные предупреждения до применения рискованной конфигурации.
## 11) Обратная совместимость
- Текущие `/api/v1/traffic/*` продолжают работать в legacy-режиме.
- При отсутствии multi-client политики ядро использует текущий single-client pipeline.
- Миграция: legacy `vpn|direct` может быть автоматически представлена как:
- `client_id=legacy-vpn`,
- `client_id=legacy-direct`.

View File

@@ -0,0 +1,602 @@
# E2 API-контракт transport control-plane
Дата: 2026-03-05
Статус: in-progress
Владелец: Engineering
## 1) Цель
- Зафиксировать стабильный `/api/v1/transport/*` контракт для управления несколькими transport-клиентами через единый Go control-plane.
- Обеспечить одинаковый API для desktop/web/iOS/android.
- Встроить anti-conflict workflow: `validate -> confirm -> apply`.
## 2) Область и ограничения
- Контракт описывает API-слой и DTO; низкоуровневый backend-runner (systemd/process supervisor) реализуется отдельно.
- Все изменения policy должны идти только через `validate` и `apply`.
- Для совместимости с текущим API проект использует паттерн:
- HTTP `200` + `"ok": false` для операционных ошибок;
- HTTP `4xx` для ошибки запроса (bad json, invalid id, missing fields).
## 3) Общие правила
### 3.1 Базовые поля ответа
```json
{
"ok": true,
"message": "ok",
"request_id": "req-01JABC...",
"data": {}
}
```
### 3.2 Ошибка доменной валидации
```json
{
"ok": false,
"message": "policy has blocking conflicts",
"code": "POLICY_CONFLICT_BLOCK",
"issues": []
}
```
### 3.3 Идемпотентность
- Для mutating POST/PATCH/DELETE клиент передаёт `Idempotency-Key`.
- Для `apply` дополнительно используется `policy_revision` (optimistic lock).
- Для `POST /api/v1/transport/policies/apply` и `POST /api/v1/transport/policies/rollback` backend хранит persisted replay-state:
- одинаковые `(scope, Idempotency-Key, request payload)` возвращают один и тот же сохранённый response без повторного runtime apply;
- повторное использование того же `Idempotency-Key` с другим payload возвращает `IDEMPOTENCY_KEY_REUSED`.
## 4) Модели данных
### 4.1 TransportClient
```json
{
"id": "phoenix-eu",
"name": "Phoenix EU",
"kind": "phoenix",
"enabled": true,
"status": "up",
"iface": "phx0",
"routing_table": "agvpn_phoenix_eu",
"mark_hex": "0x110",
"priority_base": 13050,
"capabilities": ["tcp", "udp", "ssh_tunnel"],
"health": {
"last_check": "2026-03-05T10:11:12Z",
"latency_ms": 83,
"last_error": ""
},
"config": {
"runtime_mode": "exec",
"runner": "systemd",
"endpoint": "eu.example.net:443",
"profile": "default"
},
"updated_at": "2026-03-05T10:11:12Z"
}
```
### 4.2 RouteIntent
```json
{
"selector_type": "domain",
"selector_value": "youtube.com",
"client_id": "phoenix-eu",
"priority": 100,
"mode": "strict"
}
```
### 4.3 ConflictRecord
```json
{
"key": "domain:youtube.com",
"type": "ownership",
"severity": "block",
"owners": ["phoenix-eu", "dnstt-home"],
"reason": "one selector is assigned to multiple clients",
"suggested_resolution": "keep only one owner or use force_override"
}
```
## 5) Endpoints: clients
### 5.1 `GET /api/v1/transport/clients`
- Назначение: список клиентов.
- Query:
- `enabled_only=true|false` (optional)
- `kind=singbox|dnstt|phoenix` (optional)
Response:
```json
{
"ok": true,
"message": "ok",
"items": [],
"count": 3
}
```
### 5.2 `POST /api/v1/transport/clients`
- Назначение: создать клиента.
Request:
```json
{
"id": "dnstt-home",
"name": "DNSTT Home",
"kind": "dnstt",
"enabled": true,
"config": {
"runtime_mode": "exec",
"runner": "systemd",
"packaging_profile": "bundled",
"bin_root": "/opt/selective-vpn/bin",
"server": "1.2.3.4:443",
"domain": "tunnel.example.org",
"pubkey": "base64..."
}
}
```
Response:
```json
{
"ok": true,
"message": "client created",
"item": {}
}
```
### 5.3 `GET /api/v1/transport/clients/{id}`
- Назначение: получить детальную карточку клиента.
### 5.4 `PATCH /api/v1/transport/clients/{id}`
- Назначение: частичное обновление метаданных/конфига.
- Поддерживаемые поля: `name`, `enabled`, `config`.
### 5.5 `DELETE /api/v1/transport/clients/{id}`
- Назначение: удалить клиента.
- Правило: удаление запрещено, если есть активные policy-ссылки без `force=true`.
### 5.6 `POST /api/v1/transport/clients/{id}/start`
### 5.7 `POST /api/v1/transport/clients/{id}/stop`
### 5.8 `POST /api/v1/transport/clients/{id}/restart`
- Назначение: lifecycle операции backend-клиента.
- Ответ: унифицированный `cmdResult`-совместимый формат + backend runtime поля (`status_before/status_after`, `runtime.metrics`, `runtime.last_error`).
Пример:
```json
{
"ok": true,
"message": "start done",
"exitCode": 0,
"client_id": "phoenix-eu",
"kind": "phoenix",
"action": "start",
"status_before": "down",
"status_after": "up",
"health": { "last_check": "2026-03-07T10:11:12Z", "latency_ms": 83, "last_error": "" },
"runtime": {
"backend": "phoenix",
"allowed_actions": ["start", "stop", "restart"],
"metrics": { "restarts": 1, "state_changes": 2, "uptime_sec": 17 }
}
}
```
### 5.9 `GET /api/v1/transport/clients/{id}/health`
- Назначение: быстрый probe статуса и деградации.
Response:
```json
{
"ok": true,
"message": "ok",
"code": "TRANSPORT_CLIENT_DEGRADED",
"client_id": "phoenix-eu",
"kind": "phoenix",
"status": "degraded",
"latency_ms": 480,
"last_error": "upstream timeout",
"health": {
"last_check": "2026-03-07T10:11:12Z",
"latency_ms": 480,
"last_error": "upstream timeout"
},
"runtime": {
"backend": "phoenix",
"metrics": { "restarts": 1, "state_changes": 4, "uptime_sec": 0 },
"last_error": {
"code": "BACKEND_RUNTIME_ERROR",
"message": "upstream timeout",
"retryable": true
}
}
}
```
### 5.10 `GET /api/v1/transport/clients/{id}/metrics`
- Назначение: read-only срез lifecycle metrics для UI (desktop/web/iOS/android) без знания backend-внутренностей.
Response:
```json
{
"ok": true,
"message": "ok",
"client_id": "phoenix-eu",
"kind": "phoenix",
"status": "up",
"metrics": {
"restarts": 2,
"state_changes": 8,
"uptime_sec": 341,
"last_transition_at": "2026-03-07T10:11:12Z"
},
"runtime": {
"backend": "phoenix",
"last_action": "restart",
"last_action_at": "2026-03-07T10:11:12Z"
}
}
```
### 5.11 `POST /api/v1/transport/clients/{id}/provision`
- Назначение: backend-side provision (создание/обновление unit/runner-конфигурации) перед lifecycle-операциями.
- Для `runner=systemd` пишет unit-файлы и делает `systemctl daemon-reload`.
### 5.12 `GET /api/v1/transport/runtime/observability`
- Назначение: unified multi-interface runtime snapshot для карточек/дашбордов без ручной склейки `interfaces + clients + egress + policy`.
- Источники:
- `transport-interfaces`,
- `transport-clients`,
- compile-plan policy,
- `egress identity` для active client на интерфейсе.
Response:
```json
{
"ok": true,
"message": "ok",
"generated_at": "2026-03-16T12:10:00Z",
"count": 2,
"items": [
{
"iface_id": "edge-a",
"name": "Edge A",
"mode": "dedicated",
"runtime_iface": "tun-edge",
"active_iface": "tun-edge0",
"netns_name": "svpn-edge-a",
"routing_table": "agvpn_if_edge_a",
"client_id": "sb-main",
"client_ids": ["sb-main", "dnstt-fallback"],
"status": "degraded",
"latency_ms": 81,
"last_error": "fallback probe failed",
"last_check": "2026-03-16T12:09:30Z",
"egress": {
"scope": "transport:sb-main",
"source": "transport",
"source_id": "sb-main",
"ip": "203.0.113.10",
"country_code": "SG",
"country_name": "Singapore",
"stale": false
},
"counters": {
"client_count": 2,
"enabled_count": 2,
"up_count": 1,
"degraded_count": 1,
"rule_count": 4
},
"engine_counts": [
{ "kind": "dnstt", "count": 1, "degraded_count": 1 },
{ "kind": "singbox", "count": 1, "up_count": 1 }
]
}
]
}
```
## 6) Endpoints: policies
### 6.1 `GET /api/v1/transport/policies`
- Назначение: получить текущую политику и ревизию.
Response:
```json
{
"ok": true,
"message": "ok",
"policy_revision": 12,
"intents": []
}
```
### 6.2 `POST /api/v1/transport/policies/validate`
- Назначение: dry-run валидация без применения.
Request:
```json
{
"base_revision": 12,
"intents": [],
"options": {
"allow_warnings": true,
"force_override": false
}
}
```
Response:
```json
{
"ok": true,
"message": "validation complete",
"valid": false,
"summary": {
"block_count": 1,
"warn_count": 2
},
"conflicts": [],
"diff": {
"added": 10,
"changed": 3,
"removed": 1
}
}
```
### 6.3 `POST /api/v1/transport/policies/apply`
- Назначение: атомарное применение новой policy.
- Обязательные условия:
- `base_revision` совпадает с текущей ревизией,
- нет blocking-конфликтов или задан `force_override=true` с подтверждением.
Request:
```json
{
"base_revision": 12,
"intents": [],
"options": {
"force_override": true,
"confirm_token": "cnf-01JABC..."
}
}
```
Response:
```json
{
"ok": true,
"message": "policy applied",
"policy_revision": 13,
"apply_id": "apl-01JABC...",
"rollback_available": true
}
```
Ошибка конкурентного изменения:
```json
{
"ok": false,
"message": "stale policy revision",
"code": "POLICY_REVISION_MISMATCH",
"current_revision": 13
}
```
### 6.4 `POST /api/v1/transport/policies/rollback`
- Назначение: откатить policy к предыдущему snapshot.
- Условия:
- snapshot должен существовать,
- `base_revision` (если задан) должен совпадать с текущей ревизией,
- snapshot проходит текущую валидацию конфликтов.
Request:
```json
{
"base_revision": 13
}
```
Response:
```json
{
"ok": true,
"message": "policy rollback applied",
"policy_revision": 14,
"apply_id": "rbk-01JABC...",
"rollback_available": true
}
```
### 6.5 `GET /api/v1/transport/conflicts`
- Назначение: получить актуальные конфликты активной конфигурации.
Response:
```json
{
"ok": true,
"message": "ok",
"items": [],
"has_blocking": true
}
```
### 6.6 `GET /api/v1/transport/capabilities`
- Назначение: матрица возможностей backend-клиентов и текущей платформы.
Response:
```json
{
"ok": true,
"message": "ok",
"clients": {
"singbox": { "tcp": true, "udp": true, "dns_tunnel": true, "ssh_tunnel": false },
"dnstt": { "tcp": true, "udp": false, "dns_tunnel": true, "ssh_tunnel": true },
"phoenix": { "tcp": true, "udp": true, "dns_tunnel": false, "ssh_tunnel": true }
},
"runtime_modes": {
"exec": true,
"embedded": false,
"sidecar": false
},
"packaging_profiles": {
"system": true,
"bundled": true
},
"lifecycle": ["provision", "start", "stop", "restart"],
"health_fields": ["status", "latency_ms", "last_error", "health.last_check"],
"metrics_fields": ["restarts", "state_changes", "uptime_sec", "last_transition_at"],
"error_codes": [
"TRANSPORT_CLIENT_NOT_FOUND",
"TRANSPORT_CLIENT_SAVE_FAILED",
"TRANSPORT_CLIENT_DEGRADED",
"BACKEND_RUNTIME_ERROR",
"TRANSPORT_BACKEND_UNIT_REQUIRED",
"TRANSPORT_BACKEND_ACTION_FAILED",
"TRANSPORT_BACKEND_HEALTH_FAILED",
"TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED",
"TRANSPORT_BACKEND_PROVISION_FAILED",
"TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED"
]
}
```
Примечание по `config.runtime_mode`:
- `exec` — текущий production режим (внешний companion-бинарь под управлением backend-адаптера);
- `embedded`, `sidecar` — зарезервированы для следующих фаз; при попытке lifecycle/provision сейчас возвращается `TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED`;
- alias `external|companion` нормализуются в `exec`.
Примечание по packaging для `runtime_mode=exec`:
- `packaging_profile=system` (default): поиск бинарей в системных путях (`/usr/bin`, `/usr/local/bin`, `$PATH`);
- `packaging_profile=bundled`: поиск в `bin_root` (default `/opt/selective-vpn/bin`) с опциональным fallback в system (`packaging_system_fallback=true`);
- `require_binary=true`: fail-fast на этапе `provision`/template build, если целевой бинарь не найден;
- для ручного override (`singbox_bin`, `dnstt_bin`, `phoenix_bin`) `require_binary=true` также валидирует существование.
- manual updater/rollback MVP:
- `scripts/transport-packaging/update.sh` читает pinned manifest, проверяет `sha256`, устанавливает release и атомарно переключает symlink;
- `update.sh` поддерживает trusted-source policy (`--source-policy`), optional/required detached signature verify (`signature.type=openssl-sha256`) и staged rollout (`rollout.stage/percent`, `--rollout-stage`, `--cohort-id`);
- `scripts/transport-packaging/auto_update.sh` — opt-in scheduler-wrapper (`enabled=true`) с interval gate/lock/jitter для безопасного фонового запуска;
- `scripts/transport-packaging/rollback.sh` откатывает компонент на предыдущую запись в `BIN_ROOT/.packaging/*.history`.
Примечание для `dnstt`:
- При `runner=systemd` допускается единая оркестрация `dnstt + ssh overlay`:
- `unit`: systemd unit DNSTT-клиента;
- `exec_start`: явный override команды запуска DNSTT-клиента (опционально);
- если `exec_start` не задан, Go-ядро строит команду по шаблону из полей:
- resolver: `resolver_mode=doh|dot|udp` + `doh_url|dot_addr|udp_addr|resolver_addr`,
- ключ: `pubkey` или `pubkey_file`,
- endpoint: `domain` + `local_addr` (default `127.0.0.1:7000`);
- `ssh_tunnel` или `ssh_overlay`: `true`;
- `ssh_unit`: systemd unit SSH-туннеля;
- `ssh_exec_start` (или `ssh_host` + `ssh_user` + `ssh_port` + `socks_port`): команда запуска SSH overlay.
Примечание для `singbox` и `phoenix`:
- при `runner=systemd` `exec_start` также опционален;
- при отсутствии `exec_start` команда строится шаблонами ядра:
- `singbox`: `<bin> run -c <config_path>`;
- `phoenix`: `<bin> -config <config_path>`.
Примечание для `runner=systemd` (общий tuning):
- `restart_policy`: `no|on-success|on-failure|on-abnormal|on-watchdog|on-abort|always` (default `always`);
- `restart_sec`: задержка перезапуска в секундах (default `2`);
- `start_limit_interval_sec`, `start_limit_burst`: анти-flap лимиты unit (defaults `300`, `30`);
- `timeout_start_sec`, `timeout_stop_sec`: таймауты старта/остановки (defaults `90`, `20`);
- `watchdog_sec`: опциональный systemd watchdog (default `0`, отключён);
- для `dnstt + ssh overlay` поддержаны `ssh_*` overrides тех же ключей (`ssh_restart_sec`, `ssh_watchdog_sec` и т.д.) для отдельного tuning SSH unit.
Примечание для `runner=systemd` (unit hardening):
- `hardening_profile`: `baseline|strict|off` (default `baseline`);
- `hardening_enabled`: `true|false` (может принудительно включить/выключить hardening);
- baseline-профиль включает:
- `NoNewPrivileges=yes`, `PrivateTmp=yes`,
- `ProtectSystem=full`, `ProtectHome=read-only`,
- `ProtectControlGroups=yes`, `ProtectKernelModules=yes`, `ProtectKernelTunables=yes`,
- `RestrictSUIDSGID=yes`, `LockPersonality=yes`, `UMask=0077`;
- strict-профиль дополнительно включает `ProtectSystem=strict` и `PrivateDevices=yes`;
- тонкие override-ключи:
- `no_new_privileges`, `private_tmp`, `protect_system`, `protect_home`,
- `protect_control_groups`, `protect_kernel_modules`, `protect_kernel_tunables`,
- `restrict_suid_sgid`, `lock_personality`, `private_devices`, `umask`;
- для overlay-пары поддержаны `ssh_*` overrides этих же hardening-ключей (например `ssh_hardening_enabled`, `ssh_protect_system`, `ssh_umask`).
## 7) События SSE (проект)
- `transport_client_state_changed`
- `{"id":"phoenix-eu","from":"starting","to":"up"}`
- `transport_client_provisioned`
- `{"id":"dnstt-home","ok":true,"msg":"provision done"}`
- `transport_policy_validated`
- `{"valid":false,"block_count":1,"warn_count":2}`
- `transport_policy_applied`
- `{"apply_id":"apl-...","policy_revision":13}`
- `transport_runtime_snapshot_changed`
- `{"reason":"transport_client_state_changed","generated_at":"2026-03-16T12:10:00Z","client_ids":["sb-main"],"iface_ids":["edge-a"],"items":[...]}`
- payload переиспользует тот же DTO, что и `GET /api/v1/transport/runtime/observability`, чтобы UI мог либо сделать re-fetch, либо обновиться напрямую без ручной агрегации.
- `transport_conflict_detected`
- `{"key":"domain:youtube.com","severity":"block"}`
## 8) Правила anti-conflict
- Ownership lock:
- один `selector_type + selector_value` принадлежит только одному `client_id`.
- По умолчанию конфликты `severity=block` блокируют `apply`.
- `force_override` разрешен только с `confirm_token`, полученным на этапе `validate`.
- Для UX предупреждений backend возвращает:
- список конфликтов,
- потенциальный impact (`flows_rebind_required`, `session_drop_risk`),
- diff по изменениям политики.
## 9) Безопасность и аудит
- Все mutating endpoints требуют `Authorization` + RBAC scope `transport:write`.
- Для операций `apply`, `delete`, `force_override` обязателен audit record:
- user id,
- request id,
- previous revision,
- new revision,
- short diff summary.
## 10) Минимальный план внедрения
- E2.1: ввести DTO и read-only endpoints (`GET clients`, `GET policies`, `GET capabilities`).
- E2.2: добавить `validate` с ownership/overlap анализом.
- E2.3: добавить `apply` с optimistic lock + rollback snapshot.
- E2.4: подключить SSE события и UI flow подтверждения.
## 11) Статус реализации в коде (2026-03-07)
- Реализовано в `selective-vpn-api/app/transport_handlers.go`:
- `GET/POST /api/v1/transport/clients`
- `GET/PATCH/DELETE /api/v1/transport/clients/{id}`
- `POST /api/v1/transport/clients/{id}/provision`
- `POST /api/v1/transport/clients/{id}/{start|stop|restart}`
- `GET /api/v1/transport/clients/{id}/health`
- `GET /api/v1/transport/clients/{id}/metrics`
- `GET /api/v1/transport/policies`
- `POST /api/v1/transport/policies/validate`
- `POST /api/v1/transport/policies/apply`
- `POST /api/v1/transport/policies/rollback`
- `GET /api/v1/transport/conflicts`
- `GET /api/v1/transport/capabilities`
- D4.1-контракт в Go:
- унифицированные DTO для lifecycle/health/metrics/errors,
- runtime-срез в `TransportClient` (`backend`, `allowed_actions`, counters, `last_error`),
- method-level ответы с кодами ошибок (`TRANSPORT_CLIENT_*`, `BACKEND_RUNTIME_ERROR`).
- D4.2 foundation в Go:
- backend-адаптеры `mock/systemd` с выбором по `client.config.runner`,
- для `dnstt` поддержан режим dual-unit orchestration (`ssh overlay`) в `provision/lifecycle/health`,
- шаблонный build `exec_start` в Go для `singbox|dnstt|phoenix` (с manual override через `config.exec_start`),
- systemd tuning для restart/start-limit/timeout/watchdog с отдельными `ssh_*` override для overlay unit,
- unit hardening профили (`baseline/strict/off`) и `ssh_*` hardening overrides для overlay unit.
- Валидация конфликтов:
- ownership conflict (`selector` на несколько клиентов),
- overlap CIDR между разными клиентами,
- unknown client / invalid selector.
- Apply flow:
- `base_revision` lock,
- `confirm_token` при `force_override`,
- snapshot предыдущей policy (`transport-policies.prev.json`),
- SSE события `transport_policy_validated`, `transport_policy_applied`, `transport_conflict_detected`.
- Allocator policy v2:
- резервные диапазоны для `mark_hex` и `priority_base`,
- детерминированное восстановление слотов при загрузке state,
- auto re-balance при коллизиях/битых слотах в `transport-clients.json`,
- детерминированная генерация уникальных `routing_table` (с защитой от коллизий длинных ID).

View File

@@ -0,0 +1,91 @@
# E3 План реализации мультиинтерфейса (execution roadmap)
Дата: 2026-03-15
Статус: in-progress
Владелец: Engineering
## 1) Цель
- Реализовать мультиинтерфейсную архитектуру для transport-клиентов (`singbox`, далее `dnstt`, `phoenix`) без конфликтов маршрутизации.
- Сохранить инвариант: вся логика маршрутизации живёт в Go-ядре, GUI/Web/Mobile остаются тонкими клиентами API.
- Добавить безопасный path миграции без поломки текущего single-interface контура.
## 2) Инварианты реализации
- Никаких прямых мутаций `ip rule`/`ip route`/`nft` из UI.
- Один destination/intent может иметь только одного owner (до явного override).
- Сначала foundation/state/contract, потом orchestration data-plane.
- Каждый этап обратим (rollback), каждый этап проверяется `go test ./...`.
## 3) Фазы
### M1. Foundation интерфейсов (без изменения data-plane)
- Добавить логический `iface_id` в `TransportClient` (default: `shared`).
- Добавить state-файл интерфейсов (`transport-interfaces.json`) и нормализацию.
- Добавить read-only endpoint `GET /api/v1/transport/interfaces`.
- Обновить трекер и тесты миграции state.
- Критерий: поведение runtime не меняется, старые профили продолжают работать.
### M2. Interface Orchestrator core (E3.3)
- Ввести оркестратор `create/bind/start/stop/cleanup` по `iface_id`.
- Разделить "логический интерфейс" (`iface_id`) и "runtime iface" (`tunX/dev`) с явным mapping.
- Добавить lock-стратегию на уровне `iface_id`, чтобы исключить race между клиентами.
- Критерий: один API-path для оркестрации всех движков, без дублирования per-client логики.
### M3. Policy compiler per-interface
- Компилировать intents в наборы правил per `iface_id`: table/mark/pref/nft sets.
- Гарантировать непересекаемые allocator-пулы для разных интерфейсов.
- Подготовить атомарный apply-plan для группы интерфейсов.
- Критерий: отдельные интерфейсы не перетирают таблицы/правила друг друга.
### M4. Anti-mixing и ownership guardrails (E3.4/E3.5)
- Strict ownership registry (`domain/cidr/app`) с явным conflict reason.
- Destination stickiness (`conntrack mark` + owner lock).
- Predictable override-flow с подтверждением.
- Критерий: один destination не может "гулять" между двумя интерфейсами без явного switch.
### M5. Transaction pipeline (E3.6)
- Расширить apply до `validate -> plan -> confirm -> apply -> health-check -> commit`.
- На любой ошибке health-check выполнять auto-rollback на previous snapshot.
- Добавить idempotency/optimistic lock для multi-interface apply.
- Критерий: частично применённой политики не остаётся.
### M6. Unified observability API (E6.6)
- Добавить runtime endpoint для карточек/дашбордов:
- `active_iface`,
- `egress` (ip/country),
- `latency`,
- `last_error`,
- counters per engine/policy.
- Вынести метрики в единый DTO для GUI/Web/Mobile.
- Критерий: UI не склеивает статус из нескольких endpoint-ов вручную.
### M7. UI/Web адаптация после backend-ready
- Desktop: переключение iface/client через новый orchestration API.
- Web/Mobile: reuse того же backend-контракта без новой бизнес-логики.
- Добавить feature-flag/compat-mode для плавной миграции.
- Критерий: backend-контракт единый для всех фронтов.
- Текущий дизайн desktop-first зафиксирован: `docs/phase-e/E4_2_MULTI_INTERFACE_GUI_DESIGN.md`.
## 4) Что делаем прямо сейчас
- M1 завершён:
- `iface_id` + `transport-interfaces` state + `GET /transport/interfaces` + тесты.
- M2 завершён:
- добавлен per-`iface_id` lock manager для mutating lifecycle/provision (`start/stop/restart/provision`);
- добавлен mapping-layer `iface_id -> runtime_iface/netns/routing_table` (dedicated iface defaults + interface hints + apply на create/patch/lifecycle/netns-toggle/provision);
- закрыт owner-scope compile этап: `nft_set` генерируется в scope `iface+client+selector` (без shared-set mixing на одном `iface_id`).
- M3 завершён (foundation):
- добавлен compile-plan `iface_id -> table/mark/pref/nft sets` с persisted state (`transport-policies.plan.json`) и возвратом в `validate/apply/rollback/get-policy`;
- добавлен atomic apply executor foundation (`transport-policies.runtime.json` + runtime snapshot/restore) и врезан в `apply/rollback` до commit policy revision;
- подключён kernel stage в executor: per-interface CIDR nft sets apply/cleanup + optional `ip rule` stage под feature-flag;
- ownership foundation (M4-start): добавлен persisted registry `transport-ownership.json` + `GET /api/v1/transport/owners`;
- apply guardrails усилены: `force_override` допускается только для `owner_switch`, hard blocks не bypass-ятся override-флагом;
- anti-mixing foundation (M4-start): owner switch теперь блокируется runtime owner-lock (если previous owner в статусе `up|starting|degraded`);
- ownership observability: `GET /api/v1/transport/owners` аннотирует записи `owner_status/lock_active`, возвращает агрегат `lock_count` для UI/Web;
- conntrack stickiness foundation:
- kernel-stage (feature-flag `SVPN_TRANSPORT_POLICY_CONNTRACK_STICKY=1`) собирает destination-lock state из `conntrack -L -f ipv4` по `mark -> owner`;
- persisted state: `transport-owner-locks.json`;
- read-only endpoint: `GET /api/v1/transport/owner-locks`;
- validate/apply добавляют `destination_lock` block для `cidr` owner-switch, если destination ещё sticky-locked на предыдущего owner.
- owner-lock recovery:
- endpoint `POST /api/v1/transport/owner-locks/clear` (point clear by `client_id` and/or `destination_ip(s)`);
- двухшаговый confirm flow (`confirm_token`) для предотвращения случайного lock-loss.
- следующий шаг: hardening kernel stage (расширение selector coverage, guardrails/observability) + M4 stickiness (`conntrack owner lock`).

View File

@@ -0,0 +1,101 @@
# E4.4 Multi-Interface GUI Design (Desktop-first)
Дата: 2026-03-15
Статус: planned (design approved)
Владелец: Engineering
## 1) Ответ на ключевой вопрос
- Да, это общий мультиинтерфейсный контур на всё приложение.
- Источник истины: Go-ядро (`transport interfaces/policies/owners/owner-locks`).
- GUI/Web/Mobile только рисуют состояние и вызывают API.
## 2) Границы
- `AdGuardVPN` остаётся отдельным engine-контуром (autoloop/tun0) и не смешивается с transport policy ownership.
- Мультиинтерфейс (`iface_id`, `routing_table`, owner-locks) относится к transport-движкам (`singbox`, далее `dnstt`, `phoenix`).
- Дизайн делаем универсальным, без жёсткой привязки к одному протоколу.
## 3) UI-композиция (вкладка/модуль Transport)
### A. Interface Summary (верхний блок)
- Карточки по `iface_id`:
- `iface_id`,
- `routing_table`,
- количество клиентов на интерфейсе,
- количество rule/intents.
- Источники:
- `GET /api/v1/transport/interfaces`,
- `GET /api/v1/transport/policies`.
### B. Clients Grid (карточки подключений)
- Карточки клиентов остаются, но группируются по `iface_id`.
- На карточке:
- `client name/id`,
- `protocol/transport/security`,
- `status/latency`,
- `egress ip/country` (если доступно).
- Источник:
- `GET /api/v1/transport/clients`,
- `GET /api/v1/egress/identity?scope=transport:<client_id>`.
### C. Ownership & Locks Panel (новый блок)
- Вкладки внутри панели:
- `Ownership`:
- данные из `GET /api/v1/transport/owners`,
- поля: selector, owner client, iface, `owner_status`, `lock_active`.
- `Destination locks`:
- данные из `GET /api/v1/transport/owner-locks`,
- поля: destination ip, owner client, mark, proto, updated_at.
- Назначение:
- быстро понять, почему блокируется owner-switch.
### D. Safe Lock Recovery (точечная очистка lock)
- Кнопка: `Clear selected lock(s)` в `Destination locks`.
- Только точечный режим:
- по `client_id`,
- по `destination_ip`/`destination_ips`.
- Полный clear без фильтра запрещён.
## 4) UX-flow clear owner-lock (двухшаговый)
1. Пользователь выбирает фильтры и нажимает `Clear`.
2. GUI вызывает `POST /api/v1/transport/owner-locks/clear` без `confirm_token`.
3. Backend отвечает:
- `OWNER_LOCK_CLEAR_CONFIRM_REQUIRED`,
- `confirm_token`,
- список matched lock.
4. GUI показывает confirm-диалог с последствиями (что удалится).
5. При подтверждении GUI повторяет запрос с `confirm_token`.
6. Успех:
- перечитать `owner-locks`,
- обновить `owners`,
- показать `cleared_count`.
## 5) Правила безопасности UI
- Нельзя отправить clear-запрос без фильтра.
- Нельзя кешировать `confirm_token` между сессиями.
- При `*_REVISION_MISMATCH` GUI обязан перечитать `owner-locks` и повторить выбор.
- Все mutating-кнопки блокируются на время in-flight запроса.
## 6) События/обновление данных
- В приоритете SSE refresh от уже существующих transport-событий.
- На `transport_policy_applied`:
- перечитать `owners`,
- если включён sticky-режим, перечитать `owner-locks`.
- После clear:
- локально optimistic update запрещён, только re-fetch из API.
## 7) Контракт API для GUI (фикс)
- `GET /api/v1/transport/interfaces`
- `GET /api/v1/transport/clients`
- `GET /api/v1/transport/policies`
- `GET /api/v1/transport/owners`
- `GET /api/v1/transport/owner-locks`
- `POST /api/v1/transport/owner-locks/clear`
- `POST /api/v1/transport/policies/validate`
- `POST /api/v1/transport/policies/apply`
## 8) Порядок внедрения GUI
1. Добавить read-only `Ownership & Locks Panel`.
2. Подключить фильтры и таблицу `Destination locks`.
3. Подключить двухшаговый clear-flow с confirm-диалогом.
4. Встроить обновление после validate/apply и transport refresh.
5. После desktop-стабилизации переиспользовать UI-контракт в web/mobile.

View File

@@ -0,0 +1,126 @@
# E4 UX-поток предупреждений: validate -> confirm -> apply
Дата: 2026-03-05
Статус: in-progress (E4.2 foundation реализован в GUI controller)
Владелец: Engineering
## 1) Цель
- Зафиксировать единый UX-флоу для безопасного применения multi-client policy.
- Исключить "тихие" конфликтные применения.
- Дать пользователю прозрачный diff, риски и явное подтверждение.
## 2) Базовый сценарий
1. Пользователь редактирует routing policy.
2. UI вызывает `POST /api/v1/transport/policies/validate`.
3. UI показывает результат валидации:
- `valid=true` и `block_count=0` -> можно применять.
- `valid=false` или `block_count>0` -> блокируем apply до подтверждения/исправления.
4. Если пользователь выбирает принудительное применение:
- UI показывает модал подтверждения риска,
- использует `confirm_token` из validate.
5. UI вызывает `POST /api/v1/transport/policies/apply`.
### 2.1 Сценарий Engine Switch / Connect
1. Пользователь выбирает целевой engine (`singbox|dnstt|phoenix`) или нажимает `Connect`.
2. UI формирует draft policy, где default ownership переходит к выбранному `client_id`.
3. Дальше используется тот же pipeline:
- `validate` -> (safe|risky) -> `confirm` -> `apply`.
4. После apply UI проверяет:
- `GET /api/v1/transport/clients/{id}/health`,
- расхождение `desired_engine` vs `active_engine`.
5. Если engine не поднялся, UI предлагает `rollback`.
## 3) Состояния UI
### 3.1 Draft
- Политика редактируется, но не проверена.
- Кнопка Apply отключена.
- Доступна кнопка Validate.
### 3.2 Validated (safe)
- `block_count=0`.
- Показываем diff (`added/changed/removed`).
- Apply активен без force режима.
### 3.3 Validated (risky)
- Есть блокирующие конфликты (`ownership`, `cidr_overlap`, `unknown_client`).
- Показываем список конфликтов и конкретные селекторы.
- Обычный Apply отключен.
- Доступен `Force apply` только через отдельный confirm-step.
### 3.4 Confirm required
- Модал с явным предупреждением:
- что будет перезаписано,
- какие flow могут быть прерваны,
- какие сайты/сети сменят client owner.
- Кнопка подтверждения вызывает `apply` с `force_override=true` + `confirm_token`.
### 3.5 Applied
- Показываем `policy_revision` и `apply_id`.
- Обновляем текущую policy в UI.
- Слушаем SSE `transport_policy_applied`.
### 3.6 Switching engine
- Идёт переключение активного engine.
- Кнопки mutating-действий блокируются до завершения.
- Отображается прогресс: `Validating`, `Applying`, `Waiting for health`.
### 3.7 Switch failed
- `apply` или `health` завершились ошибкой.
- Показываем `last_error` активного клиента и причину валидации/применения.
- Предлагаем быстрые действия:
- `Rollback`,
- `Switch back to previous engine`.
## 4) Тексты предупреждений (шаблоны)
- `ownership`:
- "Один и тот же селектор назначен разным клиентам. Это может вызвать нестабильную маршрутизацию."
- `cidr_overlap`:
- "CIDR-подсети пересекаются между клиентами. Пакеты могут идти не по ожидаемому интерфейсу."
- `unknown_client`:
- "Политика ссылается на несуществующий клиент. Сначала добавьте/включите клиент."
Force confirm warning:
- "Принудительное применение может вызвать кратковременный обрыв активных сессий и смену маршрута для части трафика."
## 5) UX-правила безопасности
- Без `validate` кнопка `apply` неактивна.
- `confirm_token` не хранится между сессиями UI и считается одноразовым.
- При смене `policy_revision` в фоне UI обязан повторно выполнить validate.
- При `POLICY_REVISION_MISMATCH` UI показывает "Конфигурация изменилась, нужно повторить проверку".
## 6) Web/iOS/Android паритет
- Один и тот же флоу и тексты рисков для всех клиентов.
- Разница только в визуальном представлении:
- web: side panel + modal,
- mobile: full-screen sheet.
- Логика decision-state остается одинаковой:
- draft -> validate -> (safe|risky) -> confirm -> apply.
## 7) Минимальный UI-чеклист внедрения
- Отображение `summary.block_count/warn_count`.
- Таблица `conflicts[]` с фильтром по severity/type.
- Видимый diff `added/changed/removed`.
- Модал force confirmation с явным перечислением рисков.
- Бейдж текущей ревизии policy (`policy_revision`).
- SSE-подписка на `transport_policy_validated`, `transport_policy_applied`, `transport_conflict_detected`.
- Для engine UX:
- индикатор `desired_engine / active_engine`,
- кнопки `Connect`/`Switch`,
- блокировка повторного switch, пока предыдущий не завершён,
- action `Rollback to previous engine` при неуспехе.
## 8) Статус внедрения (2026-03-07)
- E4.2 foundation в GUI controller реализован: `draft -> validate -> confirm -> apply`.
- E4.3.1 foundation в GUI реализован:
- на вкладке `AdGuardVPN` был добавлен foundation-блок `Transport engine`;
- доступен выбор client и действия `Prepare/Connect/Disconnect/Restart` через API `/api/v1/transport/clients/{id}/*`;
- отображается runtime-состояние выбранного engine (`status/iface/table/latency/last_error`);
- refresh блока привязан к transport SSE-событиям.
- E4.3.2 реализован:
- engine-блок вынесен в отдельную вкладку `SingBox`;
- `Connect/Switch` переведён на pipeline `validate -> confirm -> apply`, direct `start` для switch больше не используется;
- добавлен `Rollback policy` button.
- Следующий UX-этап:
- `desired_engine/active_engine` индикаторы и блокировка повторного switch;
- settings-переключатель видимости protocol tabs (`SingBox/DNSTT/Phoenix`).

View File

@@ -0,0 +1,75 @@
# E5.2 SingBox Desktop Dashboard Spec
Дата: 2026-03-07
Статус: in-progress (foundation implemented)
Владелец: Engineering
## 1) Цель
- Зафиксировать дизайн вкладки `SingBox` для desktop, чтобы не смешивать runtime-управление и конфигурацию профилей.
- Подготовить структуру, которую позже можно переиспользовать в web/mobile.
## 2) Структура вкладки (fixed)
- Визуальная модель: `card-based dashboard` (верхние metric cards + grid profile cards + control panels).
### A. Connection card (runtime)
- Показывает только текущее состояние подключения:
- runtime status,
- active profile,
- protocol/transport,
- routing/dns/kill-switch effective state,
- last update timestamp.
- Быстрые действия:
- `Prepare`,
- `Connect/Switch`,
- `Disconnect`,
- `Restart`,
- `Rollback policy`.
### B. Profile settings (per profile)
- Настройки конкретного профиля:
- `Routing mode`,
- `DNS mode`,
- `Kill-switch`.
- Для каждого блока поддерживается `Use global ...` (наследование).
- Действия профиля:
- `Save draft`,
- `Validate profile`,
- `Apply profile`.
Примечание этапа:
- `Validate/Apply profile` через `/api/v1/transport/singbox/profiles/*` будут полноценно подключены на шагах `E5.2/E5.3`.
- На foundation-этапе эти кнопки логируют намерение и не ломают runtime-flow.
### C. Global defaults
- Общие дефолты для всех профилей:
- default routing mode,
- default DNS mode,
- default kill-switch.
- Сохранение настроек и применение в effective-резолюции профиля.
## 3) Правило приоритета (обязательное)
- `Profile override` > `Global default`.
- Если в профиле включено `Use global ...`, используется глобальное значение.
- Runtime card всегда показывает итоговое effective состояние.
## 4) Границы ответственности
- Вкладка `SingBox` управляет только `SingBox` профилями/engine.
- `DNSTT/Phoenix` в этом этапе не добавляются во вкладку (backend-ready трек отдельно).
- `Routing policy` ownership/anti-conflict остаётся в Go API pipeline `validate -> confirm -> apply`.
## 5) Что уже реализовано в GUI foundation
- Перестроена вкладка на 3 секции: runtime card, profile settings, global defaults.
- Добавлен card-based UI слой:
- верхний ряд compact metric cards,
- кликабельный grid connection profile cards (выбор карточки синхронизирован с active engine selector).
- Реализован compact-mode для настроек:
- `Runtime details`, `Profile settings`, `Global defaults`, `Activity log` открываются кнопками, по умолчанию скрыты.
- Добавлены `Use global` переключатели и effective summary.
- Добавлено локальное сохранение настроек (`QSettings`) для global/profile режимов.
- Сохранены рабочие runtime-кнопки `Prepare/Connect-Switch/Disconnect/Restart/Rollback`.
## 6) Следующий технический шаг
- Подключить профильные кнопки `Validate/Apply` к реальному Go API:
- `POST /api/v1/transport/singbox/profiles/{id}/validate`,
- `POST /api/v1/transport/singbox/profiles/{id}/apply`,
- с обработкой `base_revision`, ошибок и rollback-подсказок в UI.

View File

@@ -0,0 +1,101 @@
# E5 SingBox Client Form Matrix (UI-first, VLESS baseline)
Дата: 2026-03-09
Статус: active
Владелец: Engineering
## 1) Что берём из твоего примера
- Берём структуру "блоками", как на скрине:
- базовый блок,
- transport,
- security,
- sniffing,
- advanced toggles.
- Не копируем серверные поля биллинга/лимитов/истечения, потому что это не клиентский outbound.
## 2) Что исключаем (server-only)
- `Email`, `Subscription`, `Total used`, `Traffic reset`, `Expire date`, `Fallbacks` (как серверный inbound list), и прочие учёт/статистика-поля.
## 3) Клиентская форма (VLESS + Reality) — целевая MVP
### 3.1 Block A: Profile
| UI field | JSON path | Required | Notes |
|---|---|---|---|
| Profile name | `profile.name` | yes | имя карточки |
| Enabled | `profile.enabled` | yes | локальный toggle |
| Protocol | `outbound.type` | yes | для этого шаблона фикс `vless` |
### 3.2 Block B: Server/Auth
| UI field | JSON path | Required | Notes |
|---|---|---|---|
| Address | `outbound.server` | yes | домен/IP |
| Port | `outbound.server_port` | yes | `1..65535` |
| UUID | `outbound.uuid` | yes | vless user id |
| Flow | `outbound.flow` | no | напр. `xtls-rprx-vision` |
| Packet encoding | `outbound.packet_encoding` | no | default `none` |
### 3.3 Block C: Transport
| UI field | JSON path | Required | Notes |
|---|---|---|---|
| Transport type | `outbound.transport.type` | no | `tcp|ws|grpc|http|httpupgrade|quic` |
| Path | `outbound.transport.path` | depends | для `ws/http/httpupgrade` |
| Host/Headers | `outbound.transport.headers` | no | advanced |
| Service name | `outbound.transport.service_name` | depends | для `grpc` |
### 3.4 Block D: Security
| UI field | JSON path | Required | Notes |
|---|---|---|---|
| Security mode | `outbound.tls.enabled` + `outbound.tls.reality.enabled` | yes | `none|tls|reality` (segmented control) |
| SNI | `outbound.tls.server_name` | depends | для `tls/reality` |
| uTLS fingerprint | `outbound.tls.utls.fingerprint` | no | `chrome|firefox|safari|...` |
| Reality public key | `outbound.tls.reality.public_key` | depends | must для reality |
| Reality short id | `outbound.tls.reality.short_id` | no | обычно короткий hex |
| Insecure | `outbound.tls.insecure` | no | advanced toggle |
### 3.5 Block E: Sniffing/Local inbound (опционально)
| UI field | JSON path | Required | Notes |
|---|---|---|---|
| Sniffing enabled | `inbounds[*].sniff` | no | если используем локальный socks inbound в generated config |
| Sniff override destination | `inbounds[*].sniff_override_destination` | no | advanced |
### 3.6 Block F: Advanced Dial
| UI field | JSON path | Required | Notes |
|---|---|---|---|
| Network | `outbound.network` | no | `tcp|udp` |
| Connect timeout | `outbound.connect_timeout` | no | duration |
| Bind interface | `outbound.bind_interface` | no | advanced |
| Routing mark | `outbound.routing_mark` | no | advanced |
| Multiplex | `outbound.multiplex` | no | advanced group |
| UDP over TCP | `outbound.udp_over_tcp` | no | advanced group |
## 4) Guardrails (обязательные)
- `CV-001`: при `security=reality` автоматически `tls.enabled=true`, `tls.reality.enabled=true`.
- `CV-002`: при `security=reality` поле `reality.public_key` обязательно.
- `CV-003`: при `transport=grpc` поле `service_name` обязательно.
- `CV-004`: при `transport=ws|http|httpupgrade` поле `path` обязательно.
- `CV-005`: `address/port/uuid` обязательны всегда.
## 5) UI-компоновка (desktop)
- Compact panel:
- `Profile`,
- `Server/Auth`,
- `Transport`,
- `Security`,
- collapsed `Advanced`.
- Actions:
- `Preview render`,
- `Validate profile`,
- `Apply profile`,
- `History`,
- `Rollback profile`.
## 6) Расширение на другие протоколы
- Сохраняем те же блоки UI, меняются только поля блока `Server/Auth` + часть `Security/Transport`.
- То есть форма будет модульной, а не отдельное "окно под каждый протокол".

View File

@@ -0,0 +1,252 @@
{
"matrix_version": "2026-03-08",
"singbox_version_target": ">=1.12.0",
"schema": "e5.singbox.protocol.matrix.v1",
"protocols": [
{
"id": "vless",
"mode": "typed+raw",
"fields": [
{
"path": "outbound.server",
"type": "string",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.server_port",
"type": "int",
"required": true,
"ui_level": "mvp",
"constraints": {
"min": 1,
"max": 65535
}
},
{
"path": "outbound.uuid",
"type": "string",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.flow",
"type": "string",
"required": false,
"ui_level": "mvp"
},
{
"path": "outbound.tls.reality.public_key",
"type": "string",
"required": false,
"ui_level": "mvp"
}
],
"guardrails": [
{
"id": "VLESS-001",
"condition": "outbound.tls.reality.enabled == true",
"constraint": "outbound.tls.enabled == true",
"level": "block"
}
]
},
{
"id": "trojan",
"mode": "typed+raw",
"fields": [
{
"path": "outbound.server",
"type": "string",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.server_port",
"type": "int",
"required": true,
"ui_level": "mvp",
"constraints": {
"min": 1,
"max": 65535
}
},
{
"path": "outbound.password",
"type": "secret",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.tls.server_name",
"type": "string",
"required": false,
"ui_level": "mvp"
}
]
},
{
"id": "shadowsocks",
"mode": "typed+raw",
"fields": [
{
"path": "outbound.server",
"type": "string",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.server_port",
"type": "int",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.method",
"type": "enum",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.password",
"type": "secret",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.plugin",
"type": "string",
"required": false,
"ui_level": "advanced"
}
]
},
{
"id": "wireguard",
"mode": "typed+raw",
"fields": [
{
"path": "outbound.server",
"type": "string",
"required": false,
"ui_level": "mvp"
},
{
"path": "outbound.server_port",
"type": "int",
"required": false,
"ui_level": "mvp"
},
{
"path": "outbound.local_address",
"type": "array[string]",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.private_key",
"type": "secret",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.peer_public_key",
"type": "string",
"required": false,
"ui_level": "mvp"
},
{
"path": "outbound.peers",
"type": "array[object]",
"required": false,
"ui_level": "advanced"
}
]
},
{
"id": "hysteria2",
"mode": "typed+raw",
"fields": [
{
"path": "outbound.server",
"type": "string",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.server_port",
"type": "int",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.password",
"type": "secret",
"required": false,
"ui_level": "mvp"
},
{
"path": "outbound.up_mbps",
"type": "int",
"required": false,
"ui_level": "mvp"
},
{
"path": "outbound.down_mbps",
"type": "int",
"required": false,
"ui_level": "mvp"
},
{
"path": "outbound.obfs",
"type": "string",
"required": false,
"ui_level": "advanced"
}
]
},
{
"id": "tuic",
"mode": "typed+raw",
"fields": [
{
"path": "outbound.server",
"type": "string",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.server_port",
"type": "int",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.uuid",
"type": "string",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.password",
"type": "secret",
"required": true,
"ui_level": "mvp"
},
{
"path": "outbound.congestion_control",
"type": "string",
"required": false,
"ui_level": "advanced"
},
{
"path": "outbound.udp_relay_mode",
"type": "string",
"required": false,
"ui_level": "advanced"
}
]
}
]
}

View File

@@ -0,0 +1,229 @@
# E5 SingBox Protocols Matrix (Desktop-first)
Дата: 2026-03-08
Статус: active
Владелец: Engineering
Цель: зафиксировать поля подключения до реализации форм протоколов в GUI.
## 1) Источники (primary)
- Outbound overview: https://sing-box.sagernet.org/configuration/outbound/
- VLESS: https://sing-box.sagernet.org/configuration/outbound/vless/
- Trojan: https://sing-box.sagernet.org/configuration/outbound/trojan/
- Shadowsocks: https://sing-box.sagernet.org/configuration/outbound/shadowsocks/
- WireGuard: https://sing-box.sagernet.org/configuration/outbound/wireguard/
- Hysteria2: https://sing-box.sagernet.org/configuration/outbound/hysteria2/
- TUIC: https://sing-box.sagernet.org/configuration/outbound/tuic/
- Shared TLS: https://sing-box.sagernet.org/configuration/shared/tls/
- Shared V2Ray transport: https://sing-box.sagernet.org/configuration/shared/v2ray-transport/
- Shared Dial fields: https://sing-box.sagernet.org/configuration/shared/dial/
- Multiplex: https://sing-box.sagernet.org/configuration/shared/multiplex/
- UDP over TCP: https://sing-box.sagernet.org/configuration/shared/udp-over-tcp/
## 2) Фрейм реализации
- Target: `sing-box >= 1.12.x` (текущий runtime у нас `1.12.12`).
- UI-стратегия:
- `MVP`: критичные поля подключения.
- `Advanced`: дополнительные tuning-поля.
- `Raw-only`: редкие/экзотические поля, остаются в raw JSON режиме.
---
## 3) Общие блоки (для большинства outbound)
### 3.1 Базовые outbound-поля
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.type` | enum/string | yes | MVP | фиксируется выбранным протоколом |
| `outbound.tag` | string | yes | MVP | стабильный id карточки |
| `outbound.detour` | string | no | Advanced | цепочка через другой outbound |
### 3.2 Shared Dial fields (ядро соединения)
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.server` | string | yes | MVP | hostname/IP сервера |
| `outbound.server_port` | int | yes | MVP | 1..65535 |
| `outbound.network` | enum | no | MVP | `tcp|udp` (по протоколу) |
| `outbound.connect_timeout` | duration | no | Advanced | таймаут dial |
| `outbound.tcp_fast_open` | bool | no | Advanced | TCP Fast Open |
| `outbound.tcp_multi_path` | bool | no | Advanced | MPTCP (если поддерживается) |
| `outbound.udp_fragment` | bool | no | Advanced | UDP fragmentation |
| `outbound.domain_resolver` | string/object | no | Advanced | используется с новым DNS-форматом |
| `outbound.bind_interface` | string | no | Advanced | привязка к интерфейсу |
| `outbound.routing_mark` | int | no | Advanced | fwmark для выхода |
### 3.3 Shared TLS/Reality блок (где применимо)
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.tls.enabled` | bool | depends | MVP | must be `true` для Reality |
| `outbound.tls.server_name` | string | depends | MVP | SNI |
| `outbound.tls.insecure` | bool | no | Advanced | skip verify |
| `outbound.tls.alpn` | []string | no | Advanced | ALPN list |
| `outbound.tls.utls.enabled` | bool | no | Advanced | uTLS toggle |
| `outbound.tls.utls.fingerprint` | enum/string | no | MVP | для Reality-сценариев обычно обязательно |
| `outbound.tls.reality.enabled` | bool | depends | MVP | Reality mode |
| `outbound.tls.reality.public_key` | string | depends | MVP | Reality PBK |
| `outbound.tls.reality.short_id` | string | no | MVP | Reality SID |
### 3.4 Shared V2Ray transport блок (где применимо)
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.transport.type` | enum/string | no | MVP | `ws|http|grpc|quic|httpupgrade|...` |
| `outbound.transport.path` | string | depends | MVP | чаще для `ws/http/httpupgrade` |
| `outbound.transport.host` | string/[]string | depends | Advanced | Host header/SNI-like values |
| `outbound.transport.service_name` | string | depends | MVP | обычно для `grpc` |
| `outbound.transport.headers` | object | no | Advanced | custom headers |
### 3.5 Shared Multiplex / UDP-over-TCP (где применимо)
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.multiplex` | object | no | Advanced | pooling/stream mux |
| `outbound.udp_over_tcp` | object | no | Advanced | туннелирование UDP через TCP |
---
## 4) Протокольные матрицы
## 4.1 VLESS outbound
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.type` | const `vless` | yes | MVP | |
| `outbound.server` | string | yes | MVP | |
| `outbound.server_port` | int | yes | MVP | |
| `outbound.uuid` | string | yes | MVP | UUID |
| `outbound.flow` | string | no | MVP | напр. `xtls-rprx-vision` |
| `outbound.packet_encoding` | enum/string | no | Advanced | с `1.11` default `none` |
| `outbound.network` | enum | no | MVP | |
| `outbound.tls` | object | depends | MVP | TLS/Reality для production-сценариев |
| `outbound.transport` | object | no | MVP | V2Ray transport block |
Guardrails:
- `VLESS-001`: если `tls.reality.enabled=true` -> `tls.enabled=true`.
- `VLESS-002`: если `flow=xtls-rprx-vision` -> transport должен быть совместим с сервером и TLS включён.
## 4.2 Trojan outbound
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.type` | const `trojan` | yes | MVP | |
| `outbound.server` | string | yes | MVP | |
| `outbound.server_port` | int | yes | MVP | |
| `outbound.password` | string | yes | MVP | secret |
| `outbound.network` | enum | no | MVP | |
| `outbound.tls` | object | yes (обычно) | MVP | TLS-блок |
| `outbound.transport` | object | no | MVP | V2Ray transport |
Guardrails:
- `TRJ-001`: пустой `password` блокирует apply.
- `TRJ-002`: без TLS для стандартного trojan-сервера помечать как risky.
## 4.3 Shadowsocks outbound
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.type` | const `shadowsocks` | yes | MVP | |
| `outbound.server` | string | yes | MVP | |
| `outbound.server_port` | int | yes | MVP | |
| `outbound.method` | enum/string | yes | MVP | cipher method |
| `outbound.password` | string | yes | MVP | secret |
| `outbound.plugin` | string | no | Advanced | SIP003 plugin |
| `outbound.plugin_opts` | string | no | Advanced | plugin options |
| `outbound.network` | enum | no | Advanced | |
| `outbound.udp_over_tcp` | object | no | Advanced | shared UDP-over-TCP fields |
| `outbound.multiplex` | object | no | Advanced | shared multiplex fields |
Guardrails:
- `SS-001`: `method` обязателен и валидируется против поддерживаемых cipher.
- `SS-002`: если `plugin` задан, но `plugin_opts` пуст и plugin его требует -> warning/block.
## 4.4 WireGuard outbound
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.type` | const `wireguard` | yes | MVP | |
| `outbound.server` | string | depends | MVP | при single-peer setup |
| `outbound.server_port` | int | depends | MVP | |
| `outbound.system_interface` | bool | no | Advanced | |
| `outbound.interface_name` | string | deprecated | Raw-only | deprecated с `1.11` |
| `outbound.local_address` | []string | yes | MVP | local tunnel address(es) |
| `outbound.private_key` | string | yes | MVP | secret |
| `outbound.peer_public_key` | string | depends | MVP | single-peer |
| `outbound.pre_shared_key` | string | no | Advanced | secret |
| `outbound.reserved` | []int | no | Advanced | |
| `outbound.workers` | int | no | Advanced | |
| `outbound.mtu` | int | no | Advanced | |
| `outbound.network` | enum | no | Advanced | |
| `outbound.peers` | []object | depends | Advanced | multi-peer schema |
| `outbound.peer_allowed_ips` | []string | no | Advanced | |
| `outbound.packet_encoding` | enum/string | no | Advanced | |
Guardrails:
- `WG-001`: `private_key` обязателен.
- `WG-002`: нужен или `peer_public_key` (single) или `peers[]` (multi).
## 4.5 Hysteria2 outbound
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.type` | const `hysteria2` | yes | MVP | |
| `outbound.server` | string | yes | MVP | |
| `outbound.server_port` | int | yes | MVP | |
| `outbound.up_mbps` | int | no | MVP | upload bandwidth hint |
| `outbound.down_mbps` | int | no | MVP | download bandwidth hint |
| `outbound.obfs` | string | no | Advanced | obfuscation method |
| `outbound.obfs_password` | string | depends | Advanced | secret |
| `outbound.password` | string | depends | MVP | auth password |
| `outbound.network` | enum | no | Advanced | |
| `outbound.tls` | object | yes (обычно) | MVP | TLS block |
| `outbound.brutal_debug` | bool | no | Raw-only | debug option |
Guardrails:
- `HY2-001`: если задан `obfs`, то `obfs_password` обязателен.
- `HY2-002`: пустой `password` при password-auth конфиге блокирует apply.
## 4.6 TUIC outbound
| JSON path | Type | Required | UI level | Notes |
|---|---|---|---|---|
| `outbound.type` | const `tuic` | yes | MVP | |
| `outbound.server` | string | yes | MVP | |
| `outbound.server_port` | int | yes | MVP | |
| `outbound.uuid` | string | yes | MVP | auth uuid |
| `outbound.password` | string | yes | MVP | secret |
| `outbound.congestion_control` | enum/string | no | Advanced | algo |
| `outbound.udp_relay_mode` | enum/string | no | Advanced | relay mode |
| `outbound.udp_over_stream` | bool | no | Advanced | |
| `outbound.zero_rtt_handshake` | bool | no | Raw-only | advanced/risky |
| `outbound.heartbeat` | duration/int | no | Raw-only | keepalive tuning |
| `outbound.network` | enum | no | Advanced | |
| `outbound.tls` | object | yes (обычно) | MVP | TLS block |
Guardrails:
- `TUIC-001`: `uuid` и `password` обязательны.
- `TUIC-002`: `zero_rtt_handshake=true` помечать warning (опция совместимости/риска).
---
## 5) Срез MVP полей для первой версии GUI
- `VLESS`: `server`, `server_port`, `uuid`, `flow`, `tls.server_name`, `tls.reality.public_key`, `tls.reality.short_id`, `tls.utls.fingerprint`.
- `Trojan`: `server`, `server_port`, `password`, `tls.server_name`.
- `Shadowsocks`: `server`, `server_port`, `method`, `password`.
- `WireGuard`: `server`, `server_port`, `local_address`, `private_key`, `peer_public_key`.
- `Hysteria2`: `server`, `server_port`, `password`, `up_mbps`, `down_mbps`.
- `TUIC`: `server`, `server_port`, `uuid`, `password`, `congestion_control`.
Все остальные поля:
- либо в секции `Advanced`,
- либо в `Raw JSON` (без потери функциональности).
## 6) DoD для этапа "матрица готова"
- Шаблон и матрица лежат в `docs/phase-e`.
- Для каждого протокола есть список MVP/Advanced/Raw-only полей.
- Для каждого протокола есть минимум 2 guardrail-правила валидации.
- Есть machine-readable манифест для последующей генерации форм.

View File

@@ -0,0 +1,158 @@
# E5 Требования: SingBox Protocols (UI tab + Go API)
Дата: 2026-03-07
Статус: planned (requirements fixed)
Владелец: Engineering
## 1) Цель
- Зафиксировать требования для реализации протоколов во вкладке `SingBox` без архитектурной "мешанины".
- Зафиксировать целевой Go API для `singbox` с поддержкой "всех фишек" через typed-профили и raw-режим.
## 2) Архитектурные границы (чтобы не смешивать слои)
- Слой `Transport Engine`:
- lifecycle (`provision/start/stop/restart`), health/metrics, runtime_mode/packaging.
- уже реализован в `/api/v1/transport/clients/*`.
- Слой `Routing Policy`:
- `validate -> confirm -> apply -> rollback`.
- уже реализован в `/api/v1/transport/policies/*`.
- Слой `Protocol Profile` (новый E5):
- описание конфигов `sing-box` (outbounds/rules/dns/tun и т.д.).
- не дублирует lifecycle и не дублирует policy.
Правило:
- `Protocol Profile` готовит/валидирует/рендерит `sing-box` config.
- `Transport Engine` запускает этот config.
- `Routing Policy` решает, какой engine владеет трафиком.
## 3) Требования к вкладке `SingBox` (GUI)
### 3.1 Блоки UI (обязательные)
- `Profiles list`:
- список профилей, тип протокола, версия, последний apply, статус валидации.
- `Editor`:
- режим `Typed` (форма) и режим `Raw JSON` (полный контроль).
- `Validation`:
- синтаксис, обязательные поля, совместимость с бинарём/features.
- `Apply`:
- `Validate` -> `Preview` -> `Apply`.
- при рисках/конфликтах — confirm flow.
- `Diagnostics`:
- last_error, warning, render-diff, health summary.
- `Rollback`:
- откат к предыдущей рабочей версии профиля.
### 3.2 Поведение
- Нельзя применять профиль без успешной валидации.
- `Apply` профиля не должен неявно менять transport-policy ownership.
- Секреты в UI маскируются, редактируются только write-only полями.
- Все mutating-операции идемпотентны и привязаны к ревизии.
### 3.3 Desktop layout freeze
- Вкладка `SingBox` на desktop фиксируется в 3 секции:
- `Connection card (runtime)`,
- `Profile settings (per profile)`,
- `Global defaults`.
- Для profile-параметров используется правило `Use global`/`Override`.
- Детальная спецификация: `docs/phase-e/E5_2_SINGBOX_DESKTOP_DASHBOARD_SPEC.md`.
## 4) Требования к модели профилей (Go)
### 4.1 Поддерживаемые режимы профиля
- `typed`:
- структурированная схема под основные протоколы.
- `raw`:
- полный `sing-box` JSON для "всех фишек", не покрытых формой.
### 4.2 Минимальный набор протоколов для typed-режима
- `vless` (в т.ч. reality/tls варианты),
- `trojan`,
- `shadowsocks`,
- `wireguard`,
- `hysteria2`,
- `tuic`.
Примечание:
- Для нестандартных/редких фич используется `raw` без потери совместимости.
### 4.3 Версионирование
- `schema_version` для профиля.
- `profile_revision` (optimistic lock).
- `render_revision` (версия сгенерированного итогового конфига).
### 4.4 DNS (встроенный resolver sing-box)
- Профиль `singbox` должен включать секцию DNS как часть единого конфига (`dns.servers`, `dns.rules`, `strategy`, `final` и связанные поля).
- В typed-режиме нужен базовый DNS-набор:
- выбор DNS-провайдеров/серверов,
- rule-based DNS routing (по доменам/гео/режимам),
- переключение стратегий резолва (например, prefer IPv4/IPv6 по профилю).
- Для расширенных сценариев (fakeip, cache tuning, нетиповые rules/actions) используется `raw` режим без ограничений формы.
- Ограничение границ:
- DNS `sing-box` отвечает за резолв внутри transport-профиля,
- системный selective routing policy (`/transport/policies/*`) остаётся отдельным слоем и не дублируется в profile editor.
## 5) Требования к Go API `singbox`
### 5.1 Новые endpoint-группы (target)
- `GET/POST /api/v1/transport/singbox/profiles`
- `GET/PATCH/DELETE /api/v1/transport/singbox/profiles/{id}`
- `POST /api/v1/transport/singbox/profiles/{id}/validate`
- `POST /api/v1/transport/singbox/profiles/{id}/render`
- `POST /api/v1/transport/singbox/profiles/{id}/apply`
- `POST /api/v1/transport/singbox/profiles/{id}/rollback`
- `GET /api/v1/transport/singbox/profiles/{id}/history`
- `GET /api/v1/transport/singbox/features`
### 5.2 Контракт операций
- `validate`:
- возвращает ошибки схемы, ошибок протокола, неподдерживаемых фич по текущему binary.
- `render`:
- детерминированно строит финальный `sing-box` config + diff к текущему active.
- `apply`:
- атомарно: запись конфига -> backend provision -> optional restart/start -> health check.
- при fail: автоматический rollback.
- `rollback`:
- откат к последнему успешному render/apply snapshot.
### 5.3 Совместимость и безопасность
- Не ломать текущий `/api/v1/transport/*` контракт.
- Для mutating запросов:
- `Idempotency-Key`,
- `base_revision`.
- Секреты:
- отдельное хранение, выдача в API только masked.
- запрет на логирование plain-secret в trace/events.
## 6) "Все фишки" sing-box: как закрываем требование
- Typed-форма покрывает частые production-сценарии.
- Raw-режим гарантирует доступ к полному синтаксису sing-box.
- `GET /transport/singbox/features` отражает реальные возможности текущего binary:
- поддерживаемые протоколы,
- transport/tls/reality/quic опции,
- ограничения версии.
## 7) Хранение и артефакты
- Профили: `/var/lib/selective-vpn/transport/singbox-profiles.json`
- История/snapshots: `/var/lib/selective-vpn/transport/singbox-history/*.json`
- Rendered configs: `/var/lib/selective-vpn/transport/singbox-rendered/{profile_id}.json`
- Secrets store: `/var/lib/selective-vpn/transport/secrets/singbox/{profile_id}.json` (`0600`)
## 8) SSE-события (обязательные)
- `singbox_profile_saved`
- `singbox_profile_validated`
- `singbox_profile_rendered`
- `singbox_profile_applied`
- `singbox_profile_rollback`
- `singbox_profile_failed`
## 9) Поэтапная реализация (рекомендуемый порядок)
1. `E5.1` Requirements freeze (этот документ).
2. `E5.2` Go: state/model + CRUD + versioning + secrets storage.
3. `E5.3` Go: validate/render/apply/rollback + SSE events.
4. `E5.4` GUI: SingBox profiles tab (`list/editor/validate/preview/apply/rollback`).
5. `E5.5` Совместимость web/mobile: reuse того же API-контракта.
## 10) Критерии готовности E5
- Можно создать/редактировать профиль `singbox` без ручного правки файлов на хосте.
- `Apply` проходит через validate и имеет rollback safety.
- Raw-режим позволяет применить фичи, которых нет в typed-форме.
- Наблюдаемость достаточна для диагностики (events + health + last_error + history).

View File

@@ -0,0 +1,42 @@
# E5 SingBox Protocol Matrix Template
Дата: 2026-03-08
Статус: active template
Владелец: Engineering
## 1) Назначение
- Единый шаблон, по которому фиксируем поля протокола `sing-box` до реализации GUI.
- Используется для desktop сейчас и для web/iOS/Android позже.
## 2) Карточка протокола
- `Protocol`: `<vless|trojan|shadowsocks|wireguard|hysteria2|tuic|...>`
- `Mode`: `typed` / `raw` / `typed+raw`
- `Target sing-box`: `>=1.12`
- `Source docs`: ссылки на официальные страницы (`sing-box.sagernet.org`)
- `UI phase`: `MVP` / `Phase-2` / `Raw-only`
## 3) Матрица полей (шаблон)
| JSON path | Type | Required | Default | Since | Validation | UI level | Notes |
|---|---|---|---|---|---|---|---|
| `outbound.type` | enum/string | yes | `<protocol>` | - | fixed const | MVP | |
| `...` | | | | | | | |
## 4) Зависимости/guardrails (шаблон)
| Rule ID | Condition | Constraint | Error text |
|---|---|---|---|
| `R-001` | `...` | `...` | `...` |
## 5) Runtime/apply flow (шаблон)
- `Preview render` -> `Validate` -> `Apply` -> `History` -> `Rollback`.
- `Apply` только после `Validate ok=true`.
- Mutating операции через `base_revision` (optimistic lock).
## 6) Минимум для GUI
- Секция `Server/Auth` (endpoint + credentials).
- Секция `TLS/Reality` (если применимо).
- Секция `Transport` (если применимо).
- Секция `Advanced` (скрыта по умолчанию).
- `Raw JSON` всегда доступен как escape hatch.

View File

@@ -0,0 +1,171 @@
# E6 API-контракт: egress identity (IP + country)
Дата: 2026-03-10
Статус: draft (E6.1 freeze)
Владелец: Engineering
## 1) Цель
- Ввести единый backend-контракт для определения текущей egress-идентичности для любого движка.
- Избежать дублирования логики в desktop/web/mobile: UI только читает готовые поля из Go API.
- Дать унифицированные поля для показа `IP + страна` и построения флага в UI из `country_code`.
## 2) Область и ограничения
- Источник истины: только Go-ядро.
- UI не делает внешние IP/Geo запросы напрямую.
- Поддерживаемые scope:
- `adguardvpn`
- `transport:<client_id>`
- `system`
- Первичный формат ответа: `HTTP 200 + ok/message` (совместимо с текущим API-паттерном).
## 3) Модель данных
### 3.1 EgressIdentity
```json
{
"scope": "transport:sg-realnetns",
"source": "transport",
"source_id": "sg-realnetns",
"ip": "203.0.113.10",
"country_code": "SG",
"country_name": "Singapore",
"updated_at": "2026-03-10T07:20:00Z",
"stale": false,
"refresh_in_progress": false,
"last_error": "",
"next_retry_at": ""
}
```
Пояснения:
- `scope`: нормализованный ключ области.
- `source`: `adguardvpn|transport|system`.
- `source_id`: для `transport` это `client_id`, иначе пусто.
- `ip`: egress IP для выбранного scope.
- `country_code`: ISO-2 uppercase (например `US`, `SG`).
- `country_name`: человекочитаемое имя страны.
- `updated_at/stale/refresh_in_progress/last_error/next_retry_at`: SWR-метаданные.
### 3.2 EgressIdentityResponse
```json
{
"ok": true,
"message": "ok",
"item": {
"scope": "adguardvpn",
"source": "adguardvpn",
"source_id": "",
"ip": "198.51.100.5",
"country_code": "NL",
"country_name": "Netherlands",
"updated_at": "2026-03-10T07:20:00Z",
"stale": false,
"refresh_in_progress": false,
"last_error": "",
"next_retry_at": ""
}
}
```
### 3.3 EgressIdentityRefreshRequest
```json
{
"scopes": ["adguardvpn", "transport:sg-realnetns"],
"force": false
}
```
### 3.4 EgressIdentityRefreshResponse
```json
{
"ok": true,
"message": "refresh queued",
"count": 2,
"queued": 1,
"skipped": 1,
"items": [
{
"scope": "adguardvpn",
"status": "queued",
"queued": true,
"reason": ""
},
{
"scope": "transport:sg-realnetns",
"status": "skipped",
"queued": false,
"reason": "throttled or already fresh"
}
]
}
```
## 4) Endpoint-ы
### 4.1 `GET /api/v1/egress/identity`
Назначение:
- Получить текущий snapshot egress identity для одного scope.
Query:
- `scope` (required): `adguardvpn|transport:<id>|system`
- `refresh=1` (optional): best-effort trigger фонового refresh перед возвратом snapshot.
Response:
- `200 + EgressIdentityResponse`.
Ошибки запроса:
- `400` при невалидном `scope`.
### 4.2 `POST /api/v1/egress/identity/refresh`
Назначение:
- Поставить refresh в очередь для одного или нескольких scope без блокировки UI.
Request body:
- `scopes[]` optional (если пусто -> refresh всех известных scope);
- `force` optional (`true` игнорирует freshness TTL, но уважает single-flight lock).
Response:
- `200 + EgressIdentityRefreshResponse`.
Ошибки запроса:
- `400` bad json / invalid scope format.
## 5) Правила freshness/SWR
- Refresh делается в фоне, UI получает последний cache snapshot мгновенно.
- Для каждого scope:
- single-flight (не запускать параллельные одинаковые refresh);
- backoff при ошибках;
- `stale=true` если snapshot устарел или нет новых данных;
- `next_retry_at` выставляется при backoff.
- Рекомендуемая стратегия UI:
- сразу рисовать cache (`GET`),
- отдельным действием триггерить `POST .../refresh`,
- обновляться по SSE/ручному poll.
## 6) SSE событие
Событие для подписчиков:
- `egress_identity_changed`
Payload (минимум):
```json
{
"scope": "transport:sg-realnetns",
"ip": "203.0.113.10",
"country_code": "SG",
"country_name": "Singapore",
"updated_at": "2026-03-10T07:20:00Z",
"stale": false,
"last_error": ""
}
```
## 7) Отрисовка флага
- Backend возвращает только `country_code`.
- Флаг рендерится в UI из `country_code` (desktop/web/mobile одинаково).
- Если `country_code` пустой/невалидный: UI показывает `N/A` без флага.
## 8) Минимальные критерии готовности E6.1
- Документирован единый контракт `GET/POST` для egress identity.
- Зафиксированы scope и поля snapshot.
- Зафиксированы правила SWR и SSE-событие.
- Явно указано, что флаг строится в UI из `country_code`.

View File

@@ -0,0 +1,140 @@
# F1 План рефакторинга и модульности
Дата: 2026-03-07
Статус: planned
Владелец: Engineering
## 1) Цель
- Уменьшить размер и связность крупных файлов без изменения поведения приложения.
- Подготовить кодовую базу к быстрому развитию desktop/web/mobile поверх одного Go API.
## 2) Текущие "горячие" файлы
- `selective-vpn-gui/vpn_dashboard_qt.py``6103` строк (до старта F1.4; после текущего разреза: `116` + `main_window/ui_shell_mixin.py` + `main_window/singbox/*` + `main_window/runtime_actions_mixin.py`).
- `selective-vpn-gui/api_client.py``2220` строк.
- `selective-vpn-gui/dashboard_controller.py``1430` строк.
- `selective-vpn-api/app/transport_handlers.go``1984` строки.
- `selective-vpn-api/app/server.go` (legacy) — центральная связка entrypoints/bootstrap/routes, высокий риск регрессий при точечных изменениях.
## 3) Принципы разрезания
- Поведение не меняем: сначала вынос кода, потом точечные улучшения.
- Модули режем по domain-границам, а не "по 500 строк".
- Сохраняем один публичный entrypoint (facade) для UI и для backend-router.
- После каждого шага прогоняем существующие smoke/e2e (без новых требований к тестам от пользователя).
## 4) План разрезания GUI
### 4.1 `vpn_dashboard_qt.py` -> `gui/main_window/*`
- Цель: вынести табы и event-обвязку в отдельные модули.
- Разделение:
- `main_window.py` — сборка окна и общий lifecycle.
- `tabs/status_tab.py`
- `tabs/vpn_tab.py`
- `tabs/routes_tab.py`
- `tabs/dns_tab.py`
- `tabs/domains_tab.py`
- `tabs/trace_tab.py`
- `services/events_bridge.py` (`EventThread`, dispatch)
- `services/locations_loader.py` (`LocationsThread`, SWR-ui orchestration)
- Критерий: исходный файл `vpn_dashboard_qt.py` становится thin-bootstrap.
- Статус: выполнено (`2026-03-10`).
- Уже сделано:
- добавлены `main_window/constants.py` и `main_window/workers.py` (shared constants + `EventThread`/`LocationsThread`);
- вынесен UI shell (`build tabs + helpers + locations/egress`) в `main_window/ui_shell_mixin.py`;
- `ui_tabs_singbox_mixin.py` дополнительно разрезан на `ui_tabs_singbox_layout_mixin.py` и `ui_tabs_singbox_editor_mixin.py`;
- SingBox-контур дополнительно разрезан на подпакет `main_window/singbox/*` (`editor`, `cards`, `links`, `runtime`) с фасадом `main_window/singbox_mixin.py`;
- вынесен runtime/refresh/actions контур в `main_window/runtime_actions_mixin.py`;
- проведён второй проход декомпозиции: `ui_tabs_*`, `runtime_{state,refresh,auth,ops}`, `singbox/{links_*,runtime_*}`; фасады оставлены для совместимости;
- `MainWindow` переведён на наследование mixin-классов, поведение сохранено; в итоге крупные GUI-модули уложены в читаемые размеры (максимум `~633` строк).
### 4.2 `dashboard_controller.py` -> `controllers/*`
- Разделение:
- `controllers/status_controller.py`
- `controllers/vpn_controller.py`
- `controllers/routes_controller.py`
- `controllers/dns_controller.py`
- `controllers/domains_controller.py`
- `controllers/transport_controller.py`
- `controllers/trace_controller.py`
- Сохраняется фасад `DashboardController` (совместимость с текущим UI-кодом).
- Статус: выполнено (`2026-03-10`).
- Что сделано:
- добавлен пакет `selective-vpn-gui/controllers/` (`core + views + domain mixin-модули`);
- `selective-vpn-gui/dashboard_controller.py` переведён в thin-facade с прежней точкой входа;
- `py_compile` и импорты UI-модулей (`vpn_dashboard_qt.py`, `traffic_mode_dialog.py`, `dns_benchmark_dialog.py`) проходят.
### 4.3 `api_client.py` -> `api/*`
- Разделение:
- `api/client.py` (HTTP/SSE base + shared helpers)
- `api/models.py` (dataclasses)
- `api/status.py`, `api/vpn.py`, `api/routes.py`, `api/traffic.py`, `api/dns.py`, `api/domains.py`, `api/trace.py`
- `api/transport.py` facade + `api/transport_clients.py`, `api/transport_policy.py`, `api/transport_singbox.py`
- Сохраняется фасад `ApiClient` (старая точка импорта для UI).
- Статус: выполнено (`2026-03-10`).
- Что сделано:
- добавлен пакет `selective-vpn-gui/api/` с доменными mixin-модулями;
- `api_client.py` переведён в backward-compatible facade (legacy imports сохранены);
- `api/client.py` сокращён до base-слоя, доменные методы вынесены по подпапкам;
- `api/transport.py` разрезан на subdomain-модули с сохранением класса `TransportApiMixin`.
## 5) План разрезания Go-ядра
### 5.1 `transport_handlers.go` -> `app/transport_*`
- Разделение:
- `transport_handlers_clients.go` (CRUD/lifecycle/health/metrics)
- `transport_handlers_policy.go` (validate/apply/rollback/conflicts)
- `transport_validator.go` (normalize + conflict detection)
- `transport_confirm_token.go` (token lifecycle)
- `transport_state_store.go` (load/save state/snapshots)
- Сохраняем текущие endpoint-path и response-контракт без изменений.
### 5.2 `server.go` -> `app/entrypoints.go + api_bootstrap.go + api_routes.go`
- Цель: развязать entrypoint-логику (`Run*CLI`), bootstrap API-сервера и registry маршрутов.
- Статус: выполнено (`2026-03-10`).
- Состав:
- `entrypoints.go``Run`, `RunAPIServer`, `RunRoutesUpdateCLI`, `RunRoutesClearCLI`, `RunAutoloopCLI`, legacy-dispatch;
- `api_bootstrap.go` — bootstrap и lifecycle HTTP-сервера (`runAPIServerAtAddr`);
- `api_routes.go` — thin-facade `registerAPIRoutes`;
- `api_routes_*.go` — доменные registrars (`core`, `traffic`, `transport`, `trace`, `dns`, `vpn`) без изменения endpoint-path.
### 5.3 `app/*` -> подпакеты runtime (`app/cli`, `app/bootstrap`)
- Цель: убрать "кучу" в корне `app` и перенести исполнимые runner-модули в отдельные папки, сохранив фасады в `app`.
- Статус: выполнено (`2026-03-10`).
- Состав:
- `app/cli/*``routes-update`, `routes-clear`, `autoloop` раннеры с dependency-injection (callback deps);
- `app/bootstrap/server_runner.go` — единый HTTP server runner;
- `app/entrypoints.go` и `app/api_bootstrap.go` оставлены thin-facade слоями.
### 5.4 `transport_handlers.go` -> модульные transport файлы
- Цель: убрать крупнейший монолит transport control-plane без изменения endpoint-контракта.
- Статус: выполнено (`2026-03-10`).
- Состав:
- `transport_shared.go` — state/version constants, mutex, общие типы;
- `transport_handlers_clients.go` — clients CRUD/lifecycle/netns toggle handlers;
- `transport_handlers_policy.go` — policy validate/apply/rollback/conflicts/capabilities handlers;
- `transport_policy_validate.go` — normalize/validate/diff/conflict helpers;
- `transport_client_runtime.go` — runtime/health/lifecycle/allocation helpers;
- `transport_tokens_state.go` — confirm-token и state persistence.
### 5.5 Подпакеты transport (без циклических зависимостей)
- Цель: выносить переиспользуемые части transport-логики в подпапки только там, где не возникает import-cycle с `app`.
- Статус: in_progress (`2026-03-10`).
- Уже вынесено:
- `app/transporttoken/store.go` — confirm-token store (`issue/consume/ttl cleanup`) и token generator.
- Дальше:
- выделить в подпакеты часть stateless helper-функций (при сохранении фасадов в `app`).
## 6) Порядок выполнения (безопасный)
1. `api_client.py` (минимальный риск для UI, проще ревьюить).
2. `dashboard_controller.py`.
3. `vpn_dashboard_qt.py` (таб за табом).
4. `transport_handlers.go` в Go.
## 7) Definition of Done
- Все текущие smoke/e2e проходят:
- `./tests/run_all.sh`
- плюс ручная проверка пользователем в desktop приложении.
- Нет изменений API-контракта (`/api/v1/*`) и event-kind.
- В каждом крупном модуле остаётся читаемый размер (ориентир `< 700` строк; предпочтительно `< 500`).
## 8) Дополнительно (после F1)
- Добавить settings-механику видимости вкладок протоколов (`SingBox`, `DNSTT`, `Phoenix`) через `QSettings`/profile, чтобы включать только нужные модули UI.

View File

@@ -0,0 +1,36 @@
# F3 План: библиотека модулей Go-ядра (deferred)
Дата: 2026-03-14
Статус: deferred
Владелец: Engineering
## 1) Цель
- Подготовить ядро к формату внутренней библиотеки модулей, чтобы backend собирался из чётких переиспользуемых пакетов.
- Снизить связность `app/*` и закрепить стабильные интерфейсы orchestration/PBR/transport/resolver.
## 2) Принцип scope
- `linux-first`: библиотека ориентирована на Linux runtime backend.
- `netns` — отладочный/инфраструктурный модуль для backend/desktop тестового контура.
- `mobile (iOS/Android)` не является целью для `netns`-модуля.
- Для mobile не планируется `netns` слой: только вызовы backend API (control-plane), без platform-specific system hooks.
- GUI/Web/Mobile должны использовать только API control-plane, а не прямые системные модули.
## 3) Целевые пакеты
- `pkg/orchestrator` — lifecycle orchestration (`prepare/start/stop/restart/rollback`) и locking.
- `pkg/transport` — shared transport-модели/валидация/runtime helpers.
- `pkg/pbr` — marks/tables/prefs allocator + compiler.
- `pkg/resolver` — resolver policy/execution primitives.
- `pkg/netnsdebug` (или `pkg/netns`) — Linux-only netns setup/cleanup helpers для debug/test-contour.
## 4) Шаги реализации (будущий этап)
1. Выделить публичные интерфейсы зависимостей (runner, nft/ip adapters, state store, logger).
2. Перенести код в `pkg/*` без изменения поведения.
3. Оставить `app/*` как thin-facade + HTTP слой.
4. Добавить compile/runtime guardrails для Linux-only модулей.
5. Обновить runbook миграции и интеграционные чеклисты.
## 5) Критерии готовности
- Backend-сборка не зависит от монолитных `app/*` helper-блоков.
- `netns` модуль чётко помечен как Linux-only и debug/test-oriented.
- Внешние API (`/api/v1/*`) не меняются.
- `go test ./...` зелёный после миграции.

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -euo pipefail
# Runtime dependency checker for selective-vpn-api.
# Note: go.mod tracks only Go modules. External services/binaries are checked here.
strict=0
if [[ "${1:-}" == "--strict" ]]; then
strict=1
fi
missing_required=0
warnings=0
ok() { printf 'OK %s\n' "$1"; }
warn() { printf 'WARN %s\n' "$1"; warnings=$((warnings + 1)); }
fail() { printf 'MISS %s\n' "$1"; missing_required=$((missing_required + 1)); }
check_cmd_required() {
local name="$1"
if command -v "$name" >/dev/null 2>&1; then
ok "cmd:$name"
else
fail "cmd:$name (required)"
fi
}
check_cmd_optional() {
local name="$1"
if command -v "$name" >/dev/null 2>&1; then
ok "cmd:$name"
else
warn "cmd:$name (optional)"
fi
}
check_bin_required() {
local path="$1"
if [[ -x "$path" ]]; then
ok "bin:$path"
else
fail "bin:$path (required)"
fi
}
check_bin_optional_any() {
local title="$1"; shift
local found=""
local p
for p in "$@"; do
if [[ -x "$p" ]]; then
found="$p"
break
fi
done
if [[ -n "$found" ]]; then
ok "bin:$title -> $found"
else
warn "bin:$title (optional)"
fi
}
check_unit_required() {
local unit="$1"
if ! command -v systemctl >/dev/null 2>&1; then
fail "unit:$unit (required, systemctl missing)"
return
fi
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q "$unit"; then
ok "unit:$unit"
else
fail "unit:$unit (required, not installed)"
fi
}
check_unit_optional() {
local unit="$1"
if ! command -v systemctl >/dev/null 2>&1; then
warn "unit:$unit (systemctl missing)"
return
fi
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q "$unit"; then
ok "unit:$unit"
else
warn "unit:$unit (optional, not installed)"
fi
}
printf '== Core required ==\n'
check_cmd_required systemctl
check_cmd_required nft
check_cmd_required ip
check_cmd_required curl
check_bin_required /usr/local/bin/adguardvpn-cli-root
printf '\n== Core optional/recommended ==\n'
check_cmd_optional nsenter
check_cmd_optional wget
check_cmd_optional ps
check_cmd_optional ipset
printf '\n== Transport binaries (optional by enabled client kind) ==\n'
check_bin_optional_any sing-box /usr/local/bin/sing-box /usr/bin/sing-box
check_bin_optional_any dnstt-client /usr/local/bin/dnstt-client /usr/bin/dnstt-client
check_bin_optional_any phoenix-client /usr/local/bin/phoenix-client /usr/bin/phoenix-client
printf '\n== Service units required for current production path ==\n'
check_unit_required singbox@.service
printf '\n== Service units optional by deployment profile ==\n'
check_unit_optional adguardvpn-autoconnect.service
check_unit_optional smartdns-local.service
check_unit_optional selective-vpn2@.service
check_unit_optional dnstt-client.service
check_unit_optional phoenix-client.service
check_unit_optional sing-box.service
printf '\n== Summary ==\n'
printf 'missing_required=%d warnings=%d\n' "$missing_required" "$warnings"
if (( strict == 1 )); then
if (( missing_required > 0 || warnings > 0 )); then
exit 1
fi
exit 0
fi
if (( missing_required > 0 )); then
exit 2
fi
exit 0

View File

@@ -0,0 +1,114 @@
# Transport Packaging Scripts
Эти скрипты реализуют MVP для `manual + pinned` доставки transport-бинарей
в режиме `runtime_mode=exec`.
## Файлы
- `manifest.example.json` — шаблон pinned-манифеста (заполнить реальными версиями/URL/checksum).
- `manifest.production.json` — pinned production-манифест с зафиксированными версиями и checksum.
- `source_policy.example.json` — шаблон trusted-source/signature policy.
- `source_policy.production.json` — production policy с trusted URL-prefix и режимами подписи.
- `update.sh` — ручной update (скачивание, checksum verify, атомарный switch symlink, history).
- `auto_update.sh` — opt-in обёртка над `update.sh` (interval gate + lock + jitter).
- `rollback.sh` — откат на предыдущую версию из history.
- `systemd/transport-packaging-auto-update.{service,timer}` — шаблоны для systemd расписания.
## Быстрый старт
1. Выберите манифест:
- production: `scripts/transport-packaging/manifest.production.json`;
- кастомный: скопируйте `manifest.example.json` и заполните значения.
2. Для кастомного манифеста заполните:
- `enabled=true` для нужных компонентов;
- `version`, `url`, `sha256` для каждой target-платформы.
3. Выполните update:
```bash
./scripts/transport-packaging/update.sh \
--manifest /path/to/manifest.json \
--source-policy ./scripts/transport-packaging/source_policy.production.json \
--component singbox,dnstt \
--target linux-amd64
```
4. При проблеме откатите:
```bash
./scripts/transport-packaging/rollback.sh \
--bin-root /opt/selective-vpn/bin \
--component singbox,dnstt
```
## Поведение update.sh
- Поддерживает `packaging_profile=system|bundled` в API-контракте.
- Для `bundled` активный бинарь переключается symlink в `bin_root`.
- История обновлений: `BIN_ROOT/.packaging/<component>.history`.
- Fail-fast валидация checksum (`sha256sum`) обязательна.
- Trusted source policy:
- `--source-policy` ограничивает допустимые URL (`https`, host/prefix allowlist).
- для `manifest.production.json` policy подхватывается автоматически из `source_policy.production.json`.
- Signature policy:
- настраивается через `policy.signature.default_mode` и `components.<name>.signature_mode` (`off|optional|required`);
- поддержан `signature.type=openssl-sha256` (detached signature);
- для `required` нужны поля `signature.url` + `signature.public_key_path` (и опционально `signature.sha256`).
- Staged rollout / canary:
- `target.rollout.stage`: `stable|canary` (default `stable`);
- `target.rollout.percent`: `0..100` (default `100`);
- runtime-флаги: `--rollout-stage`, `--cohort-id`, `--force-rollout`, `--canary`.
## Auto-update opt-in
- По умолчанию выключен (`--enabled false`).
- Скрипт `auto_update.sh`:
- запускает `update.sh` только при `enabled=true`;
- защищён lock'ом (`flock`) от параллельных запусков;
- поддерживает интервал запуска (`--min-interval-sec`) и jitter (`--jitter-sec`);
- хранит state в `.../.packaging/auto-update` (`last_run_epoch`, `last_success_epoch`, `last_error`).
Пример ручного запуска:
```bash
./scripts/transport-packaging/auto_update.sh \
--enabled true \
--manifest ./scripts/transport-packaging/manifest.production.json \
--source-policy ./scripts/transport-packaging/source_policy.production.json \
--component singbox,phoenix \
--min-interval-sec 21600 \
--jitter-sec 300
```
Шаблон systemd:
1. Скопировать `systemd/transport-packaging-auto-update.service` и `.timer` в `/etc/systemd/system/`.
2. Скопировать `systemd/transport-packaging-auto-update.env.example` в `/etc/selective-vpn/transport-packaging-auto-update.env`.
3. Включить timer:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now transport-packaging-auto-update.timer
```
## Примеры rollout
```bash
# Установить только stable targets
./scripts/transport-packaging/update.sh \
--manifest ./scripts/transport-packaging/manifest.production.json \
--rollout-stage stable
# Установить только canary targets для конкретного cohort
./scripts/transport-packaging/update.sh \
--manifest /path/to/manifest-with-canary.json \
--rollout-stage canary \
--cohort-id 5
```
## Ограничения MVP
- Обновление запускается вручную (без авто-таймера).
- Нет фонового scheduler/каналов обновления.
- В `manifest.production.json` компонент `dnstt` выключен по умолчанию (`enabled=false`), т.к. текущий источник prebuilt-бинарей не является официальным release upstream.

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UPDATER="${SCRIPT_DIR}/update.sh"
ENABLED="false"
MANIFEST="${SCRIPT_DIR}/manifest.production.json"
SOURCE_POLICY=""
BIN_ROOT="/opt/selective-vpn/bin"
TARGET=""
COMPONENTS_RAW=""
ROLLOUT_STAGE="stable"
COHORT_ID=""
FORCE_ROLLOUT=0
SIGNATURE_MODE=""
MIN_INTERVAL_SEC=21600
JITTER_SEC=0
FORCE_NOW=0
DRY_RUN=0
STATE_DIR=""
LOCK_FILE=""
usage() {
cat <<'EOF'
Usage:
auto_update.sh [--enabled true|false] [--manifest PATH] [--source-policy PATH]
[--bin-root DIR] [--target OS-ARCH] [--component NAME[,NAME...]]
[--rollout-stage stable|canary|any] [--cohort-id 0..99]
[--signature-mode off|optional|required]
[--min-interval-sec N] [--jitter-sec N]
[--state-dir DIR] [--lock-file PATH]
[--force-rollout] [--force-now] [--dry-run]
Description:
Opt-in scheduler wrapper around update.sh.
Default behavior is disabled; when enabled, it enforces interval gating and lock.
Examples:
./scripts/transport-packaging/auto_update.sh --enabled true
./scripts/transport-packaging/auto_update.sh --enabled true --component singbox,phoenix --min-interval-sec 3600
EOF
}
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "[transport-auto-update] missing required command: ${cmd}" >&2
exit 1
fi
}
bool_normalize() {
local raw
raw="$(echo "$1" | tr '[:upper:]' '[:lower:]' | xargs)"
case "$raw" in
1|true|yes|on) echo "true" ;;
0|false|no|off|"") echo "false" ;;
*)
echo "[transport-auto-update] invalid boolean value: ${1}" >&2
exit 1
;;
esac
}
int_validate_non_negative() {
local name="$1"
local value="$2"
if [[ ! "$value" =~ ^[0-9]+$ ]]; then
echo "[transport-auto-update] ${name} must be a non-negative integer" >&2
exit 1
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--enabled)
ENABLED="${2:-}"
shift 2
;;
--manifest)
MANIFEST="${2:-}"
shift 2
;;
--source-policy)
SOURCE_POLICY="${2:-}"
shift 2
;;
--bin-root)
BIN_ROOT="${2:-}"
shift 2
;;
--target)
TARGET="${2:-}"
shift 2
;;
--component)
COMPONENTS_RAW="${2:-}"
shift 2
;;
--rollout-stage)
ROLLOUT_STAGE="${2:-}"
shift 2
;;
--cohort-id)
COHORT_ID="${2:-}"
shift 2
;;
--signature-mode)
SIGNATURE_MODE="${2:-}"
shift 2
;;
--min-interval-sec)
MIN_INTERVAL_SEC="${2:-}"
shift 2
;;
--jitter-sec)
JITTER_SEC="${2:-}"
shift 2
;;
--state-dir)
STATE_DIR="${2:-}"
shift 2
;;
--lock-file)
LOCK_FILE="${2:-}"
shift 2
;;
--force-rollout)
FORCE_ROLLOUT=1
shift
;;
--force-now)
FORCE_NOW=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "[transport-auto-update] unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
ENABLED="$(bool_normalize "$ENABLED")"
int_validate_non_negative "min-interval-sec" "$MIN_INTERVAL_SEC"
int_validate_non_negative "jitter-sec" "$JITTER_SEC"
if [[ "$ENABLED" != "true" ]]; then
echo "[transport-auto-update] disabled (opt-in mode)"
exit 0
fi
if [[ ! -x "$UPDATER" ]]; then
echo "[transport-auto-update] updater not found or not executable: ${UPDATER}" >&2
exit 1
fi
if [[ ! -f "$MANIFEST" ]]; then
echo "[transport-auto-update] manifest not found: ${MANIFEST}" >&2
exit 1
fi
if [[ -n "$SOURCE_POLICY" && ! -f "$SOURCE_POLICY" ]]; then
echo "[transport-auto-update] source policy not found: ${SOURCE_POLICY}" >&2
exit 1
fi
require_cmd flock
require_cmd date
if [[ -z "$STATE_DIR" ]]; then
STATE_DIR="${BIN_ROOT}/.packaging/auto-update"
fi
mkdir -p "$STATE_DIR"
if [[ -z "$LOCK_FILE" ]]; then
LOCK_FILE="${STATE_DIR}/auto-update.lock"
fi
last_run_file="${STATE_DIR}/last_run_epoch"
last_success_file="${STATE_DIR}/last_success_epoch"
last_error_file="${STATE_DIR}/last_error"
echo "[transport-auto-update] enabled=true"
echo "[transport-auto-update] manifest=${MANIFEST}"
echo "[transport-auto-update] state_dir=${STATE_DIR}"
echo "[transport-auto-update] min_interval_sec=${MIN_INTERVAL_SEC}"
if [[ -n "$COMPONENTS_RAW" ]]; then
echo "[transport-auto-update] components=${COMPONENTS_RAW}"
fi
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "[transport-auto-update] skip: another auto-update process is running"
exit 0
fi
now_epoch="$(date +%s)"
last_run_epoch=0
if [[ -f "$last_run_file" ]]; then
last_run_epoch="$(cat "$last_run_file" 2>/dev/null || echo 0)"
[[ "$last_run_epoch" =~ ^[0-9]+$ ]] || last_run_epoch=0
fi
elapsed=$((now_epoch - last_run_epoch))
if [[ "$FORCE_NOW" -ne 1 && "$elapsed" -lt "$MIN_INTERVAL_SEC" ]]; then
echo "[transport-auto-update] skip: interval gate (elapsed=${elapsed}s < ${MIN_INTERVAL_SEC}s)"
exit 0
fi
if [[ "$JITTER_SEC" -gt 0 && "$FORCE_NOW" -ne 1 ]]; then
jitter=$((RANDOM % (JITTER_SEC + 1)))
if [[ "$jitter" -gt 0 ]]; then
echo "[transport-auto-update] jitter sleep: ${jitter}s"
sleep "$jitter"
fi
fi
cmd=("$UPDATER" "--manifest" "$MANIFEST" "--bin-root" "$BIN_ROOT" "--rollout-stage" "$ROLLOUT_STAGE")
if [[ -n "$SOURCE_POLICY" ]]; then
cmd+=("--source-policy" "$SOURCE_POLICY")
fi
if [[ -n "$TARGET" ]]; then
cmd+=("--target" "$TARGET")
fi
if [[ -n "$COMPONENTS_RAW" ]]; then
cmd+=("--component" "$COMPONENTS_RAW")
fi
if [[ -n "$COHORT_ID" ]]; then
cmd+=("--cohort-id" "$COHORT_ID")
fi
if [[ -n "$SIGNATURE_MODE" ]]; then
cmd+=("--signature-mode" "$SIGNATURE_MODE")
fi
if [[ "$FORCE_ROLLOUT" -eq 1 ]]; then
cmd+=("--force-rollout")
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
cmd+=("--dry-run")
fi
echo "[transport-auto-update] run: ${cmd[*]}"
if "${cmd[@]}"; then
date +%s >"$last_run_file"
date +%s >"$last_success_file"
: >"$last_error_file"
echo "[transport-auto-update] success"
exit 0
fi
rc=$?
date +%s >"$last_run_file"
{
echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "exit_code=${rc}"
echo "cmd=${cmd[*]}"
} >"$last_error_file"
echo "[transport-auto-update] failed rc=${rc}" >&2
exit "$rc"

View File

@@ -0,0 +1,108 @@
{
"schema_version": 1,
"updated_at": "2026-03-07T00:00:00Z",
"components": {
"singbox": {
"enabled": false,
"binary_name": "sing-box",
"targets": {
"linux-amd64": {
"version": "REPLACE_ME",
"url": "https://example.invalid/sing-box-linux-amd64",
"sha256": "REPLACE_ME_SHA256_64_HEX",
"asset_type": "raw",
"rollout": {
"stage": "stable",
"percent": 100
},
"signature": {
"type": "openssl-sha256",
"url": "https://example.invalid/sing-box-linux-amd64.sig",
"sha256": "REPLACE_ME_SIGNATURE_SHA256_64_HEX",
"public_key_path": "/etc/selective-vpn/keys/singbox-release.pub"
}
},
"linux-arm64": {
"version": "REPLACE_ME",
"url": "https://example.invalid/sing-box-linux-arm64",
"sha256": "REPLACE_ME_SHA256_64_HEX",
"asset_type": "raw",
"rollout": {
"stage": "stable",
"percent": 100
},
"signature": {
"type": "openssl-sha256",
"url": "https://example.invalid/sing-box-linux-arm64.sig",
"sha256": "REPLACE_ME_SIGNATURE_SHA256_64_HEX",
"public_key_path": "/etc/selective-vpn/keys/singbox-release.pub"
}
}
}
},
"dnstt": {
"enabled": false,
"binary_name": "dnstt-client",
"targets": {
"linux-amd64": {
"version": "REPLACE_ME",
"url": "https://example.invalid/dnstt-client-linux-amd64",
"sha256": "REPLACE_ME_SHA256_64_HEX",
"asset_type": "raw",
"rollout": {
"stage": "stable",
"percent": 100
}
},
"linux-arm64": {
"version": "REPLACE_ME",
"url": "https://example.invalid/dnstt-client-linux-arm64",
"sha256": "REPLACE_ME_SHA256_64_HEX",
"asset_type": "raw",
"rollout": {
"stage": "stable",
"percent": 100
}
}
}
},
"phoenix": {
"enabled": false,
"binary_name": "phoenix-client",
"targets": {
"linux-amd64": {
"version": "REPLACE_ME",
"url": "https://example.invalid/phoenix-client-linux-amd64",
"sha256": "REPLACE_ME_SHA256_64_HEX",
"asset_type": "raw",
"rollout": {
"stage": "stable",
"percent": 100
},
"signature": {
"type": "openssl-sha256",
"url": "https://example.invalid/phoenix-client-linux-amd64.sig",
"sha256": "REPLACE_ME_SIGNATURE_SHA256_64_HEX",
"public_key_path": "/etc/selective-vpn/keys/phoenix-release.pub"
}
},
"linux-arm64": {
"version": "REPLACE_ME",
"url": "https://example.invalid/phoenix-client-linux-arm64",
"sha256": "REPLACE_ME_SHA256_64_HEX",
"asset_type": "raw",
"rollout": {
"stage": "stable",
"percent": 100
},
"signature": {
"type": "openssl-sha256",
"url": "https://example.invalid/phoenix-client-linux-arm64.sig",
"sha256": "REPLACE_ME_SIGNATURE_SHA256_64_HEX",
"public_key_path": "/etc/selective-vpn/keys/phoenix-release.pub"
}
}
}
}
}
}

View File

@@ -0,0 +1,88 @@
{
"schema_version": 1,
"updated_at": "2026-03-07T14:35:00Z",
"components": {
"singbox": {
"enabled": true,
"binary_name": "sing-box",
"targets": {
"linux-amd64": {
"version": "v1.13.2",
"url": "https://github.com/SagerNet/sing-box/releases/download/v1.13.2/sing-box-1.13.2-linux-amd64.tar.gz",
"sha256": "679fd29c38c6cdd33908a7e52cb277ecfb8e214b6384a93cc8f8d5b55bc1c894",
"asset_type": "tar.gz",
"asset_binary_path": "sing-box-1.13.2-linux-amd64/sing-box",
"rollout": {
"stage": "stable",
"percent": 100
}
},
"linux-arm64": {
"version": "v1.13.2",
"url": "https://github.com/SagerNet/sing-box/releases/download/v1.13.2/sing-box-1.13.2-linux-arm64.tar.gz",
"sha256": "2e784c913b57369d891b6cc7be5e4a1457fee22978054c5e01d280ba864a2d92",
"asset_type": "tar.gz",
"asset_binary_path": "sing-box-1.13.2-linux-arm64/sing-box",
"rollout": {
"stage": "stable",
"percent": 100
}
}
}
},
"dnstt": {
"enabled": false,
"binary_name": "dnstt-client",
"targets": {
"linux-amd64": {
"version": "2025-06-22",
"url": "https://dnstt.network/dnstt-client-linux-amd64",
"sha256": "b583b8e68c4b4e93088352fd5160f4d6a8529a4be8db08447d8b2bc0d16bcf6f",
"asset_type": "raw",
"rollout": {
"stage": "stable",
"percent": 100
}
},
"linux-arm64": {
"version": "2025-06-22",
"url": "https://dnstt.network/dnstt-client-linux-arm64",
"sha256": "73762a59a9d2f29ddba3f09e28c430db5146eaa2b7479a27a6f61d68d30ff433",
"asset_type": "raw",
"rollout": {
"stage": "stable",
"percent": 100
}
}
}
},
"phoenix": {
"enabled": true,
"binary_name": "phoenix-client",
"targets": {
"linux-amd64": {
"version": "v1.0.1",
"url": "https://github.com/Fox-Fig/phoenix/releases/download/v1.0.1/phoenix-client-linux-amd64.zip",
"sha256": "2de52fef373c4e1a0d569551200903366023088e384fda6e6254f96d016be1cb",
"asset_type": "zip",
"asset_binary_path": "phoenix-client",
"rollout": {
"stage": "stable",
"percent": 100
}
},
"linux-arm64": {
"version": "v1.0.1",
"url": "https://github.com/Fox-Fig/phoenix/releases/download/v1.0.1/phoenix-client-linux-arm64.zip",
"sha256": "8e0e148dc44fae9372a8d0583bc2b70b97470f3ee270b610845952b30aeb6e8f",
"asset_type": "zip",
"asset_binary_path": "phoenix-client",
"rollout": {
"stage": "stable",
"percent": 100
}
}
}
}
}
}

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
BIN_ROOT="/opt/selective-vpn/bin"
COMPONENTS_RAW=""
DRY_RUN=0
usage() {
cat <<'EOF'
Usage:
rollback.sh [--bin-root DIR] [--component NAME[,NAME...]] [--dry-run]
Description:
Rolls back transport companion binaries by one history step.
Uses history files created by update.sh in BIN_ROOT/.packaging/*.history.
Examples:
./scripts/transport-packaging/rollback.sh --component singbox
./scripts/transport-packaging/rollback.sh --bin-root /tmp/svpn-bin --dry-run
EOF
}
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "[transport-rollback] missing required command: ${cmd}" >&2
exit 1
fi
}
collect_components() {
local state_dir="$1"
local out=()
if [[ -n "$COMPONENTS_RAW" ]]; then
IFS=',' read -r -a out <<< "$COMPONENTS_RAW"
else
if [[ -d "$state_dir" ]]; then
while IFS= read -r file; do
local base
base="$(basename "$file")"
out+=("${base%.history}")
done < <(find "$state_dir" -maxdepth 1 -type f -name '*.history' | sort)
fi
fi
printf '%s\n' "${out[@]}"
}
rollback_component() {
local component="$1"
local state_dir="$2"
local history_file="${state_dir}/${component}.history"
if [[ ! -f "$history_file" ]]; then
echo "[transport-rollback] ${component}: history file not found (${history_file})" >&2
return 1
fi
mapfile -t lines < "$history_file"
if [[ "${#lines[@]}" -lt 2 ]]; then
echo "[transport-rollback] ${component}: not enough history entries to rollback" >&2
return 1
fi
local current_line prev_line
current_line="${lines[${#lines[@]}-1]}"
prev_line="${lines[${#lines[@]}-2]}"
IFS='|' read -r _ts_curr bin_curr version_curr target_curr <<< "$current_line"
IFS='|' read -r _ts_prev bin_prev version_prev target_prev <<< "$prev_line"
local binary_name="${bin_curr:-$bin_prev}"
local active_link="${BIN_ROOT}/${binary_name}"
if [[ -z "$binary_name" || -z "$target_prev" ]]; then
echo "[transport-rollback] ${component}: invalid history lines" >&2
return 1
fi
if [[ ! -e "$target_prev" ]]; then
echo "[transport-rollback] ${component}: previous target does not exist: ${target_prev}" >&2
return 1
fi
echo "[transport-rollback] ${component}: ${binary_name} ${version_curr} -> ${version_prev}"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[transport-rollback] DRY-RUN ${component}: switch ${active_link} -> ${target_prev}"
return 0
fi
ln -sfn "$target_prev" "$active_link"
if [[ "${#lines[@]}" -eq 2 ]]; then
printf '%s\n' "${lines[0]}" > "$history_file"
else
printf '%s\n' "${lines[@]:0:${#lines[@]}-1}" > "$history_file"
fi
echo "[transport-rollback] ${component}: active -> ${target_prev}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--bin-root)
BIN_ROOT="${2:-}"
shift 2
;;
--component)
COMPONENTS_RAW="${2:-}"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "[transport-rollback] unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
require_cmd ln
require_cmd find
require_cmd basename
state_dir="${BIN_ROOT}/.packaging"
mapfile -t components < <(collect_components "$state_dir")
if [[ "${#components[@]}" -eq 0 ]]; then
echo "[transport-rollback] no components selected/found" >&2
exit 1
fi
echo "[transport-rollback] bin_root=${BIN_ROOT}"
if [[ -n "$COMPONENTS_RAW" ]]; then
echo "[transport-rollback] components=${COMPONENTS_RAW}"
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[transport-rollback] mode=dry-run"
fi
for component in "${components[@]}"; do
component="$(echo "$component" | xargs)"
if [[ -z "$component" ]]; then
continue
fi
rollback_component "$component" "$state_dir"
done
echo "[transport-rollback] done"

View File

@@ -0,0 +1,33 @@
{
"schema_version": 1,
"updated_at": "2026-03-07T18:00:00Z",
"require_https": true,
"allow_file_scheme": false,
"allowed_schemes": ["https"],
"default_allowed_hosts": [],
"default_allowed_url_prefixes": [],
"signature": {
"default_mode": "off",
"allowed_types": ["openssl-sha256"]
},
"components": {
"singbox": {
"allowed_url_prefixes": [
"https://github.com/SagerNet/sing-box/releases/download/"
],
"signature_mode": "optional"
},
"phoenix": {
"allowed_url_prefixes": [
"https://github.com/Fox-Fig/phoenix/releases/download/"
],
"signature_mode": "optional"
},
"dnstt": {
"allowed_url_prefixes": [
"https://dnstt.network/"
],
"signature_mode": "off"
}
}
}

View File

@@ -0,0 +1,31 @@
{
"schema_version": 1,
"updated_at": "2026-03-07T18:00:00Z",
"require_https": true,
"allow_file_scheme": false,
"allowed_schemes": ["https"],
"default_allowed_hosts": [],
"default_allowed_url_prefixes": [],
"signature": {
"default_mode": "optional",
"allowed_types": ["openssl-sha256"]
},
"components": {
"singbox": {
"allowed_url_prefixes": [
"https://github.com/SagerNet/sing-box/releases/download/"
]
},
"phoenix": {
"allowed_url_prefixes": [
"https://github.com/Fox-Fig/phoenix/releases/download/"
]
},
"dnstt": {
"allowed_url_prefixes": [
"https://dnstt.network/"
],
"signature_mode": "off"
}
}
}

View File

@@ -0,0 +1,18 @@
# Auto-update opt-in gate.
ENABLED=false
# Defaults match production manifest/policy in repository.
MANIFEST=/opt/stack/adguardapp/scripts/transport-packaging/manifest.production.json
SOURCE_POLICY=/opt/stack/adguardapp/scripts/transport-packaging/source_policy.production.json
BIN_ROOT=/opt/selective-vpn/bin
# Optional filters.
COMPONENTS=singbox,phoenix
TARGET=linux-amd64
ROLLOUT_STAGE=stable
COHORT_ID=
SIGNATURE_MODE=
# Frequency controls.
MIN_INTERVAL_SEC=21600
JITTER_SEC=300

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Selective VPN transport packaging auto-update (opt-in)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
EnvironmentFile=-/etc/selective-vpn/transport-packaging-auto-update.env
ExecStart=/opt/stack/adguardapp/scripts/transport-packaging/auto_update.sh \
--enabled ${ENABLED:-false} \
--manifest ${MANIFEST:-/opt/stack/adguardapp/scripts/transport-packaging/manifest.production.json} \
--source-policy ${SOURCE_POLICY:-/opt/stack/adguardapp/scripts/transport-packaging/source_policy.production.json} \
--bin-root ${BIN_ROOT:-/opt/selective-vpn/bin} \
--component ${COMPONENTS:-} \
--target ${TARGET:-} \
--rollout-stage ${ROLLOUT_STAGE:-stable} \
--cohort-id ${COHORT_ID:-} \
--signature-mode ${SIGNATURE_MODE:-} \
--min-interval-sec ${MIN_INTERVAL_SEC:-21600} \
--jitter-sec ${JITTER_SEC:-300}
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Run transport packaging auto-update periodically
[Timer]
OnBootSec=5min
OnUnitActiveSec=30min
RandomizedDelaySec=5min
Persistent=true
Unit=transport-packaging-auto-update.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,687 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_MANIFEST="${SCRIPT_DIR}/manifest.example.json"
DEFAULT_SOURCE_POLICY="${SCRIPT_DIR}/source_policy.production.json"
MANIFEST="${DEFAULT_MANIFEST}"
BIN_ROOT="/opt/selective-vpn/bin"
TARGET=""
COMPONENTS_RAW=""
SOURCE_POLICY=""
SIGNATURE_MODE_OVERRIDE=""
ROLLOUT_STAGE="stable"
COHORT_ID=""
FORCE_ROLLOUT=0
DRY_RUN=0
usage() {
cat <<'EOF'
Usage:
update.sh [--manifest PATH] [--bin-root DIR] [--target OS-ARCH] [--component NAME[,NAME...]]
[--source-policy PATH] [--signature-mode off|optional|required]
[--rollout-stage stable|canary|any] [--cohort-id 0..99] [--force-rollout]
[--canary] [--dry-run]
Description:
Manual pinned updater for transport companion binaries in runtime_mode=exec.
Reads versions/urls/checksums from manifest and atomically switches active symlinks in BIN_ROOT.
Examples:
./scripts/transport-packaging/update.sh --manifest ./manifest.json --component singbox
./scripts/transport-packaging/update.sh --manifest ./manifest.json --target linux-amd64 --dry-run
./scripts/transport-packaging/update.sh --manifest ./manifest.production.json --source-policy ./source_policy.production.json
./scripts/transport-packaging/update.sh --manifest /path/to/manifest-with-canary.json --rollout-stage canary
EOF
}
normalize_os() {
local raw="$1"
raw="$(echo "$raw" | tr '[:upper:]' '[:lower:]')"
case "$raw" in
linux) echo "linux" ;;
darwin) echo "darwin" ;;
*) echo "$raw" ;;
esac
}
normalize_arch() {
local raw="$1"
raw="$(echo "$raw" | tr '[:upper:]' '[:lower:]')"
case "$raw" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
armv7l|armv7) echo "armv7" ;;
*) echo "$raw" ;;
esac
}
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "[transport-update] missing required command: ${cmd}" >&2
exit 1
fi
}
history_append_if_changed() {
local history_file="$1"
local line="$2"
local target="$3"
local last_target=""
if [[ -f "$history_file" ]]; then
last_target="$(tail -n1 "$history_file" | awk -F'|' '{print $4}')"
fi
if [[ "$last_target" == "$target" ]]; then
return 0
fi
mkdir -p "$(dirname "$history_file")"
echo "$line" >> "$history_file"
}
trim_spaces() {
echo "$1" | xargs
}
normalize_signature_mode() {
local raw
raw="$(echo "$1" | tr '[:upper:]' '[:lower:]' | xargs)"
case "$raw" in
""|off|optional|required) echo "$raw" ;;
*)
echo "[transport-update] invalid signature mode: ${raw}" >&2
exit 1
;;
esac
}
compute_cohort_id() {
local component="$1"
local target="$2"
local seed=""
if [[ -r /etc/machine-id ]]; then
seed="$(tr -d '\n' </etc/machine-id)"
fi
if [[ -z "$seed" ]]; then
seed="$(hostname 2>/dev/null || uname -n || echo "unknown-host")"
fi
local input="${seed}:${component}:${target}"
local hash
hash="$(printf '%s' "$input" | sha256sum | awk '{print $1}')"
printf '%d' $((16#${hash:0:8} % 100))
}
evaluate_policy_and_signature_mode() {
local component="$1"
local url="$2"
local sig_url="$3"
local sig_type="$4"
python3 - "$SOURCE_POLICY" "$component" "$url" "$sig_url" "$sig_type" "$SIGNATURE_MODE_OVERRIDE" <<'PY'
import json
import sys
from urllib.parse import urlparse
policy_path, component, asset_url, sig_url, sig_type_raw, mode_override_raw = sys.argv[1:]
component = component.strip()
asset_url = asset_url.strip()
sig_url = sig_url.strip()
sig_type = sig_type_raw.strip().lower()
mode_override = mode_override_raw.strip().lower()
def as_bool(value, default=False):
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
raw = value.strip().lower()
if raw in {"1", "true", "yes", "on"}:
return True
if raw in {"0", "false", "no", "off"}:
return False
return default
def as_list(value):
if value is None:
return []
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
if isinstance(value, str):
raw = value.strip()
return [raw] if raw else []
raise SystemExit("policy list value has unsupported type")
def fail(msg):
raise SystemExit(msg)
def normalize_mode(raw):
raw = (raw or "").strip().lower()
if raw == "":
return ""
if raw not in {"off", "optional", "required"}:
fail(f"signature mode must be off|optional|required (got {raw})")
return raw
policy = {}
if not policy_path:
mode = normalize_mode(mode_override) or "off"
print(mode)
raise SystemExit(0)
if policy_path:
with open(policy_path, "r", encoding="utf-8") as f:
policy = json.load(f)
if not isinstance(policy, dict):
fail("source policy must be a JSON object")
components_cfg = policy.get("components", {})
if components_cfg is None:
components_cfg = {}
if not isinstance(components_cfg, dict):
fail("policy.components must be an object")
component_cfg = components_cfg.get(component, {})
if component_cfg is None:
component_cfg = {}
if not isinstance(component_cfg, dict):
fail(f"policy.components.{component} must be an object")
require_https = as_bool(policy.get("require_https", True), default=True)
allow_file_scheme = as_bool(policy.get("allow_file_scheme", False), default=False)
allowed_schemes = [s.lower() for s in as_list(policy.get("allowed_schemes"))]
default_hosts = set(as_list(policy.get("default_allowed_hosts")))
default_prefixes = as_list(policy.get("default_allowed_url_prefixes"))
component_hosts = set(as_list(component_cfg.get("allowed_hosts")))
component_prefixes = as_list(component_cfg.get("allowed_url_prefixes"))
hosts = component_hosts if component_hosts else default_hosts
prefixes = component_prefixes if component_prefixes else default_prefixes
def validate_url(kind, raw_url):
raw_url = raw_url.strip()
if not raw_url:
return
parsed = urlparse(raw_url)
scheme = (parsed.scheme or "").lower()
if not scheme:
fail(f"{kind} URL is missing scheme: {raw_url}")
if scheme == "file":
if not allow_file_scheme:
fail(f"{kind} URL uses file:// but policy allow_file_scheme=false: {raw_url}")
return
if allowed_schemes and scheme not in allowed_schemes:
fail(f"{kind} URL scheme {scheme} is not in policy.allowed_schemes")
if require_https and scheme != "https":
fail(f"{kind} URL must use https:// by policy: {raw_url}")
host = (parsed.hostname or "").lower()
if hosts and host not in {h.lower() for h in hosts}:
fail(f"{kind} URL host {host} is not trusted for component {component}")
if prefixes and not any(raw_url.startswith(prefix) for prefix in prefixes):
fail(f"{kind} URL is not in trusted prefixes for component {component}")
validate_url("asset", asset_url)
if sig_url:
validate_url("signature", sig_url)
sig_cfg = policy.get("signature", {})
if sig_cfg is None:
sig_cfg = {}
if not isinstance(sig_cfg, dict):
fail("policy.signature must be an object")
allowed_sig_types = [s.lower() for s in as_list(sig_cfg.get("allowed_types"))]
if sig_type and allowed_sig_types and sig_type not in allowed_sig_types:
fail(f"signature type {sig_type} is not allowed by policy")
mode = normalize_mode(mode_override)
if not mode:
mode = normalize_mode(component_cfg.get("signature_mode", ""))
if not mode:
mode = normalize_mode(sig_cfg.get("default_mode", "off")) or "off"
print(mode)
PY
}
manifest_rows() {
python3 - "$MANIFEST" "$TARGET" "$COMPONENTS_RAW" <<'PY'
import json
import sys
manifest_path, target_key, components_raw = sys.argv[1], sys.argv[2], sys.argv[3]
with open(manifest_path, "r", encoding="utf-8") as f:
doc = json.load(f)
components = doc.get("components")
if not isinstance(components, dict):
raise SystemExit("manifest.components must be an object")
wanted = set()
if components_raw.strip():
for part in components_raw.split(","):
part = part.strip()
if part:
wanted.add(part)
def fail(msg: str):
raise SystemExit(msg)
seen = set()
rows = []
for name, meta in sorted(components.items()):
if wanted and name not in wanted:
continue
if not isinstance(meta, dict):
fail(f"component {name} config must be an object")
enabled = bool(meta.get("enabled", True))
if not enabled:
continue
binary_name = str(meta.get("binary_name", "")).strip()
if not binary_name:
fail(f"component {name} missing binary_name")
targets = meta.get("targets")
if not isinstance(targets, dict):
fail(f"component {name} missing targets map")
rec = targets.get(target_key)
if rec is None:
fail(f"component {name} has no target {target_key}")
if not isinstance(rec, dict):
fail(f"component {name} target {target_key} must be object")
version = str(rec.get("version", "")).strip()
url = str(rec.get("url", "")).strip()
sha256 = str(rec.get("sha256", "")).strip().lower()
asset_type = str(rec.get("asset_type", "raw")).strip().lower()
asset_binary_path = str(rec.get("asset_binary_path", "")).strip()
rollout = rec.get("rollout")
if rollout is None:
rollout = {}
if not isinstance(rollout, dict):
fail(f"component {name} target {target_key} rollout must be object")
rollout_stage = str(rollout.get("stage", "stable")).strip().lower()
rollout_percent_raw = rollout.get("percent", 100)
try:
rollout_percent = int(rollout_percent_raw)
except Exception:
fail(f"component {name} target {target_key} rollout.percent must be int")
sig = rec.get("signature")
if sig is None:
sig = {}
if not isinstance(sig, dict):
fail(f"component {name} target {target_key} signature must be object")
sig_type = str(sig.get("type", "")).strip().lower()
sig_url = str(sig.get("url", "")).strip()
sig_sha256 = str(sig.get("sha256", "")).strip().lower()
sig_public_key_path = str(sig.get("public_key_path", "")).strip()
if not version:
fail(f"component {name} target {target_key} missing version")
if not url:
fail(f"component {name} target {target_key} missing url")
if len(sha256) != 64 or any(c not in "0123456789abcdef" for c in sha256):
fail(f"component {name} target {target_key} has invalid sha256")
if asset_type not in ("raw", "tar.gz", "zip"):
fail(f"component {name} target {target_key} has unsupported asset_type={asset_type}")
if asset_type in ("tar.gz", "zip") and not asset_binary_path:
fail(f"component {name} target {target_key} requires asset_binary_path for {asset_type}")
if rollout_stage not in ("stable", "canary"):
fail(f"component {name} target {target_key} rollout.stage must be stable|canary")
if rollout_percent < 0 or rollout_percent > 100:
fail(f"component {name} target {target_key} rollout.percent must be 0..100")
if sig_type and not sig_url:
fail(f"component {name} target {target_key} signature.url is required when signature.type is set")
if sig_sha256:
if len(sig_sha256) != 64 or any(c not in "0123456789abcdef" for c in sig_sha256):
fail(f"component {name} target {target_key} signature.sha256 is invalid")
if sig_public_key_path and not sig_type:
fail(f"component {name} target {target_key} signature.type is required when signature.public_key_path is set")
rows.append((
name, binary_name, version, url, sha256, asset_type, asset_binary_path,
rollout_stage, str(rollout_percent), sig_type, sig_url, sig_sha256, sig_public_key_path
))
seen.add(name)
if wanted:
missing = sorted(wanted - seen)
if missing:
fail("missing/enabled=false components in manifest: " + ",".join(missing))
for row in rows:
print("\x1f".join(row))
PY
}
verify_asset_signature() {
local component="$1"
local asset_path="$2"
local tmp_dir="$3"
local sig_mode="$4"
local sig_type="$5"
local sig_url="$6"
local sig_sha256="$7"
local sig_public_key_path="$8"
if [[ "$sig_mode" == "off" ]]; then
return 0
fi
if [[ -z "$sig_type" || -z "$sig_url" || -z "$sig_public_key_path" ]]; then
if [[ "$sig_mode" == "required" ]]; then
echo "[transport-update] ${component}: signature is required, but signature fields are incomplete" >&2
return 1
fi
echo "[transport-update] WARN ${component}: signature_mode=${sig_mode}, signature metadata is incomplete, skip signature check"
return 0
fi
if [[ ! -f "$sig_public_key_path" ]]; then
if [[ "$sig_mode" == "required" ]]; then
echo "[transport-update] ${component}: signature public key not found: ${sig_public_key_path}" >&2
return 1
fi
echo "[transport-update] WARN ${component}: signature public key not found: ${sig_public_key_path}, skip signature check"
return 0
fi
case "$sig_type" in
openssl-sha256)
require_cmd openssl
;;
*)
if [[ "$sig_mode" == "required" ]]; then
echo "[transport-update] ${component}: unsupported signature type: ${sig_type}" >&2
return 1
fi
echo "[transport-update] WARN ${component}: unsupported signature type: ${sig_type}, skip signature check"
return 0
;;
esac
local sig_file="${tmp_dir}/asset.sig"
echo "[transport-update] ${component}: downloading signature ${sig_url}"
curl -fsSL "$sig_url" -o "$sig_file"
if [[ -n "$sig_sha256" ]]; then
echo "${sig_sha256} ${sig_file}" | sha256sum -c - >/dev/null
echo "[transport-update] ${component}: signature checksum ok"
fi
if ! openssl dgst -sha256 -verify "$sig_public_key_path" -signature "$sig_file" "$asset_path" >/dev/null 2>&1; then
echo "[transport-update] ${component}: signature verification failed (${sig_type})" >&2
return 1
fi
echo "[transport-update] ${component}: signature verified (${sig_type})"
}
download_install_binary() {
local component="$1"
local binary_name="$2"
local version="$3"
local url="$4"
local sha256="$5"
local asset_type="$6"
local asset_binary_path="$7"
local sig_mode="$8"
local sig_type="$9"
local sig_url="${10}"
local sig_sha256="${11}"
local sig_public_key_path="${12}"
local release_dir="${13}"
local release_binary="${14}"
if [[ -x "$release_binary" ]]; then
echo "[transport-update] ${component}: release already present ${release_binary}"
return 0
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[transport-update] DRY-RUN ${component}: download ${url} -> ${release_binary}"
if [[ "$sig_mode" != "off" ]]; then
echo "[transport-update] DRY-RUN ${component}: signature_mode=${sig_mode} signature_type=${sig_type:-none}"
fi
return 0
fi
local tmp_dir
tmp_dir="$(mktemp -d)"
local asset="${tmp_dir}/asset"
local unpack="${tmp_dir}/unpack"
mkdir -p "$release_dir" "$unpack"
echo "[transport-update] ${component}: downloading ${url}"
curl -fsSL "$url" -o "$asset"
echo "${sha256} ${asset}" | sha256sum -c - >/dev/null
echo "[transport-update] ${component}: checksum ok"
verify_asset_signature "$component" "$asset" "$tmp_dir" "$sig_mode" "$sig_type" "$sig_url" "$sig_sha256" "$sig_public_key_path"
local source_binary=""
case "$asset_type" in
raw)
source_binary="$asset"
;;
tar.gz)
require_cmd tar
tar -xzf "$asset" -C "$unpack"
source_binary="${unpack}/${asset_binary_path}"
;;
zip)
require_cmd unzip
unzip -q "$asset" -d "$unpack"
source_binary="${unpack}/${asset_binary_path}"
;;
*)
rm -rf "$tmp_dir"
echo "[transport-update] ${component}: unsupported asset_type ${asset_type}" >&2
return 1
;;
esac
if [[ ! -f "$source_binary" ]]; then
rm -rf "$tmp_dir"
echo "[transport-update] ${component}: binary not found in asset: ${source_binary}" >&2
return 1
fi
install -m 0755 "$source_binary" "$release_binary"
rm -rf "$tmp_dir"
echo "[transport-update] ${component}: installed ${release_binary}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--manifest)
MANIFEST="${2:-}"
shift 2
;;
--bin-root)
BIN_ROOT="${2:-}"
shift 2
;;
--target)
TARGET="${2:-}"
shift 2
;;
--component)
COMPONENTS_RAW="${2:-}"
shift 2
;;
--source-policy)
SOURCE_POLICY="${2:-}"
shift 2
;;
--signature-mode)
SIGNATURE_MODE_OVERRIDE="${2:-}"
shift 2
;;
--rollout-stage)
ROLLOUT_STAGE="${2:-}"
shift 2
;;
--canary)
ROLLOUT_STAGE="canary"
shift
;;
--cohort-id)
COHORT_ID="${2:-}"
shift 2
;;
--force-rollout)
FORCE_ROLLOUT=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "[transport-update] unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
require_cmd curl
require_cmd sha256sum
require_cmd python3
require_cmd install
require_cmd mktemp
require_cmd readlink
require_cmd ln
require_cmd awk
require_cmd tail
if [[ ! -f "$MANIFEST" ]]; then
echo "[transport-update] manifest not found: ${MANIFEST}" >&2
exit 1
fi
if [[ -z "$SOURCE_POLICY" && "$(basename "$MANIFEST")" == "manifest.production.json" && -f "$DEFAULT_SOURCE_POLICY" ]]; then
SOURCE_POLICY="$DEFAULT_SOURCE_POLICY"
fi
if [[ -n "$SOURCE_POLICY" && ! -f "$SOURCE_POLICY" ]]; then
echo "[transport-update] source policy not found: ${SOURCE_POLICY}" >&2
exit 1
fi
SIGNATURE_MODE_OVERRIDE="$(normalize_signature_mode "$SIGNATURE_MODE_OVERRIDE")"
ROLLOUT_STAGE="$(echo "$ROLLOUT_STAGE" | tr '[:upper:]' '[:lower:]' | xargs)"
case "$ROLLOUT_STAGE" in
stable|canary|any) ;;
*)
echo "[transport-update] rollout stage must be stable|canary|any" >&2
exit 1
;;
esac
if [[ -n "$COHORT_ID" ]]; then
if [[ ! "$COHORT_ID" =~ ^[0-9]+$ ]]; then
echo "[transport-update] cohort id must be integer 0..99" >&2
exit 1
fi
if (( COHORT_ID < 0 || COHORT_ID > 99 )); then
echo "[transport-update] cohort id must be in range 0..99" >&2
exit 1
fi
fi
if [[ -z "$TARGET" ]]; then
TARGET="$(normalize_os "$(uname -s)")-$(normalize_arch "$(uname -m)")"
fi
echo "[transport-update] manifest=${MANIFEST}"
echo "[transport-update] bin_root=${BIN_ROOT}"
echo "[transport-update] target=${TARGET}"
if [[ -n "$COMPONENTS_RAW" ]]; then
echo "[transport-update] components=${COMPONENTS_RAW}"
fi
if [[ -n "$SOURCE_POLICY" ]]; then
echo "[transport-update] source_policy=${SOURCE_POLICY}"
fi
echo "[transport-update] rollout_stage=${ROLLOUT_STAGE}"
if [[ -n "$COHORT_ID" ]]; then
echo "[transport-update] cohort_id=${COHORT_ID}"
fi
if [[ "$FORCE_ROLLOUT" -eq 1 ]]; then
echo "[transport-update] force_rollout=true"
fi
if [[ -n "$SIGNATURE_MODE_OVERRIDE" ]]; then
echo "[transport-update] signature_mode_override=${SIGNATURE_MODE_OVERRIDE}"
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[transport-update] mode=dry-run"
fi
mapfile -t ROWS < <(manifest_rows)
if [[ "${#ROWS[@]}" -eq 0 ]]; then
echo "[transport-update] no enabled components selected" >&2
exit 1
fi
mkdir -p "$BIN_ROOT" "$BIN_ROOT/releases" "$BIN_ROOT/.packaging"
for row in "${ROWS[@]}"; do
IFS=$'\x1f' read -r component binary_name version url sha256 asset_type asset_binary_path rollout_stage rollout_percent sig_type sig_url sig_sha256 sig_public_key_path <<< "$row"
release_dir="${BIN_ROOT}/releases/${component}/${version}"
release_binary="${release_dir}/${binary_name}"
active_link="${BIN_ROOT}/${binary_name}"
history_file="${BIN_ROOT}/.packaging/${component}.history"
sig_mode="$(evaluate_policy_and_signature_mode "$component" "$url" "$sig_url" "$sig_type")"
sig_mode="$(trim_spaces "$sig_mode")"
sig_mode="$(normalize_signature_mode "$sig_mode")"
if [[ "$sig_mode" == "required" && ( -z "$sig_type" || -z "$sig_url" || -z "$sig_public_key_path" ) ]]; then
echo "[transport-update] ${component}: signature_mode=required but signature metadata is incomplete" >&2
exit 1
fi
if [[ "$ROLLOUT_STAGE" != "any" && "$rollout_stage" != "$ROLLOUT_STAGE" ]]; then
echo "[transport-update] ${component}: skip rollout stage=${rollout_stage} (requested ${ROLLOUT_STAGE})"
continue
fi
effective_cohort="$COHORT_ID"
if [[ -z "$effective_cohort" ]]; then
effective_cohort="$(compute_cohort_id "$component" "$TARGET")"
fi
if [[ "$FORCE_ROLLOUT" -ne 1 && "$rollout_percent" -lt 100 && "$effective_cohort" -ge "$rollout_percent" ]]; then
echo "[transport-update] ${component}: skip rollout percent=${rollout_percent}% cohort=${effective_cohort}"
continue
fi
echo "[transport-update] ${component}: version=${version} binary=${binary_name}"
download_install_binary \
"$component" "$binary_name" "$version" "$url" "$sha256" "$asset_type" "$asset_binary_path" \
"$sig_mode" "$sig_type" "$sig_url" "$sig_sha256" "$sig_public_key_path" \
"$release_dir" "$release_binary"
prev_target=""
if [[ -L "$active_link" || -e "$active_link" ]]; then
prev_target="$(readlink -f "$active_link" || true)"
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[transport-update] DRY-RUN ${component}: switch ${active_link} -> ${release_binary}"
continue
fi
if [[ ! -x "$release_binary" ]]; then
echo "[transport-update] ${component}: installed binary is not executable: ${release_binary}" >&2
exit 1
fi
if [[ -n "$prev_target" && "$prev_target" != "$release_binary" && ! -f "$history_file" ]]; then
history_append_if_changed "$history_file" "$(date -u +%Y-%m-%dT%H:%M:%SZ)|${binary_name}|preexisting|${prev_target}" "$prev_target"
fi
ln -sfn "$release_binary" "$active_link"
history_append_if_changed "$history_file" "$(date -u +%Y-%m-%dT%H:%M:%SZ)|${binary_name}|${version}|${release_binary}" "$release_binary"
echo "[transport-update] ${component}: active -> ${release_binary}"
done
echo "[transport-update] done"

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import time
from typing import Dict, Optional, Tuple
import urllib.error
import urllib.parse
import urllib.request
def request_json(api_url: str, method: str, path: str, payload: Optional[Dict] = None) -> Tuple[int, Dict]:
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
f"{api_url.rstrip('/')}{path}",
data=data,
method=method.upper(),
headers=headers,
)
try:
with urllib.request.urlopen(req, timeout=30.0) as resp:
raw = resp.read().decode("utf-8", errors="replace")
status = int(resp.getcode() or 200)
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
status = int(e.code or 500)
except Exception as e:
return 0, {"ok": False, "message": str(e), "code": "HTTP_CLIENT_ERROR"}
try:
parsed = json.loads(raw) if raw else {}
except Exception:
parsed = {"raw": raw}
if not isinstance(parsed, dict):
parsed = {"raw": parsed}
return status, parsed
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Recovery runbook for /api/v1/transport/clients/{id} lifecycle/health"
)
parser.add_argument("--api-url", default=os.environ.get("API_URL", "http://127.0.0.1:8080"))
parser.add_argument("--client-id", required=True)
parser.add_argument("--max-restarts", type=int, default=2)
parser.add_argument("--retry-delay-sec", type=float, default=1.0)
parser.add_argument("--provision-if-needed", dest="provision_if_needed", action="store_true")
parser.add_argument("--no-provision-if-needed", dest="provision_if_needed", action="store_false")
parser.set_defaults(provision_if_needed=True)
parser.add_argument("--diagnostics-json", default="")
return parser.parse_args()
def summarize(resp: Dict) -> str:
status = str(resp.get("status") or "").strip().lower()
code = str(resp.get("code") or "").strip()
last_err = str(resp.get("last_error") or "").strip()
if not last_err:
health = resp.get("health") or {}
if isinstance(health, dict):
last_err = str(health.get("last_error") or "").strip()
return f"status={status or 'unknown'} code={code or '-'} last_error={last_err or '-'}"
def is_healthy_up(health: Dict) -> bool:
status = str(health.get("status") or "").strip().lower()
if status != "up":
return False
code = str(health.get("code") or "").strip()
if code and code != "TRANSPORT_CLIENT_DEGRADED":
return False
last_err = str(health.get("last_error") or "").strip()
if not last_err:
h = health.get("health") or {}
if isinstance(h, dict):
last_err = str(h.get("last_error") or "").strip()
return last_err == ""
def action(api_url: str, client_id: str, name: str) -> Tuple[int, Dict]:
method = "POST"
path = f"/api/v1/transport/clients/{urllib.parse.quote(client_id)}/{name}"
if name in ("health", "metrics"):
method = "GET"
return request_json(api_url, method, path)
def client_card(api_url: str, client_id: str) -> Tuple[int, Dict]:
return request_json(api_url, "GET", f"/api/v1/transport/clients/{urllib.parse.quote(client_id)}")
def write_diagnostics(path: str, diag: Dict) -> None:
if not path.strip():
return
with open(path, "w", encoding="utf-8") as f:
json.dump(diag, f, ensure_ascii=False, indent=2)
def main() -> int:
args = parse_args()
api_url = args.api_url.strip()
client_id = args.client_id.strip()
if not api_url:
print("[transport_recovery] ERROR: empty --api-url")
return 1
if not client_id:
print("[transport_recovery] ERROR: empty --client-id")
return 1
if args.max_restarts < 0:
print("[transport_recovery] ERROR: --max-restarts must be >= 0")
return 1
print(
f"[transport_recovery] API_URL={api_url} client_id={client_id} "
f"max_restarts={args.max_restarts} provision_if_needed={args.provision_if_needed}"
)
diagnostics: Dict = {
"client_id": client_id,
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"steps": [],
}
c_status, c_data = client_card(api_url, client_id)
diagnostics["client_card_before"] = {"http": c_status, "payload": c_data}
if c_status != 200 or not bool(c_data.get("ok", False)):
print(f"[transport_recovery] ERROR: client card unavailable http={c_status} payload={c_data}")
write_diagnostics(args.diagnostics_json, diagnostics)
return 1
h_status, health = action(api_url, client_id, "health")
diagnostics["steps"].append({"action": "health", "http": h_status, "payload": health})
if h_status != 200:
print(f"[transport_recovery] ERROR: health request failed http={h_status}")
write_diagnostics(args.diagnostics_json, diagnostics)
return 1
print(f"[transport_recovery] initial {summarize(health)}")
if is_healthy_up(health):
print("[transport_recovery] already healthy")
write_diagnostics(args.diagnostics_json, diagnostics)
return 0
recovered = False
provision_tried = False
for attempt in range(1, args.max_restarts + 1):
r_status, restart = action(api_url, client_id, "restart")
diagnostics["steps"].append({"action": "restart", "attempt": attempt, "http": r_status, "payload": restart})
print(
f"[transport_recovery] restart attempt={attempt} "
f"http={r_status} ok={restart.get('ok')} code={restart.get('code')}"
)
if args.retry_delay_sec > 0:
time.sleep(args.retry_delay_sec)
h_status, health = action(api_url, client_id, "health")
diagnostics["steps"].append(
{"action": "health_after_restart", "attempt": attempt, "http": h_status, "payload": health}
)
if h_status == 200 and is_healthy_up(health):
recovered = True
print(f"[transport_recovery] recovered after restart attempt={attempt}")
break
if args.provision_if_needed and not provision_tried:
p_status, provision = action(api_url, client_id, "provision")
diagnostics["steps"].append({"action": "provision", "http": p_status, "payload": provision})
print(
f"[transport_recovery] provision "
f"http={p_status} ok={provision.get('ok')} code={provision.get('code')}"
)
provision_tried = True
s_status, start = action(api_url, client_id, "start")
diagnostics["steps"].append({"action": "start", "http": s_status, "payload": start})
print(f"[transport_recovery] start http={s_status} ok={start.get('ok')} code={start.get('code')}")
if args.retry_delay_sec > 0:
time.sleep(args.retry_delay_sec)
h_status, health = action(api_url, client_id, "health")
diagnostics["steps"].append({"action": "health_after_start", "http": h_status, "payload": health})
if h_status == 200 and is_healthy_up(health):
recovered = True
print("[transport_recovery] recovered after provision/start")
break
m_status, metrics = action(api_url, client_id, "metrics")
diagnostics["metrics"] = {"http": m_status, "payload": metrics}
diagnostics["finished_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
if recovered:
print("[transport_recovery] RESULT: recovered")
write_diagnostics(args.diagnostics_json, diagnostics)
return 0
c2_status, c2_data = client_card(api_url, client_id)
diagnostics["client_card_after"] = {"http": c2_status, "payload": c2_data}
h2_status, h2 = action(api_url, client_id, "health")
diagnostics["health_after"] = {"http": h2_status, "payload": h2}
print(f"[transport_recovery] RESULT: unrecovered ({summarize(h2 if isinstance(h2, dict) else {})})")
write_diagnostics(args.diagnostics_json, diagnostics)
return 2
if __name__ == "__main__":
raise SystemExit(main())

168
scripts/transport_runbook.py Executable file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Dict, List, Optional, Tuple
import urllib.error
import urllib.parse
import urllib.request
def request_json(api_url: str, method: str, path: str, payload: Optional[Dict] = None) -> Tuple[int, Dict]:
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
f"{api_url.rstrip('/')}{path}",
data=data,
method=method.upper(),
headers=headers,
)
try:
with urllib.request.urlopen(req, timeout=30.0) as resp:
raw = resp.read().decode("utf-8", errors="replace")
status = int(resp.getcode() or 200)
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
status = int(e.code or 500)
except Exception as e:
return 0, {"ok": False, "message": str(e), "code": "HTTP_CLIENT_ERROR"}
try:
parsed = json.loads(raw) if raw else {}
except Exception:
parsed = {"raw": raw}
if not isinstance(parsed, dict):
parsed = {"raw": parsed}
return status, parsed
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Transport runbook helper for /api/v1/transport/*")
parser.add_argument("--api-url", default=os.environ.get("API_URL", "http://127.0.0.1:8080"))
parser.add_argument("--client-id", default="")
parser.add_argument("--kind", default="singbox", choices=["singbox", "dnstt", "phoenix"])
parser.add_argument("--name", default="")
parser.add_argument("--enabled", action="store_true")
parser.add_argument("--config-json", default='{"runner":"mock","runtime_mode":"exec"}')
parser.add_argument("--actions", default="capabilities")
parser.add_argument("--force-delete", action="store_true")
parser.add_argument("--allow-fail", default="")
return parser.parse_args()
def require_client_id(action: str, client_id: str) -> None:
if action == "capabilities":
return
if not client_id.strip():
raise ValueError(f"--client-id is required for action '{action}'")
def format_summary(action: str, status: int, payload: Dict) -> str:
ok = payload.get("ok")
code = payload.get("code")
msg = payload.get("message")
extras: List[str] = []
if "status" in payload:
extras.append(f"status={payload.get('status')}")
if "status_before" in payload:
extras.append(f"before={payload.get('status_before')}")
if "status_after" in payload:
extras.append(f"after={payload.get('status_after')}")
extra_text = f" {' '.join(extras)}" if extras else ""
return f"[transport_runbook] {action}: http={status} ok={ok} code={code} message={msg}{extra_text}"
def main() -> int:
args = parse_args()
api_url = args.api_url.strip()
if not api_url:
print("[transport_runbook] ERROR: empty --api-url")
return 1
actions = [a.strip().lower() for a in args.actions.split(",") if a.strip()]
if not actions:
print("[transport_runbook] ERROR: --actions must not be empty")
return 1
allow_fail = {a.strip().lower() for a in args.allow_fail.split(",") if a.strip()}
try:
cfg = json.loads(args.config_json)
except Exception as e:
print(f"[transport_runbook] ERROR: invalid --config-json: {e}")
return 1
if not isinstance(cfg, dict):
print("[transport_runbook] ERROR: --config-json must be a JSON object")
return 1
for action in actions:
try:
require_client_id(action, args.client_id)
except ValueError as e:
print(f"[transport_runbook] ERROR: {e}")
return 1
path = ""
method = "GET"
payload: Optional[Dict] = None
if action == "capabilities":
path = "/api/v1/transport/capabilities"
method = "GET"
elif action == "create":
path = "/api/v1/transport/clients"
method = "POST"
payload = {
"id": args.client_id,
"name": args.name.strip() or f"Runbook {args.client_id}",
"kind": args.kind,
"enabled": bool(args.enabled),
"config": cfg,
}
elif action == "provision":
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/provision"
method = "POST"
elif action == "start":
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/start"
method = "POST"
elif action == "health":
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/health"
method = "GET"
elif action == "metrics":
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/metrics"
method = "GET"
elif action == "restart":
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/restart"
method = "POST"
elif action == "stop":
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/stop"
method = "POST"
elif action == "delete":
q = "?force=true" if args.force_delete else ""
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}{q}"
method = "DELETE"
else:
print(f"[transport_runbook] ERROR: unsupported action '{action}'")
return 1
status, res = request_json(api_url, method, path, payload)
print(format_summary(action, status, res))
if status != 200:
if action in allow_fail:
continue
return 1
if not bool(res.get("ok", False)) and action not in allow_fail:
return 1
print("[transport_runbook] done")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,38 @@
package app
import (
"context"
"errors"
"log"
"net/http"
appbootstrap "selective-vpn-api/app/bootstrap"
"time"
)
func runAPIServerAtAddr(addr string) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
prepareAPIRuntime()
log.Printf("selective-vpn API listening on %s", addr)
if err := appbootstrap.Run(ctx, appbootstrap.Config{
Addr: addr,
ReadHeaderTimeout: 5 * time.Second,
RegisterRoutes: registerAPIRoutes,
WrapHandler: logRequests,
StartWatchers: startWatchers,
}); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}
func prepareAPIRuntime() {
ensureSeeds()
if err := ensureAppMarksNft(); err != nil {
log.Printf("traffic appmarks nft init warning: %v", err)
}
if err := restoreAppMarksFromState(); err != nil {
log.Printf("traffic appmarks restore warning: %v", err)
}
}

View File

@@ -0,0 +1,92 @@
package app
import (
"net/http"
apiroutes "selective-vpn-api/app/apiroutes"
)
func registerAPIRoutes(mux *http.ServeMux) {
apiroutes.Register(mux, apiroutes.Handlers{
Healthz: handleHealthz,
EventsStream: handleEventsStream,
GetStatus: handleGetStatus,
VPNLoginState: handleVPNLoginState,
SystemdState: handleSystemdState,
RoutesServiceStart: makeRoutesServiceActionHandler("start"),
RoutesServiceStop: makeRoutesServiceActionHandler("stop"),
RoutesServiceRestart: makeRoutesServiceActionHandler("restart"),
RoutesService: handleRoutesService,
RoutesUpdate: handleRoutesUpdate,
RoutesTimer: handleRoutesTimer,
RoutesTimerToggle: handleRoutesTimerToggle,
RoutesRollback: handleRoutesClear,
RoutesClear: handleRoutesClear,
RoutesCacheRestore: handleRoutesCacheRestore,
RoutesPrecheckDebug: handleRoutesPrecheckDebug,
RoutesFixPolicyRoute: handleFixPolicyRoute,
RoutesFixPolicyAlias: handleFixPolicyRoute,
TrafficMode: handleTrafficMode,
TrafficModeTest: handleTrafficModeTest,
TrafficAdvancedReset: handleTrafficAdvancedReset,
TrafficInterfaces: handleTrafficInterfaces,
TrafficCandidates: handleTrafficCandidates,
TrafficAppMarks: handleTrafficAppMarks,
TrafficAppMarksItems: handleTrafficAppMarksItems,
TrafficAppProfiles: handleTrafficAppProfiles,
TrafficAudit: handleTrafficAudit,
TransportClients: handleTransportClients,
TransportClientByID: handleTransportClientByID,
TransportInterfaces: handleTransportInterfaces,
TransportRuntimeObservability: handleTransportRuntimeObservability,
TransportPolicies: handleTransportPolicies,
TransportPoliciesValidate: handleTransportPoliciesValidate,
TransportPoliciesApply: handleTransportPoliciesApply,
TransportPoliciesRollback: handleTransportPoliciesRollback,
TransportConflicts: handleTransportConflicts,
TransportOwnership: handleTransportOwnership,
TransportOwnerLocks: handleTransportOwnerLocks,
TransportOwnerLocksClear: handleTransportOwnerLocksClear,
TransportCapabilities: handleTransportCapabilities,
TransportHealthRefresh: handleTransportHealthRefresh,
TransportNetnsToggle: handleTransportNetnsToggle,
TransportSingBoxProfiles: handleTransportSingBoxProfiles,
TransportSingBoxProfileByID: handleTransportSingBoxProfileByID,
TransportSingBoxFeatures: handleTransportSingBoxFeatures,
EgressIdentityGet: handleEgressIdentityGet,
EgressIdentityRefresh: handleEgressIdentityRefresh,
TraceTailPlain: handleTraceTailPlain,
TraceJSON: handleTraceJSON,
TraceAppend: handleTraceAppend,
DNSUpstreams: handleDNSUpstreams,
DNSUpstreamPool: handleDNSUpstreamPool,
DNSStatus: handleDNSStatus,
DNSModeSet: handleDNSModeSet,
DNSBenchmark: handleDNSBenchmark,
DNSSmartdnsService: handleDNSSmartdnsService,
SmartdnsService: handleSmartdnsService,
SmartdnsRuntime: handleSmartdnsRuntime,
SmartdnsPrewarm: handleSmartdnsPrewarm,
SmartdnsWildcards: handleSmartdnsWildcards,
DomainsTable: handleDomainsTable,
DomainsFile: handleDomainsFile,
VPNAutoloopStatus: handleVPNAutoloopStatus,
VPNStatus: handleVPNStatus,
VPNAutoconnect: handleVPNAutoconnect,
VPNListLocations: handleVPNListLocations,
VPNSetLocation: handleVPNSetLocation,
VPNLoginSessionStart: handleVPNLoginSessionStart,
VPNLoginSessionState: handleVPNLoginSessionState,
VPNLoginSessionAction: handleVPNLoginSessionAction,
VPNLoginSessionStop: handleVPNLoginSessionStop,
VPNLogout: handleVPNLogout,
})
}

View File

@@ -0,0 +1,198 @@
package apiroutes
import "net/http"
type Handlers struct {
Healthz http.HandlerFunc
EventsStream http.HandlerFunc
GetStatus http.HandlerFunc
VPNLoginState http.HandlerFunc
SystemdState http.HandlerFunc
RoutesServiceStart http.HandlerFunc
RoutesServiceStop http.HandlerFunc
RoutesServiceRestart http.HandlerFunc
RoutesService http.HandlerFunc
RoutesUpdate http.HandlerFunc
RoutesTimer http.HandlerFunc
RoutesTimerToggle http.HandlerFunc
RoutesRollback http.HandlerFunc
RoutesClear http.HandlerFunc
RoutesCacheRestore http.HandlerFunc
RoutesPrecheckDebug http.HandlerFunc
RoutesFixPolicyRoute http.HandlerFunc
RoutesFixPolicyAlias http.HandlerFunc
TrafficMode http.HandlerFunc
TrafficModeTest http.HandlerFunc
TrafficAdvancedReset http.HandlerFunc
TrafficInterfaces http.HandlerFunc
TrafficCandidates http.HandlerFunc
TrafficAppMarks http.HandlerFunc
TrafficAppMarksItems http.HandlerFunc
TrafficAppProfiles http.HandlerFunc
TrafficAudit http.HandlerFunc
TransportClients http.HandlerFunc
TransportClientByID http.HandlerFunc
TransportInterfaces http.HandlerFunc
TransportRuntimeObservability http.HandlerFunc
TransportPolicies http.HandlerFunc
TransportPoliciesValidate http.HandlerFunc
TransportPoliciesApply http.HandlerFunc
TransportPoliciesRollback http.HandlerFunc
TransportConflicts http.HandlerFunc
TransportOwnership http.HandlerFunc
TransportOwnerLocks http.HandlerFunc
TransportOwnerLocksClear http.HandlerFunc
TransportCapabilities http.HandlerFunc
TransportHealthRefresh http.HandlerFunc
TransportNetnsToggle http.HandlerFunc
TransportSingBoxProfiles http.HandlerFunc
TransportSingBoxProfileByID http.HandlerFunc
TransportSingBoxFeatures http.HandlerFunc
EgressIdentityGet http.HandlerFunc
EgressIdentityRefresh http.HandlerFunc
TraceTailPlain http.HandlerFunc
TraceJSON http.HandlerFunc
TraceAppend http.HandlerFunc
DNSUpstreams http.HandlerFunc
DNSUpstreamPool http.HandlerFunc
DNSStatus http.HandlerFunc
DNSModeSet http.HandlerFunc
DNSBenchmark http.HandlerFunc
DNSSmartdnsService http.HandlerFunc
SmartdnsService http.HandlerFunc
SmartdnsRuntime http.HandlerFunc
SmartdnsPrewarm http.HandlerFunc
SmartdnsWildcards http.HandlerFunc
DomainsTable http.HandlerFunc
DomainsFile http.HandlerFunc
VPNAutoloopStatus http.HandlerFunc
VPNStatus http.HandlerFunc
VPNAutoconnect http.HandlerFunc
VPNListLocations http.HandlerFunc
VPNSetLocation http.HandlerFunc
VPNLoginSessionStart http.HandlerFunc
VPNLoginSessionState http.HandlerFunc
VPNLoginSessionAction http.HandlerFunc
VPNLoginSessionStop http.HandlerFunc
VPNLogout http.HandlerFunc
}
func Register(mux *http.ServeMux, h Handlers) {
registerCoreRoutes(mux, h)
registerRoutesControlRoutes(mux, h)
registerTrafficRoutes(mux, h)
registerTransportRoutes(mux, h)
registerTraceRoutes(mux, h)
registerDNSRoutes(mux, h)
registerSmartDNSRoutes(mux, h)
registerDomainsRoutes(mux, h)
registerVPNRoutes(mux, h)
}
func registerCoreRoutes(mux *http.ServeMux, h Handlers) {
mux.HandleFunc("/healthz", h.Healthz)
mux.HandleFunc("/api/v1/events/stream", h.EventsStream)
mux.HandleFunc("/api/v1/status", h.GetStatus)
mux.HandleFunc("/api/v1/routes/status", h.GetStatus)
mux.HandleFunc("/api/v1/vpn/login-state", h.VPNLoginState)
mux.HandleFunc("/api/v1/systemd/state", h.SystemdState)
}
func registerRoutesControlRoutes(mux *http.ServeMux, h Handlers) {
mux.HandleFunc("/api/v1/routes/service/start", h.RoutesServiceStart)
mux.HandleFunc("/api/v1/routes/service/stop", h.RoutesServiceStop)
mux.HandleFunc("/api/v1/routes/service/restart", h.RoutesServiceRestart)
mux.HandleFunc("/api/v1/routes/service", h.RoutesService)
mux.HandleFunc("/api/v1/routes/update", h.RoutesUpdate)
mux.HandleFunc("/api/v1/routes/timer", h.RoutesTimer)
mux.HandleFunc("/api/v1/routes/timer/toggle", h.RoutesTimerToggle)
mux.HandleFunc("/api/v1/routes/rollback", h.RoutesRollback)
mux.HandleFunc("/api/v1/routes/clear", h.RoutesClear)
mux.HandleFunc("/api/v1/routes/cache/restore", h.RoutesCacheRestore)
mux.HandleFunc("/api/v1/routes/precheck/debug", h.RoutesPrecheckDebug)
mux.HandleFunc("/api/v1/routes/fix-policy-route", h.RoutesFixPolicyRoute)
mux.HandleFunc("/api/v1/routes/fix-policy", h.RoutesFixPolicyAlias)
}
func registerTrafficRoutes(mux *http.ServeMux, h Handlers) {
mux.HandleFunc("/api/v1/traffic/mode", h.TrafficMode)
mux.HandleFunc("/api/v1/traffic/mode/test", h.TrafficModeTest)
mux.HandleFunc("/api/v1/traffic/advanced/reset", h.TrafficAdvancedReset)
mux.HandleFunc("/api/v1/traffic/interfaces", h.TrafficInterfaces)
mux.HandleFunc("/api/v1/traffic/candidates", h.TrafficCandidates)
mux.HandleFunc("/api/v1/traffic/appmarks", h.TrafficAppMarks)
mux.HandleFunc("/api/v1/traffic/appmarks/items", h.TrafficAppMarksItems)
mux.HandleFunc("/api/v1/traffic/app-profiles", h.TrafficAppProfiles)
mux.HandleFunc("/api/v1/traffic/audit", h.TrafficAudit)
}
func registerTransportRoutes(mux *http.ServeMux, h Handlers) {
mux.HandleFunc("/api/v1/transport/clients", h.TransportClients)
mux.HandleFunc("/api/v1/transport/clients/", h.TransportClientByID)
mux.HandleFunc("/api/v1/transport/interfaces", h.TransportInterfaces)
mux.HandleFunc("/api/v1/transport/runtime/observability", h.TransportRuntimeObservability)
mux.HandleFunc("/api/v1/transport/policies", h.TransportPolicies)
mux.HandleFunc("/api/v1/transport/policies/validate", h.TransportPoliciesValidate)
mux.HandleFunc("/api/v1/transport/policies/apply", h.TransportPoliciesApply)
mux.HandleFunc("/api/v1/transport/policies/rollback", h.TransportPoliciesRollback)
mux.HandleFunc("/api/v1/transport/conflicts", h.TransportConflicts)
mux.HandleFunc("/api/v1/transport/owners", h.TransportOwnership)
mux.HandleFunc("/api/v1/transport/owner-locks", h.TransportOwnerLocks)
mux.HandleFunc("/api/v1/transport/owner-locks/clear", h.TransportOwnerLocksClear)
mux.HandleFunc("/api/v1/transport/capabilities", h.TransportCapabilities)
mux.HandleFunc("/api/v1/transport/health/refresh", h.TransportHealthRefresh)
mux.HandleFunc("/api/v1/transport/netns/toggle", h.TransportNetnsToggle)
mux.HandleFunc("/api/v1/transport/singbox/profiles", h.TransportSingBoxProfiles)
mux.HandleFunc("/api/v1/transport/singbox/profiles/", h.TransportSingBoxProfileByID)
mux.HandleFunc("/api/v1/transport/singbox/features", h.TransportSingBoxFeatures)
mux.HandleFunc("/api/v1/egress/identity", h.EgressIdentityGet)
mux.HandleFunc("/api/v1/egress/identity/refresh", h.EgressIdentityRefresh)
}
func registerTraceRoutes(mux *http.ServeMux, h Handlers) {
mux.HandleFunc("/api/v1/trace", h.TraceTailPlain)
mux.HandleFunc("/api/v1/trace-json", h.TraceJSON)
mux.HandleFunc("/api/v1/trace/append", h.TraceAppend)
}
func registerDNSRoutes(mux *http.ServeMux, h Handlers) {
mux.HandleFunc("/api/v1/dns-upstreams", h.DNSUpstreams)
mux.HandleFunc("/api/v1/dns/upstream-pool", h.DNSUpstreamPool)
mux.HandleFunc("/api/v1/dns/status", h.DNSStatus)
mux.HandleFunc("/api/v1/dns/mode", h.DNSModeSet)
mux.HandleFunc("/api/v1/dns/benchmark", h.DNSBenchmark)
mux.HandleFunc("/api/v1/dns/smartdns-service", h.DNSSmartdnsService)
}
func registerSmartDNSRoutes(mux *http.ServeMux, h Handlers) {
mux.HandleFunc("/api/v1/smartdns/service", h.SmartdnsService)
mux.HandleFunc("/api/v1/smartdns/runtime", h.SmartdnsRuntime)
mux.HandleFunc("/api/v1/smartdns/prewarm", h.SmartdnsPrewarm)
mux.HandleFunc("/api/v1/smartdns/wildcards", h.SmartdnsWildcards)
}
func registerDomainsRoutes(mux *http.ServeMux, h Handlers) {
mux.HandleFunc("/api/v1/domains/table", h.DomainsTable)
mux.HandleFunc("/api/v1/domains/file", h.DomainsFile)
}
func registerVPNRoutes(mux *http.ServeMux, h Handlers) {
mux.HandleFunc("/api/v1/vpn/autoloop-status", h.VPNAutoloopStatus)
mux.HandleFunc("/api/v1/vpn/status", h.VPNStatus)
mux.HandleFunc("/api/v1/vpn/autoconnect", h.VPNAutoconnect)
mux.HandleFunc("/api/v1/vpn/locations", h.VPNListLocations)
mux.HandleFunc("/api/v1/vpn/location", h.VPNSetLocation)
mux.HandleFunc("/api/v1/vpn/login/session/start", h.VPNLoginSessionStart)
mux.HandleFunc("/api/v1/vpn/login/session/state", h.VPNLoginSessionState)
mux.HandleFunc("/api/v1/vpn/login/session/action", h.VPNLoginSessionAction)
mux.HandleFunc("/api/v1/vpn/login/session/stop", h.VPNLoginSessionStop)
mux.HandleFunc("/api/v1/vpn/logout", h.VPNLogout)
}

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"log"
"os"
"regexp"
"strings"
"time"
)
@@ -44,28 +43,7 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string)
}
writeLoginState := func(state, email, msg string) {
ts := time.Now().Format(time.RFC3339)
payload := fmt.Sprintf(`{"ts":"%s","state":"%s","email":"%s","msg":"%s"}`, ts, escapeJSON(state), escapeJSON(email), escapeJSON(msg))
_ = os.WriteFile(loginStateFile, []byte(payload), 0o644)
}
getLocation := func() string {
if data, err := os.ReadFile(locFile); err == nil {
for _, ln := range strings.Split(string(data), "\n") {
t := strings.TrimSpace(ln)
if t != "" && !strings.HasPrefix(t, "#") {
return t
}
}
}
return defaultLoc
}
isConnected := func(out string) bool {
low := strings.ToLower(out)
return strings.Contains(low, "vpn is connected") ||
strings.Contains(low, "connected to") ||
strings.Contains(low, "after connect: connected")
writeAutoloopLoginState(loginStateFile, state, email, msg)
}
fixPolicy := func() {
@@ -83,45 +61,9 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string)
" mtu " + fmt.Sprintf("%d", mtu) + " OK")
}
}
var emailRe = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+`)
parseEmail := func(text string) string {
return emailRe.FindString(text)
}
isLoginRequired := func(t string) bool {
low := strings.ToLower(t)
return strings.Contains(low, "please log in") ||
strings.Contains(low, "not logged in") ||
strings.Contains(low, "login required") ||
strings.Contains(low, "sign in")
}
updateLoginStateFromText := func(text string) {
if isLoginRequired(text) {
writeLoginState("no_login", "", "NOT LOGGED IN")
logLine("login: NO (detected from output)")
return
}
if em := parseEmail(text); em != "" {
writeLoginState("ok", em, "logged in")
logLine("login: OK email=" + em)
return
}
low := strings.ToLower(text)
if strings.Contains(low, "not logged in") ||
strings.Contains(low, "expired") ||
strings.Contains(low, "no active license") {
writeLoginState("no_login", "", "NOT LOGGED IN (license)")
logLine("login: NO (license says not logged in)")
return
}
if strings.Contains(low, "license") &&
(strings.Contains(low, "active") || strings.Contains(low, "valid")) {
writeLoginState("ok", "", "logged in (license ok)")
logLine("login: OK (license ok, email not found)")
return
}
updateAutoloopLoginStateFromText(text, writeLoginState, logLine)
}
updateLicense := func() {
@@ -144,7 +86,7 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string)
if err != nil {
logLine(fmt.Sprintf("status: ERROR exit=%d err=%v raw=%q", exitCode, err, statusOut))
}
if isConnected(statusOut) {
if isAutoloopConnected(statusOut) {
logLine("status: CONNECTED; raw: " + statusOut)
fixPolicy()
updateLicense()
@@ -163,18 +105,30 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string)
})
updateLoginStateFromText(statusOut)
loc := getLocation()
logLine("reconnecting to " + loc)
loc := resolveAutoloopLocationSpec(locFile, defaultLoc)
primary := strings.TrimSpace(loc.Primary)
if primary == "" {
primary = strings.TrimSpace(defaultLoc)
}
logLine("reconnecting to " + primary)
_, _, _, _ = runCommandTimeout(disconnectTimeout, adgvpnCLI, "disconnect")
connectOut, _, _, _ := runCommandTimeout(connectTimeout, adgvpnCLI, "connect", "-l", loc, "--log-to-file")
connectOut, _, _, _ := runCommandTimeout(connectTimeout, adgvpnCLI, "connect", "-l", primary, "--log-to-file")
connectOut = stripANSI(connectOut)
logLine("connect raw: " + connectOut)
updateLoginStateFromText(connectOut)
if !isAutoloopConnected(connectOut) && loc.ISO != "" && !strings.EqualFold(loc.ISO, primary) {
logLine("connect fallback to ISO: " + loc.ISO)
fallbackOut, _, _, _ := runCommandTimeout(connectTimeout, adgvpnCLI, "connect", "-l", loc.ISO, "--log-to-file")
fallbackOut = stripANSI(fallbackOut)
logLine("connect fallback raw: " + fallbackOut)
updateLoginStateFromText(fallbackOut)
}
statusAfter, _, _, _ := runCommandTimeout(statusTimeout, adgvpnCLI, "status")
statusAfter = stripANSI(statusAfter)
if isConnected(statusAfter) {
if isAutoloopConnected(statusAfter) {
logLine("after connect: CONNECTED; raw: " + statusAfter)
fixPolicy()
updateLicense()
@@ -190,15 +144,3 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string)
time.Sleep(10 * time.Second)
}
}
// ---------------------------------------------------------------------
// autoloop helpers
// ---------------------------------------------------------------------
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\\"`)
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
return s
}

View File

@@ -0,0 +1,12 @@
package app
import (
"regexp"
)
type autoloopLocationSpec struct {
Primary string
ISO string
}
var autoloopEmailRe = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+`)

View File

@@ -0,0 +1,107 @@
package app
import (
"encoding/json"
"os"
"strings"
)
func resolveAutoloopLocationSpec(locFile, defaultLoc string) autoloopLocationSpec {
raw := ""
if data, err := os.ReadFile(locFile); err == nil {
for _, ln := range strings.Split(string(data), "\n") {
t := strings.TrimSpace(ln)
if t != "" && !strings.HasPrefix(t, "#") {
raw = t
break
}
}
}
if raw == "" {
raw = strings.TrimSpace(defaultLoc)
}
raw = strings.TrimSpace(raw)
primary := raw
iso := ""
if p := strings.SplitN(raw, "|", 2); len(p) == 2 {
primary = strings.TrimSpace(p[0])
iso = strings.ToUpper(strings.TrimSpace(p[1]))
}
if primary == "" {
primary = strings.TrimSpace(defaultLoc)
}
if isISO2(primary) {
iso = strings.ToUpper(primary)
}
if iso == "" {
if tokens := strings.Fields(primary); len(tokens) > 0 && isISO2(tokens[0]) {
iso = strings.ToUpper(tokens[0])
}
}
if iso == "" {
iso = lookupISOFromLocationsCache(primary)
}
if iso == "" && isISO2(defaultLoc) {
iso = strings.ToUpper(strings.TrimSpace(defaultLoc))
}
return autoloopLocationSpec{
Primary: primary,
ISO: iso,
}
}
func isISO2(v string) bool {
s := strings.TrimSpace(v)
if len(s) != 2 {
return false
}
for _, ch := range s {
if (ch < 'A' || ch > 'Z') && (ch < 'a' || ch > 'z') {
return false
}
}
return true
}
func lookupISOFromLocationsCache(primary string) string {
want := strings.ToLower(strings.TrimSpace(primary))
if want == "" {
return ""
}
var disk struct {
Locations []struct {
ISO string `json:"iso"`
Label string `json:"label"`
Target string `json:"target"`
} `json:"locations"`
}
data, err := os.ReadFile(vpnLocationsCachePath)
if err != nil {
return ""
}
if err := json.Unmarshal(data, &disk); err != nil {
return ""
}
for _, it := range disk.Locations {
iso := strings.ToUpper(strings.TrimSpace(it.ISO))
if !isISO2(iso) {
continue
}
target := strings.ToLower(strings.TrimSpace(it.Target))
if target != "" && target == want {
return iso
}
labelNorm := strings.ToLower(strings.TrimSpace(it.Label))
if labelNorm != "" {
if inferVPNLocationTargetFromLabel(it.Label, iso) == primary {
return iso
}
if strings.Contains(labelNorm, want) {
return iso
}
}
}
return ""
}

View File

@@ -0,0 +1,89 @@
package app
import (
"fmt"
"os"
"strings"
"time"
)
func writeAutoloopLoginState(loginStateFile, state, email, msg string) {
ts := time.Now().Format(time.RFC3339)
payload := fmt.Sprintf(`{"ts":"%s","state":"%s","email":"%s","msg":"%s"}`,
ts,
escapeJSON(state),
escapeJSON(email),
escapeJSON(msg),
)
_ = os.WriteFile(loginStateFile, []byte(payload), 0o644)
}
func isAutoloopConnected(out string) bool {
low := strings.ToLower(out)
return strings.Contains(low, "vpn is connected") ||
strings.Contains(low, "connected to") ||
strings.Contains(low, "after connect: connected")
}
func parseAutoloopEmail(text string) string {
return autoloopEmailRe.FindString(text)
}
func isAutoloopLoginRequired(text string) bool {
low := strings.ToLower(text)
return strings.Contains(low, "please log in") ||
strings.Contains(low, "not logged in") ||
strings.Contains(low, "login required") ||
strings.Contains(low, "sign in")
}
func updateAutoloopLoginStateFromText(
text string,
writeState func(state, email, msg string),
logLine func(msg string),
) {
if writeState == nil {
return
}
if isAutoloopLoginRequired(text) {
writeState("no_login", "", "NOT LOGGED IN")
if logLine != nil {
logLine("login: NO (detected from output)")
}
return
}
if em := parseAutoloopEmail(text); em != "" {
writeState("ok", em, "logged in")
if logLine != nil {
logLine("login: OK email=" + em)
}
return
}
low := strings.ToLower(text)
if strings.Contains(low, "not logged in") ||
strings.Contains(low, "expired") ||
strings.Contains(low, "no active license") {
writeState("no_login", "", "NOT LOGGED IN (license)")
if logLine != nil {
logLine("login: NO (license says not logged in)")
}
return
}
if strings.Contains(low, "license") &&
(strings.Contains(low, "active") || strings.Contains(low, "valid")) {
writeState("ok", "", "logged in (license ok)")
if logLine != nil {
logLine("login: OK (license ok, email not found)")
}
return
}
}
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\\"`)
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
return s
}

View File

@@ -0,0 +1,54 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"net/http"
"time"
)
type Config struct {
Addr string
ReadHeaderTimeout time.Duration
RegisterRoutes func(mux *http.ServeMux)
WrapHandler func(next http.Handler) http.Handler
StartWatchers func(ctx context.Context)
}
func Run(ctx context.Context, cfg Config) error {
if cfg.RegisterRoutes == nil {
return fmt.Errorf("register routes callback is required")
}
if cfg.Addr == "" {
return fmt.Errorf("addr is required")
}
readHeaderTimeout := cfg.ReadHeaderTimeout
if readHeaderTimeout <= 0 {
readHeaderTimeout = 5 * time.Second
}
mux := http.NewServeMux()
cfg.RegisterRoutes(mux)
handler := http.Handler(mux)
if cfg.WrapHandler != nil {
handler = cfg.WrapHandler(handler)
}
srv := &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadHeaderTimeout: readHeaderTimeout,
}
if cfg.StartWatchers != nil {
go cfg.StartWatchers(ctx)
}
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}

View File

@@ -0,0 +1,59 @@
package cli
import (
"flag"
"fmt"
"io"
"os"
)
type AutoloopParams struct {
Iface string
Table string
MTU int
StateDir string
DefaultLocation string
}
type AutoloopDeps struct {
StateDirDefault string
ResolveIface func(flagIface string) string
Run func(params AutoloopParams)
Stderr io.Writer
}
func RunAutoloop(args []string, deps AutoloopDeps) int {
if deps.ResolveIface == nil || deps.Run == nil {
return 1
}
stderr := deps.Stderr
if stderr == nil {
stderr = os.Stderr
}
fs := flag.NewFlagSet("autoloop", flag.ContinueOnError)
fs.SetOutput(stderr)
iface := fs.String("iface", "", "VPN interface (empty/auto = detect active)")
table := fs.String("table", "agvpn", "routing table name")
mtu := fs.Int("mtu", 1380, "MTU for default route")
stateDir := fs.String("state-dir", deps.StateDirDefault, "state directory")
defaultLoc := fs.String("default-location", "Austria", "default location")
if err := fs.Parse(args); err != nil {
return 2
}
resolvedIface := deps.ResolveIface(*iface)
if resolvedIface == "" {
fmt.Fprintln(stderr, "autoloop: cannot resolve VPN interface (set --iface or preferred iface)")
return 1
}
deps.Run(AutoloopParams{
Iface: resolvedIface,
Table: *table,
MTU: *mtu,
StateDir: *stateDir,
DefaultLocation: *defaultLoc,
})
return 0
}

View File

@@ -0,0 +1,47 @@
package cli
import (
"flag"
"fmt"
"io"
"os"
"strings"
)
type RoutesClearDeps struct {
Clear func() (ok bool, message string)
Stdout io.Writer
Stderr io.Writer
}
func RunRoutesClear(args []string, deps RoutesClearDeps) int {
if deps.Clear == nil {
return 1
}
stdout := deps.Stdout
if stdout == nil {
stdout = os.Stdout
}
stderr := deps.Stderr
if stderr == nil {
stderr = os.Stderr
}
fs := flag.NewFlagSet("routes-clear", flag.ContinueOnError)
fs.SetOutput(stderr)
if err := fs.Parse(args); err != nil {
return 2
}
ok, message := deps.Clear()
if ok {
fmt.Fprintln(stdout, strings.TrimSpace(message))
return 0
}
msg := strings.TrimSpace(message)
if msg == "" {
msg = "routes clear failed"
}
fmt.Fprintln(stderr, msg)
return 1
}

View File

@@ -0,0 +1,65 @@
package cli
import (
"flag"
"fmt"
"io"
"os"
"strings"
"syscall"
)
type RoutesUpdateDeps struct {
LockFile string
Update func(iface string) (ok bool, message string)
Stdout io.Writer
Stderr io.Writer
}
func RunRoutesUpdate(args []string, deps RoutesUpdateDeps) int {
if deps.Update == nil || deps.LockFile == "" {
return 1
}
stdout := deps.Stdout
if stdout == nil {
stdout = os.Stdout
}
stderr := deps.Stderr
if stderr == nil {
stderr = os.Stderr
}
fs := flag.NewFlagSet("routes-update", flag.ContinueOnError)
fs.SetOutput(stderr)
iface := fs.String("iface", "", "VPN interface (empty/auto = detect active)")
if err := fs.Parse(args); err != nil {
return 2
}
lock, err := os.OpenFile(deps.LockFile, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
fmt.Fprintf(stderr, "lock open error: %v\n", err)
return 1
}
defer lock.Close()
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
fmt.Fprintln(stdout, "routes update already running")
return 0
}
defer func() {
_ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
}()
ok, message := deps.Update(*iface)
if ok {
fmt.Fprintln(stdout, strings.TrimSpace(message))
return 0
}
msg := strings.TrimSpace(message)
if msg == "" {
msg = "routes update failed"
}
fmt.Fprintln(stderr, msg)
return 1
}

View File

@@ -12,28 +12,40 @@ import "embed"
// ---------------------------------------------------------------------
const (
stateDir = "/var/lib/selective-vpn"
statusFilePath = stateDir + "/status.json"
dnsModePath = stateDir + "/dns-mode.json"
trafficModePath = stateDir + "/traffic-mode.json"
trafficAppMarksPath = stateDir + "/traffic-appmarks.json"
trafficAppProfilesPath = stateDir + "/traffic-app-profiles.json"
stateDir = "/var/lib/selective-vpn"
statusFilePath = stateDir + "/status.json"
dnsModePath = stateDir + "/dns-mode.json"
trafficModePath = stateDir + "/traffic-mode.json"
trafficAppMarksPath = stateDir + "/traffic-appmarks.json"
trafficAppProfilesPath = stateDir + "/traffic-app-profiles.json"
transportClientsPath = stateDir + "/transport-clients.json"
transportInterfacesPath = stateDir + "/transport-interfaces.json"
transportPolicyPath = stateDir + "/transport-policies.json"
transportPolicyPlanPath = stateDir + "/transport-policies.plan.json"
transportPolicyRuntimePath = stateDir + "/transport-policies.runtime.json"
transportPolicyRuntimeSnap = stateDir + "/transport-policies.runtime.prev.json"
transportOwnershipPath = stateDir + "/transport-ownership.json"
transportOwnerLocksPath = stateDir + "/transport-owner-locks.json"
transportConflictsPath = stateDir + "/transport-conflicts.json"
transportPolicySnap = stateDir + "/transport-policies.prev.json"
transportBootstrapPath = stateDir + "/transport-bootstrap-routes.json"
traceLogPath = stateDir + "/trace.log"
smartdnsLogPath = stateDir + "/smartdns.log"
lastIPsPath = stateDir + "/last-ips.txt"
lastIPsMapPath = stateDir + "/last-ips-map.txt"
lastIPsDirect = stateDir + "/last-ips-direct.txt"
lastIPsDyn = stateDir + "/last-ips-dyn.txt"
lastIPsMapDirect = stateDir + "/last-ips-map-direct.txt"
lastIPsMapDyn = stateDir + "/last-ips-map-wildcard.txt"
routesCacheMeta = stateDir + "/routes-clear-cache.json"
routesCacheIPs = stateDir + "/routes-clear-cache-ips.txt"
routesCacheDyn = stateDir + "/routes-clear-cache-ips-dyn.txt"
routesCacheMap = stateDir + "/routes-clear-cache-ips-map.txt"
routesCacheMapD = stateDir + "/routes-clear-cache-ips-map-direct.txt"
routesCacheMapW = stateDir + "/routes-clear-cache-ips-map-wildcard.txt"
routesCacheRT = stateDir + "/routes-clear-cache-routes.txt"
traceLogPath = stateDir + "/trace.log"
smartdnsLogPath = stateDir + "/smartdns.log"
lastIPsPath = stateDir + "/last-ips.txt"
lastIPsMapPath = stateDir + "/last-ips-map.txt"
lastIPsDirect = stateDir + "/last-ips-direct.txt"
lastIPsDyn = stateDir + "/last-ips-dyn.txt"
lastIPsMapDirect = stateDir + "/last-ips-map-direct.txt"
lastIPsMapDyn = stateDir + "/last-ips-map-wildcard.txt"
routesCacheMeta = stateDir + "/routes-clear-cache.json"
routesCacheIPs = stateDir + "/routes-clear-cache-ips.txt"
routesCacheDyn = stateDir + "/routes-clear-cache-ips-dyn.txt"
routesCacheMap = stateDir + "/routes-clear-cache-ips-map.txt"
routesCacheMapD = stateDir + "/routes-clear-cache-ips-map-direct.txt"
routesCacheMapW = stateDir + "/routes-clear-cache-ips-map-wildcard.txt"
routesCacheRT = stateDir + "/routes-clear-cache-routes.txt"
precheckForcePath = stateDir + "/precheck-force.once"
autoloopLogPath = stateDir + "/adguard-autoloop.log"
loginStatePath = stateDir + "/adguard-login.json"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
package app
import (
dnscfgpkg "selective-vpn-api/app/dnscfg"
"time"
)
var dnsBenchmarkDefaultDomains = append([]string(nil), dnscfgpkg.BenchmarkDefaultDomains...)
const (
dnsBenchmarkProfileQuick = "quick"
dnsBenchmarkProfileLoad = "load"
)
type dnsBenchmarkOptions = dnscfgpkg.BenchmarkOptions
func normalizeBenchmarkUpstreams(in []DNSBenchmarkUpstream) []string {
if len(in) == 0 {
return nil
}
raw := make([]string, 0, len(in))
for _, item := range in {
raw = append(raw, item.Addr)
}
return dnscfgpkg.NormalizeBenchmarkUpstreamStrings(raw, normalizeDNSUpstream)
}
func benchmarkDNSUpstream(upstream string, domains []string, timeout time.Duration, attempts int, opts dnsBenchmarkOptions) DNSBenchmarkResult {
classify := func(err error) string {
switch classifyDNSError(err) {
case dnsErrorNXDomain:
return dnscfgpkg.BenchmarkErrorNXDomain
case dnsErrorTimeout:
return dnscfgpkg.BenchmarkErrorTimeout
case dnsErrorTemporary:
return dnscfgpkg.BenchmarkErrorTemporary
default:
return dnscfgpkg.BenchmarkErrorOther
}
}
pkgRes := dnscfgpkg.BenchmarkDNSUpstream(upstream, domains, timeout, attempts, opts, dnsLookupAOnce, classify)
return DNSBenchmarkResult{
Upstream: pkgRes.Upstream,
Attempts: pkgRes.Attempts,
OK: pkgRes.OK,
Fail: pkgRes.Fail,
NXDomain: pkgRes.NXDomain,
Timeout: pkgRes.Timeout,
Temporary: pkgRes.Temporary,
Other: pkgRes.Other,
AvgMS: pkgRes.AvgMS,
P95MS: pkgRes.P95MS,
Score: pkgRes.Score,
Color: pkgRes.Color,
}
}
func dnsLookupAOnce(host string, upstream string, timeout time.Duration) ([]string, error) {
return dnscfgpkg.DNSLookupAOnce(host, upstream, timeout, splitDNS, isPrivateIPv4)
}
func benchmarkTopN(results []DNSBenchmarkResult, n int, fallback []string) []string {
pkgResults := make([]dnscfgpkg.BenchmarkResult, 0, len(results))
for _, item := range results {
pkgResults = append(pkgResults, dnscfgpkg.BenchmarkResult{
Upstream: item.Upstream,
Attempts: item.Attempts,
OK: item.OK,
Fail: item.Fail,
NXDomain: item.NXDomain,
Timeout: item.Timeout,
Temporary: item.Temporary,
Other: item.Other,
AvgMS: item.AvgMS,
P95MS: item.P95MS,
Score: item.Score,
Color: item.Color,
})
}
return dnscfgpkg.BenchmarkTopN(pkgResults, n, fallback)
}

View File

@@ -0,0 +1,140 @@
package app
import (
"encoding/json"
"io"
"net/http"
dnscfgpkg "selective-vpn-api/app/dnscfg"
"sort"
"sync"
"time"
)
func handleDNSBenchmark(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req DNSBenchmarkRequest
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && err != io.EOF {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
upstreams := normalizeBenchmarkUpstreams(req.Upstreams)
if len(upstreams) == 0 {
pool := loadDNSUpstreamPoolState()
if len(pool) > 0 {
tmp := make([]DNSBenchmarkUpstream, 0, len(pool))
for _, item := range pool {
tmp = append(tmp, DNSBenchmarkUpstream{Addr: item.Addr, Enabled: item.Enabled})
}
upstreams = normalizeBenchmarkUpstreams(tmp)
}
if len(upstreams) == 0 {
cfg := loadDNSUpstreamsConf()
upstreams = dnscfgpkg.NormalizeBenchmarkUpstreamStrings([]string{
cfg.Default1,
cfg.Default2,
cfg.Meta1,
cfg.Meta2,
}, normalizeDNSUpstream)
}
}
if len(upstreams) == 0 {
http.Error(w, "no upstreams", http.StatusBadRequest)
return
}
domains := dnscfgpkg.NormalizeBenchmarkDomains(req.Domains)
if len(domains) == 0 {
domains = append(domains, dnsBenchmarkDefaultDomains...)
}
timeoutMS := req.TimeoutMS
if timeoutMS <= 0 {
timeoutMS = 1800
}
if timeoutMS < 300 {
timeoutMS = 300
}
if timeoutMS > 5000 {
timeoutMS = 5000
}
attempts := req.Attempts
if attempts <= 0 {
attempts = 1
}
if attempts > 3 {
attempts = 3
}
profile := dnscfgpkg.NormalizeBenchmarkProfile(req.Profile)
if profile == dnsBenchmarkProfileLoad && attempts < 2 {
// Load profile should emulate real resolver pressure.
attempts = 2
}
concurrency := req.Concurrency
if concurrency <= 0 {
concurrency = 6
}
if concurrency < 1 {
concurrency = 1
}
if concurrency > 32 {
concurrency = 32
}
if concurrency > len(upstreams) {
concurrency = len(upstreams)
}
opts := dnscfgpkg.MakeDNSBenchmarkOptions(profile, concurrency)
results := make([]DNSBenchmarkResult, 0, len(upstreams))
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, concurrency)
timeout := time.Duration(timeoutMS) * time.Millisecond
for _, upstream := range upstreams {
wg.Add(1)
sem <- struct{}{}
go func(upstream string) {
defer wg.Done()
defer func() { <-sem }()
result := benchmarkDNSUpstream(upstream, domains, timeout, attempts, opts)
mu.Lock()
results = append(results, result)
mu.Unlock()
}(upstream)
}
wg.Wait()
sort.Slice(results, func(i, j int) bool {
if results[i].Score == results[j].Score {
if results[i].AvgMS == results[j].AvgMS {
if results[i].OK == results[j].OK {
return results[i].Upstream < results[j].Upstream
}
return results[i].OK > results[j].OK
}
return results[i].AvgMS < results[j].AvgMS
}
return results[i].Score > results[j].Score
})
resp := DNSBenchmarkResponse{
Results: results,
DomainsUsed: domains,
TimeoutMS: timeoutMS,
AttemptsPerDomain: attempts,
Profile: profile,
}
resp.RecommendedDefault = benchmarkTopN(results, 2, upstreams)
resp.RecommendedMeta = benchmarkTopN(results, 2, upstreams)
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -0,0 +1,72 @@
package app
import (
"encoding/json"
"io"
"net/http"
"strings"
)
// ---------------------------------------------------------------------
// EN: `handleDNSStatus` is an HTTP handler for dns status.
// RU: `handleDNSStatus` - HTTP-обработчик для dns status.
// ---------------------------------------------------------------------
func handleDNSStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
mode := loadDNSMode()
writeJSON(w, http.StatusOK, makeDNSStatusResponse(mode))
}
// ---------------------------------------------------------------------
// EN: `handleDNSModeSet` is an HTTP handler for dns mode set.
// RU: `handleDNSModeSet` - HTTP-обработчик для dns mode set.
// ---------------------------------------------------------------------
func handleDNSModeSet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req DNSModeRequest
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && err != io.EOF {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
mode := loadDNSMode()
mode.Mode = normalizeDNSResolverMode(req.Mode, req.ViaSmartDNS)
mode.ViaSmartDNS = mode.Mode != DNSModeDirect
if strings.TrimSpace(req.SmartDNSAddr) != "" {
mode.SmartDNSAddr = req.SmartDNSAddr
}
if err := saveDNSMode(mode); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
mode = loadDNSMode()
writeJSON(w, http.StatusOK, makeDNSStatusResponse(mode))
}
func makeDNSStatusResponse(mode DNSMode) DNSStatusResponse {
rt := smartDNSRuntimeSnapshot()
resp := DNSStatusResponse{
ViaSmartDNS: mode.ViaSmartDNS,
SmartDNSAddr: mode.SmartDNSAddr,
Mode: mode.Mode,
UnitState: smartdnsUnitState(),
RuntimeNftset: rt.Enabled,
WildcardSource: rt.WildcardSource,
RuntimeCfgPath: rt.ConfigPath,
}
if rt.Message != "" {
resp.RuntimeCfgError = rt.Message
}
return resp
}

View File

@@ -0,0 +1,88 @@
package app
import (
"encoding/json"
"io"
"net/http"
"strings"
)
// ---------------------------------------------------------------------
// EN: `handleDNSSmartdnsService` is an HTTP handler for dns smartdns service.
// RU: `handleDNSSmartdnsService` - HTTP-обработчик для dns smartdns service.
// ---------------------------------------------------------------------
func handleDNSSmartdnsService(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Action string `json:"action"`
}
if r.Body != nil {
defer r.Body.Close()
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body)
}
action := strings.ToLower(strings.TrimSpace(body.Action))
if action == "" {
action = "restart"
}
switch action {
case "start", "stop", "restart":
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
res := runSmartdnsUnitAction(action)
mode := loadDNSMode()
rt := smartDNSRuntimeSnapshot()
writeJSON(w, http.StatusOK, map[string]any{
"ok": res.OK,
"message": res.Message,
"exitCode": res.ExitCode,
"stdout": res.Stdout,
"stderr": res.Stderr,
"unit_state": smartdnsUnitState(),
"via_smartdns": mode.ViaSmartDNS,
"smartdns_addr": mode.SmartDNSAddr,
"mode": mode.Mode,
"runtime_nftset": rt.Enabled,
"wildcard_source": rt.WildcardSource,
})
}
// ---------------------------------------------------------------------
// EN: `handleSmartdnsService` is an HTTP handler for smartdns service.
// RU: `handleSmartdnsService` - HTTP-обработчик для smartdns service.
// ---------------------------------------------------------------------
func handleSmartdnsService(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]string{"state": smartdnsUnitState()})
case http.MethodPost:
var body struct {
Action string `json:"action"`
}
if r.Body != nil {
defer r.Body.Close()
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body)
}
action := strings.ToLower(strings.TrimSpace(body.Action))
if action == "" {
action = "restart"
}
switch action {
case "start", "stop", "restart":
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, runSmartdnsUnitAction(action))
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

View File

@@ -0,0 +1,61 @@
package app
import (
"encoding/json"
"io"
"net/http"
)
// ---------------------------------------------------------------------
// EN: `handleDNSUpstreams` is an HTTP handler for dns upstreams.
// RU: `handleDNSUpstreams` - HTTP-обработчик для dns upstreams.
// ---------------------------------------------------------------------
func handleDNSUpstreams(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, loadDNSUpstreamsConf())
case http.MethodPost:
var cfg DNSUpstreams
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&cfg); err != nil && err != io.EOF {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
if err := saveDNSUpstreamsConf(cfg); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"cfg": loadDNSUpstreamsConf(),
})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func handleDNSUpstreamPool(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
items := loadDNSUpstreamPoolState()
writeJSON(w, http.StatusOK, DNSUpstreamPoolState{Items: items})
case http.MethodPost:
var body DNSUpstreamPoolState
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
if err := saveDNSUpstreamPoolState(body.Items); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, DNSUpstreamPoolState{Items: loadDNSUpstreamPoolState()})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

View File

@@ -0,0 +1,63 @@
package app
import dnscfgpkg "selective-vpn-api/app/dnscfg"
// ---------------------------------------------------------------------
// EN: `loadDNSMode` loads dns mode from storage or config.
// RU: `loadDNSMode` - загружает dns mode из хранилища или конфига.
// ---------------------------------------------------------------------
func loadDNSMode() DNSMode {
st, needPersist := dnscfgpkg.LoadMode(dnscfgModeConfig())
mode := DNSMode{
ViaSmartDNS: st.ViaSmartDNS,
SmartDNSAddr: st.SmartDNSAddr,
Mode: DNSResolverMode(st.Mode),
}
if needPersist {
_ = saveDNSMode(mode)
}
return mode
}
// ---------------------------------------------------------------------
// EN: `saveDNSMode` saves dns mode to persistent storage.
// RU: `saveDNSMode` - сохраняет dns mode в постоянное хранилище.
// ---------------------------------------------------------------------
func saveDNSMode(mode DNSMode) error {
return dnscfgpkg.SaveMode(
dnscfgModeConfig(),
dnscfgpkg.ModeState{
ViaSmartDNS: mode.ViaSmartDNS,
SmartDNSAddr: mode.SmartDNSAddr,
Mode: string(mode.Mode),
},
)
}
func dnscfgModeConfig() dnscfgpkg.ModeConfig {
return dnscfgpkg.ModeConfig{
Path: dnsModePath,
DirectMode: string(DNSModeDirect),
DefaultSmartDNSAddr: resolveDefaultSmartDNSAddr(),
NormalizeResolverMode: func(mode string, viaSmartDNS bool) string {
return string(normalizeDNSResolverMode(DNSResolverMode(mode), viaSmartDNS))
},
NormalizeSmartDNSAddr: normalizeSmartDNSAddr,
}
}
// ---------------------------------------------------------------------
// EN: `normalizeDNSResolverMode` normalizes dns resolver mode values.
// RU: `normalizeDNSResolverMode` - нормализует значения режима dns резолвера.
// ---------------------------------------------------------------------
func normalizeDNSResolverMode(mode DNSResolverMode, viaSmartDNS bool) DNSResolverMode {
return DNSResolverMode(
dnscfgpkg.NormalizeResolverMode(
string(mode),
viaSmartDNS,
string(DNSModeDirect),
string(DNSModeSmartDNS),
string(DNSModeHybridWildcard),
),
)
}

View File

@@ -0,0 +1,90 @@
package app
import (
"os"
"strings"
dnscfgpkg "selective-vpn-api/app/dnscfg"
)
// ---------------------------------------------------------------------
// EN: `smartDNSAddr` contains core logic for smart d n s addr.
// RU: `smartDNSAddr` - содержит основную логику для smart d n s addr.
// ---------------------------------------------------------------------
func smartDNSAddr() string {
return loadDNSMode().SmartDNSAddr
}
// ---------------------------------------------------------------------
// EN: `smartDNSForced` contains core logic for smart d n s forced.
// RU: `smartDNSForced` - содержит основную логику для smart d n s forced.
// ---------------------------------------------------------------------
func smartDNSForced() bool {
return dnscfgpkg.SmartDNSForced(os.Getenv(smartDNSForceEnv))
}
// ---------------------------------------------------------------------
// EN: `smartdnsUnitState` contains core logic for smartdns unit state.
// RU: `smartdnsUnitState` - содержит основную логику для smartdns unit state.
// ---------------------------------------------------------------------
func smartdnsUnitState() string {
return dnscfgpkg.UnitState(runCommand, "smartdns-local.service")
}
// ---------------------------------------------------------------------
// EN: `runSmartdnsUnitAction` runs the workflow for smartdns unit action.
// RU: `runSmartdnsUnitAction` - запускает рабочий процесс для smartdns unit action.
// ---------------------------------------------------------------------
func runSmartdnsUnitAction(action string) cmdResult {
res := dnscfgpkg.RunUnitAction(runCommand, "smartdns-local.service", action)
msg := res.Message
if res.OK {
msg = "smartdns " + strings.TrimSpace(action) + " done"
}
return cmdResult{
OK: res.OK,
ExitCode: res.ExitCode,
Stdout: res.Stdout,
Stderr: res.Stderr,
Message: msg,
}
}
// ---------------------------------------------------------------------
// EN: `resolveDefaultSmartDNSAddr` resolves default smart d n s addr into concrete values.
// RU: `resolveDefaultSmartDNSAddr` - резолвит default smart d n s addr в конкретные значения.
// ---------------------------------------------------------------------
func resolveDefaultSmartDNSAddr() string {
return dnscfgpkg.ResolveDefaultSmartDNSAddr(
os.Getenv(smartDNSAddrEnv),
[]string{
"/opt/stack/adguardapp/smartdns.conf",
"/etc/selective-vpn/smartdns.conf",
},
smartDNSDefaultAddr,
)
}
// ---------------------------------------------------------------------
// EN: `smartDNSAddrFromConfig` loads smart d n s addr from config.
// RU: `smartDNSAddrFromConfig` - загружает smart d n s addr из конфига.
// ---------------------------------------------------------------------
func smartDNSAddrFromConfig(path string) string {
return dnscfgpkg.SmartDNSAddrFromConfig(path)
}
// ---------------------------------------------------------------------
// EN: `normalizeDNSUpstream` parses dns upstream and returns normalized values.
// RU: `normalizeDNSUpstream` - парсит dns upstream и возвращает нормализованные значения.
// ---------------------------------------------------------------------
func normalizeDNSUpstream(raw string, defaultPort string) string {
return dnscfgpkg.NormalizeDNSUpstream(raw, defaultPort)
}
// ---------------------------------------------------------------------
// EN: `normalizeSmartDNSAddr` parses smart d n s addr and returns normalized values.
// RU: `normalizeSmartDNSAddr` - парсит smart d n s addr и возвращает нормализованные значения.
// ---------------------------------------------------------------------
func normalizeSmartDNSAddr(raw string) string {
return dnscfgpkg.NormalizeSmartDNSAddr(raw)
}

View File

@@ -0,0 +1,104 @@
package app
import dnscfgpkg "selective-vpn-api/app/dnscfg"
func dnscfgPoolItemsFromApp(items []DNSUpstreamPoolItem) []dnscfgpkg.UpstreamPoolItem {
out := make([]dnscfgpkg.UpstreamPoolItem, 0, len(items))
for _, item := range items {
out = append(out, dnscfgpkg.UpstreamPoolItem{
Addr: item.Addr,
Enabled: item.Enabled,
})
}
return out
}
func dnscfgPoolItemsToApp(items []dnscfgpkg.UpstreamPoolItem) []DNSUpstreamPoolItem {
out := make([]DNSUpstreamPoolItem, 0, len(items))
for _, item := range items {
out = append(out, DNSUpstreamPoolItem{
Addr: item.Addr,
Enabled: item.Enabled,
})
}
return out
}
func dnscfgUpstreamsFromApp(cfg DNSUpstreams) dnscfgpkg.Upstreams {
return dnscfgpkg.Upstreams{
Default1: cfg.Default1,
Default2: cfg.Default2,
Meta1: cfg.Meta1,
Meta2: cfg.Meta2,
}
}
func dnscfgUpstreamsToApp(cfg dnscfgpkg.Upstreams) DNSUpstreams {
return DNSUpstreams{
Default1: cfg.Default1,
Default2: cfg.Default2,
Meta1: cfg.Meta1,
Meta2: cfg.Meta2,
}
}
func dnscfgLegacyDefaults() dnscfgpkg.Upstreams {
return dnscfgpkg.Upstreams{
Default1: defaultDNS1,
Default2: defaultDNS2,
Meta1: defaultMeta1,
Meta2: defaultMeta2,
}
}
func normalizeDNSUpstreamPoolItems(items []DNSUpstreamPoolItem) []DNSUpstreamPoolItem {
return dnscfgPoolItemsToApp(
dnscfgpkg.NormalizeUpstreamPoolItems(dnscfgPoolItemsFromApp(items), normalizeDNSUpstream),
)
}
func dnsUpstreamPoolFromLegacy(cfg DNSUpstreams) []DNSUpstreamPoolItem {
return dnscfgPoolItemsToApp(
dnscfgpkg.UpstreamPoolFromLegacy(dnscfgUpstreamsFromApp(cfg), normalizeDNSUpstream),
)
}
func dnsUpstreamPoolToLegacy(items []DNSUpstreamPoolItem) DNSUpstreams {
return dnscfgUpstreamsToApp(
dnscfgpkg.UpstreamPoolToLegacy(
dnscfgPoolItemsFromApp(items),
dnscfgLegacyDefaults(),
normalizeDNSUpstream,
),
)
}
func loadEnabledDNSUpstreamPool() []string {
items := loadDNSUpstreamPoolState()
return uniqueStrings(
dnscfgpkg.EnabledPool(dnscfgPoolItemsFromApp(items), normalizeDNSUpstream),
)
}
// ---------------------------------------------------------------------
// EN: `loadDNSUpstreamsConf` loads dns upstreams conf from storage or config.
// RU: `loadDNSUpstreamsConf` - загружает dns upstreams conf из хранилища или конфига.
// ---------------------------------------------------------------------
func loadDNSUpstreamsConf() DNSUpstreams {
pool := loadDNSUpstreamPoolState()
if len(pool) > 0 {
return dnsUpstreamPoolToLegacy(pool)
}
return loadDNSUpstreamsConfFile()
}
// ---------------------------------------------------------------------
// EN: `saveDNSUpstreamsConf` saves dns upstreams conf to persistent storage.
// RU: `saveDNSUpstreamsConf` - сохраняет dns upstreams conf в постоянное хранилище.
// ---------------------------------------------------------------------
func saveDNSUpstreamsConf(cfg DNSUpstreams) error {
if err := saveDNSUpstreamsConfFile(cfg); err != nil {
return err
}
return saveDNSUpstreamPoolFile(dnsUpstreamPoolFromLegacy(cfg))
}

View File

@@ -0,0 +1,42 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
dnscfgpkg "selective-vpn-api/app/dnscfg"
)
func loadDNSUpstreamsConfFile() DNSUpstreams {
data, err := os.ReadFile(dnsUpstreamsConf)
if err != nil {
return dnscfgUpstreamsToApp(dnscfgLegacyDefaults())
}
pkgCfg := dnscfgpkg.ParseUpstreamsConf(string(data), dnscfgLegacyDefaults(), normalizeDNSUpstream)
return dnscfgUpstreamsToApp(pkgCfg)
}
func saveDNSUpstreamsConfFile(cfg DNSUpstreams) error {
pkgCfg := dnscfgpkg.NormalizeUpstreams(dnscfgUpstreamsFromApp(cfg), dnscfgLegacyDefaults(), normalizeDNSUpstream)
cfg = dnscfgUpstreamsToApp(pkgCfg)
content := dnscfgpkg.RenderUpstreamsConf(pkgCfg)
if err := os.MkdirAll(filepath.Dir(dnsUpstreamsConf), 0o755); err != nil {
return err
}
tmp := dnsUpstreamsConf + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
return err
}
if err := os.Rename(tmp, dnsUpstreamsConf); err != nil {
return err
}
// Legacy JSON mirror for backward compatibility with older UI/runtime bits.
_ = os.MkdirAll(stateDir, 0o755)
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
_ = os.WriteFile(dnsUpstreamsPath, b, 0o644)
}
return nil
}

View File

@@ -0,0 +1,53 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
)
func saveDNSUpstreamPoolFile(items []DNSUpstreamPoolItem) error {
state := DNSUpstreamPoolState{Items: normalizeDNSUpstreamPoolItems(items)}
if err := os.MkdirAll(filepath.Dir(dnsUpstreamPool), 0o755); err != nil {
return err
}
tmp := dnsUpstreamPool + ".tmp"
b, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, dnsUpstreamPool)
}
func loadDNSUpstreamPoolState() []DNSUpstreamPoolItem {
data, err := os.ReadFile(dnsUpstreamPool)
if err == nil {
var st DNSUpstreamPoolState
if json.Unmarshal(data, &st) == nil {
items := normalizeDNSUpstreamPoolItems(st.Items)
if len(items) > 0 {
return items
}
}
}
legacy := loadDNSUpstreamsConfFile()
items := dnsUpstreamPoolFromLegacy(legacy)
if len(items) > 0 {
_ = saveDNSUpstreamPoolFile(items)
}
return items
}
func saveDNSUpstreamPoolState(items []DNSUpstreamPoolItem) error {
items = normalizeDNSUpstreamPoolItems(items)
if len(items) == 0 {
items = dnsUpstreamPoolFromLegacy(loadDNSUpstreamsConfFile())
}
if err := saveDNSUpstreamPoolFile(items); err != nil {
return err
}
return saveDNSUpstreamsConfFile(dnsUpstreamPoolToLegacy(items))
}

View File

@@ -0,0 +1,5 @@
package app
// SmartDNS HTTP handlers are split by role:
// - runtime status/toggle: dns_smartdns_handlers_runtime.go
// - prewarm execution/helpers: dns_smartdns_handlers_prewarm.go

View File

@@ -0,0 +1,141 @@
package app
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
dnscfgpkg "selective-vpn-api/app/dnscfg"
"time"
)
// ---------------------------------------------------------------------
// EN: `handleSmartdnsPrewarm` forces DNS lookups for wildcard domains via SmartDNS.
// EN: This warms agvpn_dyn4 in realtime through SmartDNS nftset runtime integration.
// RU: `handleSmartdnsPrewarm` принудительно резолвит wildcard-домены через SmartDNS.
// RU: Это прогревает agvpn_dyn4 в realtime через runtime-интеграцию SmartDNS nftset.
// ---------------------------------------------------------------------
func handleSmartdnsPrewarm(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Limit int `json:"limit"`
Workers int `json:"workers"`
TimeoutMS int `json:"timeout_ms"`
AggressiveSubs bool `json:"aggressive_subs"`
}
if r.Body != nil {
defer r.Body.Close()
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body)
}
writeJSON(w, http.StatusOK, runSmartdnsPrewarm(body.Limit, body.Workers, body.TimeoutMS, body.AggressiveSubs))
}
func runSmartdnsPrewarm(limit, workers, timeoutMS int, aggressiveSubs bool) cmdResult {
mode := loadDNSMode()
runtimeEnabled := smartDNSRuntimeEnabled()
source := "resolver"
if runtimeEnabled {
source = "smartdns_runtime"
}
smartdnsAddr := normalizeSmartDNSAddr(mode.SmartDNSAddr)
if smartdnsAddr == "" {
smartdnsAddr = resolveDefaultSmartDNSAddr()
}
aggressive := aggressiveSubs || prewarmAggressiveFromEnv()
subs := []string{}
subsPerBaseLimit := 0
if aggressive {
subs = loadList(domainDir + "/subs.txt")
subsPerBaseLimit = envInt("RESOLVE_SUBS_PER_BASE_LIMIT", 0)
if subsPerBaseLimit < 0 {
subsPerBaseLimit = 0
}
}
res := dnscfgpkg.RunPrewarm(
dnscfgpkg.PrewarmInput{
Mode: string(mode.Mode),
Source: source,
RuntimeEnabled: runtimeEnabled,
SmartDNSAddr: smartdnsAddr,
Wildcards: loadSmartDNSWildcardDomains(nil),
AggressiveSubs: aggressive,
Subs: subs,
SubsPerBaseLimit: subsPerBaseLimit,
Limit: limit,
Workers: workers,
TimeoutMS: timeoutMS,
EnvWorkers: envInt("SMARTDNS_PREWARM_WORKERS", 24),
EnvTimeoutMS: envInt("SMARTDNS_PREWARM_TIMEOUT_MS", 1800),
MaxHostsLog: 200,
WildcardMapPath: lastIPsMapDyn,
},
dnscfgpkg.PrewarmDeps{
IsGoogleLike: isGoogleLike,
EnsureRuntimeSet: func() {
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", "agvpn")
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn_dyn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}")
},
DigA: func(host string, dnsList []string, timeout time.Duration) ([]string, dnscfgpkg.PrewarmDNSMetrics) {
ips, stats := digA(host, dnsList, timeout, nil)
return ips, prewarmMetricsFromDNSMetrics(stats)
},
ReadDynSet: func() ([]string, error) {
return readNftSetElements("agvpn_dyn4")
},
ApplyDynSet: func(ips []string) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
return nftUpdateSetIPsSmart(ctx, "agvpn_dyn4", ips, nil)
},
Logf: func(message string) {
appendTraceLineTo(smartdnsLogPath, "smartdns", message)
},
},
)
return cmdResult{
OK: res.OK,
Message: res.Message,
ExitCode: res.ExitCode,
}
}
func prewarmAggressiveFromEnv() bool {
return dnscfgpkg.SmartDNSForced(os.Getenv("SMARTDNS_PREWARM_AGGRESSIVE"))
}
func prewarmMetricsFromDNSMetrics(in dnsMetrics) dnscfgpkg.PrewarmDNSMetrics {
out := dnscfgpkg.PrewarmDNSMetrics{
Attempts: in.Attempts,
OK: in.OK,
NXDomain: in.NXDomain,
Timeout: in.Timeout,
Temporary: in.Temporary,
Other: in.Other,
Skipped: in.Skipped,
}
if len(in.PerUpstream) > 0 {
out.PerUpstream = make(map[string]dnscfgpkg.PrewarmDNSUpstreamMetrics, len(in.PerUpstream))
for upstream, stats := range in.PerUpstream {
if stats == nil {
continue
}
out.PerUpstream[upstream] = dnscfgpkg.PrewarmDNSUpstreamMetrics{
Attempts: stats.Attempts,
OK: stats.OK,
NXDomain: stats.NXDomain,
Timeout: stats.Timeout,
Temporary: stats.Temporary,
Other: stats.Other,
Skipped: stats.Skipped,
}
}
}
return out
}

View File

@@ -0,0 +1,70 @@
package app
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
func handleSmartdnsRuntime(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, smartDNSRuntimeSnapshot())
case http.MethodPost:
var body SmartDNSRuntimeRequest
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
if body.Enabled == nil {
http.Error(w, "enabled is required", http.StatusBadRequest)
return
}
prev := loadSmartDNSRuntimeState(nil)
next := prev
next.Enabled = *body.Enabled
if err := saveSmartDNSRuntimeState(next); err != nil {
http.Error(w, "runtime state write error", http.StatusInternalServerError)
return
}
changed, err := applySmartDNSRuntimeConfig(next.Enabled)
if err != nil {
_ = saveSmartDNSRuntimeState(prev)
http.Error(w, "runtime config apply error: "+err.Error(), http.StatusInternalServerError)
return
}
restart := true
if body.Restart != nil {
restart = *body.Restart
}
restarted := false
msg := ""
if restart && smartdnsUnitState() == "active" {
res := runSmartdnsUnitAction("restart")
restarted = res.OK
if !res.OK {
msg = "runtime config changed, but smartdns restart failed: " + strings.TrimSpace(res.Message)
}
}
if msg == "" {
msg = fmt.Sprintf("smartdns runtime set: enabled=%t changed=%t restarted=%t", next.Enabled, changed, restarted)
}
appendTraceLineTo(smartdnsLogPath, "smartdns", msg)
resp := smartDNSRuntimeSnapshot()
resp.Changed = changed
resp.Restarted = restarted
resp.Message = msg
writeJSON(w, http.StatusOK, resp)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

View File

@@ -0,0 +1,358 @@
package dnscfg
import (
"context"
"fmt"
"net"
"sort"
"strings"
"sync"
"time"
)
const (
BenchmarkProfileQuick = "quick"
BenchmarkProfileLoad = "load"
BenchmarkErrorNXDomain = "nxdomain"
BenchmarkErrorTimeout = "timeout"
BenchmarkErrorTemporary = "temporary"
BenchmarkErrorOther = "other"
)
var BenchmarkDefaultDomains = []string{
"cloudflare.com",
"google.com",
"telegram.org",
"github.com",
"youtube.com",
"twitter.com",
}
type BenchmarkOptions struct {
Profile string
LoadWorkers int
Rounds int
SyntheticPerDomain int
}
type BenchmarkResult struct {
Upstream string
Attempts int
OK int
Fail int
NXDomain int
Timeout int
Temporary int
Other int
AvgMS int
P95MS int
Score float64
Color string
}
func NormalizeBenchmarkProfile(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", BenchmarkProfileLoad:
return BenchmarkProfileLoad
case BenchmarkProfileQuick:
return BenchmarkProfileQuick
default:
return BenchmarkProfileLoad
}
}
func MakeDNSBenchmarkOptions(profile string, concurrency int) BenchmarkOptions {
if concurrency < 1 {
concurrency = 1
}
if profile == BenchmarkProfileQuick {
return BenchmarkOptions{
Profile: BenchmarkProfileQuick,
LoadWorkers: 1,
Rounds: 1,
SyntheticPerDomain: 0,
}
}
workers := concurrency * 2
if workers < 4 {
workers = 4
}
if workers > 16 {
workers = 16
}
return BenchmarkOptions{
Profile: BenchmarkProfileLoad,
LoadWorkers: workers,
Rounds: 3,
SyntheticPerDomain: 2,
}
}
func NormalizeBenchmarkUpstreamStrings(in []string, normalizeUpstream func(string, string) string) []string {
out := make([]string, 0, len(in))
seen := map[string]struct{}{}
for _, raw := range in {
n := strings.TrimSpace(raw)
if normalizeUpstream != nil {
n = normalizeUpstream(n, "53")
}
if n == "" {
continue
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out
}
func NormalizeBenchmarkDomains(in []string) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
seen := map[string]struct{}{}
for _, raw := range in {
d := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")
if d == "" || strings.HasPrefix(d, "#") {
continue
}
if _, ok := seen[d]; ok {
continue
}
seen[d] = struct{}{}
out = append(out, d)
}
if len(out) > 100 {
out = out[:100]
}
return out
}
func BenchmarkDNSUpstream(
upstream string,
domains []string,
timeout time.Duration,
attempts int,
opts BenchmarkOptions,
lookupAOnce func(host, upstream string, timeout time.Duration) ([]string, error),
classifyErr func(error) string,
) BenchmarkResult {
res := BenchmarkResult{Upstream: upstream}
probes := BuildBenchmarkProbeHosts(domains, attempts, opts)
if len(probes) == 0 {
return res
}
durations := make([]int, 0, len(probes))
var mu sync.Mutex
jobs := make(chan string, len(probes))
workers := opts.LoadWorkers
if workers < 1 {
workers = 1
}
if workers > len(probes) {
workers = len(probes)
}
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for host := range jobs {
start := time.Now()
_, err := lookupAOnce(host, upstream, timeout)
elapsed := int(time.Since(start).Milliseconds())
if elapsed < 1 {
elapsed = 1
}
mu.Lock()
res.Attempts++
durations = append(durations, elapsed)
if err != nil {
res.Fail++
switch strings.ToLower(strings.TrimSpace(classifyErr(err))) {
case BenchmarkErrorNXDomain:
res.NXDomain++
case BenchmarkErrorTimeout:
res.Timeout++
case BenchmarkErrorTemporary:
res.Temporary++
default:
res.Other++
}
} else {
res.OK++
}
mu.Unlock()
}
}()
}
for _, host := range probes {
jobs <- host
}
close(jobs)
wg.Wait()
if len(durations) > 0 {
sort.Ints(durations)
sum := 0
for _, d := range durations {
sum += d
}
res.AvgMS = sum / len(durations)
idx := int(float64(len(durations)-1) * 0.95)
if idx < 0 {
idx = 0
}
res.P95MS = durations[idx]
}
total := res.Attempts
if total > 0 {
okRate := float64(res.OK) / float64(total)
answeredRate := float64(res.OK+res.NXDomain+res.Temporary+res.Other) / float64(total)
timeoutRate := float64(res.Timeout) / float64(total)
temporaryRate := float64(res.Temporary) / float64(total)
otherRate := float64(res.Other) / float64(total)
avg := float64(res.AvgMS)
if avg <= 0 {
avg = float64(timeout.Milliseconds())
}
p95 := float64(res.P95MS)
if p95 <= 0 {
p95 = avg
}
res.Score = answeredRate*100.0 + okRate*15.0 - timeoutRate*120.0 - temporaryRate*35.0 - otherRate*20.0 - (avg / 25.0) - (p95 / 45.0)
}
timeoutRate := 0.0
answeredRate := 0.0
if res.Attempts > 0 {
timeoutRate = float64(res.Timeout) / float64(res.Attempts)
answeredRate = float64(res.OK+res.NXDomain+res.Temporary+res.Other) / float64(res.Attempts)
}
switch {
case answeredRate < 0.85 || timeoutRate >= 0.10 || res.P95MS > 1800:
res.Color = "red"
case answeredRate >= 0.97 && timeoutRate <= 0.02 && res.P95MS <= 700:
res.Color = "green"
default:
res.Color = "yellow"
}
return res
}
func BuildBenchmarkProbeHosts(domains []string, attempts int, opts BenchmarkOptions) []string {
if len(domains) == 0 {
return nil
}
if attempts < 1 {
attempts = 1
}
rounds := opts.Rounds
if rounds < 1 {
rounds = 1
}
synth := opts.SyntheticPerDomain
if synth < 0 {
synth = 0
}
out := make([]string, 0, len(domains)*attempts*rounds*(1+synth))
for round := 0; round < rounds; round++ {
for _, host := range domains {
for i := 0; i < attempts; i++ {
out = append(out, host)
}
for n := 0; n < synth; n++ {
out = append(out, fmt.Sprintf("svpn-bench-%d-%d.%s", round+1, n+1, host))
}
}
}
if len(out) > 10000 {
out = out[:10000]
}
return out
}
func DNSLookupAOnce(
host string,
upstream string,
timeout time.Duration,
splitDNS func(string) (string, string),
isPrivateIPv4 func(string) bool,
) ([]string, error) {
if splitDNS == nil {
return nil, fmt.Errorf("splitDNS callback is nil")
}
server, port := splitDNS(upstream)
if server == "" {
return nil, fmt.Errorf("upstream empty")
}
if port == "" {
port = "53"
}
addr := net.JoinHostPort(server, port)
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "udp", addr)
},
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
records, err := resolver.LookupHost(ctx, host)
cancel()
if err != nil {
return nil, err
}
seen := map[string]struct{}{}
out := make([]string, 0, len(records))
for _, ip := range records {
if isPrivateIPv4 != nil && isPrivateIPv4(ip) {
continue
}
if _, ok := seen[ip]; ok {
continue
}
seen[ip] = struct{}{}
out = append(out, ip)
}
if len(out) == 0 {
return nil, fmt.Errorf("no public ips")
}
return out, nil
}
func BenchmarkTopN(results []BenchmarkResult, n int, fallback []string) []string {
out := make([]string, 0, n)
for _, item := range results {
if item.OK <= 0 {
continue
}
out = append(out, item.Upstream)
if len(out) >= n {
return out
}
}
for _, item := range fallback {
if len(out) >= n {
break
}
dup := false
for _, e := range out {
if e == item {
dup = true
break
}
}
if !dup {
out = append(out, item)
}
}
return out
}

View File

@@ -0,0 +1,136 @@
package dnscfg
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
func NormalizeResolverMode(mode string, viaSmartDNS bool, directMode string, smartDNSMode string, hybridWildcardMode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case strings.ToLower(strings.TrimSpace(directMode)):
return directMode
case strings.ToLower(strings.TrimSpace(smartDNSMode)):
return hybridWildcardMode
case strings.ToLower(strings.TrimSpace(hybridWildcardMode)), "hybrid":
return hybridWildcardMode
default:
if viaSmartDNS {
return hybridWildcardMode
}
return directMode
}
}
func SmartDNSForced(envRaw string) bool {
switch strings.TrimSpace(strings.ToLower(envRaw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
type ModeState struct {
ViaSmartDNS bool `json:"via_smartdns"`
SmartDNSAddr string `json:"smartdns_addr"`
Mode string `json:"mode"`
}
type ModeConfig struct {
Path string
DirectMode string
DefaultSmartDNSAddr string
NormalizeResolverMode func(mode string, viaSmartDNS bool) string
NormalizeSmartDNSAddr func(raw string) string
}
func LoadMode(cfg ModeConfig) (ModeState, bool) {
mode := ModeState{
ViaSmartDNS: false,
SmartDNSAddr: strings.TrimSpace(cfg.DefaultSmartDNSAddr),
Mode: strings.TrimSpace(cfg.DirectMode),
}
needPersist := false
data, err := os.ReadFile(strings.TrimSpace(cfg.Path))
switch {
case err == nil:
var stored ModeState
if err := json.Unmarshal(data, &stored); err == nil {
normalized, changed := normalizeModeState(stored, cfg)
mode = normalized
if strings.TrimSpace(stored.Mode) == "" || stored.ViaSmartDNS != normalized.ViaSmartDNS || changed {
needPersist = true
}
} else {
needPersist = true
}
case os.IsNotExist(err):
needPersist = true
}
normalized, changed := normalizeModeState(mode, cfg)
mode = normalized
if changed {
needPersist = true
}
return mode, needPersist
}
func SaveMode(cfg ModeConfig, mode ModeState) error {
normalized, _ := normalizeModeState(mode, cfg)
path := strings.TrimSpace(cfg.Path)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
b, err := json.MarshalIndent(normalized, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func normalizeModeState(mode ModeState, cfg ModeConfig) (ModeState, bool) {
changed := false
prevMode := mode.Mode
mode.Mode = normalizeResolverMode(cfg, mode.Mode, mode.ViaSmartDNS)
if mode.Mode != prevMode {
changed = true
}
viaSmartDNS := mode.Mode != strings.TrimSpace(cfg.DirectMode)
if mode.ViaSmartDNS != viaSmartDNS {
mode.ViaSmartDNS = viaSmartDNS
changed = true
}
prevAddr := mode.SmartDNSAddr
mode.SmartDNSAddr = normalizeSmartDNSAddr(cfg, mode.SmartDNSAddr)
if mode.SmartDNSAddr == "" {
mode.SmartDNSAddr = strings.TrimSpace(cfg.DefaultSmartDNSAddr)
}
if mode.SmartDNSAddr != prevAddr {
changed = true
}
return mode, changed
}
func normalizeResolverMode(cfg ModeConfig, mode string, viaSmartDNS bool) string {
if cfg.NormalizeResolverMode == nil {
return strings.TrimSpace(mode)
}
return strings.TrimSpace(cfg.NormalizeResolverMode(mode, viaSmartDNS))
}
func normalizeSmartDNSAddr(cfg ModeConfig, raw string) string {
if cfg.NormalizeSmartDNSAddr == nil {
return strings.TrimSpace(raw)
}
return strings.TrimSpace(cfg.NormalizeSmartDNSAddr(raw))
}

View File

@@ -0,0 +1,100 @@
package dnscfg
import "strings"
type Upstreams struct {
Default1 string
Default2 string
Meta1 string
Meta2 string
}
type UpstreamPoolItem struct {
Addr string
Enabled bool
}
func NormalizeUpstreamPoolItems(items []UpstreamPoolItem, normalizeUpstream func(raw string, defaultPort string) string) []UpstreamPoolItem {
if len(items) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]UpstreamPoolItem, 0, len(items))
for _, item := range items {
addr := strings.TrimSpace(item.Addr)
if normalizeUpstream != nil {
addr = normalizeUpstream(addr, "53")
}
if addr == "" {
continue
}
if _, ok := seen[addr]; ok {
continue
}
seen[addr] = struct{}{}
out = append(out, UpstreamPoolItem{
Addr: addr,
Enabled: item.Enabled,
})
}
return out
}
func UpstreamPoolFromLegacy(cfg Upstreams, normalizeUpstream func(raw string, defaultPort string) string) []UpstreamPoolItem {
out := []UpstreamPoolItem{
{Addr: cfg.Default1, Enabled: true},
{Addr: cfg.Default2, Enabled: true},
{Addr: cfg.Meta1, Enabled: true},
{Addr: cfg.Meta2, Enabled: true},
}
return NormalizeUpstreamPoolItems(out, normalizeUpstream)
}
func UpstreamPoolToLegacy(items []UpstreamPoolItem, defaults Upstreams, normalizeUpstream func(raw string, defaultPort string) string) Upstreams {
items = NormalizeUpstreamPoolItems(items, normalizeUpstream)
out := defaults
enabled := make([]string, 0, len(items))
for _, item := range items {
if !item.Enabled {
continue
}
addr := strings.TrimSpace(item.Addr)
if normalizeUpstream != nil {
addr = normalizeUpstream(addr, "53")
}
if addr != "" {
enabled = append(enabled, addr)
}
}
if len(enabled) > 0 {
out.Default1 = enabled[0]
}
if len(enabled) > 1 {
out.Default2 = enabled[1]
}
if len(enabled) > 2 {
out.Meta1 = enabled[2]
}
if len(enabled) > 3 {
out.Meta2 = enabled[3]
}
return out
}
func EnabledPool(items []UpstreamPoolItem, normalizeUpstream func(raw string, defaultPort string) string) []string {
items = NormalizeUpstreamPoolItems(items, normalizeUpstream)
out := make([]string, 0, len(items))
for _, item := range items {
if !item.Enabled {
continue
}
addr := strings.TrimSpace(item.Addr)
if normalizeUpstream != nil {
addr = normalizeUpstream(addr, "53")
}
if addr != "" {
out = append(out, addr)
}
}
return out
}

View File

@@ -0,0 +1,392 @@
package dnscfg
import (
"fmt"
"sort"
"strings"
"time"
)
type PrewarmDNSUpstreamMetrics struct {
Attempts int
OK int
NXDomain int
Timeout int
Temporary int
Other int
Skipped int
}
type PrewarmDNSMetrics struct {
Attempts int
OK int
NXDomain int
Timeout int
Temporary int
Other int
Skipped int
PerUpstream map[string]PrewarmDNSUpstreamMetrics
}
func (m *PrewarmDNSMetrics) Merge(other PrewarmDNSMetrics) {
m.Attempts += other.Attempts
m.OK += other.OK
m.NXDomain += other.NXDomain
m.Timeout += other.Timeout
m.Temporary += other.Temporary
m.Other += other.Other
m.Skipped += other.Skipped
if len(other.PerUpstream) == 0 {
return
}
if m.PerUpstream == nil {
m.PerUpstream = map[string]PrewarmDNSUpstreamMetrics{}
}
for upstream, src := range other.PerUpstream {
dst := m.PerUpstream[upstream]
dst.Attempts += src.Attempts
dst.OK += src.OK
dst.NXDomain += src.NXDomain
dst.Timeout += src.Timeout
dst.Temporary += src.Temporary
dst.Other += src.Other
dst.Skipped += src.Skipped
m.PerUpstream[upstream] = dst
}
}
func (m PrewarmDNSMetrics) TotalErrors() int {
return m.NXDomain + m.Timeout + m.Temporary + m.Other
}
func (m PrewarmDNSMetrics) FormatPerUpstream() string {
if len(m.PerUpstream) == 0 {
return ""
}
keys := make([]string, 0, len(m.PerUpstream))
for k := range m.PerUpstream {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
v := m.PerUpstream[k]
parts = append(parts, fmt.Sprintf("%s{attempts=%d ok=%d nxdomain=%d timeout=%d temporary=%d other=%d skipped=%d}", k, v.Attempts, v.OK, v.NXDomain, v.Timeout, v.Temporary, v.Other, v.Skipped))
}
return strings.Join(parts, "; ")
}
type PrewarmInput struct {
Mode string
Source string
RuntimeEnabled bool
SmartDNSAddr string
Wildcards []string
AggressiveSubs bool
Subs []string
SubsPerBaseLimit int
Limit int
Workers int
TimeoutMS int
EnvWorkers int
EnvTimeoutMS int
MaxHostsLog int
WildcardMapPath string
}
type PrewarmDeps struct {
IsGoogleLike func(string) bool
EnsureRuntimeSet func()
DigA func(host string, dnsList []string, timeout time.Duration) ([]string, PrewarmDNSMetrics)
ReadDynSet func() ([]string, error)
ApplyDynSet func([]string) error
Logf func(message string)
}
type PrewarmResult struct {
OK bool
Message string
ExitCode int
ResolvedHosts int
}
func RunPrewarm(in PrewarmInput, deps PrewarmDeps) PrewarmResult {
smartdnsAddr := strings.TrimSpace(in.SmartDNSAddr)
if smartdnsAddr == "" {
return PrewarmResult{OK: false, Message: "SmartDNS address is empty"}
}
wildcards := trimNonEmptyUnique(in.Wildcards)
if len(wildcards) == 0 {
msg := "prewarm skipped: wildcard list is empty"
logPrewarm(deps.Logf, msg)
return PrewarmResult{OK: true, Message: msg}
}
aggressive := in.AggressiveSubs
subs := trimNonEmptyUnique(in.Subs)
subsPerBaseLimit := in.SubsPerBaseLimit
if subsPerBaseLimit < 0 {
subsPerBaseLimit = 0
}
domainSet := make(map[string]struct{}, len(wildcards)*(len(subs)+1))
for _, d := range wildcards {
domainSet[d] = struct{}{}
if !aggressive || isGoogleLikeSafe(deps.IsGoogleLike, d) {
continue
}
maxSubs := len(subs)
if subsPerBaseLimit > 0 && subsPerBaseLimit < maxSubs {
maxSubs = subsPerBaseLimit
}
for i := 0; i < maxSubs; i++ {
domainSet[subs[i]+"."+d] = struct{}{}
}
}
domains := make([]string, 0, len(domainSet))
for d := range domainSet {
domains = append(domains, d)
}
sort.Strings(domains)
if in.Limit > 0 && len(domains) > in.Limit {
domains = domains[:in.Limit]
}
if len(domains) == 0 {
msg := "prewarm skipped: expanded wildcard list is empty"
logPrewarm(deps.Logf, msg)
return PrewarmResult{OK: true, Message: msg}
}
workers := in.Workers
if workers <= 0 {
workers = in.EnvWorkers
if workers <= 0 {
workers = 24
}
}
if workers < 1 {
workers = 1
}
if workers > 200 {
workers = 200
}
timeoutMS := in.TimeoutMS
if timeoutMS <= 0 {
timeoutMS = in.EnvTimeoutMS
if timeoutMS <= 0 {
timeoutMS = 1800
}
}
if timeoutMS < 200 {
timeoutMS = 200
}
if timeoutMS > 15000 {
timeoutMS = 15000
}
timeout := time.Duration(timeoutMS) * time.Millisecond
if deps.EnsureRuntimeSet != nil {
deps.EnsureRuntimeSet()
}
logPrewarm(
deps.Logf,
fmt.Sprintf(
"prewarm start: mode=%s source=%s runtime_nftset=%t smartdns=%s wildcard_domains=%d expanded=%d aggressive_subs=%t workers=%d timeout_ms=%d",
strings.TrimSpace(in.Mode),
strings.TrimSpace(in.Source),
in.RuntimeEnabled,
smartdnsAddr,
len(wildcards),
len(domains),
aggressive,
workers,
timeoutMS,
),
)
type prewarmItem struct {
host string
ips []string
stats PrewarmDNSMetrics
}
jobs := make(chan string, len(domains))
results := make(chan prewarmItem, len(domains))
for i := 0; i < workers; i++ {
go func() {
for host := range jobs {
ips, stats := safeDigA(deps.DigA, host, []string{smartdnsAddr}, timeout)
results <- prewarmItem{host: host, ips: ips, stats: stats}
}
}()
}
for _, host := range domains {
jobs <- host
}
close(jobs)
resolvedHosts := 0
totalIPs := 0
errorHosts := 0
stats := PrewarmDNSMetrics{}
resolvedIPSet := map[string]struct{}{}
loggedHosts := 0
maxHostsLog := in.MaxHostsLog
if maxHostsLog <= 0 {
maxHostsLog = 200
}
for i := 0; i < len(domains); i++ {
item := <-results
stats.Merge(item.stats)
if item.stats.TotalErrors() > 0 {
errorHosts++
}
if len(item.ips) == 0 {
continue
}
resolvedHosts++
totalIPs += len(item.ips)
for _, ip := range item.ips {
if strings.TrimSpace(ip) != "" {
resolvedIPSet[ip] = struct{}{}
}
}
if loggedHosts < maxHostsLog {
logPrewarm(deps.Logf, fmt.Sprintf("prewarm add: %s -> %s", item.host, strings.Join(item.ips, ", ")))
loggedHosts++
}
}
manualAdded := 0
totalDynText := "n/a"
if !in.RuntimeEnabled {
existing, _ := safeReadDynSet(deps.ReadDynSet)
mergedSet := make(map[string]struct{}, len(existing)+len(resolvedIPSet))
for _, ip := range existing {
if strings.TrimSpace(ip) != "" {
mergedSet[ip] = struct{}{}
}
}
for ip := range resolvedIPSet {
if _, ok := mergedSet[ip]; !ok {
manualAdded++
}
mergedSet[ip] = struct{}{}
}
merged := make([]string, 0, len(mergedSet))
for ip := range mergedSet {
merged = append(merged, ip)
}
totalDynText = fmt.Sprintf("%d", len(merged))
if err := safeApplyDynSet(deps.ApplyDynSet, merged); err != nil {
msg := fmt.Sprintf("prewarm manual apply failed: %v", err)
logPrewarm(deps.Logf, msg)
return PrewarmResult{OK: false, Message: msg}
}
logPrewarm(
deps.Logf,
fmt.Sprintf("prewarm manual merge: existing=%d resolved=%d added=%d total_dyn=%d", len(existing), len(resolvedIPSet), manualAdded, len(merged)),
)
}
if len(domains) > loggedHosts {
logPrewarm(
deps.Logf,
fmt.Sprintf(
"prewarm add: trace truncated, omitted=%d hosts (full wildcard map: %s)",
len(domains)-loggedHosts,
strings.TrimSpace(in.WildcardMapPath),
),
)
}
msg := fmt.Sprintf(
"prewarm done: source=%s expanded=%d resolved=%d total_ips=%d error_hosts=%d dns_attempts=%d dns_ok=%d dns_errors=%d manual_added=%d dyn_total=%s",
strings.TrimSpace(in.Source),
len(domains),
resolvedHosts,
totalIPs,
errorHosts,
stats.Attempts,
stats.OK,
stats.TotalErrors(),
manualAdded,
totalDynText,
)
logPrewarm(deps.Logf, msg)
if perUpstream := stats.FormatPerUpstream(); perUpstream != "" {
logPrewarm(deps.Logf, "prewarm dns upstreams: "+perUpstream)
}
return PrewarmResult{
OK: true,
Message: msg,
ExitCode: resolvedHosts,
ResolvedHosts: resolvedHosts,
}
}
func logPrewarm(logf func(string), msg string) {
if logf != nil {
logf(msg)
}
}
func safeDigA(
dig func(host string, dnsList []string, timeout time.Duration) ([]string, PrewarmDNSMetrics),
host string,
dnsList []string,
timeout time.Duration,
) ([]string, PrewarmDNSMetrics) {
if dig == nil {
return nil, PrewarmDNSMetrics{}
}
return dig(host, dnsList, timeout)
}
func safeReadDynSet(read func() ([]string, error)) ([]string, error) {
if read == nil {
return nil, nil
}
return read()
}
func safeApplyDynSet(apply func([]string) error, ips []string) error {
if apply == nil {
return fmt.Errorf("apply dyn set callback is nil")
}
return apply(ips)
}
func isGoogleLikeSafe(check func(string) bool, domain string) bool {
if check == nil {
return false
}
return check(domain)
}
func trimNonEmptyUnique(in []string) []string {
if len(in) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, item := range in {
v := strings.TrimSpace(item)
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}

View File

@@ -0,0 +1,102 @@
package dnscfg
import (
"net"
"os"
"strings"
)
func ResolveDefaultSmartDNSAddr(addrEnvValue string, configPaths []string, fallback string) string {
if v := strings.TrimSpace(addrEnvValue); v != "" {
if addr := NormalizeSmartDNSAddr(v); addr != "" {
return addr
}
}
for _, path := range configPaths {
if addr := SmartDNSAddrFromConfig(path); addr != "" {
return addr
}
}
return strings.TrimSpace(fallback)
}
func SmartDNSAddrFromConfig(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
for _, ln := range strings.Split(string(data), "\n") {
s := strings.TrimSpace(ln)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
if !strings.HasPrefix(strings.ToLower(s), "bind ") {
continue
}
parts := strings.Fields(s)
if len(parts) < 2 {
continue
}
if addr := NormalizeSmartDNSAddr(parts[1]); addr != "" {
return addr
}
}
return ""
}
func NormalizeDNSUpstream(raw string, defaultPort string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}
s = strings.TrimPrefix(s, "udp://")
s = strings.TrimPrefix(s, "tcp://")
if strings.Contains(s, "#") {
parts := strings.SplitN(s, "#", 2)
host := strings.Trim(strings.TrimSpace(parts[0]), "[]")
port := strings.TrimSpace(parts[1])
if host == "" {
return ""
}
if port == "" {
port = defaultPort
}
return host + "#" + port
}
if host, port, err := net.SplitHostPort(s); err == nil {
host = strings.Trim(strings.TrimSpace(host), "[]")
port = strings.TrimSpace(port)
if host == "" {
return ""
}
if port == "" {
port = defaultPort
}
return host + "#" + port
}
if strings.Count(s, ":") == 1 {
parts := strings.SplitN(s, ":", 2)
host := strings.TrimSpace(parts[0])
port := strings.TrimSpace(parts[1])
if host != "" && port != "" {
return host + "#" + port
}
}
return s
}
func NormalizeSmartDNSAddr(raw string) string {
s := NormalizeDNSUpstream(raw, "6053")
if s == "" {
return ""
}
if strings.Contains(s, "#") {
return s
}
return s + "#6053"
}

View File

@@ -0,0 +1,47 @@
package dnscfg
import "strings"
type RunCommandFunc func(name string, args ...string) (stdout string, stderr string, exitCode int, err error)
type CmdResult struct {
OK bool
ExitCode int
Stdout string
Stderr string
Message string
}
func UnitState(run RunCommandFunc, unit string) string {
if run == nil {
return "unknown"
}
stdout, _, _, _ := run("systemctl", "is-active", strings.TrimSpace(unit))
st := strings.TrimSpace(stdout)
if st == "" {
return "unknown"
}
return st
}
func RunUnitAction(run RunCommandFunc, unit, action string) CmdResult {
if run == nil {
return CmdResult{
OK: false,
Message: "run command func is nil",
}
}
stdout, stderr, exitCode, err := run("systemctl", strings.TrimSpace(action), strings.TrimSpace(unit))
res := CmdResult{
OK: err == nil && exitCode == 0,
ExitCode: exitCode,
Stdout: stdout,
Stderr: stderr,
}
if err != nil {
res.Message = err.Error()
} else {
res.Message = strings.TrimSpace(unit) + " " + strings.TrimSpace(action) + " done"
}
return res
}

View File

@@ -0,0 +1,69 @@
package dnscfg
import (
"fmt"
"strings"
)
func NormalizeUpstreams(cfg Upstreams, defaults Upstreams, normalizeUpstream func(raw string, defaultPort string) string) Upstreams {
if normalizeUpstream != nil {
cfg.Default1 = normalizeUpstream(cfg.Default1, "53")
cfg.Default2 = normalizeUpstream(cfg.Default2, "53")
cfg.Meta1 = normalizeUpstream(cfg.Meta1, "53")
cfg.Meta2 = normalizeUpstream(cfg.Meta2, "53")
}
if strings.TrimSpace(cfg.Default1) == "" {
cfg.Default1 = defaults.Default1
}
if strings.TrimSpace(cfg.Default2) == "" {
cfg.Default2 = defaults.Default2
}
if strings.TrimSpace(cfg.Meta1) == "" {
cfg.Meta1 = defaults.Meta1
}
if strings.TrimSpace(cfg.Meta2) == "" {
cfg.Meta2 = defaults.Meta2
}
return cfg
}
func ParseUpstreamsConf(content string, defaults Upstreams, normalizeUpstream func(raw string, defaultPort string) string) Upstreams {
cfg := defaults
for _, ln := range strings.Split(content, "\n") {
s := strings.TrimSpace(ln)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
parts := strings.Fields(s)
if len(parts) < 2 {
continue
}
key := strings.ToLower(strings.TrimSpace(parts[0]))
vals := parts[1:]
switch key {
case "default":
if len(vals) > 0 {
cfg.Default1 = vals[0]
}
if len(vals) > 1 {
cfg.Default2 = vals[1]
}
case "meta":
if len(vals) > 0 {
cfg.Meta1 = vals[0]
}
if len(vals) > 1 {
cfg.Meta2 = vals[1]
}
}
}
return NormalizeUpstreams(cfg, defaults, normalizeUpstream)
}
func RenderUpstreamsConf(cfg Upstreams) string {
return fmt.Sprintf(
"default %s %s\nmeta %s %s\n",
cfg.Default1, cfg.Default2, cfg.Meta1, cfg.Meta2,
)
}

View File

@@ -1,16 +1,5 @@
package app
import (
"encoding/json"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
)
// ---------------------------------------------------------------------
// domains editor + smartdns wildcards
// ---------------------------------------------------------------------
@@ -31,210 +20,3 @@ var domainFiles = map[string]string{
"last-ips-map-direct": lastIPsMapDirect,
"last-ips-map-wildcard": lastIPsMapDyn,
}
// ---------------------------------------------------------------------
// domains table
// ---------------------------------------------------------------------
// GET /api/v1/domains/table -> { "lines": [ ... ] }
func handleDomainsTable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
lines := []string{}
for _, setName := range []string{"agvpn4", "agvpn_dyn4"} {
stdout, _, code, _ := runCommand("nft", "list", "set", "inet", "agvpn", setName)
if code == 0 {
for _, l := range strings.Split(stdout, "\n") {
l = strings.TrimRight(l, "\r")
if l != "" {
lines = append(lines, l)
}
}
continue
}
// Backward-compatible fallback for legacy hosts that still have ipset.
stdout, _, code, _ = runCommand("ipset", "list", setName)
if code != 0 {
continue
}
for _, l := range strings.Split(stdout, "\n") {
l = strings.TrimRight(l, "\r")
if l != "" {
lines = append(lines, l)
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"lines": lines})
}
// ---------------------------------------------------------------------
// domains file
// ---------------------------------------------------------------------
// GET /api/v1/domains/file?name=bases|meta|subs|static|smartdns|last-ips-map|last-ips-map-direct|last-ips-map-wildcard|wildcard-observed-hosts
// POST /api/v1/domains/file { "name": "...", "content": "..." }
func handleDomainsFile(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
name := strings.TrimSpace(r.URL.Query().Get("name"))
if name == "smartdns" {
domains, source := loadSmartDNSWildcardDomainsState(nil)
writeJSON(w, http.StatusOK, map[string]string{
"content": renderSmartDNSDomainsContent(domains),
"source": source,
})
return
}
if name == "wildcard-observed-hosts" {
writeJSON(w, http.StatusOK, map[string]string{
"content": readWildcardObservedHostsContent(),
"source": "derived",
})
return
}
path, ok := domainFiles[name]
if !ok {
http.Error(w, "unknown file name", http.StatusBadRequest)
return
}
source := "file"
if strings.HasPrefix(name, "last-ips-map") {
source = "artifact"
}
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
http.Error(w, "read error", http.StatusInternalServerError)
return
}
switch name {
case "bases", "meta", "subs":
// fallback to embedded seed
embedName := name + ".txt"
if name == "meta" {
embedName = "meta-special.txt"
}
data, _ = fs.ReadFile(embeddedDomains, "assets/domains/"+embedName)
source = "embedded"
default:
data = []byte{}
}
}
writeJSON(w, http.StatusOK, map[string]string{
"content": string(data),
"source": source,
})
case http.MethodPost:
var body struct {
Name string `json:"name"`
Content string `json:"content"`
}
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
if strings.TrimSpace(body.Name) == "smartdns" {
domains := parseSmartDNSDomainsContent(body.Content)
if err := saveSmartDNSWildcardDomainsState(domains); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
if body.Name == "last-ips-map-direct" || body.Name == "last-ips-map-wildcard" || body.Name == "wildcard-observed-hosts" {
http.Error(w, "read-only file name", http.StatusBadRequest)
return
}
path, ok := domainFiles[strings.TrimSpace(body.Name)]
if !ok {
http.Error(w, "unknown file name", http.StatusBadRequest)
return
}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
if err := os.WriteFile(path, []byte(body.Content), 0o644); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func readWildcardObservedHostsContent() string {
data, err := os.ReadFile(lastIPsMapDyn)
if err != nil {
return ""
}
seen := make(map[string]struct{})
out := make([]string, 0, 256)
for _, ln := range strings.Split(string(data), "\n") {
ln = strings.TrimSpace(ln)
if ln == "" || strings.HasPrefix(ln, "#") {
continue
}
fields := strings.Fields(ln)
if len(fields) < 2 {
continue
}
host := strings.TrimSpace(fields[1])
if host == "" || strings.HasPrefix(host, "[") {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
out = append(out, host)
}
sort.Strings(out)
if len(out) == 0 {
return ""
}
return strings.Join(out, "\n") + "\n"
}
// ---------------------------------------------------------------------
// smartdns wildcards
// ---------------------------------------------------------------------
func handleSmartdnsWildcards(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
payload := struct {
Domains []string `json:"domains"`
}{Domains: readSmartDNSWildcardDomains()}
writeJSON(w, http.StatusOK, payload)
case http.MethodPost:
var payload struct {
Domains []string `json:"domains"`
}
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&payload); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
if err := saveSmartDNSWildcardDomainsState(payload.Domains); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func readSmartDNSWildcardDomains() []string {
domains, _ := loadSmartDNSWildcardDomainsState(nil)
return domains
}

View File

@@ -0,0 +1,109 @@
package app
import (
"encoding/json"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
)
// ---------------------------------------------------------------------
// domains file
// ---------------------------------------------------------------------
// GET /api/v1/domains/file?name=bases|meta|subs|static|smartdns|last-ips-map|last-ips-map-direct|last-ips-map-wildcard|wildcard-observed-hosts
// POST /api/v1/domains/file { "name": "...", "content": "..." }
func handleDomainsFile(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
name := strings.TrimSpace(r.URL.Query().Get("name"))
if name == "smartdns" {
domains, source := loadSmartDNSWildcardDomainsState(nil)
writeJSON(w, http.StatusOK, map[string]string{
"content": renderSmartDNSDomainsContent(domains),
"source": source,
})
return
}
if name == "wildcard-observed-hosts" {
writeJSON(w, http.StatusOK, map[string]string{
"content": readWildcardObservedHostsContent(),
"source": "derived",
})
return
}
path, ok := domainFiles[name]
if !ok {
http.Error(w, "unknown file name", http.StatusBadRequest)
return
}
source := "file"
if strings.HasPrefix(name, "last-ips-map") {
source = "artifact"
}
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
http.Error(w, "read error", http.StatusInternalServerError)
return
}
switch name {
case "bases", "meta", "subs":
// fallback to embedded seed
embedName := name + ".txt"
if name == "meta" {
embedName = "meta-special.txt"
}
data, _ = fs.ReadFile(embeddedDomains, "assets/domains/"+embedName)
source = "embedded"
default:
data = []byte{}
}
}
writeJSON(w, http.StatusOK, map[string]string{
"content": string(data),
"source": source,
})
case http.MethodPost:
var body struct {
Name string `json:"name"`
Content string `json:"content"`
}
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
if strings.TrimSpace(body.Name) == "smartdns" {
domains := parseSmartDNSDomainsContent(body.Content)
if err := saveSmartDNSWildcardDomainsState(domains); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
if body.Name == "last-ips-map-direct" || body.Name == "last-ips-map-wildcard" || body.Name == "wildcard-observed-hosts" {
http.Error(w, "read-only file name", http.StatusBadRequest)
return
}
path, ok := domainFiles[strings.TrimSpace(body.Name)]
if !ok {
http.Error(w, "unknown file name", http.StatusBadRequest)
return
}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
if err := os.WriteFile(path, []byte(body.Content), 0o644); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

View File

@@ -0,0 +1,40 @@
package app
import (
"os"
"sort"
"strings"
)
func readWildcardObservedHostsContent() string {
data, err := os.ReadFile(lastIPsMapDyn)
if err != nil {
return ""
}
seen := make(map[string]struct{})
out := make([]string, 0, 256)
for _, ln := range strings.Split(string(data), "\n") {
ln = strings.TrimSpace(ln)
if ln == "" || strings.HasPrefix(ln, "#") {
continue
}
fields := strings.Fields(ln)
if len(fields) < 2 {
continue
}
host := strings.TrimSpace(fields[1])
if host == "" || strings.HasPrefix(host, "[") {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
out = append(out, host)
}
sort.Strings(out)
if len(out) == 0 {
return ""
}
return strings.Join(out, "\n") + "\n"
}

View File

@@ -0,0 +1,44 @@
package app
import (
"encoding/json"
"io"
"net/http"
)
// ---------------------------------------------------------------------
// smartdns wildcards
// ---------------------------------------------------------------------
func handleSmartdnsWildcards(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
payload := struct {
Domains []string `json:"domains"`
}{Domains: readSmartDNSWildcardDomains()}
writeJSON(w, http.StatusOK, payload)
case http.MethodPost:
var payload struct {
Domains []string `json:"domains"`
}
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&payload); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
if err := saveSmartDNSWildcardDomainsState(payload.Domains); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func readSmartDNSWildcardDomains() []string {
domains, _ := loadSmartDNSWildcardDomainsState(nil)
return domains
}

View File

@@ -0,0 +1,45 @@
package app
import (
"net/http"
"strings"
)
// ---------------------------------------------------------------------
// domains table
// ---------------------------------------------------------------------
// GET /api/v1/domains/table -> { "lines": [ ... ] }
func handleDomainsTable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
lines := []string{}
for _, setName := range []string{"agvpn4", "agvpn_dyn4"} {
stdout, _, code, _ := runCommand("nft", "list", "set", "inet", "agvpn", setName)
if code == 0 {
for _, l := range strings.Split(stdout, "\n") {
l = strings.TrimRight(l, "\r")
if l != "" {
lines = append(lines, l)
}
}
continue
}
// Backward-compatible fallback for legacy hosts that still have ipset.
stdout, _, code, _ = runCommand("ipset", "list", setName)
if code != 0 {
continue
}
for _, l := range strings.Split(stdout, "\n") {
l = strings.TrimRight(l, "\r")
if l != "" {
lines = append(lines, l)
}
}
}
writeJSON(w, http.StatusOK, map[string]any{"lines": lines})
}

View File

@@ -0,0 +1,78 @@
package app
import (
"net/http"
"sync"
"time"
)
const (
egressIdentityFreshTTL = 3 * time.Minute
egressIdentityBackoffMin = 3 * time.Second
egressIdentityBackoffMax = 2 * time.Minute
egressIdentityProbeTimeout = 4 * time.Second
egressIdentityGeoTimeout = 4 * time.Second
egressIdentityGeoCacheTTL = 24 * time.Hour
egressIdentityGeoFailTTL = 30 * time.Second
egressIdentityMaxConcurrency = 2
)
var (
egressIdentitySWR = newEgressIdentityService(
envInt("SVPN_EGRESS_MAX_PARALLEL", egressIdentityMaxConcurrency),
)
egressHTTPClient = &http.Client{}
)
type egressScopeTarget struct {
Scope string
Source string
SourceID string
}
type egressSourceProvider interface {
Probe(target egressScopeTarget) (string, error)
}
type egressIdentityEntry struct {
item EgressIdentity
swr refreshCoordinator
}
type egressGeoCacheEntry struct {
CountryCode string
CountryName string
LastError string
ExpiresAt time.Time
}
type egressIdentityService struct {
mu sync.Mutex
entries map[string]*egressIdentityEntry
sem chan struct{}
providers map[string]egressSourceProvider
geoMu sync.Mutex
geoCache map[string]egressGeoCacheEntry
}
type egressSystemProvider struct{}
type egressAdGuardProvider struct{}
type egressTransportProvider struct{}
func newEgressIdentityService(maxConcurrent int) *egressIdentityService {
n := maxConcurrent
if n <= 0 {
n = egressIdentityMaxConcurrency
}
return &egressIdentityService{
entries: map[string]*egressIdentityEntry{},
sem: make(chan struct{}, n),
providers: map[string]egressSourceProvider{
"system": egressSystemProvider{},
"adguardvpn": egressAdGuardProvider{},
"transport": egressTransportProvider{},
},
geoCache: map[string]egressGeoCacheEntry{},
}
}

View File

@@ -0,0 +1,76 @@
package app
import (
"fmt"
egressutilpkg "selective-vpn-api/app/egressutil"
"strings"
"time"
)
func (s *egressIdentityService) lookupGeo(ip string, force bool) (string, string, error) {
ip = strings.TrimSpace(ip)
if ip == "" {
return "", "", fmt.Errorf("empty ip")
}
now := time.Now()
s.geoMu.Lock()
if entry, ok := s.geoCache[ip]; ok && !entry.ExpiresAt.IsZero() && now.Before(entry.ExpiresAt) {
code := egressutilpkg.NormalizeCountryCode(entry.CountryCode)
name := strings.TrimSpace(entry.CountryName)
errMsg := strings.TrimSpace(entry.LastError)
s.geoMu.Unlock()
if code != "" || name != "" {
return code, name, nil
}
if errMsg != "" && !force {
return "", "", fmt.Errorf("%s", errMsg)
}
if !force {
return "", "", nil
}
// Force refresh bypasses negative geo cache to recover country flag quickly.
}
stale := s.geoCache[ip]
s.geoMu.Unlock()
geoURLs := egressGeoEndpointsForIP(ip)
errs := make([]string, 0, len(geoURLs))
for _, rawURL := range geoURLs {
body, err := egressutilpkg.HTTPGetBody(egressHTTPClient, rawURL, egressIdentityGeoTimeout, "selective-vpn-api/egress-identity", 8*1024)
if err != nil {
errs = append(errs, err.Error())
continue
}
code, name, err := egressutilpkg.ParseGeoResponse(body)
if err != nil {
errs = append(errs, err.Error())
continue
}
s.geoMu.Lock()
s.geoCache[ip] = egressGeoCacheEntry{
CountryCode: egressutilpkg.NormalizeCountryCode(code),
CountryName: strings.TrimSpace(name),
ExpiresAt: now.Add(egressIdentityGeoCacheTTL),
}
s.geoMu.Unlock()
return egressutilpkg.NormalizeCountryCode(code), strings.TrimSpace(name), nil
}
if strings.TrimSpace(stale.CountryCode) != "" || strings.TrimSpace(stale.CountryName) != "" {
return egressutilpkg.NormalizeCountryCode(stale.CountryCode), strings.TrimSpace(stale.CountryName), nil
}
msg := "geo lookup failed"
if len(errs) > 0 {
msg = strings.Join(errs, "; ")
}
s.geoMu.Lock()
s.geoCache[ip] = egressGeoCacheEntry{
LastError: msg,
ExpiresAt: now.Add(egressIdentityGeoFailTTL),
}
s.geoMu.Unlock()
return "", "", fmt.Errorf("%s", msg)
}

View File

@@ -0,0 +1,61 @@
package app
import (
"encoding/json"
"io"
"net/http"
"strings"
)
func handleEgressIdentityGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
if scope == "" {
http.Error(w, "scope is required", http.StatusBadRequest)
return
}
refresh := false
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) {
case "1", "true", "yes", "on":
refresh = true
}
item, err := egressIdentitySWR.getSnapshot(scope, refresh)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, EgressIdentityResponse{
OK: true,
Message: "ok",
Item: item,
})
}
func handleEgressIdentityRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body EgressIdentityRefreshRequest
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
resp, err := egressIdentitySWR.queueRefresh(body.Scopes, body.Force)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -0,0 +1 @@
package app

View File

@@ -0,0 +1,44 @@
package app
import (
"fmt"
"strings"
egressutilpkg "selective-vpn-api/app/egressutil"
)
func egressProbeExternalIP() (string, error) {
endpoints := egressIPEndpoints()
ip, errs := egressutilpkg.ProbeFirstSuccess(endpoints, func(rawURL string) (string, error) {
body, err := egressutilpkg.HTTPGetBody(egressHTTPClient, rawURL, egressIdentityProbeTimeout, "selective-vpn-api/egress-identity", 8*1024)
if err != nil {
return "", err
}
return egressutilpkg.ParseIPFromBody(body)
})
if strings.TrimSpace(ip) != "" {
return ip, nil
}
if len(errs) == 0 {
return "", fmt.Errorf("egress probe endpoints are not configured")
}
return "", fmt.Errorf("%s", strings.Join(errs, "; "))
}
func egressProbeExternalIPViaInterface(iface string) (string, error) {
iface = strings.TrimSpace(iface)
if iface == "" {
return egressProbeExternalIP()
}
endpoints := egressIPEndpoints()
ip, errs := egressutilpkg.ProbeFirstSuccess(endpoints, func(rawURL string) (string, error) {
return egressProbeURLViaInterface(rawURL, iface, egressIdentityProbeTimeout)
})
if strings.TrimSpace(ip) != "" {
return ip, nil
}
if len(errs) == 0 {
return "", fmt.Errorf("egress probe endpoints are not configured")
}
return "", fmt.Errorf("%s", strings.Join(errs, "; "))
}

View File

@@ -0,0 +1,74 @@
package app
import (
"encoding/json"
"net/netip"
"os"
"strings"
"time"
egressutilpkg "selective-vpn-api/app/egressutil"
)
func egressIPEndpoints() []string {
return egressutilpkg.IPEndpoints(os.Getenv("SVPN_EGRESS_IP_ENDPOINTS"))
}
func egressGeoEndpointsForIP(ip string) []string {
return egressutilpkg.GeoEndpointsForIP(os.Getenv("SVPN_EGRESS_GEO_ENDPOINTS"), ip)
}
func egressLimitEndpointsForNetns(in []string) []string {
maxN := envInt("SVPN_EGRESS_NETNS_MAX_ENDPOINTS", 1)
return egressutilpkg.LimitEndpoints(in, maxN)
}
func egressJoinErrorsCompact(errs []string) string {
return egressutilpkg.JoinErrorsCompact(errs)
}
func egressSingBoxSOCKSProxyURL(client TransportClient) string {
if client.Kind != TransportClientSingBox {
return ""
}
path := transportSingBoxConfigPath(client)
if strings.TrimSpace(path) == "" {
return ""
}
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return ""
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
return ""
}
return egressutilpkg.ParseSingBoxSOCKSProxyURL(root)
}
func egressInterfaceBindAddress(iface string) string {
iface = strings.TrimSpace(iface)
if iface == "" {
return ""
}
stdout, _, code, err := runCommandTimeout(1500*time.Millisecond, "ip", "-4", "-o", "addr", "show", "dev", iface, "scope", "global")
if err != nil || code != 0 {
return ""
}
for _, line := range strings.Split(stdout, "\n") {
fields := strings.Fields(strings.TrimSpace(line))
for i := 0; i < len(fields); i++ {
if fields[i] != "inet" || i+1 >= len(fields) {
continue
}
ip := strings.TrimSpace(fields[i+1])
if slash := strings.Index(ip, "/"); slash > 0 {
ip = ip[:slash]
}
if addr, err := netip.ParseAddr(ip); err == nil && addr.Is4() {
return addr.String()
}
}
}
return ""
}

View File

@@ -0,0 +1,146 @@
package app
import (
"fmt"
"strconv"
"strings"
"time"
egressutilpkg "selective-vpn-api/app/egressutil"
)
func egressProbeExternalIPInNetns(client TransportClient, ns string) (string, error) {
endpoints := egressLimitEndpointsForNetns(egressIPEndpoints())
ip, errs := egressutilpkg.ProbeFirstSuccess(endpoints, func(rawURL string) (string, error) {
return egressProbeURLInNetns(client, ns, rawURL, egressIdentityProbeTimeout)
})
if strings.TrimSpace(ip) != "" {
return ip, nil
}
if len(errs) == 0 {
return "", fmt.Errorf("egress probe endpoints are not configured")
}
return "", fmt.Errorf("%s", egressJoinErrorsCompact(errs))
}
func egressProbeExternalIPInNetnsViaProxy(client TransportClient, ns, proxyURL string) (string, error) {
proxy := strings.TrimSpace(proxyURL)
if proxy == "" {
return "", fmt.Errorf("proxy url is empty")
}
endpoints := egressLimitEndpointsForNetns(egressIPEndpoints())
ip, errs := egressutilpkg.ProbeFirstSuccess(endpoints, func(rawURL string) (string, error) {
return egressProbeURLInNetnsViaProxy(client, ns, rawURL, proxy, egressIdentityProbeTimeout)
})
if strings.TrimSpace(ip) != "" {
return ip, nil
}
if len(errs) == 0 {
return "", fmt.Errorf("egress probe endpoints are not configured")
}
return "", fmt.Errorf("%s", egressJoinErrorsCompact(errs))
}
func egressProbeURLViaInterface(rawURL, iface string, timeout time.Duration) (string, error) {
curl := egressutilpkg.ResolveCurlPath()
sec := egressutilpkg.TimeoutSec(timeout)
if curl != "" {
args := []string{
"-4",
"-fsSL",
"--max-time", strconv.Itoa(sec),
"--connect-timeout", "2",
"--interface", iface,
rawURL,
}
stdout, stderr, code, err := runCommandTimeout(timeout+time.Second, curl, args...)
if err != nil || code != 0 {
return "", transportCommandError(shellJoinArgs(append([]string{curl}, args...)), stdout, stderr, code, err)
}
return egressutilpkg.ParseIPFromBody(stdout)
}
wget := egressutilpkg.ResolveWgetPath()
if wget == "" {
return "", fmt.Errorf("curl/wget are not available for interface-bound egress probe")
}
bindAddr := egressInterfaceBindAddress(iface)
if bindAddr == "" {
return "", fmt.Errorf("cannot resolve IPv4 address for interface %q", iface)
}
args := []string{
"-4",
"-q",
"-T", strconv.Itoa(sec),
"-O", "-",
"--bind-address", bindAddr,
rawURL,
}
stdout, stderr, code, err := runCommandTimeout(timeout+time.Second, wget, args...)
if err != nil || code != 0 {
return "", transportCommandError(shellJoinArgs(append([]string{wget}, args...)), stdout, stderr, code, err)
}
return egressutilpkg.ParseIPFromBody(stdout)
}
func egressProbeURLInNetns(client TransportClient, ns, rawURL string, timeout time.Duration) (string, error) {
sec := egressutilpkg.TimeoutSec(timeout)
resolveHost, resolvePort, resolveIP := egressutilpkg.ResolvedHostForURL(rawURL)
curlBin := egressutilpkg.ResolveCurlPath()
if curlBin == "" {
return "", fmt.Errorf("curl is not available for netns probe")
}
curlArgs := []string{
"-4",
"-fsSL",
"--max-time", strconv.Itoa(sec),
"--connect-timeout", "2",
}
if resolveHost != "" && resolveIP != "" && resolvePort > 0 {
curlArgs = append(curlArgs, "--resolve", fmt.Sprintf("%s:%d:%s", resolveHost, resolvePort, resolveIP))
}
curlArgs = append(curlArgs, rawURL)
curlCmd := append([]string{curlBin}, curlArgs...)
name, args, err := transportNetnsExecCommand(client, ns, curlCmd...)
if err != nil {
return "", err
}
stdout, stderr, code, runErr := runCommandTimeout(timeout+time.Second, name, args...)
if runErr != nil || code != 0 {
return "", transportCommandError(shellJoinArgs(append([]string{name}, args...)), stdout, stderr, code, runErr)
}
return egressutilpkg.ParseIPFromBody(stdout)
}
func egressProbeURLInNetnsViaProxy(
client TransportClient,
ns string,
rawURL string,
proxyURL string,
timeout time.Duration,
) (string, error) {
curlBin := egressutilpkg.ResolveCurlPath()
if curlBin == "" {
return "", fmt.Errorf("curl is not available for proxy probe")
}
sec := egressutilpkg.TimeoutSec(timeout)
args := []string{
"-4",
"-fsSL",
"--max-time", strconv.Itoa(sec),
"--connect-timeout", "3",
"--proxy", strings.TrimSpace(proxyURL),
rawURL,
}
cmd := append([]string{curlBin}, args...)
name, netnsArgs, err := transportNetnsExecCommand(client, ns, cmd...)
if err != nil {
return "", err
}
stdout, stderr, code, runErr := runCommandTimeout(timeout+time.Second, name, netnsArgs...)
if runErr != nil || code != 0 {
return "", transportCommandError(shellJoinArgs(append([]string{name}, netnsArgs...)), stdout, stderr, code, runErr)
}
return egressutilpkg.ParseIPFromBody(stdout)
}

View File

@@ -0,0 +1,95 @@
package app
import (
"fmt"
"strings"
"time"
)
func (egressSystemProvider) Probe(_ egressScopeTarget) (string, error) {
return egressProbeExternalIP()
}
func (egressAdGuardProvider) Probe(_ egressScopeTarget) (string, error) {
stdout, stderr, code, err := runCommandTimeout(2*time.Second, "systemctl", "is-active", adgvpnUnit)
state := strings.ToLower(strings.TrimSpace(stdout))
if state != "active" || err != nil || code != 0 {
return "", transportCommandError("systemctl is-active "+adgvpnUnit, stdout, stderr, code, err)
}
iface, _ := resolveTrafficIface(loadTrafficModeState().PreferredIface)
if iface = strings.TrimSpace(iface); iface == "" {
return "", fmt.Errorf("adguardvpn interface is not resolved")
}
if !ifaceExists(iface) {
return "", fmt.Errorf("adguardvpn interface %q is not available", iface)
}
return egressProbeExternalIPViaInterface(iface)
}
func (egressTransportProvider) Probe(target egressScopeTarget) (string, error) {
id := sanitizeID(target.SourceID)
if id == "" {
return "", fmt.Errorf("invalid transport source id")
}
transportMu.Lock()
st := loadTransportClientsState()
idx := findTransportClientIndex(st.Items, id)
var client TransportClient
if idx >= 0 {
client = st.Items[idx]
}
transportMu.Unlock()
if idx < 0 {
return "", fmt.Errorf("transport client %q not found", id)
}
if !client.Enabled {
return "", fmt.Errorf("transport client %q is disabled", id)
}
if normalizeTransportStatus(client.Status) == TransportClientDown {
backend := selectTransportBackend(client)
live := backend.Health(client)
if normalizeTransportStatus(live.Status) != TransportClientUp {
msg := strings.TrimSpace(live.Message)
if msg == "" {
msg = fmt.Sprintf("transport client %q is down", id)
}
return "", fmt.Errorf("%s", msg)
}
client.Status = TransportClientUp
}
if transportNetnsEnabled(client) {
ns := transportNetnsName(client)
if strings.TrimSpace(ns) == "" {
return "", fmt.Errorf("transport client %q netns is enabled but netns_name is empty", id)
}
if client.Kind == TransportClientSingBox {
proxyURL := egressSingBoxSOCKSProxyURL(client)
if proxyURL == "" {
return "", fmt.Errorf("proxy probe failed: singbox socks inbound not found")
}
// For SingBox in netns we must use tunnel egress probe (SOCKS inbound -> outbound proxy).
// Direct netns probe is intentionally not used: in selective mode it may return AdGuard/system IP.
ip, err := egressProbeExternalIPInNetnsViaProxy(client, ns, proxyURL)
if err == nil {
return ip, nil
}
return "", fmt.Errorf("proxy probe failed: %v", err)
}
ip, err := egressProbeExternalIPInNetns(client, ns)
if err == nil {
return ip, nil
}
return "", err
}
iface := strings.TrimSpace(client.Iface)
if iface != "" && ifaceExists(iface) {
return egressProbeExternalIPViaInterface(iface)
}
return egressProbeExternalIP()
}

View File

@@ -0,0 +1,140 @@
package app
import (
"sort"
"strings"
"time"
)
func (s *egressIdentityService) getSnapshot(scopeRaw string, refresh bool) (EgressIdentity, error) {
target, err := parseEgressScope(scopeRaw)
if err != nil {
return EgressIdentity{}, err
}
if refresh {
s.queueScopeRefresh(target, false)
}
return s.snapshot(target, time.Now()), nil
}
func (s *egressIdentityService) queueRefresh(scopes []string, force bool) (EgressIdentityRefreshResponse, error) {
rawTargets := make([]string, 0, len(scopes))
for _, raw := range scopes {
v := strings.TrimSpace(raw)
if v == "" {
continue
}
rawTargets = append(rawTargets, v)
}
if len(rawTargets) == 0 {
rawTargets = s.knownScopes()
}
if len(rawTargets) == 0 {
rawTargets = []string{"adguardvpn", "system"}
}
targets := make([]egressScopeTarget, 0, len(rawTargets))
seen := map[string]struct{}{}
for _, raw := range rawTargets {
target, err := parseEgressScope(raw)
if err != nil {
return EgressIdentityRefreshResponse{}, err
}
if _, ok := seen[target.Scope]; ok {
continue
}
seen[target.Scope] = struct{}{}
targets = append(targets, target)
}
resp := EgressIdentityRefreshResponse{
OK: true,
Message: "refresh queued",
Items: make([]EgressIdentityRefreshItem, 0, len(targets)),
}
for _, target := range targets {
queued, reason := s.queueScopeRefresh(target, force)
item := EgressIdentityRefreshItem{
Scope: target.Scope,
Queued: queued,
}
if queued {
item.Status = "queued"
resp.Queued++
} else {
item.Status = "skipped"
item.Reason = strings.TrimSpace(reason)
if item.Reason == "" {
item.Reason = "throttled or already fresh"
}
resp.Skipped++
}
resp.Items = append(resp.Items, item)
}
resp.Count = len(resp.Items)
if resp.Queued == 0 {
resp.Message = "refresh skipped"
}
return resp, nil
}
func (s *egressIdentityService) knownScopes() []string {
outSet := map[string]struct{}{
"adguardvpn": {},
"system": {},
}
transportMu.Lock()
st := loadTransportClientsState()
transportMu.Unlock()
for _, it := range st.Items {
id := sanitizeID(it.ID)
if id == "" {
continue
}
outSet["transport:"+id] = struct{}{}
}
s.mu.Lock()
for scope := range s.entries {
outSet[scope] = struct{}{}
}
s.mu.Unlock()
out := make([]string, 0, len(outSet))
for scope := range outSet {
out = append(out, scope)
}
sort.Strings(out)
return out
}
func (s *egressIdentityService) queueScopeRefresh(target egressScopeTarget, force bool) (bool, string) {
now := time.Now()
s.mu.Lock()
entry := s.ensureEntryLocked(target)
hasData := strings.TrimSpace(entry.item.IP) != ""
switch {
case entry.swr.refreshInProgress():
s.mu.Unlock()
return false, "already in progress"
case !force && !entry.swr.nextRetryAt().IsZero() && now.Before(entry.swr.nextRetryAt()):
s.mu.Unlock()
return false, "backoff in progress"
case !force && hasData && !entry.swr.isStale(now):
s.mu.Unlock()
return false, "already fresh"
}
if force {
entry.swr.clearBackoff()
}
if !entry.swr.beginRefresh(now, force, hasData) {
s.mu.Unlock()
return false, "throttled or already fresh"
}
s.mu.Unlock()
go s.refreshScope(target, force)
return true, ""
}

View File

@@ -0,0 +1,117 @@
package app
import (
"log"
"strings"
"time"
egressutilpkg "selective-vpn-api/app/egressutil"
)
func (s *egressIdentityService) refreshScope(target egressScopeTarget, force bool) {
s.acquire()
defer s.release()
now := time.Now().UTC()
provider := s.providerFor(target.Source)
if provider == nil {
s.finishError(target, "provider is not configured for scope source", now)
return
}
ip, err := provider.Probe(target)
if err != nil {
s.finishError(target, err.Error(), now)
return
}
code, name, geoErr := s.lookupGeo(ip, force)
s.finishSuccess(target, ip, code, name, geoErr, now)
}
func (s *egressIdentityService) providerFor(source string) egressSourceProvider {
s.mu.Lock()
defer s.mu.Unlock()
return s.providers[strings.ToLower(strings.TrimSpace(source))]
}
func (s *egressIdentityService) finishError(target egressScopeTarget, msg string, at time.Time) {
s.mu.Lock()
entry := s.ensureEntryLocked(target)
prev := s.entrySnapshotLocked(entry, target, at)
entry.swr.finishError(msg, at)
next := s.entrySnapshotLocked(entry, target, at)
changed := egressIdentityChanged(prev, next)
s.mu.Unlock()
if changed {
events.push("egress_identity_changed", map[string]any{
"scope": next.Scope,
"ip": next.IP,
"country_code": next.CountryCode,
"country_name": next.CountryName,
"updated_at": next.UpdatedAt,
"stale": next.Stale,
"last_error": next.LastError,
})
if target.Source == "transport" {
publishTransportRuntimeObservabilitySnapshotChanged(
"egress_identity_changed",
[]string{target.SourceID},
nil,
)
}
}
}
func (s *egressIdentityService) finishSuccess(
target egressScopeTarget,
ip string,
code string,
name string,
geoErr error,
at time.Time,
) {
s.mu.Lock()
entry := s.ensureEntryLocked(target)
prev := s.entrySnapshotLocked(entry, target, at)
previousIP := strings.TrimSpace(entry.item.IP)
entry.item.Scope = target.Scope
entry.item.Source = target.Source
entry.item.SourceID = target.SourceID
entry.item.IP = strings.TrimSpace(ip)
if geoErr == nil {
entry.item.CountryCode = egressutilpkg.NormalizeCountryCode(code)
entry.item.CountryName = strings.TrimSpace(name)
} else if previousIP != strings.TrimSpace(ip) {
entry.item.CountryCode = ""
entry.item.CountryName = ""
}
entry.swr.finishSuccess(at)
next := s.entrySnapshotLocked(entry, target, at)
changed := egressIdentityChanged(prev, next)
s.mu.Unlock()
if geoErr != nil {
log.Printf("egress geo lookup warning: scope=%s ip=%s err=%v", target.Scope, ip, geoErr)
}
if changed {
events.push("egress_identity_changed", map[string]any{
"scope": next.Scope,
"ip": next.IP,
"country_code": next.CountryCode,
"country_name": next.CountryName,
"updated_at": next.UpdatedAt,
"stale": next.Stale,
"last_error": next.LastError,
})
if target.Source == "transport" {
publishTransportRuntimeObservabilitySnapshotChanged(
"egress_identity_changed",
[]string{target.SourceID},
nil,
)
}
}
}

View File

@@ -0,0 +1,78 @@
package app
import (
"strings"
"time"
)
func (s *egressIdentityService) snapshot(target egressScopeTarget, now time.Time) EgressIdentity {
s.mu.Lock()
defer s.mu.Unlock()
entry := s.ensureEntryLocked(target)
return s.entrySnapshotLocked(entry, target, now)
}
func (s *egressIdentityService) ensureEntryLocked(target egressScopeTarget) *egressIdentityEntry {
entry := s.entries[target.Scope]
if entry != nil {
if entry.item.Scope == "" {
entry.item.Scope = target.Scope
}
if entry.item.Source == "" {
entry.item.Source = target.Source
}
if entry.item.SourceID == "" {
entry.item.SourceID = target.SourceID
}
return entry
}
entry = &egressIdentityEntry{
item: EgressIdentity{
Scope: target.Scope,
Source: target.Source,
SourceID: target.SourceID,
},
swr: newRefreshCoordinator(
egressIdentityFreshTTL,
egressIdentityBackoffMin,
egressIdentityBackoffMax,
),
}
s.entries[target.Scope] = entry
return entry
}
func (s *egressIdentityService) entrySnapshotLocked(
entry *egressIdentityEntry,
target egressScopeTarget,
now time.Time,
) EgressIdentity {
item := entry.item
if item.Scope == "" {
item.Scope = target.Scope
}
if item.Source == "" {
item.Source = target.Source
}
if item.SourceID == "" {
item.SourceID = target.SourceID
}
meta := entry.swr.snapshot(now)
item.UpdatedAt = meta.UpdatedAt
item.Stale = meta.Stale
item.RefreshInProgress = meta.RefreshInProgress
item.LastError = strings.TrimSpace(meta.LastError)
item.NextRetryAt = meta.NextRetryAt
return item
}
func (s *egressIdentityService) acquire() {
s.sem <- struct{}{}
}
func (s *egressIdentityService) release() {
select {
case <-s.sem:
default:
}
}

View File

@@ -0,0 +1,35 @@
package app
import egressutilpkg "selective-vpn-api/app/egressutil"
func parseEgressScope(raw string) (egressScopeTarget, error) {
target, err := egressutilpkg.ParseScope(raw, sanitizeID)
if err != nil {
return egressScopeTarget{}, err
}
return egressScopeTarget{
Scope: target.Scope,
Source: target.Source,
SourceID: target.SourceID,
}, nil
}
func egressIdentityChanged(prev, next EgressIdentity) bool {
return egressutilpkg.IdentityChanged(
egressIdentitySnapshot(prev),
egressIdentitySnapshot(next),
)
}
func egressIdentitySnapshot(item EgressIdentity) egressutilpkg.IdentitySnapshot {
return egressutilpkg.IdentitySnapshot{
IP: item.IP,
CountryCode: item.CountryCode,
CountryName: item.CountryName,
UpdatedAt: item.UpdatedAt,
Stale: item.Stale,
RefreshInProgress: item.RefreshInProgress,
LastError: item.LastError,
NextRetryAt: item.NextRetryAt,
}
}

View File

@@ -0,0 +1,188 @@
package app
import (
egressutilpkg "selective-vpn-api/app/egressutil"
"testing"
)
func TestParseEgressScope(t *testing.T) {
tests := []struct {
name string
in string
wantOK bool
want egressScopeTarget
}{
{
name: "adguardvpn",
in: "adguardvpn",
wantOK: true,
want: egressScopeTarget{
Scope: "adguardvpn",
Source: "adguardvpn",
},
},
{
name: "system",
in: "system",
wantOK: true,
want: egressScopeTarget{
Scope: "system",
Source: "system",
},
},
{
name: "transport normalize",
in: "transport: SG RealNetns ",
wantOK: true,
want: egressScopeTarget{
Scope: "transport:sg-realnetns",
Source: "transport",
SourceID: "sg-realnetns",
},
},
{
name: "bad scope",
in: "transport:",
wantOK: false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got, err := parseEgressScope(tc.in)
if tc.wantOK && err != nil {
t.Fatalf("parseEgressScope(%q) unexpected error: %v", tc.in, err)
}
if !tc.wantOK {
if err == nil {
t.Fatalf("parseEgressScope(%q) expected error", tc.in)
}
return
}
if got != tc.want {
t.Fatalf("parseEgressScope(%q)=%+v want %+v", tc.in, got, tc.want)
}
})
}
}
func TestParseEgressIPFromBody(t *testing.T) {
tests := []struct {
name string
in string
wantIP string
wantOK bool
}{
{
name: "plain ipv4",
in: "203.0.113.10\n",
wantIP: "203.0.113.10",
wantOK: true,
},
{
name: "json ip",
in: `{"ip":"198.51.100.7"}`,
wantIP: "198.51.100.7",
wantOK: true,
},
{
name: "json query",
in: `{"query":"2001:db8::1"}`,
wantIP: "2001:db8::1",
wantOK: true,
},
{
name: "invalid",
in: "not-an-ip",
wantOK: false,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got, err := egressutilpkg.ParseIPFromBody(tc.in)
if tc.wantOK && err != nil {
t.Fatalf("ParseIPFromBody unexpected error: %v", err)
}
if !tc.wantOK {
if err == nil {
t.Fatalf("ParseIPFromBody expected error")
}
return
}
if got != tc.wantIP {
t.Fatalf("ParseIPFromBody=%q want %q", got, tc.wantIP)
}
})
}
}
func TestEgressParseGeoResponse(t *testing.T) {
code, name, err := egressutilpkg.ParseGeoResponse(`{"success":true,"country":"Singapore","country_code":"SG"}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if code != "SG" || name != "Singapore" {
t.Fatalf("unexpected geo parse result: code=%q name=%q", code, name)
}
code, name, err = egressutilpkg.ParseGeoResponse(`{"status":"success","country":"Netherlands","countryCode":"NL"}`)
if err != nil {
t.Fatalf("unexpected ip-api error: %v", err)
}
if code != "NL" || name != "Netherlands" {
t.Fatalf("unexpected ip-api geo parse result: code=%q name=%q", code, name)
}
if _, _, err := egressutilpkg.ParseGeoResponse(`{"status":"fail","message":"private range"}`); err == nil {
t.Fatalf("expected geo parse error for fail status")
}
}
func TestNormalizeCountryCode(t *testing.T) {
if got := egressutilpkg.NormalizeCountryCode("sg"); got != "SG" {
t.Fatalf("NormalizeCountryCode(sg)=%q", got)
}
if got := egressutilpkg.NormalizeCountryCode("123"); got != "" {
t.Fatalf("NormalizeCountryCode(123) should be empty, got=%q", got)
}
}
func TestEgressResolvedHostForURL(t *testing.T) {
host, port, ip := egressutilpkg.ResolvedHostForURL("https://127.0.0.1/ip")
if host != "" || port != 0 || ip != "" {
t.Fatalf("expected empty resolve tuple for literal IP, got host=%q port=%d ip=%q", host, port, ip)
}
}
func TestEgressParseSingBoxSOCKSProxyURL(t *testing.T) {
root := map[string]any{
"inbounds": []any{
map[string]any{
"type": "socks",
"listen": "127.0.0.1",
"listen_port": 2080,
},
},
}
got := egressutilpkg.ParseSingBoxSOCKSProxyURL(root)
if got != "socks5h://127.0.0.1:2080" {
t.Fatalf("unexpected proxy url: %q", got)
}
root2 := map[string]any{
"inbounds": []any{
map[string]any{
"type": "mixed",
"listen": "0.0.0.0",
"listen_port": float64(1080),
},
},
}
got2 := egressutilpkg.ParseSingBoxSOCKSProxyURL(root2)
if got2 != "socks5h://127.0.0.1:1080" {
t.Fatalf("unexpected mixed proxy url: %q", got2)
}
}

View File

@@ -0,0 +1,48 @@
package egressutil
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
)
func HTTPGetBody(client *http.Client, rawURL string, timeout time.Duration, userAgent string, maxBytes int64) (string, error) {
if client == nil {
return "", fmt.Errorf("http client is nil")
}
limit := maxBytes
if limit <= 0 {
limit = 8 * 1024
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", err
}
if strings.TrimSpace(userAgent) != "" {
req.Header.Set("User-Agent", strings.TrimSpace(userAgent))
}
req.Header.Set("Accept", "application/json, text/plain, */*")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, limit))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := strings.TrimSpace(string(body))
if msg == "" {
msg = resp.Status
}
return "", fmt.Errorf("%s -> %s", rawURL, msg)
}
return string(body), nil
}

View File

@@ -0,0 +1,25 @@
package egressutil
import "strings"
type IdentitySnapshot struct {
IP string
CountryCode string
CountryName string
UpdatedAt string
Stale bool
RefreshInProgress bool
LastError string
NextRetryAt string
}
func IdentityChanged(prev, next IdentitySnapshot) bool {
return strings.TrimSpace(prev.IP) != strings.TrimSpace(next.IP) ||
strings.TrimSpace(prev.CountryCode) != strings.TrimSpace(next.CountryCode) ||
strings.TrimSpace(prev.CountryName) != strings.TrimSpace(next.CountryName) ||
strings.TrimSpace(prev.UpdatedAt) != strings.TrimSpace(next.UpdatedAt) ||
prev.Stale != next.Stale ||
prev.RefreshInProgress != next.RefreshInProgress ||
strings.TrimSpace(prev.LastError) != strings.TrimSpace(next.LastError) ||
strings.TrimSpace(prev.NextRetryAt) != strings.TrimSpace(next.NextRetryAt)
}

View File

@@ -0,0 +1,19 @@
package egressutil
func ProbeFirstSuccess(endpoints []string, probe func(rawURL string) (string, error)) (string, []string) {
if len(endpoints) == 0 {
return "", nil
}
errs := make([]string, 0, len(endpoints))
for _, rawURL := range endpoints {
if probe == nil {
continue
}
val, err := probe(rawURL)
if err == nil {
return val, nil
}
errs = append(errs, err.Error())
}
return "", errs
}

View File

@@ -0,0 +1,43 @@
package egressutil
import (
"fmt"
"strings"
)
type ScopeTarget struct {
Scope string
Source string
SourceID string
}
func ParseScope(raw string, sanitizeID func(string) string) (ScopeTarget, error) {
scope := strings.ToLower(strings.TrimSpace(raw))
switch {
case scope == "adguardvpn":
return ScopeTarget{
Scope: "adguardvpn",
Source: "adguardvpn",
}, nil
case scope == "system":
return ScopeTarget{
Scope: "system",
Source: "system",
}, nil
case strings.HasPrefix(scope, "transport:"):
id := strings.TrimSpace(strings.TrimPrefix(scope, "transport:"))
if sanitizeID != nil {
id = sanitizeID(id)
}
if id == "" {
return ScopeTarget{}, fmt.Errorf("invalid transport scope id")
}
return ScopeTarget{
Scope: "transport:" + id,
Source: "transport",
SourceID: id,
}, nil
default:
return ScopeTarget{}, fmt.Errorf("invalid scope, expected adguardvpn|system|transport:<id>")
}
}

View File

@@ -0,0 +1,399 @@
package egressutil
import (
"context"
"encoding/json"
"fmt"
"net"
"net/netip"
"net/url"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
var (
curlPathOnce sync.Once
curlPath string
wgetPathOnce sync.Once
wgetPath string
)
func ParseIPFromBody(raw string) (string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", fmt.Errorf("empty response")
}
var obj map[string]any
if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") {
if err := json.Unmarshal([]byte(s), &obj); err == nil && obj != nil {
keys := []string{
"ip", "origin", "query", "your_ip", "client_ip",
"ip_addr", "ip_address", "address",
}
for _, key := range keys {
if v := strings.TrimSpace(AnyToString(obj[key])); v != "" {
if i := strings.Index(v, ","); i >= 0 {
v = strings.TrimSpace(v[:i])
}
if addr, err := netip.ParseAddr(v); err == nil {
return addr.String(), nil
}
}
}
}
}
parts := strings.FieldsFunc(s, func(r rune) bool {
switch r {
case '\n', '\r', '\t', ' ', ',', ';', '[', ']', '"', '\'':
return true
default:
return false
}
})
for _, part := range parts {
v := strings.TrimSpace(strings.TrimPrefix(part, "ip="))
if v == "" {
continue
}
if addr, err := netip.ParseAddr(v); err == nil {
return addr.String(), nil
}
}
return "", fmt.Errorf("cannot parse egress ip from response: %q", s)
}
func ParseGeoResponse(raw string) (string, string, error) {
var obj map[string]any
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
return "", "", err
}
if v, ok := obj["success"]; ok {
if b, ok := v.(bool); ok && !b {
msg := strings.TrimSpace(AnyToString(obj["message"]))
if msg == "" {
msg = "geo lookup reported success=false"
}
return "", "", fmt.Errorf("%s", msg)
}
}
if status := strings.ToLower(strings.TrimSpace(AnyToString(obj["status"]))); status == "fail" {
msg := strings.TrimSpace(AnyToString(obj["message"]))
if msg == "" {
msg = "geo lookup status=fail"
}
return "", "", fmt.Errorf("%s", msg)
}
code := NormalizeCountryCode(FirstNonEmptyAny(obj, "country_code", "countryCode", "cc"))
name := strings.TrimSpace(FirstNonEmptyAny(obj, "country_name", "country", "countryName"))
if code == "" && name == "" {
return "", "", fmt.Errorf("geo response does not contain country fields")
}
return code, name, nil
}
func NormalizeCountryCode(raw string) string {
cc := strings.ToUpper(strings.TrimSpace(raw))
if len(cc) != 2 {
return ""
}
for _, ch := range cc {
if ch < 'A' || ch > 'Z' {
return ""
}
}
return cc
}
func IPEndpoints(envRaw string) []string {
raw := strings.TrimSpace(strings.ReplaceAll(envRaw, ";", ","))
if raw == "" {
return []string{
"https://api64.ipify.org",
"https://api.ipify.org",
"https://ifconfig.me/ip",
}
}
return ParseURLList(raw)
}
func GeoEndpointsForIP(envRaw, ip string) []string {
raw := strings.TrimSpace(strings.ReplaceAll(envRaw, ";", ","))
if raw == "" {
raw = "https://ipwho.is/%s,http://ip-api.com/json/%s?fields=status,country,countryCode,query,message"
}
base := ParseURLList(raw)
out := make([]string, 0, len(base))
for _, item := range base {
if strings.Contains(item, "%s") {
out = append(out, fmt.Sprintf(item, ip))
continue
}
out = append(out, strings.TrimRight(item, "/")+"/"+ip)
}
return out
}
func ParseURLList(raw string) []string {
parts := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
})
out := make([]string, 0, len(parts))
for _, part := range parts {
v := strings.TrimSpace(part)
if v == "" {
continue
}
if !strings.Contains(v, "://") {
v = "https://" + v
}
out = append(out, v)
}
return dedupeStrings(out)
}
func LimitEndpoints(in []string, maxN int) []string {
if len(in) == 0 {
return nil
}
if maxN <= 0 || maxN >= len(in) {
out := make([]string, 0, len(in))
out = append(out, in...)
return out
}
out := make([]string, 0, maxN)
out = append(out, in[:maxN]...)
return out
}
func JoinErrorsCompact(errs []string) string {
if len(errs) == 0 {
return "probe failed"
}
first := strings.TrimSpace(errs[0])
if first == "" {
first = "probe failed"
}
if len(errs) == 1 {
return first
}
return fmt.Sprintf("%s; +%d more", first, len(errs)-1)
}
func ParseSingBoxSOCKSProxyURL(root map[string]any) string {
if root == nil {
return ""
}
rawInbounds, ok := root["inbounds"].([]any)
if !ok || len(rawInbounds) == 0 {
return ""
}
for _, raw := range rawInbounds {
inb, ok := raw.(map[string]any)
if !ok {
continue
}
typ := strings.ToLower(strings.TrimSpace(AnyToString(inb["type"])))
if typ != "socks" && typ != "mixed" {
continue
}
port, ok := parseIntAny(inb["listen_port"])
if !ok || port <= 0 || port > 65535 {
continue
}
host := strings.TrimSpace(AnyToString(inb["listen"]))
switch host {
case "", "::", "::1", "0.0.0.0", "[::]":
host = "127.0.0.1"
}
if strings.TrimSpace(host) == "" {
host = "127.0.0.1"
}
return fmt.Sprintf("socks5h://%s:%d", host, port)
}
return ""
}
func ResolvedHostForURL(rawURL string) (string, int, string) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return "", 0, ""
}
host := strings.TrimSpace(u.Hostname())
if host == "" {
return "", 0, ""
}
if _, err := netip.ParseAddr(host); err == nil {
return "", 0, ""
}
port := 0
if p := strings.TrimSpace(u.Port()); p != "" {
n, err := strconv.Atoi(p)
if err == nil && n > 0 && n <= 65535 {
port = n
}
}
if port == 0 {
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
case "http":
port = 80
default:
port = 443
}
}
ip, err := ResolveHostIPv4(host, 2*time.Second)
if err != nil || ip == "" {
return "", 0, ""
}
return host, port, ip
}
func ResolveHostIPv4(host string, timeout time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, strings.TrimSpace(host))
if err != nil {
return "", err
}
for _, a := range addrs {
if ip4 := a.IP.To4(); ip4 != nil {
return ip4.String(), nil
}
}
return "", fmt.Errorf("no ipv4 address for host %q", host)
}
func ResolveCurlPath() string {
curlPathOnce.Do(func() {
if p, err := exec.LookPath("curl"); err == nil {
curlPath = strings.TrimSpace(p)
return
}
for _, cand := range []string{"/usr/bin/curl", "/bin/curl"} {
if _, err := exec.LookPath(cand); err == nil {
curlPath = strings.TrimSpace(cand)
return
}
}
curlPath = ""
})
return curlPath
}
func ResolveWgetPath() string {
wgetPathOnce.Do(func() {
if p, err := exec.LookPath("wget"); err == nil {
wgetPath = strings.TrimSpace(p)
return
}
for _, cand := range []string{"/usr/bin/wget", "/bin/wget"} {
if _, err := exec.LookPath(cand); err == nil {
wgetPath = strings.TrimSpace(cand)
return
}
}
wgetPath = ""
})
return wgetPath
}
func TimeoutSec(timeout time.Duration) int {
sec := int(timeout.Seconds())
if sec < 1 {
sec = 1
}
return sec
}
func FirstNonEmptyAny(obj map[string]any, keys ...string) string {
for _, key := range keys {
if v := strings.TrimSpace(AnyToString(obj[key])); v != "" {
return v
}
}
return ""
}
func AnyToString(v any) string {
switch x := v.(type) {
case string:
return x
case fmt.Stringer:
return x.String()
case int:
return strconv.Itoa(x)
case int64:
return strconv.FormatInt(x, 10)
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case bool:
if x {
return "true"
}
return "false"
default:
return ""
}
}
func dedupeStrings(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, raw := range in {
v := strings.TrimSpace(raw)
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
func parseIntAny(v any) (int, bool) {
switch x := v.(type) {
case int:
return x, true
case int8:
return int(x), true
case int16:
return int(x), true
case int32:
return int(x), true
case int64:
return int(x), true
case uint:
return int(x), true
case uint8:
return int(x), true
case uint16:
return int(x), true
case uint32:
return int(x), true
case uint64:
return int(x), true
case float64:
return int(x), true
case string:
n, err := strconv.Atoi(strings.TrimSpace(x))
if err != nil {
return 0, false
}
return n, true
default:
return 0, false
}
}

View File

@@ -0,0 +1,90 @@
package app
import (
"os"
appcli "selective-vpn-api/app/cli"
)
// EN: Application entrypoint and process bootstrap.
// EN: This file wires CLI modes and delegates API runtime bootstrap.
// RU: Точка входа приложения и bootstrap процесса.
// RU: Этот файл связывает CLI-режимы и делегирует запуск API-сервера.
func Run() {
if code, handled := runLegacyCLIMode(os.Args[1:]); handled {
if code != 0 {
os.Exit(code)
}
return
}
RunAPIServer()
}
// RunAPIServer starts the HTTP/SSE API server mode.
func RunAPIServer() {
runAPIServerAtAddr("127.0.0.1:8080")
}
// RunRoutesUpdateCLI executes one-shot routes update mode.
func RunRoutesUpdateCLI(args []string) int {
return appcli.RunRoutesUpdate(args, appcli.RoutesUpdateDeps{
LockFile: lockFile,
Update: func(iface string) (bool, string) {
res := routesUpdate(iface)
return res.OK, res.Message
},
Stdout: os.Stdout,
Stderr: os.Stderr,
})
}
// RunRoutesClearCLI executes one-shot routes clear mode.
func RunRoutesClearCLI(args []string) int {
return appcli.RunRoutesClear(args, appcli.RoutesClearDeps{
Clear: func() (bool, string) {
res := routesClear()
return res.OK, res.Message
},
Stdout: os.Stdout,
Stderr: os.Stderr,
})
}
// RunAutoloopCLI executes autoloop mode.
func RunAutoloopCLI(args []string) int {
return appcli.RunAutoloop(args, appcli.AutoloopDeps{
StateDirDefault: stateDir,
ResolveIface: func(flagIface string) string {
resolvedIface := normalizePreferredIface(flagIface)
if resolvedIface == "" {
resolvedIface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface)
}
return resolvedIface
},
Run: func(params appcli.AutoloopParams) {
runAutoloop(
params.Iface,
params.Table,
params.MTU,
params.StateDir,
params.DefaultLocation,
)
},
Stderr: os.Stderr,
})
}
func runLegacyCLIMode(args []string) (int, bool) {
if len(args) == 0 {
return 0, false
}
switch args[0] {
case "routes-update", "-routes-update":
return RunRoutesUpdateCLI(args[1:]), true
case "routes-clear":
return RunRoutesClearCLI(args[1:]), true
case "autoloop":
return RunAutoloopCLI(args[1:]), true
default:
return 0, false
}
}

View File

@@ -4,97 +4,35 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"
eventsbuspkg "selective-vpn-api/app/eventsbus"
)
// ---------------------------------------------------------------------
// события / event bus
// ---------------------------------------------------------------------
// EN: In-memory bounded event bus used for SSE replay and polling watchers.
// EN: It keeps only the latest N events and assigns monotonically increasing IDs.
// RU: Ограниченная in-memory шина событий для SSE-реплея и фоновых вотчеров.
// RU: Хранит только последние N событий и присваивает монотонно растущие ID.
type eventBus struct {
mu sync.Mutex
cond *sync.Cond
buf []Event
cap int
next int64
inner *eventsbuspkg.Bus
}
// ---------------------------------------------------------------------
// EN: `newEventBus` creates a new instance for event bus.
// RU: `newEventBus` - создает новый экземпляр для event bus.
// ---------------------------------------------------------------------
func newEventBus(capacity int) *eventBus {
if capacity < 16 {
capacity = 16
}
b := &eventBus{
cap: capacity,
buf: make([]Event, 0, capacity),
}
b.cond = sync.NewCond(&b.mu)
return b
return &eventBus{inner: eventsbuspkg.New(capacity)}
}
// ---------------------------------------------------------------------
// EN: `push` contains core logic for push.
// RU: `push` - содержит основную логику для push.
// ---------------------------------------------------------------------
func (b *eventBus) push(kind string, data interface{}) Event {
b.mu.Lock()
defer b.mu.Unlock()
b.next++
evt := Event{
ID: b.next,
Kind: kind,
Ts: time.Now().UTC().Format(time.RFC3339Nano),
Data: data,
}
if len(b.buf) >= b.cap {
b.buf = b.buf[1:]
}
b.buf = append(b.buf, evt)
b.cond.Broadcast()
return evt
ev := b.inner.Push(kind, data)
return Event{ID: ev.ID, Kind: ev.Kind, Ts: ev.Ts, Data: ev.Data}
}
// ---------------------------------------------------------------------
// EN: `since` contains core logic for since.
// RU: `since` - содержит основную логику для since.
// ---------------------------------------------------------------------
func (b *eventBus) since(id int64) []Event {
b.mu.Lock()
defer b.mu.Unlock()
return b.sinceLocked(id)
}
// ---------------------------------------------------------------------
// EN: `sinceLocked` contains core logic for since locked.
// RU: `sinceLocked` - содержит основную логику для since locked.
// ---------------------------------------------------------------------
func (b *eventBus) sinceLocked(id int64) []Event {
if len(b.buf) == 0 {
raw := b.inner.Since(id)
if len(raw) == 0 {
return nil
}
var out []Event
for _, ev := range b.buf {
if ev.ID > id {
out = append(out, ev)
}
out := make([]Event, 0, len(raw))
for _, ev := range raw {
out = append(out, Event{ID: ev.ID, Kind: ev.Kind, Ts: ev.Ts, Data: ev.Data})
}
return out
}
// ---------------------------------------------------------------------
// env helpers
// ---------------------------------------------------------------------
// EN: Positive integer env reader with safe default fallback.
// RU: Чтение положительного целого из env с безопасным fallback на дефолт.
func envInt(key string, def int) int {

Some files were not shown because too many files have changed in this diff Show More