platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -23,3 +23,8 @@ selective-vpn-api/_backups/
|
|||||||
|
|
||||||
# Local archive / old copies (kept out of repo root)
|
# Local archive / old copies (kept out of repo root)
|
||||||
_legacy/
|
_legacy/
|
||||||
|
|
||||||
|
# Web prototype
|
||||||
|
selective-vpn-web/node_modules/
|
||||||
|
selective-vpn-web/dist/
|
||||||
|
selective-vpn-web/.env.local
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Repo layout:
|
|||||||
- `selective-vpn-api/` - Go backend API (localhost, default `127.0.0.1:8080`).
|
- `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/` - PySide6 GUI (`vpn_dashboard_qt.py`).
|
||||||
- `selective-vpn-gui/svpn_run_profile.py` - headless launcher used by profile shortcuts.
|
- `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):
|
Requirements (high level):
|
||||||
- Linux with `systemd`, `nftables`, `iproute2`, cgroup v2.
|
- Linux with `systemd`, `nftables`, `iproute2`, cgroup v2.
|
||||||
|
|||||||
1166
docs/EXECUTION_TRACKER.md
Normal file
1166
docs/EXECUTION_TRACKER.md
Normal file
File diff suppressed because it is too large
Load Diff
54
docs/README.md
Normal file
54
docs/README.md
Normal 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. После окончания фаз A–D приступить к прототипу веб-интерфейса, опираясь на собранную матрицу endpoint → handler.
|
||||||
|
4. Интеграционный трек выполнять параллельно с завершением ядра: новые клиенты подключаются через backend-адаптеры, UI остаётся тонким слоем вызовов API.
|
||||||
144
docs/phase-a/A1_API_AUDIT.md
Normal file
144
docs/phase-a/A1_API_AUDIT.md
Normal 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`
|
||||||
100
docs/phase-b/B1_CORE_VERIFICATION.md
Normal file
100
docs/phase-b/B1_CORE_VERIFICATION.md
Normal 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.
|
||||||
76
docs/phase-b/B2_RESOLVER_DIFF_AND_IMPROVEMENT_PLAN.md
Normal file
76
docs/phase-b/B2_RESOLVER_DIFF_AND_IMPROVEMENT_PLAN.md
Normal 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 трека.
|
||||||
50
docs/phase-b/B4_RUNTIME_DEPENDENCIES_AND_PREFLIGHT.md
Normal file
50
docs/phase-b/B4_RUNTIME_DEPENDENCIES_AND_PREFLIGHT.md
Normal 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.*`) быстро проверяем среду и снижаем ложные регрессии.
|
||||||
@@ -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`.
|
||||||
72
docs/phase-c/C1_WEB_READINESS.md
Normal file
72
docs/phase-c/C1_WEB_READINESS.md
Normal 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.
|
||||||
37
docs/phase-c/C2_WEB_STACK_DECISION.md
Normal file
37
docs/phase-c/C2_WEB_STACK_DECISION.md
Normal 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.
|
||||||
184
docs/phase-d/D1_GO_READINESS_DOCS.md
Normal file
184
docs/phase-d/D1_GO_READINESS_DOCS.md
Normal 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.
|
||||||
59
docs/phase-d/D4_PLATFORM_COMPATIBILITY_MATRIX.md
Normal file
59
docs/phase-d/D4_PLATFORM_COMPATIBILITY_MATRIX.md
Normal 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.
|
||||||
34
docs/phase-d/D5_NETNS_RUNTIME_CASE.md
Normal file
34
docs/phase-d/D5_NETNS_RUNTIME_CASE.md
Normal 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)
|
||||||
188
docs/phase-e/E1_MULTI_CLIENT_PBR_DESIGN.md
Normal file
188
docs/phase-e/E1_MULTI_CLIENT_PBR_DESIGN.md
Normal 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`.
|
||||||
602
docs/phase-e/E2_TRANSPORT_API_CONTRACT.md
Normal file
602
docs/phase-e/E2_TRANSPORT_API_CONTRACT.md
Normal 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).
|
||||||
91
docs/phase-e/E3_MULTI_INTERFACE_EXECUTION_PLAN.md
Normal file
91
docs/phase-e/E3_MULTI_INTERFACE_EXECUTION_PLAN.md
Normal 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`).
|
||||||
101
docs/phase-e/E4_2_MULTI_INTERFACE_GUI_DESIGN.md
Normal file
101
docs/phase-e/E4_2_MULTI_INTERFACE_GUI_DESIGN.md
Normal 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.
|
||||||
126
docs/phase-e/E4_VALIDATE_CONFIRM_APPLY_UX.md
Normal file
126
docs/phase-e/E4_VALIDATE_CONFIRM_APPLY_UX.md
Normal 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`).
|
||||||
75
docs/phase-e/E5_2_SINGBOX_DESKTOP_DASHBOARD_SPEC.md
Normal file
75
docs/phase-e/E5_2_SINGBOX_DESKTOP_DASHBOARD_SPEC.md
Normal 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.
|
||||||
101
docs/phase-e/E5_SINGBOX_CLIENT_FORM_MATRIX.md
Normal file
101
docs/phase-e/E5_SINGBOX_CLIENT_FORM_MATRIX.md
Normal 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`.
|
||||||
|
- То есть форма будет модульной, а не отдельное "окно под каждый протокол".
|
||||||
|
|
||||||
252
docs/phase-e/E5_SINGBOX_PROTOCOLS_MANIFEST.example.json
Normal file
252
docs/phase-e/E5_SINGBOX_PROTOCOLS_MANIFEST.example.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
229
docs/phase-e/E5_SINGBOX_PROTOCOLS_MATRIX.md
Normal file
229
docs/phase-e/E5_SINGBOX_PROTOCOLS_MATRIX.md
Normal 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 манифест для последующей генерации форм.
|
||||||
|
|
||||||
158
docs/phase-e/E5_SINGBOX_PROTOCOLS_REQUIREMENTS.md
Normal file
158
docs/phase-e/E5_SINGBOX_PROTOCOLS_REQUIREMENTS.md
Normal 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).
|
||||||
42
docs/phase-e/E5_SINGBOX_PROTOCOL_MATRIX_TEMPLATE.md
Normal file
42
docs/phase-e/E5_SINGBOX_PROTOCOL_MATRIX_TEMPLATE.md
Normal 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.
|
||||||
|
|
||||||
171
docs/phase-e/E6_EGRESS_IDENTITY_API_CONTRACT.md
Normal file
171
docs/phase-e/E6_EGRESS_IDENTITY_API_CONTRACT.md
Normal 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`.
|
||||||
140
docs/phase-f/F1_REFACTOR_MODULARITY_PLAN.md
Normal file
140
docs/phase-f/F1_REFACTOR_MODULARITY_PLAN.md
Normal 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.
|
||||||
36
docs/phase-f/F3_CORE_MODULE_LIBRARY_PLAN.md
Normal file
36
docs/phase-f/F3_CORE_MODULE_LIBRARY_PLAN.md
Normal 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 ./...` зелёный после миграции.
|
||||||
131
scripts/check_runtime_dependencies.sh
Executable file
131
scripts/check_runtime_dependencies.sh
Executable 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
|
||||||
114
scripts/transport-packaging/README.md
Normal file
114
scripts/transport-packaging/README.md
Normal 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.
|
||||||
265
scripts/transport-packaging/auto_update.sh
Executable file
265
scripts/transport-packaging/auto_update.sh
Executable 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"
|
||||||
108
scripts/transport-packaging/manifest.example.json
Normal file
108
scripts/transport-packaging/manifest.example.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
scripts/transport-packaging/manifest.production.json
Normal file
88
scripts/transport-packaging/manifest.production.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
scripts/transport-packaging/rollback.sh
Executable file
153
scripts/transport-packaging/rollback.sh
Executable 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"
|
||||||
33
scripts/transport-packaging/source_policy.example.json
Normal file
33
scripts/transport-packaging/source_policy.example.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
scripts/transport-packaging/source_policy.production.json
Normal file
31
scripts/transport-packaging/source_policy.production.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
687
scripts/transport-packaging/update.sh
Executable file
687
scripts/transport-packaging/update.sh
Executable 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"
|
||||||
213
scripts/transport_recovery_runbook.py
Executable file
213
scripts/transport_recovery_runbook.py
Executable 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
168
scripts/transport_runbook.py
Executable 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())
|
||||||
38
selective-vpn-api/app/api_bootstrap.go
Normal file
38
selective-vpn-api/app/api_bootstrap.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
selective-vpn-api/app/api_routes.go
Normal file
92
selective-vpn-api/app/api_routes.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
198
selective-vpn-api/app/apiroutes/register.go
Normal file
198
selective-vpn-api/app/apiroutes/register.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -44,28 +43,7 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeLoginState := func(state, email, msg string) {
|
writeLoginState := func(state, email, msg string) {
|
||||||
ts := time.Now().Format(time.RFC3339)
|
writeAutoloopLoginState(loginStateFile, state, email, msg)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fixPolicy := func() {
|
fixPolicy := func() {
|
||||||
@@ -83,45 +61,9 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string)
|
|||||||
" mtu " + fmt.Sprintf("%d", mtu) + " OK")
|
" 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) {
|
updateLoginStateFromText := func(text string) {
|
||||||
if isLoginRequired(text) {
|
updateAutoloopLoginStateFromText(text, writeLoginState, logLine)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLicense := func() {
|
updateLicense := func() {
|
||||||
@@ -144,7 +86,7 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logLine(fmt.Sprintf("status: ERROR exit=%d err=%v raw=%q", exitCode, err, statusOut))
|
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)
|
logLine("status: CONNECTED; raw: " + statusOut)
|
||||||
fixPolicy()
|
fixPolicy()
|
||||||
updateLicense()
|
updateLicense()
|
||||||
@@ -163,18 +105,30 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string)
|
|||||||
})
|
})
|
||||||
updateLoginStateFromText(statusOut)
|
updateLoginStateFromText(statusOut)
|
||||||
|
|
||||||
loc := getLocation()
|
loc := resolveAutoloopLocationSpec(locFile, defaultLoc)
|
||||||
logLine("reconnecting to " + loc)
|
primary := strings.TrimSpace(loc.Primary)
|
||||||
|
if primary == "" {
|
||||||
|
primary = strings.TrimSpace(defaultLoc)
|
||||||
|
}
|
||||||
|
logLine("reconnecting to " + primary)
|
||||||
|
|
||||||
_, _, _, _ = runCommandTimeout(disconnectTimeout, adgvpnCLI, "disconnect")
|
_, _, _, _ = 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)
|
connectOut = stripANSI(connectOut)
|
||||||
logLine("connect raw: " + connectOut)
|
logLine("connect raw: " + connectOut)
|
||||||
updateLoginStateFromText(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, _, _, _ := runCommandTimeout(statusTimeout, adgvpnCLI, "status")
|
||||||
statusAfter = stripANSI(statusAfter)
|
statusAfter = stripANSI(statusAfter)
|
||||||
if isConnected(statusAfter) {
|
if isAutoloopConnected(statusAfter) {
|
||||||
logLine("after connect: CONNECTED; raw: " + statusAfter)
|
logLine("after connect: CONNECTED; raw: " + statusAfter)
|
||||||
fixPolicy()
|
fixPolicy()
|
||||||
updateLicense()
|
updateLicense()
|
||||||
@@ -190,15 +144,3 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string)
|
|||||||
time.Sleep(10 * time.Second)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
12
selective-vpn-api/app/autoloop_helpers.go
Normal file
12
selective-vpn-api/app/autoloop_helpers.go
Normal 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.\-]+`)
|
||||||
107
selective-vpn-api/app/autoloop_helpers_location.go
Normal file
107
selective-vpn-api/app/autoloop_helpers_location.go
Normal 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 ""
|
||||||
|
}
|
||||||
89
selective-vpn-api/app/autoloop_helpers_login.go
Normal file
89
selective-vpn-api/app/autoloop_helpers_login.go
Normal 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
|
||||||
|
}
|
||||||
54
selective-vpn-api/app/bootstrap/server_runner.go
Normal file
54
selective-vpn-api/app/bootstrap/server_runner.go
Normal 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
|
||||||
|
}
|
||||||
59
selective-vpn-api/app/cli/autoloop.go
Normal file
59
selective-vpn-api/app/cli/autoloop.go
Normal 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
|
||||||
|
}
|
||||||
47
selective-vpn-api/app/cli/routes_clear.go
Normal file
47
selective-vpn-api/app/cli/routes_clear.go
Normal 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
|
||||||
|
}
|
||||||
65
selective-vpn-api/app/cli/routes_update.go
Normal file
65
selective-vpn-api/app/cli/routes_update.go
Normal 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
|
||||||
|
}
|
||||||
@@ -18,6 +18,17 @@ const (
|
|||||||
trafficModePath = stateDir + "/traffic-mode.json"
|
trafficModePath = stateDir + "/traffic-mode.json"
|
||||||
trafficAppMarksPath = stateDir + "/traffic-appmarks.json"
|
trafficAppMarksPath = stateDir + "/traffic-appmarks.json"
|
||||||
trafficAppProfilesPath = stateDir + "/traffic-app-profiles.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"
|
traceLogPath = stateDir + "/trace.log"
|
||||||
smartdnsLogPath = stateDir + "/smartdns.log"
|
smartdnsLogPath = stateDir + "/smartdns.log"
|
||||||
@@ -34,6 +45,7 @@ const (
|
|||||||
routesCacheMapD = stateDir + "/routes-clear-cache-ips-map-direct.txt"
|
routesCacheMapD = stateDir + "/routes-clear-cache-ips-map-direct.txt"
|
||||||
routesCacheMapW = stateDir + "/routes-clear-cache-ips-map-wildcard.txt"
|
routesCacheMapW = stateDir + "/routes-clear-cache-ips-map-wildcard.txt"
|
||||||
routesCacheRT = stateDir + "/routes-clear-cache-routes.txt"
|
routesCacheRT = stateDir + "/routes-clear-cache-routes.txt"
|
||||||
|
precheckForcePath = stateDir + "/precheck-force.once"
|
||||||
|
|
||||||
autoloopLogPath = stateDir + "/adguard-autoloop.log"
|
autoloopLogPath = stateDir + "/adguard-autoloop.log"
|
||||||
loginStatePath = stateDir + "/adguard-login.json"
|
loginStatePath = stateDir + "/adguard-login.json"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
81
selective-vpn-api/app/dns_settings_benchmark.go
Normal file
81
selective-vpn-api/app/dns_settings_benchmark.go
Normal 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)
|
||||||
|
}
|
||||||
140
selective-vpn-api/app/dns_settings_benchmark_handler.go
Normal file
140
selective-vpn-api/app/dns_settings_benchmark_handler.go
Normal 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)
|
||||||
|
}
|
||||||
72
selective-vpn-api/app/dns_settings_handlers_mode.go
Normal file
72
selective-vpn-api/app/dns_settings_handlers_mode.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
selective-vpn-api/app/dns_settings_handlers_upstreams.go
Normal file
61
selective-vpn-api/app/dns_settings_handlers_upstreams.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
selective-vpn-api/app/dns_settings_state_mode.go
Normal file
63
selective-vpn-api/app/dns_settings_state_mode.go
Normal 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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
90
selective-vpn-api/app/dns_settings_state_smartdns.go
Normal file
90
selective-vpn-api/app/dns_settings_state_smartdns.go
Normal 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)
|
||||||
|
}
|
||||||
104
selective-vpn-api/app/dns_settings_state_upstreams.go
Normal file
104
selective-vpn-api/app/dns_settings_state_upstreams.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
5
selective-vpn-api/app/dns_smartdns_handlers.go
Normal file
5
selective-vpn-api/app/dns_smartdns_handlers.go
Normal 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
|
||||||
141
selective-vpn-api/app/dns_smartdns_handlers_prewarm.go
Normal file
141
selective-vpn-api/app/dns_smartdns_handlers_prewarm.go
Normal 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
|
||||||
|
}
|
||||||
70
selective-vpn-api/app/dns_smartdns_handlers_runtime.go
Normal file
70
selective-vpn-api/app/dns_smartdns_handlers_runtime.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
358
selective-vpn-api/app/dnscfg/benchmark.go
Normal file
358
selective-vpn-api/app/dnscfg/benchmark.go
Normal 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
|
||||||
|
}
|
||||||
136
selective-vpn-api/app/dnscfg/mode.go
Normal file
136
selective-vpn-api/app/dnscfg/mode.go
Normal 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))
|
||||||
|
}
|
||||||
100
selective-vpn-api/app/dnscfg/pool.go
Normal file
100
selective-vpn-api/app/dnscfg/pool.go
Normal 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
|
||||||
|
}
|
||||||
392
selective-vpn-api/app/dnscfg/prewarm.go
Normal file
392
selective-vpn-api/app/dnscfg/prewarm.go
Normal 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
|
||||||
|
}
|
||||||
102
selective-vpn-api/app/dnscfg/smartdns.go
Normal file
102
selective-vpn-api/app/dnscfg/smartdns.go
Normal 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"
|
||||||
|
}
|
||||||
47
selective-vpn-api/app/dnscfg/systemd.go
Normal file
47
selective-vpn-api/app/dnscfg/systemd.go
Normal 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
|
||||||
|
}
|
||||||
69
selective-vpn-api/app/dnscfg/upstreams.go
Normal file
69
selective-vpn-api/app/dnscfg/upstreams.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// domains editor + smartdns wildcards
|
// domains editor + smartdns wildcards
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -31,210 +20,3 @@ var domainFiles = map[string]string{
|
|||||||
"last-ips-map-direct": lastIPsMapDirect,
|
"last-ips-map-direct": lastIPsMapDirect,
|
||||||
"last-ips-map-wildcard": lastIPsMapDyn,
|
"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
|
|
||||||
}
|
|
||||||
|
|||||||
109
selective-vpn-api/app/domains_handlers_file.go
Normal file
109
selective-vpn-api/app/domains_handlers_file.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
selective-vpn-api/app/domains_handlers_helpers.go
Normal file
40
selective-vpn-api/app/domains_handlers_helpers.go
Normal 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"
|
||||||
|
}
|
||||||
44
selective-vpn-api/app/domains_handlers_smartdns.go
Normal file
44
selective-vpn-api/app/domains_handlers_smartdns.go
Normal 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
|
||||||
|
}
|
||||||
45
selective-vpn-api/app/domains_handlers_table.go
Normal file
45
selective-vpn-api/app/domains_handlers_table.go
Normal 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})
|
||||||
|
}
|
||||||
78
selective-vpn-api/app/egress_identity.go
Normal file
78
selective-vpn-api/app/egress_identity.go
Normal 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{},
|
||||||
|
}
|
||||||
|
}
|
||||||
76
selective-vpn-api/app/egress_identity_geo.go
Normal file
76
selective-vpn-api/app/egress_identity_geo.go
Normal 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)
|
||||||
|
}
|
||||||
61
selective-vpn-api/app/egress_identity_handlers.go
Normal file
61
selective-vpn-api/app/egress_identity_handlers.go
Normal 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)
|
||||||
|
}
|
||||||
1
selective-vpn-api/app/egress_identity_probe.go
Normal file
1
selective-vpn-api/app/egress_identity_probe.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package app
|
||||||
44
selective-vpn-api/app/egress_identity_probe_external.go
Normal file
44
selective-vpn-api/app/egress_identity_probe_external.go
Normal 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, "; "))
|
||||||
|
}
|
||||||
74
selective-vpn-api/app/egress_identity_probe_helpers.go
Normal file
74
selective-vpn-api/app/egress_identity_probe_helpers.go
Normal 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 ""
|
||||||
|
}
|
||||||
146
selective-vpn-api/app/egress_identity_probe_netns.go
Normal file
146
selective-vpn-api/app/egress_identity_probe_netns.go
Normal 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)
|
||||||
|
}
|
||||||
95
selective-vpn-api/app/egress_identity_providers.go
Normal file
95
selective-vpn-api/app/egress_identity_providers.go
Normal 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()
|
||||||
|
}
|
||||||
140
selective-vpn-api/app/egress_identity_refresh.go
Normal file
140
selective-vpn-api/app/egress_identity_refresh.go
Normal 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, ""
|
||||||
|
}
|
||||||
117
selective-vpn-api/app/egress_identity_refresh_runner.go
Normal file
117
selective-vpn-api/app/egress_identity_refresh_runner.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
selective-vpn-api/app/egress_identity_refresh_state.go
Normal file
78
selective-vpn-api/app/egress_identity_refresh_state.go
Normal 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:
|
||||||
|
}
|
||||||
|
}
|
||||||
35
selective-vpn-api/app/egress_identity_scope.go
Normal file
35
selective-vpn-api/app/egress_identity_scope.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
188
selective-vpn-api/app/egress_identity_test.go
Normal file
188
selective-vpn-api/app/egress_identity_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
selective-vpn-api/app/egressutil/http.go
Normal file
48
selective-vpn-api/app/egressutil/http.go
Normal 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
|
||||||
|
}
|
||||||
25
selective-vpn-api/app/egressutil/identity.go
Normal file
25
selective-vpn-api/app/egressutil/identity.go
Normal 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)
|
||||||
|
}
|
||||||
19
selective-vpn-api/app/egressutil/probe.go
Normal file
19
selective-vpn-api/app/egressutil/probe.go
Normal 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
|
||||||
|
}
|
||||||
43
selective-vpn-api/app/egressutil/scope.go
Normal file
43
selective-vpn-api/app/egressutil/scope.go
Normal 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>")
|
||||||
|
}
|
||||||
|
}
|
||||||
399
selective-vpn-api/app/egressutil/util.go
Normal file
399
selective-vpn-api/app/egressutil/util.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
90
selective-vpn-api/app/entrypoints.go
Normal file
90
selective-vpn-api/app/entrypoints.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,97 +4,35 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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 {
|
type eventBus struct {
|
||||||
mu sync.Mutex
|
inner *eventsbuspkg.Bus
|
||||||
cond *sync.Cond
|
|
||||||
buf []Event
|
|
||||||
cap int
|
|
||||||
next int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// EN: `newEventBus` creates a new instance for event bus.
|
|
||||||
// RU: `newEventBus` - создает новый экземпляр для event bus.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
func newEventBus(capacity int) *eventBus {
|
func newEventBus(capacity int) *eventBus {
|
||||||
if capacity < 16 {
|
return &eventBus{inner: eventsbuspkg.New(capacity)}
|
||||||
capacity = 16
|
|
||||||
}
|
|
||||||
b := &eventBus{
|
|
||||||
cap: capacity,
|
|
||||||
buf: make([]Event, 0, capacity),
|
|
||||||
}
|
|
||||||
b.cond = sync.NewCond(&b.mu)
|
|
||||||
return b
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// EN: `push` contains core logic for push.
|
|
||||||
// RU: `push` - содержит основную логику для push.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
func (b *eventBus) push(kind string, data interface{}) Event {
|
func (b *eventBus) push(kind string, data interface{}) Event {
|
||||||
b.mu.Lock()
|
ev := b.inner.Push(kind, data)
|
||||||
defer b.mu.Unlock()
|
return Event{ID: ev.ID, Kind: ev.Kind, Ts: ev.Ts, Data: ev.Data}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// EN: `since` contains core logic for since.
|
|
||||||
// RU: `since` - содержит основную логику для since.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
func (b *eventBus) since(id int64) []Event {
|
func (b *eventBus) since(id int64) []Event {
|
||||||
b.mu.Lock()
|
raw := b.inner.Since(id)
|
||||||
defer b.mu.Unlock()
|
if len(raw) == 0 {
|
||||||
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 {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var out []Event
|
out := make([]Event, 0, len(raw))
|
||||||
for _, ev := range b.buf {
|
for _, ev := range raw {
|
||||||
if ev.ID > id {
|
out = append(out, Event{ID: ev.ID, Kind: ev.Kind, Ts: ev.Ts, Data: ev.Data})
|
||||||
out = append(out, ev)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// env helpers
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
// EN: Positive integer env reader with safe default fallback.
|
// EN: Positive integer env reader with safe default fallback.
|
||||||
// RU: Чтение положительного целого из env с безопасным fallback на дефолт.
|
// RU: Чтение положительного целого из env с безопасным fallback на дефолт.
|
||||||
func envInt(key string, def int) int {
|
func envInt(key string, def int) int {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user