diff --git a/.gitignore b/.gitignore index 128cc26..7e9643a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ selective-vpn-api/_backups/ # Local archive / old copies (kept out of repo root) _legacy/ + +# Web prototype +selective-vpn-web/node_modules/ +selective-vpn-web/dist/ +selective-vpn-web/.env.local diff --git a/README.md b/README.md index 5d12185..ffd62e9 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Repo layout: - `selective-vpn-api/` - Go backend API (localhost, default `127.0.0.1:8080`). - `selective-vpn-gui/` - PySide6 GUI (`vpn_dashboard_qt.py`). - `selective-vpn-gui/svpn_run_profile.py` - headless launcher used by profile shortcuts. +- `selective-vpn-web/` - Vite + React + TypeScript web prototype foundation (SPA, read-only at current stage). Requirements (high level): - Linux with `systemd`, `nftables`, `iproute2`, cgroup v2. diff --git a/docs/EXECUTION_TRACKER.md b/docs/EXECUTION_TRACKER.md new file mode 100644 index 0000000..0ed2648 --- /dev/null +++ b/docs/EXECUTION_TRACKER.md @@ -0,0 +1,1166 @@ +# Execution Tracker + +Дата обновления: 2026-03-15 +Владелец: Engineering + +## Статусы фаз +- [x] A. Аудит API и HTTP-маршрутов +- [~] B. Проверка ядра и внешних зависимостей +- [~] C. Подготовка к веб-совместимости +- [~] D. Документирование и итоговые проверки +- [~] E. Дизайн multi-client PBR и anti-conflict guardrails +- [~] F. Рефакторинг и модульность (декомпозиция крупных файлов GUI/API/Go) + +## Порядок реализации (фикс) +- 1. Сначала полностью закрываем backend-интеграцию transport-клиентов (`singbox`, `dnstt-client`, `phoenix`) в Go-ядре. +- 2. Затем переиспользуем backend-контракт в desktop GUI (без дублирования бизнес-логики). +- 3. Веб-прототип (`Vite + React + TS`) начинаем только после завершения backend+GUI этапа. + +## Фокус текущего этапа (freeze) +- Desktop-first: сейчас работаем только по `SingBox` вкладке и `SingBox` Go API. +- `DNSTT` и `Phoenix`: backend foundation/e2e уже зафиксированы, UI-вкладки и протокольные экраны для них в этом этапе не развиваем. +- Приоритет реализации: + - временно сужаем активный backend scope до `E3.3/E3.6`: + - единый `interface orchestrator` path для всех mutating transport-операций, + - `validate -> plan -> confirm -> apply -> health-check -> commit` без частично подтверждённого runtime; + - `E5.4` GUI-протоколы внутри отдельной вкладки `SingBox` не расширяем, пока `E3.3/E3.6` не доведены до стабильного backend-state. + +## Текущие шаги +- Step A1: [x] каталогизация маршрутов и сопоставление с Go-обработчиками +- Step A2: [x] валидация GUI как клиента (api_client.py, dashboard_controller) +- Step B1: [~] оценка nftables/systemd/SmartDNS зависимостей (`routes_*`, `traffic_*`, `dns_*`, `smartdns_runtime`) +- Step B2: [~] запись требований root/long-running/состояний для веба +- Step B3: [~] resolver hardening plan: зафиксировать разницу `system resolver` vs `singbox DNS` и закрыть backlog улучшений +- Step C1: [~] ревью binding и SSE (`server.go`, `events_bus.go`, `trace_handlers.go`) +- Step C2: [x] запись потребностей по CORS/auth/token для веб-интерфейса +- Step C3: [x] зафиксирован стек web prototype: `Vite + React + TypeScript` (SPA, без Next.js на MVP-этапе) +- Step C4: [x] создан web foundation-модуль `selective-vpn-web/` (routing + query + SSE connectivity, без mutating controls) +- Step D1: [x] матрица endpoint → handler → dependencies + web-ready +- Step D2: [x] чеклист проверок (curl, SSE, VPN login) +- Step D3: [~] последовательность запуска multi-client (web + iOS + Android) зафиксирована +- Step D4: [~] зафиксирован транспортный интеграционный бэклог (`sing-box client`, `dnstt-client`, `phoenix->slipstream`) +- Step D4.1: [x] внедрён минимальный общий transport backend-контракт в Go (`lifecycle + health + metrics + unified errors + capabilities contract hints`) +- Step D4.2: [~] внедрены backend-адаптеры foundation (`mock/systemd`) + template `exec_start` + restart/watchdog tuning + unit hardening + `runtime_mode` foundation + packaging updater/rollback foundation для `singbox|dnstt|phoenix` +- Step D4.2.8: [x] e2e backend-проверки по клиентам (`singbox -> dnstt(+ssh) -> phoenix`) через API lifecycle/runbook +- Step D4.2.9: [x] добавлен операционный runbook helper (`scripts/transport_runbook.py`) + smoke (`tests/transport_runbook_cli_smoke.sh`) +- Step D4.2.10: [x] добавлены real-systemd e2e и backend-cleanup unit artifacts при delete client +- Step D4.2.11: [x] добавлены production-like e2e (template commands + packaging profiles `bundled|system`) +- Step D4.2.12: [x] добавлен recovery runbook (health->restart->recheck->provision/start fallback + diagnostics) +- Step D4.2.13: [x] внедрён `singbox` bootstrap-bypass в Go backend (`transport`): endpoint `/32` host-routes в `table agvpn` через `main` default-route перед `start/restart`, cleanup на `stop` +- Step D4.2.14: [x] добавлен `netns` test-contour для transport backend (`setup/cleanup + NAT + optional strict mode`) +- Step D4.2.15: [x] миграция legacy DNS-конфига `sing-box` (`address -> type/server`) в `Provision()` + backup + strict-mode +- Step D4.3: [x] зафиксирована матрица совместимости `web + iOS + Android` (единый transport control-plane контракт + platform constraints) +- Step V1: [x] стабилизация UX списка VPN локаций (async + cache + auto-apply + лёгкий manual refresh trigger) +- Step V2: [x] добавить "умный поиск" в списке локаций (по набору символов без отдельной строки поиска) +- Step E1: [x] подготовлен дизайн multi-client PBR (`docs/phase-e/E1_MULTI_CLIENT_PBR_DESIGN.md`) +- Step E2: [x] спроектирован API `transport/*` (clients/policies/validate/apply/conflicts) +- Step E2.2: [x] реализован dry-run validator конфликтов (`ownership`, `cidr_overlap`, `unknown_client`) +- Step E2.3: [x] реализован apply flow (`base_revision`, `confirm_token`, snapshot rollback, SSE events) +- Step E3.1: [x] усилен allocator (`mark/pref` резервы, auto re-balance, deterministic restore) +- Step E3.2: [x] добавлен endpoint-level rollback (`/transport/policies/rollback`) +- Step E3.3: [x] реализовать единый interface orchestrator в Go (`create/bind/start/stop/cleanup` для engine-инстансов, netns/table sync без конфликтов) +- Step E3.3.0: [x] зафиксирован execution roadmap мультиинтерфейса (`docs/phase-e/E3_MULTI_INTERFACE_EXECUTION_PLAN.md`) +- Step E3.3.1: [x] foundation-этап: `iface_id` в `transport client` + state `transport-interfaces.json` + `GET /api/v1/transport/interfaces` (без изменения data-plane) +- Step E3.3.2: [x] внедрить interface orchestrator lock+mapping (`iface_id -> runtime iface/netns/table`) с единым lifecycle path +- Step E3.3.2.1: [x] добавлен per-`iface_id` lock manager для mutating lifecycle/provision операций (`start/stop/restart/provision`) в transport-контуре; операции одного интерфейса сериализуются +- Step E3.3.2.2: [x] runtime mapping-layer завершён: `iface_id -> routing_table` (dedicated iface default + interface hints + sync в create/patch/lifecycle/netns-toggle/provision) + owner-scoped nft compile naming (`iface+client+selector`) без shared-set mixing +- Step E3.3.2.3: [x] оставшиеся mutating transport-paths переведены на iface-aware orchestration lock: + - `create`, `PATCH /transport/clients/{id}`, `DELETE /transport/clients/{id}`, `POST /transport/netns/toggle`; + - добавлен multi-`iface_id` lock helper с детерминированным порядком захвата для patch/multi-target операций; + - `lifecycle start/restart` теперь расширяют lock-set до всех `same-netns` `SingBox` peer-ов, включая binding через `iface -> netns`, поэтому cross-`iface_id` netns-конфликт не обходит orchestrator; + - `netns toggle` больше не делает прямой `backend restart`: restart/provision теперь идут через общий backend execution path (`provision` + lifecycle preflight/orchestrator), поэтому peer-stop guard, egress refresh и runtime commit не дублируются отдельной веткой; + - lock-resolver для lifecycle/netns toggle теперь использует `same-netns` peer matching без фильтра по runtime-status (lock захватывает всех netns-peer клиентов), чтобы избежать гонок при stale/lag status и не пропускать cross-iface сериализацию. + - lifecycle/provision/create/patch/delete/netns теперь используют общий `iface` serialization pattern, без отдельной GUI/local orchestration-ветки. +- Step E3.3.3: [x] внедрён M3 per-interface policy compiler + atomic apply executor foundation: + - compile-plan `iface_id -> table/mark/pref/nft-set` подключён в validate/apply/rollback/get-policy; + - apply/rollback теперь проходят через atomic runtime executor с snapshot/restore (`transport-policies.runtime.json`, `transport-policies.runtime.prev.json`) до commit policy revision; + - подключён kernel stage в executor: + - nft stage: per-interface CIDR set apply/cleanup (`inet agvpn`, managed sets `agvpn_pi_*`); + - optional ip rule stage (`fwmark/pref -> lookup table`) под отдельным флагом; + - kernel stage включается через env-флаги: `SVPN_TRANSPORT_POLICY_KERNEL_APPLY=1`, `SVPN_TRANSPORT_POLICY_KERNEL_IPRULES=1`. + - M3 owner scope закрыт: compile-plan теперь генерирует owner-scoped `nft_set` (`owner_scope=iface+client`), что исключает shared-set mixing для нескольких клиентов на одном `iface_id`. +- Step E3.4: [x] внедрён strict ownership registry для policy-intent (single-owner на domain/ip/cidr/app intent, явные conflict reason до apply) +- Step E3.4.1: [x] добавлен persisted ownership registry (`transport-ownership.json`) + endpoint `GET /api/v1/transport/owners` (auto-rebuild из compile-plan при рассинхроне revision) +- Step E3.4.2: [x] apply-guardrails усилены: `force_override` разрешён только для `owner_switch`; non-overridable block (`ownership`, `cidr_overlap`, `unknown_client`, allocator conflicts) не обходятся override-флагом +- Step E3.4.3: [x] read-side ownership rebuild усилен `plan_digest` guard: `GET /api/v1/transport/owners` пересобирает ownership не только по `policy_revision`, но и при drift compile-plan digest; в response добавлен `plan_digest`, ownership records возвращают `owner_scope` +- Step E3.5: [x] добавлен anti-mixing guard (`conntrack` stickiness + destination owner lock), чтобы один destination не ходил через два engine одновременно +- Step E3.5.1: [x] добавлен runtime owner-lock guard в validate/apply: owner switch блокируется, если previous owner клиента активен (`up|starting|degraded`); `force_override` это не обходит +- Step E3.5.2: [x] расширен observability ownership: `GET /api/v1/transport/owners` теперь возвращает `owner_status` + `lock_active` по каждой записи и агрегат `lock_count` +- Step E3.5.3: [x] добавлен conntrack stickiness foundation: kernel stage умеет собирать destination-lock state (`transport-owner-locks.json`) по `mark->owner` mapping; endpoint `GET /api/v1/transport/owner-locks`; validate/apply блокируют owner-switch по `cidr` при активных `destination_lock` +- Step E3.5.4: [x] добавлен безопасный recovery clear-flow для owner-locks: `POST /api/v1/transport/owner-locks/clear` (filter: `client_id`, `destination_ip(s)`), двухшаговый confirm-token (`clr-*`), без unscoped clear-all +- Step E3.5.5: [x] расширен destination-lock guard на `domain` selector: owner-switch по `domain`/`*.domain` теперь учитывает resolver `domain-cache` (`direct+wildcard`) и блокируется при sticky-совпадении `destination_ip` у previous owner +- Step E3.6: [x] расширен apply pipeline до `validate -> plan -> confirm -> apply -> health-check -> commit` с auto-rollback при fail health-check +- Step E3.6.1: [x] добавлен post-apply transport health-check до policy commit: + - apply/rollback после runtime apply выполняют backend health probe по активным transport-owner клиентам из compile-plan; + - для `enabled/up|starting|degraded` клиентов failure/down считается commit-blocking, inactive draft clients (`enabled=false`, `status=down`) пропускаются; + - при health-check fail выполняется runtime auto-rollback на `transport-policies.runtime.prev.json`, policy revision не коммитится; + - `TransportPolicyResponse` дополнен `health_check` summary для GUI/Web наблюдаемости. +- Step E3.6.2: [x] добить transaction pipeline: + - уменьшить удержание глобального `transportMu` на long-running runtime steps после стабилизации orchestrator path; + - расширить health gate на более полный per-interface observability/runtime coverage. +- Step E3.6.2.1: [x] добавлен persisted `Idempotency-Key` storage/replay для mutating policy endpoints: + - `POST /api/v1/transport/policies/apply` и `POST /api/v1/transport/policies/rollback` сохраняют response snapshot по `(scope, idempotency_key, request_hash)`; + - повтор того же ключа с тем же payload возвращает сохранённый response без повторного runtime apply/rollback и без инкремента `policy_revision`; + - reuse того же ключа с другим payload блокируется кодом `IDEMPOTENCY_KEY_REUSED`; + - state вынесен в отдельный persisted backend store (`transport-policy-idempotency.json`) с pruning по TTL/size. +- Step E3.6.2.2: [x] уменьшить удержание глобального `transportMu` на long-running runtime steps после стабилизации orchestrator path + - `transport client provision` переведён на двухфазный flow: + - под `transportMu` остаются только snapshot/bind и final commit, + - сам `backend.Provision()` выполняется под `iface`-lock, но вне глобального mutex; + - `transport client lifecycle` переведён на двухфазный flow: + - `same-netns` peer-stop и target `backend.Action()` выполняются под `iface`-lock set, но вне глобального mutex; + - под `transportMu` остаются только snapshot/bind/reload/final commit, включая commit peer-stop результатов; + - partial peer-stop failure теперь сохраняет уже выполненные peer changes в state до возврата ошибки target lifecycle, без silent drift между runtime и persisted state; + - `transport netns toggle` переведён на такой же двухфазный flow: + - config patch/save остаётся под `transportMu`, + - долгие `provision/restart` шаги выполняются уже через общий provision/lifecycle path под `iface`-lock set, но вне глобального mutex; + - lock resolver для toggle теперь заранее включает `same-netns` peer `iface_id`, если restart затрагивает binding после toggle. +- Step E3.6.2.3: [x] расширен health gate на более полный per-interface observability/runtime coverage + - `TransportPolicyResponse.health_check` возвращает per-`iface_id` summary c runtime-полями: + - `iface_id/mode/runtime_iface/netns_name/routing_table`, + - `client_count/checked_count/failed_count/skipped_count`, + - `status`, `latency_ms`, `last_error`, `active_client_id`, + - interface-level `ok/message` для GUI/Web без ручной агрегации по client items; + - summary строится из того же post-probe runtime snapshot (включая netns-клиентов), что и client-level health items, без отдельной UI-склейки. +- Step E3.6.2.4: [x] сокращён hold-time `transportMu` в read-side snapshot handlers (`/transport/interfaces`, `/transport/policies`, `/transport/ownership`, `/transport/runtime/observability`): + - под `transportMu` выполняется только короткий state snapshot (`clients/interfaces/policy/plan/ownership`) + token capture; + - нормализация/compile-plan выполняются вне глобального mutex; + - добавлен snapshot-aware conditional commit helper (`compileTransportPolicyPlanForSnapshot`, `saveTransportInterfacesIfSnapshotCurrent`, `saveTransportPlanIfSnapshotCurrent`, `saveTransportOwnershipIfSnapshotCurrent`) для safe save без stale overwrite; + - API/DTO контракты endpoint’ов сохранены без изменений. +- Step E3.6.2.5: [x] доведён аналогичный snapshot/conditional-save подход для `netns` toggle-пути (`POST /api/v1/transport/netns/toggle`): + - lock-id resolver теперь использует короткий snapshot (`clients/interfaces`) под `transportMu`, нормализация interfaces и conditional-save выполняются вне глобального mutex; + - в execute-path убран persist interfaces из-под `transportMu`: используется in-memory normalize для binding, а persist вынесен в post-unlock `conditional-save helper`; + - добавлен pure helper `resolveTransportLifecycleLockIDsForSnapshot` (in-memory), lock-resolver теперь использует snapshot-state без повторного I/O под mutex; flaky unit-case стабилизирован. + - устранён stale-guard gap для mutating netns-path: post-commit persist interfaces больше не зависит от `clients.updated_at` (используется interfaces-only snapshot), поэтому нормализованные iface-записи не теряются после `saveTransportClientsState`. + - mutating контракт netns-toggle сохранён (clients state commit остаётся atomic под `transportMu`, provision/restart по-прежнему вне глобального mutex). +- Step E3: [x] схема allocator'ов (`mark/table/pref`) и стратегия atomic apply/rollback +- Step E4.1: [x] зафиксирован UX-flow `validate -> confirm -> apply` для web/mobile +- Step E4.2: [x] внедрён foundation state-machine в GUI controller (`draft/validated/risky/confirm/applied`) +- Step E4.3.1: [x] добавлен GUI foundation-блок `Transport engine` (select + prepare/connect/disconnect/restart через `/api/v1/transport/clients/{id}/*`) +- Step E4.3.2: [x] `Connect/Switch` переведён на pipeline `validate -> confirm -> apply` + добавлен `Rollback policy` button +- Step E4.3: [~] детализирован UX-подпоток `Engine Switch / Connect` (desired/active engine, switch states, rollback action) +- Step E4.4: [x] зафиксирован desktop-first дизайн общего multi-interface GUI блока (`Ownership + Destination locks + safe clear-flow`) в `docs/phase-e/E4_2_MULTI_INTERFACE_GUI_DESIGN.md` +- Step E4.5: [x] desktop GUI подключён к ownership/lock API: + - read-only панель `Ownership & destination locks` во вкладке `SingBox` (таблицы ownership + destination locks, summary/revision); + - refresh-хук встроен в `refresh_singbox_tab` и transport refresh-поток; + - подключён безопасный clear-flow через `POST /api/v1/transport/owner-locks/clear` (2-step confirm token, без optimistic update, только re-fetch). +- Step E4.5.1: [x] desktop GUI подключён к `GET /api/v1/transport/interfaces`: + - в панели `Ownership & destination locks` добавлена таблица интерфейсов (`iface_id/mode/runtime_iface/netns/routing_table/client up/total`); + - summary расширен данными `interfaces count + policy revision + intents count`. +- Step E4.5.2: [x] добавлен desktop GUI policy-intents editor (в `SingBox` блоке `Ownership & destination locks`): + - draft-таблица intents (`selector_type/value`, `client_id`, `mode`, `priority`); + - actions: `Reload policy`, `Add intent`, `Remove selected`, `Validate policy`, `Apply policy`, `Rollback policy`; + - apply использует backend flow `validate -> (optional confirm for risky) -> apply`, rollback через `/transport/policies/rollback`; + - черновик не перетирается авто-refresh'ем при dirty state. +- Step E4.5.3: [x] UX-polish policy-intents в MultiIF: + - добавлены quick templates (`domain/wildcard/cidr/ip/app_key/uid`) с prefill без auto-add; + - добавлен `Load selected` + double-click по строке draft для обратной подгрузки intent в форму редактирования; + - добавлен Enter-to-add для selector value и duplicate guard (не даёт добавить полностью идентичный intent); + - в draft-таблице клиент показывается как `name (client_id)` для лучшей читаемости. +- Step E4.5.4: [x] добавлено inline-редактирование draft intent в MultiIF: + - добавлена кнопка `Update selected` (обновляет выбранную строку без remove/add); + - общий validator формы переиспользуется для add/update (domain/cidr/uid guardrails едины); + - duplicate guard поддерживает `skip_index` при update (строку можно обновлять без ложного self-duplicate). +- Step E4.5.5: [x] MultiIF policy/ownership отвязаны от `transport-only` и включают `AdGuard VPN` как virtual policy target: + - в backend compile/validate/apply/rollback path добавлен virtual client `adguardvpn` (без записи в `transport-clients.json`); + - ownership/locks/read-side используют единый `policyTargets` snapshot (`transport clients + adguardvpn`); + - в GUI включён scope-filter `All/Transport/AdGuard VPN`, client selector для intents поддерживает `AdGuard VPN (adguardvpn)`; + - сняты artificial guard-блоки `adguard-only`, чтобы low-level MultiIF слой работал единообразно для всех engine. +- Step E4.5.6: [x] интерфейсный слой MultiIF переведён на backend-source-of-truth для AdGuard: + - `GET /api/v1/transport/interfaces` теперь добавляет virtual interface-row `adguardvpn` (status/iface/table/up-count) из backend observer; + - GUI больше не строит adguard-строку локально через `vpn_status_model`/egress-хелперы; + - filter `All/Transport/AdGuard VPN` применяется единообразно к interfaces/ownership/locks таблицам. +- Step E4.5.7: [x] добавлен быстрый UX-flow для первичной проверки MultiIF policy editor: + - кнопка `Add demo intent` создаёт тестовый `domain` intent (`demo.invalid`, авто-уникализация `demo-N.invalid`) на выбранный client; + - quick-help обновлён до пошагового сценария `Add demo/fill -> Validate -> Apply`; + - flow использует тот же draft/validate/apply pipeline без отдельной ветки логики. +- Step E4.5.8: [x] в MultiIF добавлено визуальное разделение `Draft` и `Applied` policy intents: + - добавлена отдельная read-only таблица `Applied intents` (текущая backend policy); + - status/state строка теперь показывает оба счётчика (`draft` и `applied`) для быстрого сравнения; + - API-unavailable/error paths очищают обе таблицы синхронно, чтобы не было stale-данных. +- Step E4.5.9: [x] добавлена визуализация конфликтов валидации policy (MultiIF): + - отдельная read-only таблица `Validation conflicts` (`type/severity/owners/reason/suggested resolution`); + - таблица синхронизируется из `validate/apply` flow и сохраняет последний результат проверки; + - при API-unavailable/endpoint-error таблица очищается вместе с draft/applied, чтобы не показывать stale-conflicts. +- Step E4: [~] UX предупреждения и conflict flow (`validate -> confirm -> apply`, включая engine switch/connect) +- Step E5: [~] зафиксировать требования для протоколов во вкладке `SingBox` и target Go API `singbox profiles` +- Step E5.1: [x] requirements freeze для `SingBox Protocols` (UI + Go API + storage/events) +- Step E5.1.2: [x] собран шаблон и матрица полей по протоколам `singbox` (`vless/trojan/shadowsocks/wireguard/hysteria2/tuic`) + JSON manifest для генерации форм +- Step E5.1.3: [x] зафиксирована client-side UI матрица (по блокам формы, VLESS baseline + guardrails, без server-only полей) +- Step E5.2: [x] реализовать Go model/state для `singbox profiles` (CRUD + versioning + secrets store) +- Step E5.3: [x] реализовать Go flow `validate/render/apply/rollback/history/features` для `singbox profiles` +- Step E5.4: [~] реализовать GUI-блок протоколов в `SingBox` вкладке (list/editor/validate/preview/apply/rollback) +- Step E5.4.1: [x] внедрён desktop foundation-дизайн `SingBox` вкладки: `connection card` + `profile settings` + `global defaults` +- Step E5.4.2: [x] подключены GUI-кнопки `Validate profile`/`Apply profile` к Go API `/api/v1/transport/singbox/profiles/{id}/validate|apply` (с activity-log и runtime refresh) +- Step E5.4.3: [x] реализованы `Preview render` + `Rollback profile` + `History` и auto-link `engine -> profile` (auto-create/patch по `client_id`) + лёгкий рефакторинг handlers вкладки `SingBox` +- Step E5.4.4: [x] добавлен VLESS client-editor flow в GUI: auto-load по выбранному engine/profile, `Save draft` в Go API (`raw_config`), auto-sync draft перед `Preview/Validate/Apply` +- Step E5.4.5: [x] `Connection profiles` переведены в dashboard-плитки с context-menu `Run/Edit/Delete`; активный профиль подсвечивается зелёным, protocol editor вынесен в отдельный modal edit-dialog +- Step E5.4.6: [x] улучшен editor UX: non-destructive переключение `security/transport` (без reset полей), `Flow` сделан editable (preset + custom), добавлен `Create connection` (`Clipboard|Link|Manual`) +- Step E5.4.7: [x] унифицирован link-import pipeline: общий dispatcher + shared raw-config helpers для `vless/trojan/ss/hysteria2(hy2)/tuic`, без привязки к одному протоколу +- Step E5.4.8: [x] расширен form-editor на `trojan/shadowsocks/hysteria2/tuic` (единая форма + protocol switch + guardrails + raw save по выбранному protocol) +- Step E5.4.9: [x] добавлен `wireguard` в единый editor/import pipeline (поля WG + raw save + wireguard:// link parser) +- Step E6: [x] реализовать единый Go-сервис `egress identity` (IP + geo/country) для всех движков (`AdGuardVPN`, `transport:*`) +- Step E6.1: [x] зафиксировать backend-контракт `/api/v1/egress/identity` + `/api/v1/egress/identity/refresh` (scope-aware: `adguardvpn|transport:|system`) +- Step E6.2: [x] реализовать provider-адаптеры в Go (общий интерфейс источника egress IP; netns-aware для transport-клиентов) +- Step E6.3: [x] добавить общий SWR/cache/backoff для egress identity (без блокировки UI, с `stale/updated_at/last_error`) +- Step E6.4: [x] добавить GeoIP-слой (country_code/country_name) и нормализованный ответ для GUI/Web/Mobile +- Step E6.5: [x] интегрировать в desktop-карточки `AdGuardVPN` и `SingBox` (показывать `IP + country`, флаг рендерится в UI из `country_code`) +- Step E6.6: [x] добавить unified runtime observability API для multi-interface (`active_iface`, `egress`, `latency`, `last_error`, counters per engine/policy) +- Step E6.6.1: [x] добавить backend DTO snapshot для карточек multi-interface (`client_id`, `iface_id`, `active_iface`, `egress`, `latency`, `last_error`, counters) + - новый backend aggregator собирает per-`iface_id` runtime snapshot из `transport interfaces + clients + policy compile-plan + egress identity`; + - DTO включает binding (`runtime_iface/active_iface/netns/routing_table`), active client, aggregate `status/latency/last_error`, status counters и `engine_counts/rule_count` без ручной склейки на UI. +- Step E6.6.2: [x] endpoint `GET /api/v1/transport/runtime/observability` + SSE `transport_runtime_snapshot_changed` + - handler и SSE publisher переиспользуют один и тот же backend snapshot builder без отдельной event-specific логики; + - `transport_runtime_snapshot_changed` публикуется после create/patch/delete, lifecycle, health refresh, policy apply/rollback, netns toggle и transport egress updates; + - payload события содержит unified snapshot (`items`) и метаданные `reason/client_ids/iface_ids/generated_at`, так что UI может либо re-fetch endpoint, либо обновиться напрямую тем же DTO. +- Step E6.7: [~] (обязательный post-transport этап) подключить `AdGuardVPN` через adapter к unified engine control-plane (без ломки autoloop/login/location flow), чтобы multi-interface observability/control был единым для всех движков +- Step E6.7.1: [x] backend adapter-путь `adguardvpn` подключён к unified transport control-plane: + - `GET /api/v1/transport/clients?include_virtual=true` возвращает virtual client `adguardvpn` в том же DTO-контракте, что и transport clients; + - `GET /api/v1/transport/clients/adguardvpn` + `health|metrics|start|stop|restart|provision` работают через virtual adapter без записи в `transport-clients.json`; + - legacy `POST /api/v1/vpn/autoconnect` переведён на тот же adapter execution path, чтобы исключить рассинхрон между old VPN flow и unified control-plane; + - runtime observability (`/transport/runtime/observability`) теперь включает virtual snapshot `adguardvpn` и использует scope-aware egress lookup (`adguardvpn` вместо `transport:adguardvpn`). +- Step E6.7.2: [x] GUI policy-editor переведён на unified policy targets из backend (`include_virtual=true`) без локального adguard-костыля: + - добавлен отдельный UI-cache `transport_policy_clients` (source of truth для `policy client selector` и label-резолва); + - client selector и draft/applied таблицы теперь используют общий backend-список targets (`transport + virtual`) и не подмешивают `adguardvpn` вручную; + - refresh policy locks делает явный prefetch policy-targets, а при ошибке использует безопасный fallback на текущий transport-list, без остановки UI. +- Step E6.7.3: [x] добавлена визуальная индикация типа policy target в MultiIF (`[transport]`/`[virtual]`): + - policy client selector, draft/applied intents и ownership/locks таблицы показывают тип target в label; + - interfaces таблица маркирует строки по типу target и отдельно помечает virtual-mode (`... | VIRTUAL`); + - summary дополнен счётчиками targets (`transport=N`, `virtual=M`) и hint с legend по меткам. +- Step E7: [ ] (deferred) разделить policy-слои `System selective PBR` и `SingBox L7 routing` (реализация позже, после текущего контура) +- Step E7.1: [ ] зафиксировать единый L7 rule-contract для SingBox (`domain/suffix/keyword/regex`, `ip_cidr`, `port`, `network`, `protocol`, `process/user/package`, `rule_set`, `priority`, `action`) +- Step E7.2: [ ] реализовать Go renderer `policy -> singbox.json` (`dns`, `route.rules`, `rule_set`, `outbounds`, `final`) без ручного JSON в UI +- Step E7.3: [ ] внедрить pipeline `validate -> dry-run -> apply -> rollback` для L7 policy (`sing-box check` обязателен) +- Step E7.4: [ ] реализовать DNS-стратегию для L7 (`SingBox DNS policy` + `bootstrap bypass`) без дублирования intent в системном resolver-контуре +- Step E7.5: [ ] добавить conflict guardrails между PBR и L7 (single-owner intent: правило живет только в одном policy-слое) +- Step E7.6: [ ] добавить наблюдаемость L7 (`effective policy`, `active outbound`, `last match reason`, counters/hits per rule, egress per-engine) +- Step F1: [~] выполнить поэтапный рефакторинг и модульность без изменения поведения +- Step F1.1: [x] зафиксирован план декомпозиции крупных файлов (`docs/phase-f/F1_REFACTOR_MODULARITY_PLAN.md`) +- Step F1.2: [x] netns-логика вынесена в отдельные модули GUI/API (`netns_debug.py`, `transport_netns_exec.go`) + оформлен runtime-case документ +- Step F1.2.1: [x] orchestration netns-toggle перенесён в Go API endpoint `/api/v1/transport/netns/toggle`, GUI переключатель переведён на единый backend вызов +- Step F1.2.2: [x] добавлен общий backend `refresh coordinator` (SWR + single-flight + backoff), `vpn locations` переведены на общую схему без изменения API-контракта +- Step F1.2.3: [x] та же SWR-схема применена для `transport health` (`/transport/clients` background refresh + `POST /transport/health/refresh` + SSE `transport_client_health_changed`) +- Step F1.6: [x] перевести точки входа Go API на `cmd/*` (отдельные бинари `api`, `routes-update`, `routes-clear`, `autoloop`) с сохранением legacy entrypoint +- Step F1.7: [x] разрезать `selective-vpn-api/app/server.go` на `entrypoints/api_bootstrap/api_routes` без изменения API-контракта и CLI-поведения +- Step F1.8: [x] вынести доменные route-helper'ы в отдельные `app/api_routes_*.go` файлы (без изменения endpoint-path/handler-контракта) +- Step F1.9: [x] вынести CLI/bootstrap раннеры в подпакеты `app/cli` и `app/bootstrap` с сохранением фасадов `Run*` в `app` +- Step F1.10: [x] разложить `app/transport_handlers.go` на модульные файлы (`handlers_clients`, `handlers_policy`, `policy_validate`, `client_runtime`, `tokens_state`, `shared`) без изменения endpoint-path +- Step F1.11: [~] вынести переиспользуемую transport-логику в отдельные подпакеты (`app/transport/*`) только для частей без циклических зависимостей (через facade+deps) +- Step F1.11.1: [x] вынесен runtime/config helper-блок transport backend в подпакет `app/transportcfg` с сохранением app-facade (`transport_backends_runtime_helpers.go`) +- Step F1.11.2: [x] вынесен systemd helper-блок transport backend в `app/transportcfg` с сохранением app-facade (`transport_backends_systemd_helpers.go`) +- Step F1.11.3: [x] вынесен exec/binary/template helper-блок transport backend в `app/transportcfg` с сохранением app-facade (`transport_backends_exec_helpers.go`) +- Step F1.11.4: [x] вынесен probe/endpoint helper-блок transport backend в `app/transportcfg` с сохранением app-facade (`transport_backends_probe_helpers.go`) +- Step F1.11.5: [x] вынесен in-memory SSE event-bus в подпакет `app/eventsbus` с сохранением app-facade (`events_bus.go`) +- Step F1.11.6: [x] вынесен API route-registry в подпакет `app/apiroutes` (удалены `api_routes_{core,dns,trace,traffic,transport,vpn}.go`, сохранён единый `registerAPIRoutes` facade) +- Step F1.11.7: [x] объединены misc transport adapters в `transport_backends.go` (удалён `transport_backends_adapters_misc.go`) +- Step F1.11.8: [x] вынесены низкоуровневые command helpers в `app/syscmd` (`RunCommand`, `RunCommandTimeout`, `CheckPolicyRoute`) с сохранением фасада `app/shell.go` +- Step F1.11.9: [x] вынесены общие HTTP helper'ы в `app/httpx` (`LogRequests`, `WriteJSON`, `HandleHealthz`) с сохранением фасада `app/http_helpers.go` +- Step F1.11.10: [x] вынесен SSE stream-loop в `app/eventstream` (`ParseSinceID`, `Serve`) с сохранением фасада `app/events_handlers.go` +- Step F1.11.11: [x] вынесен SWR/backoff coordinator в `app/refreshcoord` с сохранением фасада `app/refresh_coordinator.go` и без изменения поведения egress/vpn-locations/transport-health контуров +- Step F1.11.12: [x] вынесен NFT update engine в `app/nftupdate` (atomic + chunked fallback) с сохранением фасада `app/nft_update.go` +- Step F1.11.13: [x] вынесен traffic candidates collector в `app/trafficcandidates` с сохранением API-контракта endpoint `GET /api/v1/traffic/candidates` +- Step F1.11.14: [x] устранена «россыпь bridge-файлов» resolver-контура: `resolver_*_bridge.go` схлопнуты в единый `app/resolver_bridge.go` (без изменения API/логики) +- Step F1.11.15: [x] вынесен traffic app profiles store в `app/trafficprofiles` с сохранением API-контракта `GET/POST/DELETE /api/v1/traffic/app-profiles` +- Step F1.11.16: [x] вынесена canonical `app_key` нормализация в `app/trafficprofiles` и удалён root-файл `traffic_appkey.go` (совместимость через app-facade функции сохранена) +- Step F1.11.17: [x] вынесены state/dedupe/persistence helper'ы `traffic app marks` в подпакет `app/trafficappmarks` с сохранением runtime/API-контракта +- Step F1.11.18: [x] вынесены cgroup path/inode helper'ы `traffic app marks` в `app/trafficappmarks` с сохранением фасадов в `app/traffic_appmarks.go` +- Step F1.11.19: [x] вынесены NFT helper'ы `traffic app marks` в `app/trafficappmarks/nft.go` (insert/delete/has/local-bypass/handle parse/ipv4-compact) с сохранением фасадов в `app/traffic_appmarks.go` +- Step F1.11.20: [x] вынесены cleanup helper'ы `traffic app marks` в `app/trafficappmarks/nft.go` (`CleanupLegacyRules`, `ClearManagedRules`) с сохранением фасадов в `app/traffic_appmarks.go` +- Step F1.11.21: [x] вынесена TTL prune-логика `traffic app marks` в `app/trafficappmarks/store.go` (`PruneExpired`) с сохранением удаления runtime nft-правил через app-facade callback +- Step F1.11.22: [x] вынесен базовый nft ensure bootstrap `traffic app marks` в `app/trafficappmarks/nft.go` (`EnsureBase`) с сохранением фасада `ensureAppMarksNft` в `app/traffic_appmarks.go` +- Step F1.11.23: [x] вынесены общие `singbox profile flow` helper'ы в `app/transportcfg/singbox_helpers.go` (`typed protocol support`, `parse port`, `digest/diff config`, `json config file io`, `optional file restore`, `history stamp`, `issue message join`) с сохранением app-facade вызовов в `transport_singbox_profiles_flow.go` +- Step F1.11.24: [x] вынесены history helper'ы `singbox profile flow` в `app/transportcfg/history_helpers.go` (`WriteFileAtomic`, `ReadJSONFiles`, `SelectRecordCandidate`, `DecodeBase64Optional`) и переведены вызовы `append/load/select/decode` в `transport_singbox_profiles_flow.go` +- Step F1.11.25: [x] удалены локальные helper'ы `findSingBoxBinary`/`sanitizeHistoryStamp` в `transport_singbox_profiles_flow.go`; вызовы переведены на `transportcfg.FirstExistingBinaryCandidate` и `transportcfg.SanitizeHistoryStamp` +- Step F1.11.26: [x] вынесены secrets-map helper'ы в `app/transportcfg/secrets_helpers.go` (`NormalizeSecretUpdates`, `Clone/Equal/MaskStringMap`, `Read/WriteStringMapJSON`), `transport_singbox_profiles.go` переведён на пакетные вызовы +- Step F1.11.27: [x] очищен `transport_singbox_profiles_flow.go` от остаточных локальных wrapper'ов (config io/file optional/diff/normalize), вызовы переведены на `transportcfg` напрямую +- Step F1.11.28: [x] вынесен state/normalization-блок `singbox profiles` в отдельный файл `app/transport_singbox_profiles_state.go` (`load/save/normalize/derive/index/active/select + cloneMapDeep`) без изменения API-контрактов +- Step F1.11.29: [x] вынесены secrets/error helper'ы `singbox profiles` в `app/transport_singbox_profiles_secrets.go` и `app/transport_singbox_profiles_errors.go`; `transport_singbox_profiles.go` оставлен как handlers/mutate фасад +- Step F1.11.30: [x] вынесен mutate-блок `singbox profiles` (`create/patch`) в `app/transport_singbox_profiles_mutate.go`; `transport_singbox_profiles.go` сфокусирован на HTTP handlers/route dispatch +- Step F1.11.31: [x] вынесены runtime helper'ы `singbox profile flow` в `app/transport_singbox_profiles_runtime.go` (`find profile by client`, `prepare profile`, `resolve apply client`, `apply runtime`) +- Step F1.11.32: [x] вынесены history helper'ы `singbox profile flow` в `app/transport_singbox_profiles_history.go` (`append/load/select/decode history`, `join issue messages`) +- Step F1.11.33: [x] вынесен eval/render helper-блок `singbox profile flow` в `app/transport_singbox_profiles_eval.go` (`evaluate`, `binary validate`, `rendered path/config writer`, issue mapping) +- Step F1.11.34: [x] вынесен state/normalization-блок `transport client runtime` в `app/transport_client_runtime_state.go` (`create builder`, `kind/capabilities normalize`, `runtime snapshot normalize`, `uptime/index/clone helpers`) без изменения API-контрактов +- Step F1.11.35: [x] вынесен `transport client runtime` health/netns-блок в отдельные файлы `app/transport_client_runtime_health.go` и `app/transport_client_runtime_netns.go` (response snapshot + netns peer-stop guard) без изменения lifecycle/API-контрактов +- Step F1.11.36: [x] выполнен разнос transport client handlers: netns toggle вынесен в `app/transport_handlers_netns.go`, lifecycle action-dispatch вынесен в `app/transport_handlers_actions.go`, `transport_handlers_clients.go` оставлен list/card routing-фасадом +- Step F1.11.37: [x] вынесен helper-блок `routes update` в `app/routes_update_helpers.go` (table/list/io/counter/fs/readiness helpers) без изменения orchestration-пайплайна `routesUpdate` +- Step F1.11.38: [x] вынесен state/storage/normalize блок `dns settings` в `app/dns_settings_state.go` (`upstream pool`, `mode state`, `smartdns helpers`, `dnscfg adapters`) без изменения API handlers +- Step F1.11.39: [x] начата декомпозиция `types.go`: DNS/SmartDNS DTO вынесены в `app/types_dns.go` (без изменения JSON-контрактов/имён типов) +- Step F1.11.40: [x] продолжена декомпозиция `types.go`: вынесены доменные DTO в `app/types_traffic.go`, `app/types_egress.go`, `app/types_transport.go`, `app/types_singbox.go`; `types.go` оставлен для shared/system/event/resolver моделей +- Step F1.11.41: [x] выполнен разнос `transport policy handlers`: `validate/apply/rollback` вынесены в `app/transport_handlers_policy_mutations.go`; `transport_handlers_policy.go` оставлен для `list/conflicts/capabilities` +- Step F1.11.42: [x] вынесен endpoint `POST /transport/health/refresh` в `app/transport_handlers_health_refresh.go`; `transport_health_refresh.go` оставлен как runtime S-W-R/probe logic +- Step F1.11.43: [x] вынесен low-level probe/helper блок `egress identity` в `app/egress_identity_probe.go` (`probe via system/iface/netns/proxy`, endpoint helpers, singbox socks parse, iface bind address) без изменения API/SWR-контракта +- Step F1.11.44: [x] выполнен разнос `routes handlers` service/timer блока в `app/routes_handlers_service.go`; `routes_handlers.go` оставлен для status/clear/fix/update endpoints +- Step F1.11.45: [x] выполнен разнос `routes handlers` clear/precheck/op-lock блока в `app/routes_handlers_ops.go`; `routes_handlers.go` оставлен для status/policy-fix/update endpoints +- Step F1.11.46: [x] вынесены `egress identity` HTTP handlers в `app/egress_identity_handlers.go`; `egress_identity.go` оставлен для service/SWR/state orchestration +- Step F1.11.47: [x] выполнен разнос монолита `app/traffic_appmarks.go` на `traffic_appmarks_handlers.go`, `traffic_appmarks_ops.go`, `traffic_appmarks_runtime.go`; `traffic_appmarks.go` оставлен для core-констант/типов (без изменения API/runtime поведения) +- Step F1.11.48: [x] вынесен helper-блок `routes cache` в `app/routes_cache_helpers.go` (table lines/io/restore-route/nft-snapshot/meta/file helpers); `routes_cache.go` оставлен orchestration-фасадом restore/save +- Step F1.11.49: [x] выполнен разнос `egress identity` runtime-контура: provider-probe вынесен в `app/egress_identity_providers.go`, refresh/snapshot orchestration в `app/egress_identity_refresh.go`, geo cache/lookup в `app/egress_identity_geo.go`, scope/identity helpers в `app/egress_identity_scope.go`; `app/egress_identity.go` оставлен с константами/типами/инициализацией +- Step F1.11.50: [x] выполнен разнос `vpn handlers` монолита на `app/vpn_handlers_auth.go` (login/logout/systemd), `app/vpn_handlers_status.go` (autoloop/status parse), `app/vpn_handlers_locations.go` (autoconnect/locations/set-location resolver); `app/vpn_handlers.go` оставлен как модульный фасад-комментарий +- Step F1.11.51: [x] вынесены helper'ы `autoloop` в `app/autoloop_helpers.go` (`login-state write/parse`, `connected check`, `location resolve`, `ISO/cache helpers`), `app/autoloop.go` оставлен как loop orchestration без изменения runtime-поведения +- Step F1.11.52: [x] выполнен разнос `transport systemd backend` на роль-файлы: `transport_backends_adapter_systemd_action.go`, `..._health.go`, `..._provision.go`, `..._cleanup.go`; базовый `transport_backends_adapter_systemd.go` оставлен для shared type aliases/render helpers +- Step F1.11.53: [x] выполнен stage-разнос `routes_update.go`: preflight вынесен в `routes_update_stage_preflight.go`, policy routing в `routes_update_stage_policy.go`, nft setup/progress update в `routes_update_stage_nft.go`, domains+resolver merge в `routes_update_stage_resolve.go`, artifacts/status publish в `routes_update_stage_artifacts.go`; `routes_update.go` оставлен orchestration-фасадом +- Step F1.11.54: [x] выполнен разнос `vpn_login_session.go`: HTTP endpoints и `loginStateAlreadyLogged` вынесены в `vpn_login_session_handlers.go`, базовый `vpn_login_session.go` оставлен для manager/PTY state-machine (без изменения API login session `/api/v1/vpn/login/session/*`) +- Step F1.11.55: [x] выполнен разнос `vpn_locations_cache.go`: refresh/SWR orchestration вынесен в `vpn_locations_cache_refresh.go`, parser/location-target блок в `vpn_locations_cache_parse.go`, cache io/normalize/store в `vpn_locations_cache_store.go`; базовый файл оставлен с типами/константами/инициализацией +- Step F1.11.56: [x] выполнен разнос `transport_health_refresh.go`: SWR/state mutex блок вынесен в `transport_health_refresh_state.go`, queue/candidate scheduling в `transport_health_refresh_queue.go`, probe runtime в `transport_health_refresh_probe.go`, persist/change-bucket helpers в `transport_health_refresh_compare.go`; базовый файл оставлен с константами/типами/конструктором +- Step F1.11.57: [x] выполнен разнос `transport_client_runtime_alloc.go`: normalize/reconcile блок вынесен в `transport_client_runtime_alloc_normalize.go`, table/mark/pref slot helper'ы в `transport_client_runtime_alloc_slots.go`; базовый файл оставлен для `allocateTransportSlots` фасада +- Step F1.11.58: [x] выполнен разнос `transport_netns.go`: spec/name/prefix/hash/existence вынесены в `transport_netns_spec.go`, nft/policy-route helpers в `transport_netns_rules.go`, command soft/must wrappers в `transport_netns_run.go`; базовый файл оставлен для `ensure/cleanup` orchestration +- Step F1.11.59: [x] выполнен разнос `transport_bootstrap_bypass.go`: candidate/source extractors вынесены в `transport_bootstrap_bypass_candidates.go`, resolve/target/main-route в `transport_bootstrap_bypass_resolve.go`, route mutation в `transport_bootstrap_bypass_routes.go`, state io/normalize в `transport_bootstrap_bypass_state.go`; базовый файл оставлен для control-flow и shared error helpers +- Step F1.11.60: [x] выполнен разнос `transport_singbox_profiles_flow.go`: HTTP handlers вынесены в role-файлы `transport_singbox_profiles_flow_{validate,render,apply,rollback,history}.go`; базовый `transport_singbox_profiles_flow.go` оставлен для shared моделей/dispatcher +- Step F1.11.61: [x] выполнен разнос `transport_singbox_profiles.go`: list handlers вынесены в `transport_singbox_profiles_list.go`, by-id/card handlers в `transport_singbox_profiles_card.go`, features endpoint в `transport_singbox_profiles_features.go`; базовый файл оставлен для state/constants/shared mutex +- Step F1.11.62: [x] выполнен разнос benchmark-контура `dns_settings.go`: heavy benchmark handlers/helper'ы вынесены в `dns_settings_benchmark.go`; `dns_settings.go` оставлен для DNS mode/upstreams/smartdns control handlers +- Step F1.11.63: [x] выполнен разнос `resolver_bridge.go`: базовые alias/type/const вынесены в `resolver_bridge.go`, util wrappers в `resolver_bridge_utils.go`, domain-cache adapters в `resolver_bridge_cache.go`, dns-mode/lookup adapters в `resolver_bridge_dns.go`, planning/pipeline adapters в `resolver_bridge_pipeline.go` +- Step F1.11.64: [x] выполнен разнос `transport_backends.go`: probe adapters (`dial/probe/endpoints/deps`) вынесены в `transport_backends_probe.go`, runtime-mode backend structs (`unsupported/mock`) в `transport_backends_runtime_modes.go`; базовый файл оставлен для backend selection/config facade +- Step F1.11.65: [x] выполнен разнос `vpn_login_session.go`: session state/mutex methods вынесены в `vpn_login_session_state.go`, PTY lifecycle/parser в `vpn_login_session_pty.go`, базовый файл оставлен для DTO/models/manager struct; HTTP handlers остаются в `vpn_login_session_handlers.go` +- Step F1.11.66: [x] выполнен разнос `types_transport.go`: модели разделены на `types_transport_core.go` (client/runtime/capabilities), `types_transport_policy.go` (policy/conflicts), `types_transport_runtime.go` (health/lifecycle/refresh/netns responses); поведение API/JSON-контракты не изменены +- Step F1.11.67: [x] выполнен разнос `transport_singbox_dns_migration.go`: rule helper'ы вынесены в `transport_singbox_dns_migration_rules.go`, convert/parse блок в `transport_singbox_dns_migration_convert.go`, общий helper `asString/parsePort` вынесен в `helpers_values.go`; миграционный runtime/API-поток сохранён без изменений +- Step F1.11.68: [x] выполнен разнос `vpn_handlers_locations.go`: selection/validation helper'ы вынесены в `vpn_locations_selection.go`, основной handler-файл оставлен для `autoconnect/list/set-location` endpoint-потока и egress refresh trigger +- Step F1.11.69: [x] выполнен разнос `egress_identity_refresh.go`: runtime refresh/publish блок вынесен в `egress_identity_refresh_runner.go`, entry snapshot/state/sem helper'ы в `egress_identity_refresh_state.go`; базовый файл оставлен для queue/scope orchestration +- Step F1.11.70: [x] выполнен разнос `dns_settings_state.go`: upstream/pool persistence вынесены в `dns_settings_state_upstreams.go`, mode load/save в `dns_settings_state_mode.go`, smartdns env/systemd/normalize helper'ы в `dns_settings_state_smartdns.go`; DNS API-контракты и runtime-поведение сохранены +- Step F1.11.71: [x] выполнен разнос `traffic_audit.go`: проверки/парсинг nft-правил вынесены в `traffic_audit_checks.go`, pretty-render блока в `traffic_audit_render.go`, базовый файл оставлен как HTTP handler-orchestration +- Step F1.11.72: [x] выполнен разнос `routes_update_helpers.go`: list/io helper'ы вынесены в `routes_update_helpers_lists.go`, smartdns wildcard trace в `routes_update_helpers_trace.go`, fs/readiness helper'ы в `routes_update_helpers_fs.go`; базовый файл оставлен для table identifiers (`routesTableName/routesTableNum`) +- Step F1.11.73: [x] выполнена декомпозиция `types_traffic.go`: traffic mode/candidates DTO вынесены в `types_traffic_mode.go`, app-marks/app-profiles DTO вынесены в `types_traffic_apps.go`; JSON-контракты сохранены без изменений +- Step F1.11.74: [x] выполнен разнос `transport_handlers_actions.go`: action-routing оставлен в `transport_handlers_actions.go`, heavy action execution блоки (`health/metrics/provision/start-stop-restart`) вынесены в `transport_handlers_actions_exec.go` без изменения transport lifecycle/API поведения +- Step F1.11.75: [x] выполнен разнос `transport_handlers_policy_mutations.go`: thin wrappers оставлены в базовом файле, validate/apply/rollback execution вынесены в `transport_handlers_policy_mutations_{validate,apply,rollback}.go` без изменения policy-контрактов (`confirm token`, `snapshot/rollback`, `conflicts`) +- Step F1.11.76: [x] выполнен разнос `resolver_pipeline.go`: `buildResolverJobContext` вынесен в `resolver_pipeline_context.go`, базовый `resolver_pipeline.go` оставлен для execution pipeline (`planning -> batch -> artifacts -> summary -> precheck finalize`) +- Step F1.11.77: [x] выполнен разнос `resolver_dns_policy.go`: DNS attempt-policy helper'ы вынесены в `resolver_dns_attempt_policy.go`, cooldown runtime в `resolver_dns_cooldown.go`, precheck force flags в `resolver_precheck_flags.go`; базовый файл оставлен с типами policy/cooldown state +- Step F1.11.78: [x] выполнен разнос `trace_handlers.go`: read endpoints вынесены в `trace_handlers_read.go`, append/write helper'ы в `trace_handlers_write.go`, bounded tail reader в `trace_tail.go`; базовый `trace_handlers.go` оставлен как module doc/header +- Step F1.11.79: [x] выполнен разнос `transport_policy_validate.go`: normalization/CIDR helper'ы вынесены в `transport_policy_validate_normalize.go`, diff+conflict-dedupe блок в `transport_policy_validate_diff.go`; базовый файл оставлен для основной валидации ownership/overlap +- Step F1.11.80: [x] выполнен разнос `watchers.go`: bootstrap/start блок вынесен в `watchers_start.go`, file/hash watchers в `watchers_state_files.go`, runtime/systemd watchers в `watchers_runtime.go`; базовый `watchers.go` оставлен как module doc/header +- Step F1.11.81: [x] выполнен разнос `domains_handlers.go`: table endpoint вынесен в `domains_handlers_table.go`, file CRUD в `domains_handlers_file.go`, smartdns wildcard endpoints в `domains_handlers_smartdns.go`, observed-hosts helper в `domains_handlers_helpers.go`; базовый `domains_handlers.go` оставлен для shared mapping `domainFiles` +- Step F1.11.82: [x] выполнен разнос `egress_identity_probe.go`: external probe блок вынесен в `egress_identity_probe_external.go`, netns/interface/proxy probe блок в `egress_identity_probe_netns.go`, endpoint/socks/bind-address helper'ы в `egress_identity_probe_helpers.go`; базовый файл оставлен как модульный entrypoint +- Step F1.11.83: [x] выполнен разнос `traffic_appmarks_handlers.go`: POST mutation flow вынесен в `traffic_appmarks_handlers_post.go`, items list endpoint вынесен в `traffic_appmarks_handlers_items.go`, базовый handler оставлен для method routing + status response +- Step F1.11.84: [x] выполнен разнос `transport_handlers_netns.go`: per-client apply/restart/provision + response finalize вынесены в `transport_handlers_netns_apply.go`, базовый файл оставлен для HTTP endpoint + target resolution/orchestration +- Step F1.11.85: [x] выполнен разнос `transport_handlers_clients.go`: list/create endpoints вынесены в `transport_handlers_clients_list_create.go`, card get/patch/delete endpoints вынесены в `transport_handlers_clients_card_ops.go`, базовый handler оставлен для route-dispatch (`/transport/clients`, `/transport/clients/{id}`) без изменения API-контрактов +- Step F1.11.86: [x] выполнен разнос `smartdns_runtime.go`: config detect/apply/normalize вынесены в `smartdns_runtime_config.go`, state load/save/infer вынесены в `smartdns_runtime_state.go`, базовый `smartdns_runtime.go` оставлен для shared runtime model/constants + status snapshot +- Step F1.11.87: [x] выполнен разнос `traffic_mode_handlers.go`: POST apply-flow вынесен в `traffic_mode_handlers_apply.go`, advanced reset endpoint вынесен в `traffic_mode_handlers_advanced.go`, базовый `traffic_mode_handlers.go` оставлен для GET/interface/test endpoint-роутинга и lock helper'а +- Step F1.11.88: [x] выполнен разнос `routes_handlers.go`: status endpoint вынесен в `routes_handlers_status.go`, policy-fix endpoint вынесен в `routes_handlers_policy_fix.go`, async update endpoint вынесен в `routes_handlers_update.go`; базовый `routes_handlers.go` оставлен для service command wrapper `makeCmdHandler` +- Step F1.11.89: [x] выполнен разнос `dns_smartdns_handlers.go`: runtime endpoint (`GET/POST /dns/smartdns/runtime`) вынесен в `dns_smartdns_handlers_runtime.go`, prewarm endpoint + helper'ы вынесены в `dns_smartdns_handlers_prewarm.go`, базовый файл оставлен как module header +- Step F1.11.90: [x] выполнен разнос `traffic_appmarks_runtime.go`: state/prune helper'ы вынесены в `traffic_appmarks_runtime_state.go`, restore sequence вынесен в `traffic_appmarks_runtime_restore.go`, базовый runtime-файл оставлен для NFT adapters/config helper'ов +- Step F1.11.91: [x] выполнен разнос `dns_settings.go`: upstream endpoints вынесены в `dns_settings_handlers_upstreams.go`, mode/status handlers вынесены в `dns_settings_handlers_mode.go`, smartdns service handlers вынесены в `dns_settings_handlers_smartdns_service.go`; базовый файл оставлен как module header +- Step F1.11.92: [x] выполнен разнос `autoloop_helpers.go`: login-state/parser helper'ы вынесены в `autoloop_helpers_login.go`, location spec/ISO resolve helper'ы вынесены в `autoloop_helpers_location.go`, базовый файл оставлен для shared `autoloopLocationSpec` и email regexp +- Step F1.11.93: [x] выполнен разнос `traffic_app_profiles.go`: store/conversion/sanitize helper'ы вынесены в `traffic_app_profiles_store.go`, базовый файл оставлен для HTTP handler `/traffic/app-profiles` и package-level store init +- Step F1.11.94: [x] выполнен разнос `routes_handlers_ops.go`: core lock/clear/status snapshot логика вынесена в `routes_handlers_ops_core.go`, базовый `routes_handlers_ops.go` оставлен для rollback/cache-restore/precheck-debug endpoint-слоя +- Step F1.11.95: [x] выполнен разнос `transport_singbox_profiles_state.go`: state load/save вынесены в `transport_singbox_profiles_state_store.go`, profile/state normalize + active-id/derive-id helper'ы вынесены в `transport_singbox_profiles_state_normalize.go`, базовый файл оставлен для shared primitive helper'ов (`mode/schema/int64/cloneMap`) +- Step F1.11.96: [x] выполнен разнос `transport_singbox_profiles_runtime.go`: client/profile selection helper'ы вынесены в `transport_singbox_profiles_runtime_select.go`, preflight render/write flow вынесен в `transport_singbox_profiles_runtime_prepare.go`, backend provision/start/restart runtime flow вынесен в `transport_singbox_profiles_runtime_apply.go`; базовый файл оставлен как module header +- Step F1.11.97: [x] выполнен разнос `transport_handlers_actions_exec.go`: health/metrics handlers вынесены в `transport_handlers_actions_exec_health.go`, provision handler вынесен в `transport_handlers_actions_exec_provision.go`, lifecycle handler (`start/stop/restart`) вынесен в `transport_handlers_actions_exec_lifecycle.go`; базовый файл оставлен как module header +- Step F1.11.98: [x] выполнен разнос `routes_update_stage_resolve.go`: доменное расширение/каппинг/wildcard trace вынесены в `routes_update_stage_resolve_domains.go`, bootstrap временных файлов вынесен в `routes_update_stage_resolve_tempfiles.go`; базовый stage-файл оставлен для resolver orchestration + runtime wildcard merge + artifact writes +- Step F1.11.99: [x] выполнен разнос `transport_backends.go`: transportcfg wrapper/helper слой вынесен в `transport_backends_config_helpers.go`, unsupported runtime backend methods вынесены в `transport_backends_runtime_unsupported.go`, базовый файл оставлен для core backend types/constants + backend selection +- Step F1.11.100: [x] выполнен разнос `transport_singbox_dns_migration.go`: migration entry/settings/path helper'ы оставлены в базовом файле, apply flow вынесен в `transport_singbox_dns_migration_apply.go`, DNS map conversion вынесен в `transport_singbox_dns_migration_map.go` +- Step F1.11.101: [x] выполнен разнос `traffic_appmarks_ops.go`: status summary helper оставлен в базовом файле, mutation-операции (`add/delete/clear`) вынесены в `traffic_appmarks_ops_mutations.go` +- Step F1.11.102: [x] выполнен разнос `routes_cache_helpers.go`: file io/helper'ы вынесены в `routes_cache_helpers_files.go`, route parse/restore в `routes_cache_helpers_routes.go`, nft snapshot/parser в `routes_cache_helpers_nft.go`, meta helpers в `routes_cache_helpers_meta.go`; базовый файл оставлен как module header +- Step F1.11.103: [x] выполнен разнос `transport_client_runtime_alloc_normalize.go`: reconcile marks/prefs allocation блок вынесен в `transport_client_runtime_alloc_reconcile.go`, базовый normalize-файл оставлен для state canonicalization и deterministic client merge +- Step F1.11.104: [x] выполнен разнос `dns_settings_benchmark.go`: HTTP benchmark endpoint вынесен в `dns_settings_benchmark_handler.go`, базовый файл оставлен для benchmark normalize/run/topN helper'ов и package aliases +- Step F1.11.105: [x] выполнен разнос `transport_singbox_profiles_card.go`: route dispatch + card method switch оставлены в базовом файле, `GET` вынесен в `transport_singbox_profiles_card_get.go`, `PATCH` в `transport_singbox_profiles_card_patch.go`, `DELETE` в `transport_singbox_profiles_card_delete.go` +- Step F1.11.106: [x] выполнен разнос `types_singbox.go`: профильные DTO вынесены в `types_singbox_profiles.go`, validate/render/apply/rollback DTO вынесены в `types_singbox_flow.go`, history DTO вынесены в `types_singbox_history.go`; базовый файл оставлен как module header +- Step F1.11.107: [x] выполнен разнос `routes_cache.go`: save snapshot flow вынесен в `routes_cache_save.go`, restore flow вынесен в `routes_cache_restore.go`, базовый файл оставлен для shared cache meta type +- Step F1.11.108: [x] выполнен разнос `transport_tokens_state.go`: clients state load/save вынесены в `transport_tokens_state_clients.go`, policy state/snapshot load/save вынесены в `transport_tokens_state_policy.go`, conflicts state load/save вынесены в `transport_tokens_state_conflicts.go`; базовый файл оставлен для token/digest helper'ов +- Step F1.11.109: [x] выполнен разнос `routes_handlers_service.go`: routes service action handler/helper'ы вынесены в `routes_handlers_service_action.go`, timer handlers/helper'ы вынесены в `routes_handlers_service_timer.go`, базовый файл оставлен как module header +- Step F1.11.110: [x] выполнен разнос `vpn_login_session_handlers.go`: login state helper вынесен в `vpn_login_session_handlers_state_helper.go`, start/state/action/stop endpoints вынесены в `vpn_login_session_handlers_{start,state,action,stop}.go`, базовый файл оставлен как module header +- Step F1.11.111: [x] выполнен разнос `resolver_bridge_pipeline.go`: planning/runtime tuning wrappers вынесены в `resolver_bridge_pipeline_planning.go`, execution/recheck/artifacts wrappers вынесены в `resolver_bridge_pipeline_exec.go`, базовый файл оставлен как module header +- Step F1.11.112: [x] выполнен разнос `transport_singbox_profiles_flow_rollback.go`: HTTP wrapper + decode body вынесены в базовый файл, rollback execution flow вынесен в `transport_singbox_profiles_flow_rollback_exec.go` без изменения кодов/контрактов endpoint `POST /api/v1/transport/singbox/profiles/{id}/rollback` +- Step F1.11.113: [x] выполнен stage-разнос `transport_singbox_profiles_flow_apply_exec.go`: preflight/validate/render и dispatch оставлены в базовом execution-файле, target-config/runtime/commit вынесены в `transport_singbox_profiles_flow_apply_exec_rendered.go` без изменения API-контрактов `POST /api/v1/transport/singbox/profiles/{id}/apply` +- Step F1.11.114: [x] выполнен stage-разнос `transport_backends_adapter_systemd_action.go`: lifecycle action orchestration оставлена в базовом файле, pre/post hooks вынесены в `transport_backends_adapter_systemd_action_hooks.go`, unit execution + auto-provision path вынесены в `transport_backends_adapter_systemd_action_exec.go` (без изменения backend/runtime контрактов) +- Step F1.11.115: [x] выполнен stage-разнос `transport_singbox_profiles_flow_rollback_exec.go`: prepare/select/restore шаг оставлен в базовом execution-файле, runtime+commit/history шаг вынесен в `transport_singbox_profiles_flow_rollback_exec_restored.go` без изменения API-контракта rollback endpoint +- Step F1.11.116: [x] выполнен разнос `transport_singbox_profiles_flow_render.go`: HTTP wrapper + body decode оставлены в базовом файле, render execution flow вынесен в `transport_singbox_profiles_flow_render_exec.go` без изменения API-контракта endpoint `POST /api/v1/transport/singbox/profiles/{id}/render` +- Step F1.11.117: [x] выполнен stage-разнос `routes_update_stage_resolve.go`: wildcard runtime merge вынесен в `routes_update_stage_resolve_wildcard_runtime.go`, запись resolver artifacts вынесена в `routes_update_stage_resolve_artifacts.go`, базовый stage-файл оставлен orchestration-слоем (без изменения routes-update поведения) +- Step F1.11.118: [x] выполнен stage-разнос `transport_backends_adapter_systemd_provision.go`: input/preflight build вынесен в `transport_backends_adapter_systemd_provision_inputs.go`, unit write + daemon-reload/enable finalize вынесены в `transport_backends_adapter_systemd_provision_finalize.go`, базовый `Provision()` оставлен orchestration-слоем без изменения runtime-контрактов +- Step F1.11.119: [x] выполнен разнос `transport_client_runtime_state.go`: client create/kind/capabilities вынесены в `transport_client_runtime_create.go`, shared helpers (`find index`, `cloneMap`) вынесены в `transport_client_runtime_helpers.go`, базовый state-файл оставлен для runtime snapshot/normalize/uptime helper'ов +- Step F1.11.120: [x] выполнен разнос `traffic_appmarks_ops_mutations.go`: mutation flow разделён на role-файлы `traffic_appmarks_ops_mutations_add.go`, `traffic_appmarks_ops_mutations_delete.go`, `traffic_appmarks_ops_mutations_clear.go`; базовый файл оставлен как module header без изменения appmarks API/runtime поведения +- Step F1.11.121: [x] выполнен stage-разнос `transport_handlers_actions_exec_lifecycle.go`: HTTP lifecycle handler оставлен в базовом файле, preflight flow вынесен в `transport_handlers_actions_exec_lifecycle_preflight.go`, locked execution/save/response flow вынесен в `transport_handlers_actions_exec_lifecycle_locked.go` без изменения lifecycle API-контракта +- Step F1.11.122: [x] выполнен stage-разнос `dns_settings_state_upstreams.go`: pool store/load/save вынесены в `dns_settings_state_upstreams_pool_store.go`, conf file load/save вынесены в `dns_settings_state_upstreams_conf_store.go`, базовый upstream-state файл оставлен для conversion/normalize/load-enabled и legacy facade методов +- Step F1.11.123: [x] выполнен stage-разнос `traffic_appmarks_handlers_post.go`: request decode/validate вынесен в `traffic_appmarks_handlers_post_parse.go`, op-specific execution вынесен в `traffic_appmarks_handlers_post_ops.go`, базовый POST handler оставлен как thin dispatch без изменения API-контракта +- Step F1.11.124: [x] выполнен stage-разнос `traffic_audit_checks.go`: duplicate-detector helper'ы вынесены в `traffic_audit_duplicates.go`, NFT appmark rule parse helper'ы вынесены в `traffic_audit_nft_parse.go`, базовый checks-файл оставлен для audit-orchestration (`auditNftAppMarks`) без изменения audit API/вывода +- Step F1.11.125: [x] зафиксирован stop-condition декомпозиции Go-ядра: max non-test файл в `selective-vpn-api/app` = `195` строк; дальнейший разнос отложен как low-priority polish +- Step F1.12: [x] выполнить пакетную декомпозицию `selective-vpn-gui/api_client.py` в `selective-vpn-gui/api/*` с сохранением legacy facade `api_client.py` +- Step F1.13: [~] выполнить декомпозицию `selective-vpn-api/app/traffic_mode.go` на подпакеты `app/trafficmode/*` без изменения policy-routing поведения +- Step F1.13.1: [x] вынесены normalize helper'ы (`tokenize/subnet/uid/cgroup`) в `app/trafficmode/normalize.go`, в `app/traffic_mode.go` оставлены фасады +- Step F1.13.2: [x] вынесен parser auto-local-bypass маршрутов в `app/trafficmode/autolocal.go` (`ParseAutoBypassRoutes` + helper'ы), в `app/traffic_mode.go` сохранены совместимые фасады для `traffic_candidates` +- Step F1.13.3: [x] вынесен cgroup->uid resolution блок в `app/trafficmode/cgroup.go` (`CgroupCandidates/ResolveCgroupPath/CollectPIDsFromCgroup/UIDRangeForPID/ResolveCgroupUIDRanges`), в `app/traffic_mode.go` сохранены фасады +- Step F1.13.4: [x] вынесен ingress-reply bypass nft-контур в `app/trafficmode/ingress.go` (`Ensure/Flush/Enable/Disable/Active`) с сохранением фасадов в `app/traffic_mode.go` +- Step F1.13.5: [x] вынесен route-rules/probe блок в `app/trafficmode/rules.go` (`ReadRules/DetectAppliedMode/ProbeMode/PrefStr`) с сохранением фасадов и `trafficRulesState` alias в `app/traffic_mode.go` +- Step F1.13.6: [x] удалены лишние внутренние wrapper-функции `traffic_mode.go` (cgroup/nftObjectMissing), `buildEffectiveOverrides` переведён на прямые вызовы `trafficmodepkg.ResolveCgroupUIDRanges` +- Step F1.13.7: [x] вынесен `ip rule` apply/remove блок в `app/trafficmode/apply.go` (`RemoveRulesForTable`, `ApplyRule`, `ApplyOverrides`) с сохранением фасадов в `app/traffic_mode.go` +- Step F1.13.8: [x] вынесен interface selection/resolve блок в `app/trafficmode/interfaces.go` (`NormalizePreferredIface`, `IfaceExists`, `ListUpIfaces`, `ListSelectableIfaces`, `ResolveTrafficIface`, `IsVPNLikeIface`), `app/traffic_mode.go` переведён на thin-facade +- Step F1.13.9: [x] вынесены HTTP/lock handlers traffic-mode в `app/traffic_mode_handlers.go` (`mode get/post`, `advanced reset`, `interfaces`, `mode test`, apply-lock); `app/traffic_mode.go` сокращён до core policy/apply/evaluate блока +- Step F1.13.10: [x] вынесены state/normalization/persistence helper'ы traffic-mode в `app/traffic_mode_state.go` (`normalize*`, `load/save/infer state`), `app/traffic_mode.go` оставлен для routing apply/evaluate orchestration +- Step F1.13.11: [x] завершена декомпозиция `traffic_mode` core: вынесены `iface/config` helper'ы в `app/traffic_mode_iface.go`, apply-orchestration в `app/traffic_mode_apply.go`, runtime-check/evaluate в `app/traffic_mode_evaluate.go`; `app/traffic_mode.go` оставлен с константами/описанием модуля +- Step F3: [ ] (deferred) выделить собственную библиотеку модулей Go-ядра (linux-first), чтобы backend собирался из переиспользуемых пакетов +- Step F3.1: [ ] вынести `netns` как отдельный debug-модуль библиотеки (`pkg/netnsdebug` / `pkg/netns`), зафиксировать Linux-only scope +- Step F3.2: [ ] зафиксировать, что `netns` модуль не является целью для mobile SDK (iOS/Android out-of-scope), используется для backend/desktop test-contour +- Step F3.2.1: [ ] не реализовывать `netns` интеграцию для mobile-клиентов; для iOS/Android использовать только API control-plane без system/netns модулей +- Step F3.3: [ ] выделить стабильные библиотечные пакеты ядра (`pkg/orchestrator`, `pkg/transport`, `pkg/pbr`, `pkg/resolver`) с явными интерфейсами зависимостей +- Step F3.4: [ ] перевести backend на использование этих пакетов через thin-facade слой `app/*` без изменения внешнего API-контракта +- Step F3.5: [ ] подготовить отдельный документ архитектуры библиотеки модулей и runbook миграции (`docs/phase-f/F3_CORE_MODULE_LIBRARY_PLAN.md`) +- Step F1.14: [~] выполнить декомпозицию `selective-vpn-api/app/egress_identity.go` на подпакеты `app/egress*` без изменения egress API/SWR-поведения +- Step F1.14.1: [x] вынесены egress utility helper'ы в `app/egressutil/util.go` (parse ip/geo, endpoint lists, curl/wget path, socks url parse, URL host resolve, generic any/string helpers) +- Step F1.14.2: [x] удалены лишние локальные egress helper-функции в `app/egress_identity.go` (dead wrappers), оставлены только необходимые facade entrypoints +- Step F1.14.3: [x] вынесены helper'ы scope/identity/probe-loop в `app/egressutil` (`ParseScope`, `IdentityChanged`, `ProbeFirstSuccess`) с сохранением app-facade функций +- Step F1.14.4: [x] вынесен HTTP fetch helper в `app/egressutil/http.go` (`HTTPGetBody`), egress probe/geo вызовы в `app/egress_identity.go` переведены на пакетный вызов +- Step F1.14.5: [x] удалены чистые egress wrapper-функции в `app/egress_identity.go` (parse geo/ip, country normalize, curl/wget/timeout/resolve helpers); app-тесты переведены на прямые вызовы `app/egressutil` +- Step F1.15: [~] выполнить декомпозицию `selective-vpn-api/app/dns_settings.go` на подпакеты `app/dnscfg/*` без изменения DNS API/SmartDNS поведения +- Step F1.15.1: [x] вынесены SmartDNS addr/upstream normalizers в `app/dnscfg/smartdns.go` (`ResolveDefaultSmartDNSAddr`, `SmartDNSAddrFromConfig`, `NormalizeDNSUpstream`, `NormalizeSmartDNSAddr`) с сохранением фасадов в `app/dns_settings.go` +- Step F1.15.2: [x] вынесен SmartDNS systemd helper-блок в `app/dnscfg/systemd.go` (`UnitState`, `RunUnitAction`) с сохранением фасада `runSmartdnsUnitAction` и прежнего success-message формата +- Step F1.15.3: [x] вынесен DNS benchmark engine в `app/dnscfg/benchmark.go` (`NormalizeProfile/Options/Upstreams/Domains`, `BenchmarkDNSUpstream`, `DNSLookupAOnce`, `BenchmarkTopN`) с сохранением HTTP/API-контракта в `app/dns_settings.go` +- Step F1.15.4: [x] вынесены pool helper'ы в `app/dnscfg/pool.go` (`NormalizeUpstreamPoolItems`, `UpstreamPoolFromLegacy`, `UpstreamPoolToLegacy`, `EnabledPool`) с сохранением фасадов в `app/dns_settings.go` +- Step F1.15.5: [x] вынесен mode-state storage в `app/dnscfg/mode.go` (`ModeConfig`, `ModeState`, `LoadMode`, `SaveMode`) с сохранением JSON-контракта `dns_mode.json` +- Step F1.15.6: [x] очищен `app/dns_settings.go` от лишних benchmark-wrapper'ов (прямые вызовы `dnscfg`), без изменения endpoint/DTO +- Step F1.15.7: [x] вынесены parse/normalize/render helper'ы `dns-upstreams.conf` в `app/dnscfg/upstreams.go` (`ParseUpstreamsConf`, `NormalizeUpstreams`, `RenderUpstreamsConf`) с сохранением файлового/JSON mirror поведения +- Step F1.15.8: [x] вынесен SmartDNS prewarm engine в `app/dnscfg/prewarm.go` (`RunPrewarm` + metrics/domain expansion/manual merge/log pipeline) через dependency-injection callbacks +- Step F1.15.9: [x] runtime/prewarm HTTP handlers вынесены из `app/dns_settings.go` в `app/dns_smartdns_handlers.go` (в `dns_settings.go` оставлен DNS settings facade слой) +- Step F2.1: [x] выполнен production-upgrade `SingBox` runtime на шаблонный `systemd` unit `singbox@.service` (instance-per-profile без генерации отдельного unit-файла на каждый профиль) +- Step F2.2: [x] после `F2.1` внедрён миграционный и cleanup-контур (`old per-profile units -> template instances`, idempotent remove/disable/reset-failed, сохранение совместимости API) +- Step F2.1.1: [x] добавлен template unit `singbox@.service` + drop-in env strategy (`SVPN_TRANSPORT_ID`, `SVPN_CONFIG_PATH`, `SVPN_NETNS_*`) и зафиксированы hardening/tuning defaults +- Step F2.1.2: [x] backend renderer/provision переведён на instance model (`singbox@.service`) без изменения API-контракта `transport/clients/*` +- Step F2.1.3: [x] lifecycle/health/metrics path переведён на instance model (`start/stop/restart/is-active/reset-failed`) с сохранением runtime-code/ошибок +- Step F2.1.4: [x] добавлены e2e/smoke проверки instance model (`create/start/switch/stop/delete`) включая netns-enabled профили и auto-provision fallback +- Step F2.2.1: [x] реализован one-shot migrator `legacy unit -> template instance` (deterministic mapping + marker/ownership check + dry-run) +- Step F2.2.2: [x] реализован cleanup legacy artifacts (`disable/stop/reset-failed/remove unit file + daemon-reload`) с idempotent повторным запуском +- Step F2.2.3: [x] добавлен rollback-план миграции (`template -> legacy`) для инцидентов деплоя и зафиксирован runbook +- Step F2.2.4: [x] обновлены docs/scripts деплоя и preflight-check для новой модели (`singbox@.service` обязательный runtime dependency) +- Step F1.3: [x] выполнить декомпозицию `selective-vpn-gui/dashboard_controller.py` на domain-контроллеры с сохранением фасада `DashboardController` +- Step F1.4: [x] выполнить декомпозицию `selective-vpn-gui/vpn_dashboard_qt.py` на `main_window/*` mixin-модули (без изменения поведения) +- Step Z1: [ ] (experimental tail) глобальный L7 orchestration-layer поверх текущего ядра для multi-engine (`AdGuardVPN` / `SingBox` / future transports) с единым policy-adapter слоем +- Step Z1.1: [~] ранняя подготовка разрешена до старта Z1 runtime: единая policy-модель/intent ownership, scope-нормализация, telemetry schema и compatibility matrix; без включения L7 data-plane в прод-путь + +## Что уже сделано +- 2026-03-20: закрыт F2.2.1 (one-shot migrator legacy unit -> template instance) для SingBox systemd path: + - в pre-action hook (start/restart) добавлен best-effort мигратор legacy unit singbox-.service -> template instance singbox@.service; + - миграция использует deterministic candidate mapping (instance-id/unit + client-id sanitize), ownership-check по marker SVPN_TRANSPORT_ID и не трогает foreign unit-файлы; + - добавлен dry-run режим через config.singbox_legacy_unit_migrate_dry_run=true (без stop/disable/remove), плюс флаг отключения config.singbox_legacy_unit_migrate=false; + - после фактической миграции выполняются daemon-reload + reset-failed для legacy unit (best-effort, idempotent). + - cleanup-path Cleanup() для template-instance расширен: удаляются и owned legacy unit-файлы singbox-.service (stop/disable/remove + daemon-reload/reset-failed), template unit singbox@.service сохраняется. + - добавлены тесты transport_systemd_singbox_template_test.go: migrate owned legacy, dry-run, ownership mismatch; локально go test ./... (ok). +- 2026-03-20: закрыт E3.3 (interface orchestrator + M3 owner-scope): + - compile-plan перешёл на owner-scoped nft naming: `owner_scope=iface+client`, `nft_set=agvpn_pi__`; + - для длинных `iface/client` добавлен deterministic hash fallback, длина set name ограничена 63 символами; + - `TransportPolicyCompileRule/Set` расширены полем `owner_scope` для observability/runtime debug; + - добавлены тесты: `TestCompileTransportPolicyPlanUsesOwnerScopedNftSets`, `TestTransportPolicyNftSetNameDeterministicAndBounded`; локально `go test ./...` (ok). +- 2026-03-20: закрыт E3.5 domain destination-lock coverage: + - `detectTransportDestinationLockConflicts` расширен на `domain` selector (включая `*.domain`), теперь owner-switch проверяет sticky-lock по `domain-cache` (`direct+wildcard`) без сетевых запросов; + - добавлен `domain-cache` bridge hook в policy guard (`transportPolicyLoadDomainCacheState`, path: `/var/lib/selective-vpn/domain-cache.json`), с lazy-load и per-selector memoization; + - добавлены тесты: `TestDetectTransportDestinationLockConflictsDomainFromCache`, `TestDetectTransportDestinationLockConflictsWildcardDomainFromCache`, `TestDetectTransportDestinationLockConflictsSkipsDomainWithoutCacheHit`; локально `go test ./...` (ok). +- 2026-03-13: уменьшен trace-spam в transport-контуре: + - добавлен rate-limited trace helper (`appendTraceLineRateLimited`) с дедупом повторяющихся строк в окне времени; + - на throttled-логирование переведены шумные ветки (`singbox dns migrate warning`, `bootstrap bypass failed`, `netns setup/cleanup failed`, `systemctl unit missing/reset-failed`); + - повторяющиеся сообщения теперь схлопываются с компактной `trace dedup: suppressed=...` строкой. +- 2026-03-13: стартовал `F2.1` (SingBox instance-model foundation): + - в `transportcfg.BackendUnit` для `kind=singbox` добавлен резолв unit в `singbox@.service`; + - в прод-коде отключён auto-map legacy unit-имён `sing-box.service` / `singbox-*.service`; + - для `kind=singbox` включена жёсткая нормализация `config.unit=singbox@.service` на create/patch/state-save; + - добавлены/обновлены тесты `app/transportcfg/runtime_helpers_test.go` (default/template/custom/sanitize). +- 2026-03-13: продолжен `F2.1` (template/drop-in provisioning для SingBox): + - `systemd` provision-path для `singbox@.service` переведён на модель `template + per-instance drop-in`; + - добавлена генерация/обновление `singbox@.service` (managed template) и drop-in `singbox@.service.d/10-selective-vpn.conf`; + - в drop-in пробрасываются env-поля `SVPN_TRANSPORT_ID`, `SVPN_TRANSPORT_KIND`, `SVPN_CONFIG_PATH`, `SVPN_NETNS_*`; + - cleanup для template-instance теперь удаляет клиентский drop-in (с ownership-check) и не удаляет общий template unit; + - добавлены e2e-like unit tests: `transport_systemd_singbox_template_test.go` (provision, auto-provision-on-missing, cleanup-keep-template); + - обновлены runtime dependency docs/script: добавлен check `singbox@.service` (legacy `sing-box.service` сохранён как compat). +- 2026-03-13: завершён cleanup legacy unit-контур для SingBox: + - state `/var/lib/selective-vpn/transport-clients.json` мигрирован на `config.unit=singbox@.service` для всех `kind=singbox` профилей; + - legacy unit-файлы `singbox-*.service` удалены из `/etc/systemd/system`, оставлен только `singbox@.service` + instance drop-in; + - после сборки/рестарта API прогнаны проверки: `go test ./...` (ok), `systemctl list-unit-files singbox*` (только template), runtime start через `singbox@.service` (ok). +- 2026-03-13: расширены smoke/e2e проверки под instance-model: + - `tests/transport_systemd_real_e2e.py` обновлён: SingBox проверяется через default instance unit (`singbox@.service`) + ownership в drop-in + сохранение `singbox@.service` после delete; + - `tests/transport_production_like_e2e.py` обновлён: production-like SingBox-кейс переведён на instance model (проверка template/drop-in артефактов и cleanup); + - добавлен unit-test на netns-instance drop-in env (`SVPN_NETNS_ENABLED`, `SVPN_NETNS_NAME`) в `transport_systemd_singbox_template_test.go`; + - прогон локальных проверок: `go test ./...` (ok), `python3 -m py_compile` для обновлённых e2e-скриптов (ok). +- 2026-03-12: продолжен рефакторинг `F1.11/F1.13` без изменения API-контрактов: + - в `app/transportcfg` добавлен `secrets_helpers.go` (нормализация/маскирование/сравнение secret-map + файловые JSON helper'ы); + - `app/transport_singbox_profiles.go` переведён на `transportcfg` secrets-helper'ы; удалены локальные дубли `normalizeSingBoxSecretUpdates`, `maskSingBoxSecrets`, `cloneStringMap`, `equalStringMap`; + - `app/transport_singbox_profiles_flow.go` дополнительно очищен от локальных wrapper-функций `config io/file optional/diff`; вызовы переведены на прямой `transportcfg`; + - state/normalization функции `singbox profiles` вынесены из `app/transport_singbox_profiles.go` в `app/transport_singbox_profiles_state.go` (декомпозиция без изменения DTO/handlers); + - функции secrets patch/store + error helpers `singbox profiles` вынесены в отдельные файлы (`transport_singbox_profiles_secrets.go`, `transport_singbox_profiles_errors.go`); + - create/patch mutate-логика `singbox profiles` вынесена в `transport_singbox_profiles_mutate.go`, а основной `transport_singbox_profiles.go` сокращён до `413` строк; + - runtime/history helper'ы `singbox profile flow` вынесены в `transport_singbox_profiles_runtime.go` и `transport_singbox_profiles_history.go`; + - eval/render helper'ы `singbox profile flow` вынесены в `transport_singbox_profiles_eval.go`; + - state/normalization helper'ы `transport client runtime` вынесены в `transport_client_runtime_state.go`, а `transport_client_runtime.go` сокращён до orchestration/lifecycle части; + - health/netns helper'ы `transport client runtime` вынесены в `transport_client_runtime_health.go` и `transport_client_runtime_netns.go`; + - `transport_handlers_clients.go` декомпозирован: netns-toggle и action-dispatch вынесены в отдельные файлы `transport_handlers_netns.go`/`transport_handlers_actions.go` без изменения endpoint-контрактов; + - helper-блок `routes_update.go` вынесен в `routes_update_helpers.go` (table/list/io/counter/fs/readiness), основной `routes_update.go` оставлен как orchestration-пайплайн; + - helper/state блок `dns_settings.go` вынесен в `dns_settings_state.go` (upstream pool + mode/smartdns helpers + dnscfg adapters), handler-файл оставлен как control-plane API фасад; + - DNS/SmartDNS типы вынесены из `types.go` в `types_dns.go` (контракты API/JSON не менялись); + - `types.go` дополнительно разрезан на доменные файлы (`types_traffic.go`, `types_egress.go`, `types_transport.go`, `types_singbox.go`) с сохранением совместимости по полям/тегам; + - `transport_handlers_policy.go` декомпозирован: мутационные endpoints (`validate/apply/rollback`) вынесены в `transport_handlers_policy_mutations.go`; + - `handleTransportHealthRefresh` вынесен в `transport_handlers_health_refresh.go`, а `transport_health_refresh.go` оставлен runtime-блоком фоновых health probe; + - из `egress_identity.go` вынесен probe/helper блок в `egress_identity_probe.go` (system/iface/netns/proxy probes + endpoint helpers + socks/iface bind helpers); + - из `routes_handlers.go` вынесен service/timer блок в `routes_handlers_service.go` (systemd service action, timer get/toggle/set); + - из `routes_handlers.go` вынесен clear/precheck/op-lock блок в `routes_handlers_ops.go` (clear/cache-restore/precheck-debug/status-snapshot/op-lock helpers); + - `egress_identity` endpoint handlers (`GET/refresh`) вынесены в `egress_identity_handlers.go`, основной файл оставлен как service/runtime orchestration; + - монолит `traffic_appmarks.go` разнесён по ролям на `traffic_appmarks_handlers.go` (HTTP), `traffic_appmarks_ops.go` (add/del/clear/status) и `traffic_appmarks_runtime.go` (nft/cgroup/state/restore), базовый файл оставлен с core-константами и alias-типами; + - `transport_singbox_profiles_flow.go` сокращён с `1336` до `893` строк без изменения API-контрактов; + - в `app/trafficmode` добавлен `interfaces.go` (iface detect/select/resolve), а `app/traffic_mode.go` переведён на thin-facade для этого блока; + - handlers/apply-lock блок `traffic mode` вынесен в `app/traffic_mode_handlers.go` без изменения endpoint-контрактов (`/api/v1/traffic/mode`, `/traffic/interfaces`, `/traffic/mode/test`, `/traffic/advanced/reset`); + - state/normalization/persistence блок `traffic mode` вынесен в `app/traffic_mode_state.go`, основной `traffic_mode.go` очищен до apply/evaluate orchestration; + - завершён разнос remaining-core `traffic mode`: `traffic_mode_iface.go` (iface/table/config helpers), `traffic_mode_apply.go` (build/apply overrides + route base + apply mode), `traffic_mode_evaluate.go` (rules read/probe/evaluate health); + - handlers/ops/runtime блок `traffic app marks` разнесён на отдельные файлы (`traffic_appmarks_handlers.go`, `traffic_appmarks_ops.go`, `traffic_appmarks_runtime.go`), core-файл `traffic_appmarks.go` сокращён до констант/типов (`34` строки); + - размеры крупных файлов дополнительно снижены: `traffic_mode.go` `820 -> 35`; + - helper-блок `routes cache` вынесен в `routes_cache_helpers.go`, основной `routes_cache.go` сокращён до orchestration restore/save пайплайна; + - `egress identity` разнесён на файлы ролей: `providers`, `refresh`, `geo`, `scope`; базовый `egress_identity.go` сокращён до констант/типов/bootstrap; + - `vpn_handlers.go` (`531` строка) разложен на отдельные роли (`auth/status/locations`) с сохранением endpoint-path и прежнего поведения location switch / autoconnect; + - `autoloop.go` очищен от внутренних helper closure-блоков; helper-логика вынесена в `autoloop_helpers.go`, loop orchestration оставлен неизменным по таймингам и reconnect-пайплайну; + - `transport_backends_adapter_systemd.go` разложен на role-файлы (`action/health/provision/cleanup`) без изменения lifecycle/runtime контрактов backend `systemd`; + - выполнен stage-разнос `routes_update.go`: preflight/policy/nft/resolve/artifacts вынесены в `routes_update_stage_*.go`, основной файл оставлен orchestration-фасадом; + - `vpn_login_session.go` разложен на manager/PTY и HTTP handlers: endpoint-обработчики вынесены в `vpn_login_session_handlers.go` без изменения API login session; + - `vpn_locations_cache.go` разложен на role-файлы (`refresh`, `parse`, `store`) без изменения SWR-контракта (`stale/refresh_in_progress/next_retry_at`) и endpoint поведения; + - `transport_health_refresh.go` разложен на role-файлы (`state`, `queue`, `probe`, `compare`) без изменения фонового health-refresh контракта (`fresh TTL`, `backoff`, `max parallel probes`, `persist min age`); + - `transport_client_runtime_alloc.go` разложен на role-файлы (`normalize/reconcile`, `table/mark/pref slots`) без изменения allocation-контракта (`MarkHex`, `PriorityBase`, `RoutingTable`, duplicate-id winner); + - `transport_netns.go` разложен на role-файлы (`spec`, `rules`, `run`) без изменения netns lifecycle-контракта (`ensure`, `cleanup`, nft NAT comment tags, policy-route update); + - `transport_bootstrap_bypass.go` разложен на role-файлы (`candidates`, `resolve`, `routes`, `state`) без изменения bypass-контракта (`start/restart sync`, `stop cleanup`, strict mode, bootstrap state persistence); + - `transport_singbox_profiles_flow.go` разложен на handler role-файлы (`validate`, `render`, `apply`, `rollback`, `history`) с сохранением endpoint-контрактов `/api/v1/transport/singbox/profiles/{id}/{action}`; + - `transport_singbox_profiles.go` разложен на role-файлы (`list`, `card`, `features`) с сохранением endpoint-контрактов `/api/v1/transport/singbox/profiles*`; + - benchmark-контур DNS вынесен из `dns_settings.go` в `dns_settings_benchmark.go` с сохранением API-контракта `POST /api/v1/dns/benchmark` и профилей `quick/load`; + - `resolver_bridge.go` разложен на role-файлы (`utils`, `cache`, `dns`, `pipeline`) без изменения bridge-контракта с подпакетом `app/resolver`; + - `transport_backends.go` разложен на role-файлы (`selection/config facade`, `probe adapters`, `runtime-mode backends`) без изменения backend action/health/provision/cleanup контрактов; + - `vpn_login_session.go` разложен на role-файлы (`models/manager`, `state methods`, `pty lifecycle`, `http handlers`) без изменения login-session endpoint-контрактов; + - размеры крупных файлов снижены: `traffic_mode.go` `923 -> 820`, `transport_singbox_profiles.go` `979 -> 897`, `transport_singbox_profiles_flow.go` `1372 -> 1336`; + - после изменений выполнены `go test ./...` и `go build ./...` — успешно. +- 2026-03-11: продолжен шаг `F1.11` (поэтапный вынос transport в подпакеты): + - добавлен новый подпакет `selective-vpn-api/app/transportcfg` и файл `runtime_helpers.go`; + - в подпакет перенесены runtime/config helper'ы transport backend: + - `ConfigString`, `ConfigBool`; + - `RuntimeMode`; + - `BackendUnit`/`DefaultBackendUnit`; + - `DNSTTSSHTunnelEnabled`, `DNSTTSSHUnit`; + - `SystemdActionUnits`, `SystemdHealthUnits`; + - добавлен `selective-vpn-api/app/transportcfg/systemd_helpers.go` и в подпакет перенесены systemd helper'ы transport backend: + - unit-name/path/ownership/file-write helper'ы; + - systemd unit renderer'ы (`primary` + `ssh-overlay`); + - service tuning/hardening нормализация и prefix-fallback parser'ы; + - добавлен `selective-vpn-api/app/transportcfg/exec_helpers.go` и в подпакет перенесены exec/binary helper'ы transport backend: + - template command builders (`singbox`, `dnstt`, `phoenix`, `ssh-overlay`); + - binary resolution/validation helpers (`ResolveBinary`, `FindBinaryPath`, `BinaryExists`, packaging profile); + - shell/config helpers (`ShellJoinArgs`, `ShellQuoteArg`, `ConfigInt`, `DefaultConfigPath`); + - добавлен `selective-vpn-api/app/transportcfg/probe_helpers.go` и в подпакет перенесены probe helper'ы transport backend: + - latency probing pipeline (`ProbeClientLatency`, host/netns endpoint probe); + - endpoint extraction/parsing/dedupe helpers для config/singbox-json; + - shared parse helpers (`ParseInt`, `SplitCSV`). + - app-facade сохранён внутри существующих transport-файлов (`transport_backends.go`, `transport_backends_adapter_systemd.go`), а отдельные файлы `transport_backends_runtime_helpers.go` и `transport_backends_systemd_helpers.go` удалены для снижения шума в корне `app/`; + - отдельный файл `transport_backends_exec_helpers.go` удалён; совместимые фасады сохранены в `transport_backends.go`; + - отдельный файл `transport_backends_probe_helpers.go` удалён; runtime dial/netns фасады и backward-compatible symbols для тестов собраны в `transport_backends.go`; + - добавлен подпакет `selective-vpn-api/app/eventsbus` (`Bus`, `Push`, `Since`); `app/events_bus.go` переведён в thin-adapter/facade с сохранением контракта `events.push/events.since` и `envInt`; + - добавлен подпакет `selective-vpn-api/app/apiroutes` (`Register` + доменные route-registrars), `app/api_routes.go` переведён на dependency-assembly facade; + - удалены файлы route-registry из корня `app`: `api_routes_core.go`, `api_routes_dns.go`, `api_routes_trace.go`, `api_routes_traffic.go`, `api_routes_transport.go`, `api_routes_vpn.go`; + - удалён файл `transport_backends_adapters_misc.go`; типы `transportUnsupportedRuntimeBackend` и `transportMockBackend` перенесены в `transport_backends.go` (единая точка transport-facade); + - добавлен подпакет `selective-vpn-api/app/syscmd` и перенесены низкоуровневые command helper'ы (`RunCommand`, `RunCommandTimeout`, `CheckPolicyRoute`); `app/shell.go` оставлен как thin-facade; + - добавлен подпакет `selective-vpn-api/app/httpx` и перенесены общие HTTP helper'ы (`LogRequests`, `WriteJSON`, `HandleHealthz`); `app/http_helpers.go` переведён в thin-facade; + - добавлен подпакет `selective-vpn-api/app/eventstream` и перенесён SSE polling/heartbeat loop (`ParseSinceID`, `Serve`); `app/events_handlers.go` оставлен как adapter к `events.since`; + - добавлен подпакет `selective-vpn-api/app/refreshcoord` и перенесена SWR/backoff state-machine (`Coordinator`, `Snapshot`); `app/refresh_coordinator.go` оставлен как thin-facade с прежними методами (`beginRefresh/finish*/snapshot`) для совместимости; + - в `egress_identity.go` убран прямой доступ к внутренним полям SWR (`refreshInProgress`, `nextRetryAt`) — заменено на фасадные методы (`refreshInProgress()`, `nextRetryAt()`, `clearBackoff()`) для безопасной декомпозиции; + - добавлен подпакет `selective-vpn-api/app/nftupdate` и перенесён алгоритм обновления nft-set (`interval compression`, `atomic transaction`, `chunked fallback`); + - `app/nft_update.go` переведён в thin-facade с прежним публичным контрактом (`nftUpdateIPsSmart`, `nftUpdateSetIPsSmart`) и прежним trace-логированием через `appendTraceLine("routes", ...)`; + - добавлен подпакет `selective-vpn-api/app/trafficcandidates` и перенесён сбор candidates (`subnets`, `units`, `uids`) в DI-модуль; + - `app/traffic_candidates.go` переведён в thin-adapter (маппинг pkg DTO -> API DTO без изменения endpoint path/поля ответа); + - для визуальной очистки корня `app/` удалены 16 отдельных resolver bridge-файлов: + - `resolver_artifacts_bridge.go` + - `resolver_dns_config_bridge.go` + - `resolver_domain_cache_bridge.go` + - `resolver_host_lookup_bridge.go` + - `resolver_mode_runtime_bridge.go` + - `resolver_planning_bridge.go` + - `resolver_precheck_finalize_bridge.go` + - `resolver_precheck_types_bridge.go` + - `resolver_resolve_batch_bridge.go` + - `resolver_runtime_tuning_bridge.go` + - `resolver_start_log_bridge.go` + - `resolver_static_labels_bridge.go` + - `resolver_summary_log_bridge.go` + - `resolver_timeout_recheck_bridge.go` + - `resolver_types_bridge.go` + - `resolver_wildcard_bridge.go` + - вместо них добавлен единый `selective-vpn-api/app/resolver_bridge.go` (консолидированный bridge-слой resolver-пайплайна); + - добавлен подпакет `selective-vpn-api/app/trafficprofiles` и вынесена state/store-логика профилей приложений (`list/upsert/delete/dedupe`, json persistence, id-derive); + - `app/traffic_app_profiles.go` переведён в thin-adapter: HTTP decode/encode + mapping DTO `app <-> trafficprofiles`; + - функция `sanitizeID` сохранена в `app` (runtime-совместимость с transport/egress/singbox контуром); + - добавлен `selective-vpn-api/app/trafficprofiles/appkey.go` и в него вынесены `CanonicalizeAppKey` + `SplitCommandTokens` + wrapper parser helpers; + - удалён root-файл `selective-vpn-api/app/traffic_appkey.go`; в `app/traffic_app_profiles.go` оставлены совместимые фасады `canonicalizeAppKey`/`splitCommandTokens` для существующих вызовов и тестов; + - добавлен подпакет `selective-vpn-api/app/trafficappmarks` и вынесены хелперы состояния app-marks: + - `LoadState` / `SaveState` (json persistence), + - `DedupeItems` / `UpsertItem`, + - `IsAllDigits`; + - в `app/traffic_appmarks.go` сохранены совместимые фасады (`loadAppMarksState`, `saveAppMarksState`, `dedupeAppMarkItems`, `upsertAppMarkItem`, `isAllDigits`) и type-alias на пакетные модели (`appMarksState`, `appMarkItem`); + - `traffic_appmarks.go` сокращён с `1140` до `1002` строк, без изменения endpoint-поведения и nft runtime-логики; + - добавлен файл `selective-vpn-api/app/trafficappmarks/cgroup.go` и вынесены `ResolveCgroupV2PathForNft`, `NormalizeCgroupRelOnly`, `CgroupDirInode`; + - в `app/traffic_appmarks.go` оставлены одноимённые фасады для совместимости текущих вызовов; + - добавлен файл `selective-vpn-api/app/trafficappmarks/nft.go` и вынесены `InsertAppMarkRule/DeleteAppMarkRule/HasAppMarkRule`, `UpdateLocalBypassSet`, `ParseNftHandle`, `CompactIPv4IntervalElements`; + - туда же вынесены cleanup helper'ы `CleanupLegacyRules` и `ClearManagedRules`, а в `app/traffic_appmarks.go` оставлены фасады `cleanupLegacyAppMarksRules`/`clearManagedAppMarkRules`; + - в `selective-vpn-api/app/trafficappmarks/store.go` добавлен `PruneExpired` (TTL prune + safe handling corrupted `expires_at` + callback на удаление nft-rule); + - в `selective-vpn-api/app/trafficappmarks/nft.go` добавлен `EnsureBase` (best-effort bootstrap table/chains/set + jump to app chain), фасад `ensureAppMarksNft` в `app` сохранён; + - в `app/traffic_appmarks.go` сохранены совместимые фасады `nftInsertAppMarkRule/nftDeleteAppMarkRule/nftHasAppMarkRule/updateAppMarkLocalBypassSet/parseNftHandle`; + - `traffic_appmarks.go` дополнительно сокращён до `723` строк; + - добавлен подпакет `selective-vpn-api/app/trafficmode` и файл `normalize.go` (вынесены `TokenizeList`, `NormalizeSubnetList`, `NormalizeUIDToken`, `NormalizeUIDList`, `NormalizeCgroupList`); + - `app/traffic_mode.go` переведён на фасады к `trafficmode` для normalize-контура (без изменения API/маршрутной логики); + - добавлен `selective-vpn-api/app/trafficmode/autolocal.go` (вынесен parser `ip route show table main` для auto-local-bypass); + - в `app/traffic_mode.go` оставлены фасады `parseRouteDevice/isContainerIface/routeLineIsLinkDown/isAutoBypassDestination` для backward-compatible вызовов из `traffic_candidates.go`; + - добавлен `selective-vpn-api/app/trafficmode/cgroup.go` (вынесен cgroup scan/resolve и derive uidrange из `/proc//status`); + - добавлен `selective-vpn-api/app/trafficmode/ingress.go` (вынесен ingress-reply bypass nft lifecycle и state-check); + - добавлен `selective-vpn-api/app/trafficmode/rules.go` (вынесен parsing `ip rule show` + route-probe `ip route get`); + - добавлен `selective-vpn-api/app/trafficmode/apply.go` (вынесен apply/remove управляемых `ip rule` и overlay overrides); + - удалены промежуточные wrapper-функции cgroup/nftObjectMissing из `traffic_mode.go`; `buildEffectiveOverrides` использует пакетные вызовы напрямую; + - `traffic_mode.go` сокращён с `1449` до `923` строк; + - добавлен подпакет `selective-vpn-api/app/egressutil` и файл `util.go`; + - в `app/egress_identity.go` переведены на фасады: `parseEgressIPFromBody`, `egressParseGeoResponse`, `normalizeCountryCode`, `egressIPEndpoints`, `egressGeoEndpointsForIP`, `egressLimitEndpointsForNetns`, `egressJoinErrorsCompact`, `egressParseSingBoxSOCKSProxyURL`, `egressResolvedHostForURL`, `resolveEgressCurlPath`, `resolveEgressWgetPath`, `egressTimeoutSec`; + - удалены устаревшие внутренние переменные/функции egress (`egressCurlPathOnce/egressWgetPathOnce`, локальные any/url helpers); + - удалены мёртвые egress wrappers (неиспользуемые after-extract), сохранён публичный runtime-контракт для активных вызовов; + - `egress_identity.go` сокращён с `1208` до `932` строк; + - добавлен подпакет `selective-vpn-api/app/dnscfg` и файл `smartdns.go`; + - `app/dns_settings.go` переведён на фасады `dnscfg` для SmartDNS default/config parsing и DNS upstream normalization; + - добавлен `selective-vpn-api/app/dnscfg/systemd.go` и вынесены helper'ы `smartdns` unit state/action; + - добавлен `selective-vpn-api/app/dnscfg/benchmark.go` и вынесен benchmark-контур DNS (core scoring/probe/normalize) с callback-зависимостями (`splitDNS`, `classifyDNSError`, `isPrivateIPv4`); + - `app/dns_settings.go` оставлен как facade HTTP/DTO слой для benchmark-endpoint, без изменения JSON-контракта; + - добавлен `selective-vpn-api/app/dnscfg/pool.go` и вынесены helper'ы pool/legacy-конвертации; + - `app/dns_settings.go` использует фасады `dnscfg` для pool нормализации/конвертации, сохраняя прежние JSON-модели и endpoint-поведение; + - в `selective-vpn-api/app/dnscfg/mode.go` добавлены `ModeConfig/ModeState/LoadMode/SaveMode`; `app/dns_settings.go` переведён на тонкий фасад для чтения/сохранения `dns_mode.json`; + - `dns_settings.go` дополнительно очищен от лишних benchmark-wrapper-функций; benchmark-контур работает через прямые вызовы `dnscfg` без изменения API-контракта; + - добавлен `selective-vpn-api/app/dnscfg/upstreams.go` и вынесены parse/normalize/render helper'ы конфига `dns-upstreams.conf`; + - `loadDNSUpstreamsConfFile/saveDNSUpstreamsConfFile` переведены на фасады `dnscfg` без изменения формата `dns-upstreams.conf` и legacy JSON mirror; + - добавлен `selective-vpn-api/app/dnscfg/prewarm.go`; логика SmartDNS prewarm (domain expansion, parallel dig, runtime/manual nft merge, summary/per-upstream log) вынесена из `app/dns_settings.go` в пакет `dnscfg` через callback-deps; + - добавлен `selective-vpn-api/app/dns_smartdns_handlers.go`; туда вынесены `handleSmartdnsRuntime`, `handleSmartdnsPrewarm`, `runSmartdnsPrewarm` (тонкий app-adapter к `dnscfg.RunPrewarm`); + - `dns_settings.go` дополнительно сокращён до `749` строк (было `1073` до этого шага), без изменения endpoint-path/JSON-контрактов; + - в `app/egressutil` добавлены `scope.go`/`identity.go`/`probe.go`/`http.go` (scope parser, identity diff, endpoint probe loop, HTTP body fetch helper); + - в `app/egress_identity.go` сохранены совместимые фасады (`parseEgressScope`, `egressIdentityChanged`) и перевод вызовов probe/geo на пакетные helper'ы; + - удалены локальные pure-wrapper'ы egress; `egress_identity_test.go` использует `egressutil` напрямую; + - `egress_identity.go` сокращён до `854` строк (было `932` на старте блока F1.14); + - добавлен `selective-vpn-api/app/transportcfg/singbox_helpers.go` и в него вынесены повторно используемые helper'ы профилей singbox (digest/diff/path/io/validators/stamp/messages); + - `transport_singbox_profiles_flow.go` переведён на пакетные helper-вызовы `transportcfg` с сохранением API/flow-поведения; файл сокращён до `1624` строк; + - добавлен `selective-vpn-api/app/transportcfg/history_helpers.go`; вынос history io/selection helpers из `transport_singbox_profiles_flow.go` выполнен через пакетные фасады; + - удалены локальные helper'ы `findSingBoxBinary`/`sanitizeHistoryStamp` из `transport_singbox_profiles_flow.go` (замена на package helpers); + - `transport_singbox_profiles_flow.go` дополнительно сокращён до `1563` строк; + - `dns_settings.go` сокращён с `1521` до `1196` строк; + - валидация: `go test ./...` и `go build ./...` в `selective-vpn-api` — успешно. +- 2026-03-10: продолжен шаг `F1.11` (декомпозиция `transport_backends.go` без смены поведения): + - вынесен systemd helper-блок в `selective-vpn-api/app/transport_backends_systemd_helpers.go`: + - unit-name/ownership/file-write helpers; + - unit renderer + ssh-overlay renderer; + - tuning/hardening normalizers и prefix-fallback config helpers. + - вынесен probe/latency helper-блок в `selective-vpn-api/app/transport_backends_probe_helpers.go`: + - endpoint collector/parser/dedupe; + - host/netns probe и latency sampling. + - вынесен exec/binary helper-блок в `selective-vpn-api/app/transport_backends_exec_helpers.go`: + - `resolveTransportPrimaryExecStart` + `buildTransport*Command`; + - packaging/binary resolution helpers (`resolveTransportBinary`, `validateRequiredBinary`, `transportPackagingProfile`); + - shell/config int helpers (`shellJoinArgs`, `shellQuoteArg`, `transportConfigInt`). + - вынесен runtime helper-блок в `selective-vpn-api/app/transport_backends_runtime_helpers.go`: + - `transportBackendUnit/defaultTransportBackendUnit`; + - `transportRuntimeMode`; + - `transportSystemdActionUnits/transportSystemdHealthUnits`; + - `transportConfigString/transportConfigBool`. + - вынесены adapter-реализации `unsupported/mock` в `selective-vpn-api/app/transport_backends_adapters_misc.go`. + - вынесен `systemd` adapter в `selective-vpn-api/app/transport_backends_adapter_systemd.go` (действия/health/provision/cleanup + missing-unit helpers). + - итоговая декомпозиция: `transport_backends.go` сокращён с `1954` до `73` строк (тонкий core/facade). + - валидация: `go test ./app -run TestTransportSystemdBackendHealth -count=1`, `go test ./...`, `go build ./...` — успешно. +- 2026-03-10: продолжен `F1.11` по resolver-контурy (введена отдельная папка `selective-vpn-api/app/resolver/`): + - вынесен wildcard matcher в подпакет `selective-vpn-api/app/resolver/wildcard_matcher.go` (`NormalizeWildcardDomain/NewWildcardMatcher/Match/IsExact/Count`); + - вынесены общие resolver helper'ы в `selective-vpn-api/app/resolver/common.go` (`UniqueStrings`, `PickDNSStartIndex`, `StripANSI`, `IsPrivateIPv4`); + - вынесены DNS метрики/типы в `selective-vpn-api/app/resolver/dns_metrics.go` (`DNSErrorKind`, `DNSMetrics`, `DNSUpstreamMetrics`); + - вынесены JSON/IO helper'ы в `selective-vpn-api/app/resolver/io_helpers.go` (`ReadLinesAllowMissing`, `LoadJSONMap`, `SaveJSON`, `LoadResolverPrecheck*`, `LoadResolverLiveBatch*`); + - вынесены DNS parser/helper'ы в `selective-vpn-api/app/resolver/dns_helpers.go` (`SplitDNS`, `ClassifyDNSError`); + - вынесен adaptive live-batch policy-калькулятор в `selective-vpn-api/app/resolver/live_batch.go` (`ComputeNextLiveBatchTarget`, `ComputeNextLiveBatchNXHeavyPct`) с сохранением прежней формулы; + - вынесен live-batch selector в `selective-vpn-api/app/resolver/live_batch_select.go` (`ClassifyLiveBatchHost`, `SplitLiveBatchCandidates`, `PickAdaptiveLiveBatch`); + - вынесен upstream-pool helper в `selective-vpn-api/app/resolver/dns_upstreams.go` (`BuildResolverFallbackPool`, `MergeDNSUpstreamPools`); + - вынесен error-policy helper в `selective-vpn-api/app/resolver/error_policy.go` (`SmartDNSFallbackForTimeoutEnabled`, `ShouldFallbackToSmartDNS`, `ClassifyHostErrorKind`, `ShouldUseStaleOnError`); + - полностью вынесен `domain cache` блок в `selective-vpn-api/app/resolver/domain_cache.go` (state/model + normalize/migrate + get/set/quarantine/stale + score/state policy + json render/summary); + - вынесены precheck/live-batch типы в `selective-vpn-api/app/resolver/precheck_types.go` + bridge `selective-vpn-api/app/resolver_precheck_types_bridge.go`; + - вынесено сохранение precheck-state в `selective-vpn-api/app/resolver/precheck_state.go` (`SaveResolverPrecheckState`) с bridge-вызовом из `app`; + - вынесен static/PTR блок в `selective-vpn-api/app/resolver/static_labels.go` (`ParseStaticEntries`, `ResolveStaticLabels`, `DigPTR`) + bridge `selective-vpn-api/app/resolver_static_labels_bridge.go`; + - вынесен timeout-quarantine recheck блок в `selective-vpn-api/app/resolver/timeout_recheck.go` (`RunTimeoutQuarantineRecheck`) + bridge `selective-vpn-api/app/resolver_timeout_recheck_bridge.go`; + - вынесен загрузчик DNS-конфига в `selective-vpn-api/app/resolver/dns_config.go` (`LoadDNSConfig`) + bridge `selective-vpn-api/app/resolver_dns_config_bridge.go`; + - вынесен host-lookup блок в `selective-vpn-api/app/resolver/host_lookup.go` (`ResolveHost`, `DigAWithPolicy`) + bridge `selective-vpn-api/app/resolver_host_lookup_bridge.go` (`resolveHostGo`, `digA`, `digAWithPolicy`); + - типы DNS policy/cooldown (`dnsAttemptPolicy`, `dnsRunCooldown`, `dnsCooldownState`) перенесены из `resolver.go` в `selective-vpn-api/app/resolver_dns_policy.go` для очистки монолита; + - вынесен runtime-tuning блок (`TTL/workers/dns timeout/precheck/live-batch/negative TTL`) в `selective-vpn-api/app/resolver/runtime_tuning.go` (`BuildResolverRuntimeTuning`) + bridge `selective-vpn-api/app/resolver_runtime_tuning_bridge.go`; + - вынесен artifact-builder блок (`IPs/IPMap/Direct/Wildcard`) в `selective-vpn-api/app/resolver/artifacts.go` (`BuildResolverArtifacts`) + bridge `selective-vpn-api/app/resolver_artifacts_bridge.go`; + - вынесен planning блок (`fresh/toResolve/cache_negative/quarantine/stale/precheck_scheduled`) в `selective-vpn-api/app/resolver/planning.go` (`BuildResolvePlanning`) + bridge `selective-vpn-api/app/resolver_planning_bridge.go`; + - вынесен precheck finalize/save блок (next live-batch calc + state persist + force-file consume) в `selective-vpn-api/app/resolver/precheck_finalize.go` (`FinalizeResolverPrecheck`) + bridge `selective-vpn-api/app/resolver_precheck_finalize_bridge.go`; + - вынесен concurrent resolve-блок (`workers/jobs/results` + stale-on-error apply) в `selective-vpn-api/app/resolver/resolve_batch.go` (`ExecuteResolveBatch`) + bridge `selective-vpn-api/app/resolver_resolve_batch_bridge.go`; + - вынесен summary/breakdown/precheck-log блок в `selective-vpn-api/app/resolver/summary_log.go` (`LogResolverSummary`) + bridge `selective-vpn-api/app/resolver_summary_log_bridge.go`; + - вынесен runtime DNS mode apply/log блок в `selective-vpn-api/app/resolver/mode_runtime.go` (`ApplyDNSModeRuntime`, `LogDNSMode`) + bridge `selective-vpn-api/app/resolver_mode_runtime_bridge.go`; + - вынесен start/policy log блок в `selective-vpn-api/app/resolver/start_log.go` (`LogResolverStart`) + bridge `selective-vpn-api/app/resolver_start_log_bridge.go`; + - финальный orchestration split: добавлен pipeline-файл `selective-vpn-api/app/resolver_pipeline.go` (`buildResolverJobContext`, `runResolverPipeline`), а `runResolverJob` оставлен тонким фасадом; + - добавлен bridge `selective-vpn-api/app/resolver_domain_cache_bridge.go` (сохранены старые имена/методы `domainCache*` в пакете `app` через wrapper-слой); + - добавлен совместимый bridge `selective-vpn-api/app/resolver_wildcard_bridge.go` (сохранены старые вызовы `normalizeWildcardDomain/newWildcardMatcher` в пакете `app`); + - добавлен type bridge `selective-vpn-api/app/resolver_types_bridge.go` (alias-константы/типы для `dnsErrorKind`/`dnsMetrics` без смены API внутри `app`); + - вынесен DNS policy/cooldown блок из монолита в `selective-vpn-api/app/resolver_dns_policy.go`; + - `resolver.go` сокращён с `2983` до `41` строк без изменения API-контрактов; + - валидация: `go test ./...` и `go build ./...` в `selective-vpn-api` — успешно. +- 2026-03-10: закрыт шаг `F1.4` (декомпозиция `vpn_dashboard_qt.py`): + - добавлен пакет `selective-vpn-gui/main_window/` и вынесены базовые модули: `constants.py` (shared UI constants), `workers.py` (`EventThread`, `LocationsThread`); + - вынесен UI shell-контур (`build tabs + helpers + locations/egress`) в `main_window/ui_shell_mixin.py`; + - вынесен крупный SingBox-контур в подпакет `main_window/singbox/*` (`editor`, `cards`, `links`, `runtime`) и сохранён фасад `main_window/singbox_mixin.py`; + - вынесен runtime/refresh/actions контур в `main_window/runtime_actions_mixin.py` (events stream, refresh, login/auth, routes/dns/domains actions, close lifecycle); + - добавлена вторичная декомпозиция mixin-слоёв: `ui_tabs_*`, `ui_helpers`, `ui_location_runtime`, `runtime_{state,refresh,auth,ops}`, `singbox/{links_*,runtime_*}`; фасады `ui_tabs_mixin.py`, `ui_shell_mixin.py`, `runtime_actions_mixin.py`, `singbox_mixin.py` сохранены; + - отдельный split `ui_tabs_singbox_mixin.py` на `ui_tabs_singbox_{layout,editor}_mixin.py` (убран последний UI-файл >700 строк); + - `selective-vpn-gui/vpn_dashboard_qt.py` сокращён с `6103` до `116` строк, при этом сохранён как thin-bootstrap+UI wiring; + - валидация: `python3 -m py_compile vpn_dashboard_qt.py main_window/*.py main_window/singbox/*.py` и import-проверка `MainWindow` проходят. +- 2026-03-10: hotfix `systemd stop` idempotency для transport lifecycle: + - в `transportSystemdBackend.Action` добавлен no-op режим для `action=stop`, если `systemctl` возвращает `Unit ... not loaded/not found/unknown unit` (чтобы отсутствующий unit не валил switch-пайплайн); + - добавлен unit-тест `TestTransportSystemdBackendStopMissingUnitIsNoop`; + - runtime-валидация на живом API: `POST /api/v1/transport/clients/sg-finland-yur/start` и `.../sg-realnetns/start` проходят, `.../sg-torjantest-ylu9ja7f/stop` возвращает `ok=true` с warning вместо `TRANSPORT_BACKEND_ACTION_FAILED`. +- 2026-03-10: hotfix `systemd start/restart` auto-provision для новых SingBox-профилей: + - в `transportSystemdBackend.Action` добавлен общий fallback для `SingBox`: при `start/restart` и ошибке `unit not found/not loaded` backend автоматически выполняет `Provision` и повторяет `systemctl start/restart` один раз; + - добавлен unit-тест `TestTransportSystemdBackendStartAutoProvisionOnMissingUnit`; + - runtime-валидация: `POST /api/v1/transport/clients/sg-torjantest-ylu9ja7f/start` вернул `ok=true`, `status_after=up` без ручного `Prepare`. +- 2026-03-10: закрыт шаг `F1.6` по `cmd/*` entrypoints в `selective-vpn-api`: + - добавлены отдельные main-пакеты: `cmd/selective-vpn-api`, `cmd/selective-vpn-routes-update`, `cmd/selective-vpn-routes-clear`, `cmd/selective-vpn-autoloop`; + - в `app/server.go` выделены явные entrypoint-функции `RunAPIServer`, `RunRoutesUpdateCLI`, `RunRoutesClearCLI`, `RunAutoloopCLI`, route registration вынесена в `registerAPIRoutes`; + - legacy `main.go` сохранён для обратной совместимости (старые unit/скрипты продолжают работать через `app.Run()`). +- 2026-03-10: выполнена повторная валидация `F1.6` (контрольный прогон перед следующим рефактором): + - `go test ./...` и `go build ./...` в `selective-vpn-api` прошли успешно; + - собраны отдельные бинарники из `cmd/*` в `/tmp` (`selective-vpn-api`, `selective-vpn-routes-update`, `selective-vpn-routes-clear`, `selective-vpn-autoloop`). +- 2026-03-10: закрыт шаг `F1.7` (декомпозиция API bootstrap/роутера): + - удалён монолит `selective-vpn-api/app/server.go` и введены файлы `entrypoints.go`, `api_bootstrap.go`, `api_routes.go`; + - сигнатуры и поведение сохранены: `Run*CLI`, `RunAPIServer`, `runAPIServerAtAddr`, `registerAPIRoutes`; + - `registerAPIRoutes` дополнительно разложен на доменные helper'ы (`registerCoreRoutes`, `registerRoutesControlRoutes`, `registerTrafficRoutes`, `registerTransportRoutes`, `registerDNSRoutes`, `registerVPNRoutes`) без изменения endpoint-path; + - контрольная валидация `go test ./...` и `go build ./...` прошла успешно. +- 2026-03-10: закрыт шаг `F1.8` (физическая декомпозиция route-registry по доменам): + - `register*Routes` вынесены из общего файла в `app/api_routes_core.go`, `api_routes_traffic.go`, `api_routes_transport.go`, `api_routes_trace.go`, `api_routes_dns.go`, `api_routes_vpn.go`; + - `app/api_routes.go` оставлен как thin-facade (`registerAPIRoutes`), который только собирает доменные registrars; + - endpoint-path и handlers сохранены 1:1; контрольная валидация `go test ./...` и `go build ./...` прошла успешно. +- 2026-03-10: закрыт шаг `F1.9` (переход от "всё в app" к подпапкам runtime-слоя): + - добавлен подпакет `app/cli` с раннерами `routes-update`, `routes-clear`, `autoloop` и dependency-injection через callbacks; + - добавлен подпакет `app/bootstrap` с HTTP server runner (`Run(ctx, Config)`), `app/api_bootstrap.go` переведён на thin-wrapper + `prepareAPIRuntime()`; + - фасады в `app` сохранены (`RunRoutesUpdateCLI`, `RunRoutesClearCLI`, `RunAutoloopCLI`, `runAPIServerAtAddr`), поэтому внешний контракт и поведение не изменились; + - контрольная валидация: `go test ./...` и `go build ./...` успешны. +- 2026-03-10: закрыт шаг `F1.10` (декомпозиция transport handler-монолита): + - удалён монолит `app/transport_handlers.go` (`~2440` строк), код разложен на модули: `transport_shared.go`, `transport_handlers_clients.go`, `transport_handlers_policy.go`, `transport_policy_validate.go`, `transport_client_runtime.go`, `transport_tokens_state.go`; + - все endpoint-handler сигнатуры и policy/lifecycle поведение сохранены (роуты `transport/*` без изменений); + - контрольная валидация: `go test ./...` и `go build ./...` успешны. +- 2026-03-10: начат шаг `F1.11` (подпакеты без циклов): + - вынесен token-store в `app/transporttoken/store.go` (issue/consume/ttl cleanup + token generation); + - `transport_tokens_state.go` переведён на новый store через facade (`issueTransportConfirmToken`, `consumeTransportConfirmToken`, `newTransportToken`); + - обновлены unit-тесты confirm-token lifecycle под новый store (`TestTransportConfirmStoreExpiresToken`), `go test ./...` и `go build ./...` успешны. +- 2026-03-10: начат шаг `F1.12` (декомпозиция `api_client.py` по папкам): + - добавлен пакет `selective-vpn-gui/api/` (`models.py`, `errors.py`, `utils.py`, `client.py`, `__init__.py`); + - `api_client.py` переведён в backward-compatible facade (реэкспорт `ApiClient/ApiError/models/strip_ansi`); + - сохранена обратная совместимость импортов (`from api_client import ...`) и добавлен новый путь (`from api import ...`); + - проверка: `py_compile` + импорты `dashboard_controller.py`, `vpn_dashboard_qt.py`, `dns_benchmark_dialog.py` проходят. +- 2026-03-10: закрыт шаг `F1.12` (полная доменная декомпозиция Python API-клиента): + - `selective-vpn-gui/api/client.py` сокращён до base HTTP/SSE + shared helpers (`_request/_json/_to_int/_parse_cmd_result`); + - доменные методы вынесены в mixin-модули: `api/status.py`, `api/routes.py`, `api/traffic.py`, `api/dns.py`, `api/domains.py`, `api/vpn.py`, `api/trace.py`; + - дополнительный разрез transport-домена: `api/transport_clients.py`, `api/transport_policy.py`, `api/transport_singbox.py` + facade `api/transport.py`; + - backward compatibility сохранена (`api_client.py` facade и `api.transport.TransportApiMixin`), `py_compile` и импорты GUI-модулей проходят. +- 2026-03-10: закрыт шаг `F1.3` (декомпозиция `dashboard_controller.py`): + - добавлен пакет `selective-vpn-gui/controllers/` с domain mixin-модулями: `status`, `vpn`, `routes`, `traffic`, `transport`, `dns`, `domains`, `trace`, плюс общий `core` и `views`; + - `dashboard_controller.py` сокращён до thin-facade (`DashboardController`) с прежней точкой импорта и совместимыми экспортами (`TraceMode`, view-models); + - проверка: `py_compile` + импорты `vpn_dashboard_qt.py`, `traffic_mode_dialog.py`, `dns_benchmark_dialog.py` проходят. +- 2026-03-10: зафиксировано разделение зависимостей `go.mod` vs runtime services: + - выполнен `go mod tidy` для `selective-vpn-api` (изменений не потребовалось; Go-модули актуальны); + - добавлен preflight-check `scripts/check_runtime_dependencies.sh` (required/optional + `--strict`); + - добавлен документ `docs/phase-b/B4_RUNTIME_DEPENDENCIES_AND_PREFLIGHT.md` с реестром бинарей/unit-зависимостей до начала крупного рефакторинга. +- 2026-03-10: стабилизирован egress refresh для `AdGuardVPN` после смены локации: + - в Go API (`/api/v1/vpn/location`) добавлен backend `egress refresh burst` для `adguardvpn` (force refresh сразу + отложенные итерации), чтобы egress IP/geo догонял фактическое переключение туннеля; + - в `egress identity` force-refresh теперь обходит negative geo-cache (ошибка geo не блокирует повторную попытку при `force=true`), а TTL negative geo-cache снижен до `30s`; + - в GUI (`refresh_vpn_tab`) добавлены дополнительные post-switch trigger'ы egress-refresh (на смену desired location и на завершение switching), а polling расширен до стабилизации `IP + country` без ранней остановки. +- 2026-03-10: доведён anti-regression контур `SingBox netns + multi-profile switch`: + - в backend lifecycle `POST /api/v1/transport/clients/{id}/start|restart` добавлен обязательный preflight для `SingBox` (render/validate + materialize `config_path`) до `systemctl`, чтобы старт не обходил профильную проверку; + - при `start/restart` выбранного `SingBox` в `netns` backend теперь автоматически останавливает другие `SingBox`-клиенты в этом же namespace перед запуском (убран конфликт `listen 127.0.0.1:10808: address already in use` при switch между профилями); + - в `systemd` backend добавлен best-effort `systemctl reset-failed ` перед `start/restart`, чтобы switch не упирался в `start-limit-hit` после прошлых неуспешных стартов; + - закрыт legacy кейс `packet_encoding: none` (SingBox panic `unknown value`): нормализация выполняется в Go-render pipeline, а в GUI-editor/link-import `none` больше не пишется в raw config; + - для `egress identity` при `transport:` и `netns_enabled=true` у `SingBox` direct-netns fallback полностью отключён: probe идёт только через локальный SOCKS inbound, иначе возвращается ошибка (без ложного AdGuard/system IP); + - runtime-проверка: `sg-finland-yur` стартует даже после удаления `/etc/selective-vpn/transports/sg-finland-yur/singbox.json` (файл воссоздаётся preflight); при последовательном switch подтверждён ожидаемый egress: `sg-finland-yur -> FI (92.42.102.181)`, `sg-realnetns -> NL (46.17.97.149)`. +- 2026-03-10: добавлен хвостовой экспериментальный этап `Z1` (глобальный L7 orchestration-layer) и отдельно зафиксировано правило: подготовку можно делать заранее, но runtime-включение только в конце планов. +- 2026-03-10: зафиксирован отложенный этап `E7` (без реализации в текущем спринте): + - целевое разделение `System selective PBR` (L3/L4 transport guardrails) и `SingBox L7 routing` (policy-engine); + - зафиксирован полный список будущих работ по L7 правилам, renderer, pipeline `validate/apply/rollback`, DNS-стратегии и observability. +- 2026-03-10: исправлен приоритет egress-проверки для `transport:` в `netns`: + - для `SingBox` с локальным `socks` inbound probe теперь сначала идёт через `socks5h://127.0.0.1:` (реальный tunnel egress), а direct-netns probe оставлен только как fallback; + - устранён ложный кейс, когда `transport:sg-realnetns` показывал системный/AdGuard IP вместо IP tunnel-профиля. +- 2026-03-10: дополнили фиксы `SingBox + netns` и старт нового профиля: + - для `transport:` при `netns + socks-inbound` direct-netns fallback отключён; при ошибке proxy probe возвращается ошибка, чтобы UI не показывал ложный AdGuard/system IP как egress transport-клиента; + - в GUI `Run/Start` добавлен preflight `singbox profile apply` (`skip_runtime=true`) перед switch/start, чтобы materialize `config_path` и исключить падение нового клиента с ошибкой `open .../singbox.json: no such file or directory`. +- 2026-03-10: исправлен конфликт `netns`/`agvpn` маршрутов для `SingBox Real NetNS`: + - устранена причина флапов `lookup n3.elmprod.tech ... deadline exceeded` при живом профиле; + - в `traffic_mode` авто-bypass больше не подхватывает `linkdown` маршруты и `svh*/svn*` интерфейсы; + - в `transport_netns` добавлена принудительная синхронизация policy-route (`table agvpn`) для `netns_subnet` на актуальный host-veth при `start/restart` + cleanup при удалении namespace; + - выполнена одноразовая очистка сиротского legacy namespace (`svpn-realnetns`) и проверен стабильный egress (`transport:sg-realnetns` -> `stale=false`, IP/geo обновляется). +- 2026-03-10: доведён `E6` netns runtime contour для `transport:`: + - netns-probe больше не зависит от DNS внутри namespace (`curl --resolve` с host-side резолвом endpoint хоста); + - добавлен fallback probe через локальный `SingBox` SOCKS inbound (`socks5h://127.0.0.1:`) для изолированных netns; + - ограничена длительность netns-probe (по умолчанию `SVPN_EGRESS_NETNS_MAX_ENDPOINTS=1`) и уплотнены сообщения ошибок (`last_error`) для UI. +- 2026-03-10: закрыт E6.2–E6.5 (egress identity implementation): + - добавлены endpoint'ы `GET /api/v1/egress/identity` и `POST /api/v1/egress/identity/refresh` в Go API; + - реализованы scope-provider’ы `adguardvpn|system|transport:` с netns-aware probe для transport; + - добавлены SWR/single-flight/backoff метаданные (`updated_at/stale/refresh_in_progress/last_error/next_retry_at`) и SSE `egress_identity_changed`; + - добавлен GeoIP lookup (country_code/country_name) с кэшем; + - desktop GUI подключён к новому API: `AdGuardVPN` и `SingBox` карточки показывают `IP + country`, флаг строится в UI из `country_code`. +- 2026-03-10: закрыт E6.1 requirements freeze для общего `egress identity`: + - добавлен контракт `docs/phase-e/E6_EGRESS_IDENTITY_API_CONTRACT.md`; + - зафиксированы scope (`adguardvpn|transport:|system`), endpoint'ы `GET /api/v1/egress/identity` и `POST /api/v1/egress/identity/refresh`; + - зафиксированы SWR/SSE поля и правило рендера флага в UI из `country_code`. +- 2026-03-10: ускорен live health-probe для `SingBox`: + - `transport` latency probe timeout снижен до `900ms`; + - устранён lock-contention в background health refresh: долгий `backend.Health()` вынесен из-под `transportMu`, чтобы запросы `GET /transport/clients/{id}/health` не ждали второй probe под mutex. +- 2026-03-09: полировка `SingBox` runtime UX и live latency: + - удалён отдельный блок `Runtime details` (убран дублирующий UI-слой в `SingBox` вкладке); + - `last update` перенесён в верхнюю карточку `Profile` (`id + updated timestamp`); + - GUI переведён на live health для выбранного профиля через `GET /api/v1/transport/clients/{id}/health` (через `ApiClient`/`DashboardController`), поэтому `latency_ms` обновляется сразу после refresh/SSE, а не только по snapshot `/transport/clients`. +- 2026-03-09: закрыт baseline по latency в transport health: + - `systemd` backend теперь пишет `health.latency_ms` для активных клиентов через TCP probe endpoint'ов из runtime-конфига (`singbox outbounds`); + - для `netns_enabled=true` probe выполняется через тот же netns (`ip netns exec/nsenter`), чтобы latency считался по фактическому runtime-контуру клиента; + - при неуспешном probe статус не деградирует (остаётся `up`, latency остаётся пустым), чтобы избежать ложных падений lifecycle; + - добавлены unit-тесты на endpoint extraction и latency sampling (`transport_backends_test.go`). +- 2026-03-09: завершён перенос netns-toggle orchestration из GUI в Go-ядро: + - добавлен и подключён endpoint `POST /api/v1/transport/netns/toggle` (config patch + provision + restart running clients); + - `ApiClient`/`DashboardController` получили отдельный метод `transport_netns_toggle`; + - `netns_debug.py` больше не выполняет локальную orchestration-цепочку и использует только backend call; + - добавлены unit-тесты `applyTransportNetnsToggleLocked` (success / partial-failure / no-targets). +- 2026-03-09: начата унификация SWR-механики для переиспользования: + - добавлен общий модуль `selective-vpn-api/app/refresh_coordinator.go` (`freshTTL`, single-flight refresh gate, exponential backoff with cap, snapshot metadata); + - `vpn_locations_cache.go` переведён на coordinator (без изменения endpoint `/api/v1/vpn/locations` и SSE `vpn_locations_changed`); + - добавлены unit-тесты coordinator (`refresh_coordinator_test.go`). +- 2026-03-09: SWR-схема расширена на transport health: + - подключён endpoint `POST /api/v1/transport/health/refresh` (manual trigger/force queue, без блокировки UI); + - `GET /api/v1/transport/clients` теперь ставит background health-refresh по eligible клиентам (`up|starting|degraded`) через shared coordinator (single-flight + backoff); + - GUI подписан на SSE `transport_client_health_changed`, кнопка refresh сначала триггерит backend health-refresh (best-effort), затем обновляет карточки. +- 2026-03-09: стабилизирован `SingBox + netns` runtime после регрессии: + - backend переведён на adaptive `nsenter` (default) с fallback на `ip netns exec`; + - netns setup/runtime используют единый exec selector (без дублирования режимов); + - исправлен `nft` comment-tag для NAT-правил (совместимо с текущим синтаксисом nft); + - добавлен документ ready-case: `docs/phase-d/D5_NETNS_RUNTIME_CASE.md`. +- 2026-03-09: выполнен рефакторинг netns-модулей: + - GUI логика переключения вынесена в `selective-vpn-gui/netns_debug.py` (state/button/toggle pipeline); + - API exec-логика вынесена в `selective-vpn-api/app/transport_netns_exec.go`; + - `vpn_dashboard_qt.py` и `transport_netns.go` оставлены как orchestrator/UI-обвязка. +- Созданы фазы A–D в `docs/phase-{a,b,c,d}` с описанием целей, критериев и задач. +- Задокументированы ожидания от Go API (routes, traffic, DNS, SmartDNS, VPN). +- Подготовлен первоначальный план для проверки GUI+API разделения. +- Закрыт этап E5.2 в Go API для `SingBox profiles`: + - добавлены endpoint'ы `GET/POST /api/v1/transport/singbox/profiles`, `GET/PATCH/DELETE /api/v1/transport/singbox/profiles/{id}`, `GET /api/v1/transport/singbox/features`; + - добавлен persistent state с ревизиями профилей (`profile_revision`) и active profile resolution; + - добавлен secrets-store (`/var/lib/selective-vpn/transport/secrets/singbox/*.json`, `0600`) с masked выдачей в API и без утечки plaintext в state. +- Добавлены unit-тесты E5.2 (`selective-vpn-api/app/transport_singbox_profiles_test.go`): CRUD+revision conflict, secrets persistence/masking, features endpoint. +- Закрыт этап E5.3 в Go API для `SingBox profiles`: + - добавлены endpoint'ы `POST /api/v1/transport/singbox/profiles/{id}/validate|render|apply|rollback` и `GET /api/v1/transport/singbox/profiles/{id}/history`; + - реализован flow `validate -> render -> apply` с binary-check (`sing-box check`), persisted rendered config и runtime apply через существующий transport backend; + - реализован rollback через history snapshots (включая restore предыдущего target config) + SSE события `singbox_profile_validated|rendered|applied|rollback|failed`. +- Добавлены unit-тесты E5.3 (`selective-vpn-api/app/transport_singbox_profiles_flow_test.go`): render/apply/rollback/history сценарий и негативная валидация. +- Усилены smoke-скрипты в `tests/`: + - `api_sanity.sh` (валидация JSON ключей, проверка method guard, проверка SSE headers), + - `events_stream.py` (живой SSE + триггер `trace_append`), + - `vpn_login_flow.py` (start/state/action/stop), + - `trace_append.sh` (append + readback через plain и json), + - `transport_flow_smoke.py` (state-machine smoke: draft/validate/confirm/apply/rollback). +- Добавлен общий раннер `tests/run_all.sh`. +- Скрипты прогнаны локально с `API_URL=http://127.0.0.1:8080`: все 5 smoke-тестов прошли успешно. +- Phase D дополнен endpoint-level матрицей (`53` legacy ручки) со статусами `ready-read/ready-write/ready-async/ready-interactive`. +- По состоянию на 2026-03-07 в ядре `61` mux-маршрут (`53 legacy + 8 transport base`), плюс action-subpath `GET /api/v1/transport/clients/{id}/metrics` внутри `clients/{id}/*`. +- В Phase C/Phase D добавлены архитектурные требования для переиспользования API на `web + iOS + Android` (единый `/api/v1`, TLS/token auth, mobile fallback/retry/idempotency). +- Зафиксирован frontend-стек для web prototype: `Vite + React + TypeScript` (SPA). `Next.js` отложен как опция только при требованиях SSR/edge/BFF. +- Запущен web prototype foundation в `selective-vpn-web/`: + - App shell (sidebar/header/navigation) на `React Router`, + - query-слой на `TanStack Query` с read-only snapshot (`healthz`, `status`, `vpn/status`, `vpn/login-state`), + - SSE connectivity-hook для `/api/v1/events/stream`, + - placeholder-страницы `VPN/Routes/DNS/Transport/Trace` для поэтапного подключения бизнес-логики. +- Зафиксирован целевой набор внешних transport-клиентов: `sing-box` (клиент), `dnstt-client`, `phoenix` (подключение к `slipstream` серверу). +- Подготовлен отдельный дизайн multi-client маршрутизации поверх единого PBR-ядра: per-client `fwmark/table/pref`, ownership-lock для доменов/IP, conntrack stickiness, UX предупреждения о конфликтных сценариях. +- Подготовлен API-контракт `transport/*` с JSON-примерами, dry-run валидацией и apply-моделью (`docs/phase-e/E2_TRANSPORT_API_CONTRACT.md`). +- В Go-ядре добавлены endpoint'ы `/api/v1/transport/*` (clients/policies/validate/apply/rollback/conflicts/capabilities + `clients/{id}/metrics`). +- Реализованы `validate` и `apply` c anti-conflict guardrails, optimistic revision lock, confirm token для force override, snapshot предыдущей policy и SSE события. +- Добавлены unit-тесты для transport validator/token lifecycle (`selective-vpn-api/app/transport_handlers_test.go`), `go test ./...` проходит. +- Реализован allocator policy v2 для transport clients: резервные диапазоны `mark/pref`, автонормализация состояния, детерминированное восстановление и auto re-balance при коллизиях. +- Реализован `POST /api/v1/transport/policies/rollback`: откат из snapshot с проверкой ревизии и валидацией перед применением. +- Подготовлен документ UX предупреждений и подтверждения конфликтного apply (`docs/phase-e/E4_VALIDATE_CONFIRM_APPLY_UX.md`). +- В `E1`/`E4` добавлены подпункты UX для переключения/подключения engine (`singbox|dnstt|phoenix`): `desired_engine vs active_engine`, switch states, guardrails и rollback action. +- Усилен transport backend-контракт (D4.1) в Go API: + - `GET /api/v1/transport/clients/{id}/metrics`, + - унифицированные DTO `TransportClientLifecycleResponse`, `TransportClientHealthResponse`, `TransportClientMetricsResponse`, + - runtime-поля клиента (`backend`, `allowed_actions`, counters/uptime, last_error с code/message), + - lifecycle/health ответы теперь возвращают кодируемые backend-ошибки и метрики в одном формате. +- Добавлен foundation backend-адаптеров (D4.2) в Go: + - выбор backend по конфигу клиента (`runner=mock|systemd`, fallback в `mock`), + - `provision/lifecycle/health` действия идут через единый adapter слой (без дублирования логики в UI), + - добавлен `POST /api/v1/transport/clients/{id}/provision` для backend-side подготовки runner (systemd units), + - для `dnstt` добавлен режим `ssh_tunnel/ssh_overlay` с orchestration двух unit (`ssh_unit` + `unit`) как единой системы. +- Запущен пункт D4.2.1 (production templates): + - `config.exec_start` переведён в optional override; + - если override не задан, Go-ядро формирует `ExecStart` по шаблонам: + - `singbox`: ` run -c `, + - `dnstt`: resolver/pubkey/domain/local_addr, + - `phoenix`: ` -config `; + - добавлены unit-тесты `transport_backends_test.go` для manual override, template build и DNSTT required fields. +- Запущен пункт D4.2.2 (systemd restart/watchdog tuning): + - для transport unit добавлены настраиваемые `Restart/RestartSec`, `StartLimitIntervalSec/StartLimitBurst`, `TimeoutStartSec/TimeoutStopSec`, `WatchdogSec`; + - для `dnstt + ssh overlay` добавлены отдельные `ssh_*` overrides tuning-полей для SSH unit; + - добавлены unit-тесты рендера tuning-полей (`transport_backends_test.go`). +- Запущен пункт D4.2.3 (unit hardening): + - для systemd unit включён baseline hardening по умолчанию (`NoNewPrivileges`, `ProtectSystem`, `ProtectHome`, `RestrictSUIDSGID`, `UMask` и др.); + - добавлен профиль `strict` и режим `off` через `config.hardening_profile`, а также `config.hardening_enabled`; + - добавлены точечные overrides hardening-полей и `ssh_*` overrides для overlay unit; + - добавлены unit-тесты hardening рендера (`baseline`, disable, `ssh_hardening_enabled=false`). +- Запущен пункт D4.2.4 (`runtime_mode` architecture foundation): + - в `client.config` введён `runtime_mode` (`exec|embedded|sidecar`) с нормализацией alias (`external|companion -> exec`); + - `exec` использует текущую production-цепочку backend-адаптеров; + - `embedded/sidecar` пока отвечают унифицированной ошибкой `TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED` (без silent fallback); + - `GET /api/v1/transport/capabilities` расширен матрицей `runtime_modes`. +- Запущен пункт D4.2.5 (exec packaging profiles): + - для template-команд добавлены профили установки бинарей: `packaging_profile=system|bundled`; + - для `bundled` добавлен `bin_root` (default `/opt/selective-vpn/bin`) и switch `packaging_system_fallback`; + - `require_binary=true` включает fail-fast проверку наличия бинаря (включая ручные `*_bin` override); + - `GET /api/v1/transport/capabilities` расширен `packaging_profiles`. +- Запущен пункт D4.2.6 (manual pinned packaging automation): + - добавлены скрипты `scripts/transport-packaging/update.sh` и `rollback.sh` (checksum verify + atomic symlink switch + history rollback); + - добавлен `manifest.example.json` для pinned версий/URL/checksum; + - добавлен `manifest.production.json` с pinned артефактами для `singbox` (`v1.13.2`) и `phoenix` (`v1.0.1`) под `linux-amd64/linux-arm64`; для `dnstt` зафиксирован prebuilt-source (checksum pinned), но компонент по умолчанию `enabled=false` до trusted-source/signature этапа; + - добавлен foundation trusted source/signature/canary policy: + - `scripts/transport-packaging/source_policy.production.json` (trusted URL-prefix + signature mode policy); + - `update.sh` поддерживает `--source-policy`, `signature.type=openssl-sha256`, `--rollout-stage stable|canary|any`, `--cohort-id`, `--force-rollout`/`--canary`; + - для `manifest.production.json` policy подхватывается автоматически (если `source_policy.production.json` рядом); + - добавлен локальный smoke `tests/transport_packaging_smoke.sh` и включён в `tests/run_all.sh`; + - добавлен дополнительный smoke `tests/transport_packaging_policy_rollout.sh` (source trust + signature verify + canary gating); + - default остаётся manual, но добавлен opt-in auto-update слой. +- Запущен пункт D4.2.7 (auto-update opt-in): + - добавлен `scripts/transport-packaging/auto_update.sh` (interval gate + jitter + flock lock + state files); + - добавлены systemd-шаблоны `scripts/transport-packaging/systemd/transport-packaging-auto-update.{service,timer}` и env-шаблон; + - добавлен smoke `tests/transport_packaging_auto_update.sh` и включён в `tests/run_all.sh`. +- Запущен пункт D4.2.8a (singbox backend e2e): + - добавлен `tests/transport_singbox_e2e.py`: + - успешный lifecycle на `runner=mock` (`provision/start/health/restart/stop/metrics`); + - negative guard `runtime_mode=embedded` -> `TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED`; + - fail-fast `require_binary=true` + missing `singbox_bin` -> `TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED`; + - тест подключён в `tests/run_all.sh`. +- Запущен пункт D4.2.8b (dnstt backend e2e): + - добавлен `tests/transport_dnstt_e2e.py`: + - успешный lifecycle на `runner=mock`; + - guard для `ssh_overlay` конфигурации (`ssh_host` обязателен, `ssh_unit` должен быть валидным) -> `TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED`; + - guard валидации шаблона DNSTT при неполном config -> `TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED`; + - тест подключён в `tests/run_all.sh`. +- Запущен пункт D4.2.8c (phoenix backend e2e): + - добавлен `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` + missing `phoenix_bin` -> `TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED`; + - тест подключён в `tests/run_all.sh`. +- Верификация D4.2.8 на live backend (2026-03-07): + - `selective-vpn-api` пересобран из текущего кода и перезапущен через systemd; + - `./tests/run_all.sh` выполнен полностью без `SKIP` на `transport_singbox_e2e`, `transport_dnstt_e2e`, `transport_phoenix_e2e`; + - подтверждён рабочий контракт `provision/start/health/restart/stop/metrics` и negative guards по каждому transport-клиенту. +- Запущен пункт D4.2.9 (операционные runbook'и): + - добавлен `scripts/transport_runbook.py` для воспроизводимого lifecycle-flow через API (`capabilities/create/provision/start/health/metrics/restart/stop/delete`); + - добавлен smoke `tests/transport_runbook_cli_smoke.sh` (mock singbox lifecycle через runbook helper); + - smoke подключён в `tests/run_all.sh`. +- Запущен пункт D4.2.10 (real-systemd e2e + cleanup): + - в backend добавлен cleanup для `runner=systemd` при `DELETE /api/v1/transport/clients/{id}`: + - удаляются только unit-файлы с ownership-marker `SVPN_TRANSPORT_ID=`; + - выполняется `stop/disable`, затем `daemon-reload` + `reset-failed` (best-effort, с предупреждением в `message`, не блокируя delete); + - добавлен тест `tests/transport_systemd_real_e2e.py`: + - lifecycle для `singbox`, `dnstt(+ssh_unit)`, `phoenix` на реальном `systemd` backend (`runner=systemd`, `exec_start=/usr/bin/sleep`); + - проверка, что unit-файлы создаются с ownership-marker и удаляются после delete cleanup; + - тест подключён в `tests/run_all.sh`. +- Запущен пункт D4.2.11 (production-like transport e2e): + - добавлен тест `tests/transport_production_like_e2e.py`; + - проверяется lifecycle `provision/start/health/metrics/restart/stop/delete` для `singbox|dnstt(+ssh)|phoenix` на `runner=systemd`; + - вместо manual `exec_start` проверяются template-команды и `packaging_profile=bundled` с `bin_root`/`require_binary=true`; + - проверяется, что в unit-файлах присутствуют ожидаемые template-аргументы (`run -c`, `-config`, `-doh`/`domain`/`local_addr`) и ownership marker; + - тест подключён в `tests/run_all.sh`. +- Запущен пункт D4.2.12 (recovery runbook): + - добавлен `scripts/transport_recovery_runbook.py`: + - проверяет health клиента, делает до `N` попыток `restart`, + - при необходимости выполняет fallback `provision -> start`, + - при нерешённой деградации пишет diagnostics (`metrics`, `health`, `client_card`, шаги recovery) и возвращает `rc=2`; + - добавлен smoke `tests/transport_recovery_runbook_smoke.sh`: + - case1: успешное восстановление (`mock+exec`) -> `rc=0`, + - case2: ожидаемый fail-path (`runtime_mode=embedded`) -> `rc=2` + diagnostics file; + - smoke подключён в `tests/run_all.sh`. +- Запущен пункт D4.3 (platform compatibility matrix): + - добавлен артефакт `docs/phase-d/D4_PLATFORM_COMPATIBILITY_MATRIX.md` с матрицей `web + iOS + Android`; + - зафиксированы ограничения runtime (`exec=true`, `embedded/sidecar=false`) и правило backend-only orchestration для `singbox/dnstt/phoenix`; + - добавлен smoke `tests/transport_platform_compatibility_smoke.py` (capabilities + policy-contract checks), тест подключён в `tests/run_all.sh`. +- Верификация D4.3 на live backend (2026-03-07): + - `env API_URL=http://127.0.0.1:8080 ./tests/transport_platform_compatibility_smoke.py` -> `passed`; + - полный `./tests/run_all.sh` выполнен успешно с включённым `transport_platform_compatibility`. +- Запущен пункт D4.2.13 (singbox bootstrap-bypass): + - добавлен backend-модуль `app/transport_bootstrap_bypass.go`; + - для `runner=systemd` в `start/restart/stop` добавлена синхронизация bypass-маршрутов endpoint'ов transport-клиента; + - `singbox` endpoint-hosts извлекаются из `client.config` и `singbox` config-файла (`outbounds[*].server/address/host`); + - перед `start/restart` backend вычисляет `main` default-route и ставит `ip -4 route replace /32 table agvpn ...` (избежание bootstrap через `tun0`); + - на `stop` backend удаляет привязанные bypass-маршруты клиента; + - добавлен strict-режим `config.bootstrap_bypass_strict=true` (ошибка `TRANSPORT_BACKEND_BOOTSTRAP_BYPASS_FAILED` при невозможности применить bypass); + - добавлены unit-тесты `transport_bootstrap_bypass_test.go`, `go test ./...` проходит. +- Запущен пункт D4.2.14 (netns test contour): + - добавлен backend-модуль `app/transport_netns.go`: + - `netns` setup перед `start/restart` (veth pair, `ip_forward`, `nft` masquerade), + - optional cleanup на `stop` (`config.netns_auto_cleanup=true`), + - cleanup при backend `DELETE client` через `Cleanup()` (best-effort); + - `Provision()` для systemd поддерживает запуск transport внутри namespace через adaptive exec (`nsenter` default, fallback `ip netns exec`) при `config.netns_enabled=true`; + - для безопасного fail-fast добавлен strict-режим `config.netns_setup_strict=true` (`TRANSPORT_BACKEND_NETNS_SETUP_FAILED`); + - добавлены unit-тесты `transport_netns_test.go`, `go test ./...` проходит. +- Запущен пункт D4.2.15 (sing-box DNS migration): + - добавлен модуль `app/transport_singbox_dns_migration.go` (best-effort миграция legacy DNS в typed format); + - migration trigger встроен в `Provision()` для `singbox`: + - `dns.servers[*].address` -> `dns.servers[*].type/server/server_port`, + - `address_resolver/address_strategy` -> `domain_resolver/domain_strategy`, + - удаление `detour=direct` для DNS servers (невалидно для новых версий), + - backup оригинала: `.legacy-dns.bak`; + - добавлены config-флаги: + - `singbox_dns_migrate_legacy` (default `true`), + - `singbox_dns_migrate_strict` (fail-fast с `TRANSPORT_BACKEND_SINGBOX_DNS_MIGRATE_FAILED`); + - добавлены unit-тесты `transport_singbox_dns_migration_test.go`; + - проверено на live client `sg-realnetns`: warning `legacy DNS servers is deprecated` исчез, transport после миграции поднимается и трафик через SOCKS проходит. +- В `selective-vpn-gui` добавлены transport методы в `api_client.py` и transition-логика в `dashboard_controller.py`: + - `draft -> validate -> (validated|risky) -> confirm -> apply`, + - обработка `POLICY_REVISION_MISMATCH` с возвратом в `draft`, + - общий foundation для последующего web/iOS/Android UX. +- Запущен пункт E4.3.1 (GUI engine foundation): + - в `selective-vpn-gui` на вкладке `AdGuardVPN` добавлен блок `Transport engine`; + - реализованы действия `Prepare/Connect/Disconnect/Restart` для выбранного transport-клиента через Go API; +- Запущен пункт E5.4.4 (GUI protocol editor flow): + - `Save draft` теперь сохраняет VLESS raw profile через `POST/PATCH /api/v1/transport/singbox/profiles/*` (через controller/api_client); + - при смене transport engine в `SingBox` вкладке editor автоматически подгружает профиль из Go API и отключается при `API unavailable/нет выбранного клиента`; + - `Preview/Validate/Apply` перед вызовом action теперь автоматически синхронизируют текущий draft из формы (guardrail: валидация обязательных client-полей до API action). + - добавлено состояние выбранного engine (status/iface/table/latency/last_error) и авто-обновление по transport SSE-событиям; + - расширен client/controller слой: `ApiClient.transport_client_action()` + `DashboardController.transport_client_action()`. +- Запущен пункт E5.4.5 (dashboard cards + context menu): + - карточки `Connection profiles` переведены на widget-плитки со state-driven стилями; + - активный runtime-профиль (`status=up`) подсвечивается зелёным; + - добавлено правокликовое меню по карточке: `Run`, `Edit`, `Delete`; + - `Edit` открывает отдельный modal-диалог и использует тот же VLESS form-editor (inline-редактор скрыт из основной вкладки); + - для `Delete` добавлен GUI/API flow с поддержкой `force=true` при policy-ссылках. +- Запущен пункт E5.4.6 (editor usability + profile creation): + - `security/transport` в VLESS editor больше не сбрасывают введённые значения при переключении режима; + - `Flow` переведён в editable режим: preset `xtls-rprx-vision` + возможность custom/raw значения; + - в блок `Connection profiles` добавлена кнопка `Create connection` с режимами: + - `Create from clipboard` (парсинг `vless://` из буфера), + - `Create from link...` (вставка URL), + - `Create manual` (пустой профиль с последующим edit); + - для создания профиля добавлен GUI/API create-flow transport-клиента (`POST /api/v1/transport/clients`) и auto-seed editor + optional draft save. +- Запущен пункт E5.4.7 (unified protocols import): + - link-import больше не завязан на VLESS: добавлен общий dispatcher по схеме URL; + - реализованы парсеры и raw-profile build для `vless/trojan/ss/hysteria2(hy2)/tuic`; + - добавлен общий набор helper-функций (query/tls/transport/raw route/inbound builder), чтобы следующие протоколы подключались без повторения кода; + - для non-VLESS профилей сохраняется raw-конфиг и доступен lifecycle (`Run/Validate/Apply`) через тот же pipeline. +- Запущен пункт E5.4.8 (multi-protocol form editor): + - `Protocol` в editor переключается между `vless/trojan/shadowsocks/hysteria2/tuic`; + - форма стала модульной: protocol-specific поля (password/ss_method/hy2_obfs/tuic options) показываются/скрываются по выбранному протоколу; + - `Save draft` теперь пишет `protocol=` и генерирует соответствующий outbound raw-конфиг; + - сохранены guardrails по transport/security и обязательным полям каждого протокола. +- Запущен пункт E5.4.9 (wireguard in common editor): + - добавлен protocol `wireguard` в тот же form-editor, без отдельной ветки UI; + - добавлены поля `private_key`, `peer_public_key`, `pre_shared_key`, `local_address`, `reserved`, `mtu`; + - включена валидация обязательных WG полей и сохранение в raw outbound `type=wireguard`; + - расширен universal link-import parser: поддержка `wireguard://` (и alias `wg://`) в `Create connection`. +- Запущен пункт E4.3.2 (SingBox switch pipeline): + - блок engine вынесен в отдельную вкладку `SingBox` (отдельно от `AdGuardVPN`); + - `Connect/Switch` теперь выполняет policy pipeline `validate -> confirm -> apply` и только потом `start`; + - при блокирующих конфликтах UI показывает confirm-диалог с force apply; + - добавлена кнопка `Rollback policy` (через `POST /api/v1/transport/policies/rollback`). +- Запущен пункт E5.1 (requirements freeze для протоколов SingBox): + - добавлен документ `docs/phase-e/E5_SINGBOX_PROTOCOLS_REQUIREMENTS.md`; + - зафиксированы архитектурные границы (`engine` vs `policy` vs `protocol profile`); + - зафиксированы требования к новой группе API `/api/v1/transport/singbox/profiles/*` (`CRUD/validate/render/apply/rollback/history/features`); + - зафиксированы требования к `typed + raw` режимам, secrets storage, versioning и SSE событиям. +- Запущен пункт E5.1.2 (protocol field inventory): + - добавлен шаблон `docs/phase-e/E5_SINGBOX_PROTOCOL_MATRIX_TEMPLATE.md`; + - добавлена заполненная матрица `docs/phase-e/E5_SINGBOX_PROTOCOLS_MATRIX.md` (общие блоки + 6 протоколов + guardrails + MVP/Advanced/Raw-only split); + - добавлен machine-readable пример `docs/phase-e/E5_SINGBOX_PROTOCOLS_MANIFEST.example.json` для последующей генерации UI-форм. +- Запущен пункт E5.1.3 (client form baseline): + - добавлен документ `docs/phase-e/E5_SINGBOX_CLIENT_FORM_MATRIX.md`; + - зафиксированы UI-блоки клиентской формы (`Profile`, `Server/Auth`, `Transport`, `Security`, `Sniffing`, `Advanced Dial`); + - явно исключены server-only поля (billing/traffic/expiry/subscription) и добавлены guardrails для `VLESS+Reality`. +- Запущен пункт E5.4.1 (desktop dashboard foundation): + - добавлен документ `docs/phase-e/E5_2_SINGBOX_DESKTOP_DASHBOARD_SPEC.md`; + - вкладка `SingBox` перестроена на 3 зоны: runtime card, profile settings, global defaults; + - внедрён `card-based dashboard`: верхние metric cards + grid profile cards (с выбором карточки); + - включён compact UX: настройки и activity log открываются кнопками (по умолчанию скрыты); + - добавлены `Use global` override-переключатели, effective summary и локальное сохранение настроек; + - runtime pipeline `Prepare/Connect-Switch/Disconnect/Restart/Rollback` сохранён без изменения API-контракта. +- Запущен пункт E5.4.2 (GUI wiring profile actions): + - в `selective-vpn-gui/api_client.py` добавлены typed-модели и методы для `POST /api/v1/transport/singbox/profiles/{id}/validate|apply`; + - в `selective-vpn-gui/dashboard_controller.py` добавлены `singbox_profile_validate_action()` и `singbox_profile_apply_action()` с унифицированным `ActionView`; + - в `selective-vpn-gui/vpn_dashboard_qt.py` заглушки `Validate profile`/`Apply profile` заменены на реальные вызовы API с логированием в activity-log и авто-refresh runtime state. +- Запущен пункт E5.4.3 (profile flow completeness + UX hardening): + - в `selective-vpn-gui/api_client.py` добавлены методы `profiles list/get/create/patch/render/rollback/history` и соответствующие typed DTO; + - в `selective-vpn-gui/dashboard_controller.py` добавлены: + - `singbox_profile_render_preview_action()`, + - `singbox_profile_rollback_action()`, + - `singbox_profile_history_lines()`, + - `singbox_profile_ensure_linked()` с auto-create профиля из `config_path` (raw mode) и auto-link `meta.client_id`; + - в `selective-vpn-gui/vpn_dashboard_qt.py` добавлены кнопки `Preview render`, `Rollback profile`, `History`; + - добавлена авто-синхронизация `engine -> profile` при выборе/refresh engine (`_sync_selected_singbox_profile_link`); + - выполнен лёгкий рефакторинг profile handlers (`_selected_singbox_profile_context`, `_run_singbox_profile_action`) без смены backend-контракта. +- Запущен пункт B3 (resolver diff + improvement plan): + - добавлен документ `docs/phase-b/B2_RESOLVER_DIFF_AND_IMPROVEMENT_PLAN.md`; + - зафиксированы границы ролей `system resolver` (authoritative для PBR) и `singbox DNS` (transport-level resolver); + - зафиксирован приоритетный backlog улучшений resolver (`R1-R4`: надёжность, качество резолва, observability API/SSE, multi-client ownership safety). +- Запущен пункт F1.1 (план модульности): + - добавлен документ `docs/phase-f/F1_REFACTOR_MODULARITY_PLAN.md`; + - зафиксированы целевые разрезы для `vpn_dashboard_qt.py`, `api_client.py`, `dashboard_controller.py`, `transport_handlers.go`; + - зафиксирован безопасный порядок реализации и DoD без изменения API-контракта. +- Текущий статус: transport backend/API foundation и базовый desktop UI-flow включены (`SingBox` tab: lifecycle actions + switch pipeline + rollback). +- Зафиксирован план по VPN локациям: + - без большой кнопки `Refresh`/`Apply`, но с лёгким icon-trigger refresh (фоновый, неблокирующий), + - в будущем без кнопки `Apply location` (выбор в списке сразу инициирует connect/reconnect), + - список грузится асинхронно, UI не блокируется при недоступном CLI/большом latency, + - используется кеш последнего успешного списка + SWR (stale-while-revalidate) + backoff/retry + single-flight lock. +- Диагностирован кейс `eu.posthog.com` (страница "region unavailable" в браузере при включенном wildcard): + - root cause: `smartdns` runtime был с `nftset-timeout yes`, а `agvpn_dyn4` создан без `timeout`-флага (`flags interval`), из-за чего runtime-добавление IP не происходило; + - applied fix: `nftset-timeout no` в `smartdns.conf`; + - verification: после `smartdns-local` restart + DNS query через `127.0.0.1#6053` все актуальные `A`-IP `eu.posthog.com` появились в `inet/agvpn/agvpn_dyn4`. +- Диагностирован второй корень проблемы для `selective`: + - `routes/update` перезаписывал `agvpn_dyn4` только списком из resolver cache, из-за чего runtime-IP (SmartDNS nftset) периодически терялись; + - applied fix в Go (`routes_update.go`): merge `resolver wildcard IPs` + `existing agvpn_dyn4` при `runtime_nftset=true`; + - verification: после `smartdns` query и `POST /api/v1/routes/update` PostHog IP (`3.121.142.0`, `3.67.52.82`, `18.158.106.188`, `63.183.90.15`, `63.177.143.4`, `63.182.85.19`) не исчезают из `agvpn_dyn4`. +- По запросу добавлен временный static fallback для PostHog в `/etc/selective-vpn/static-ips.txt`: + - `3.121.142.0/24`, `3.67.52.0/24`, `18.158.106.0/24`, `63.177.143.0/24`, `63.182.85.0/24`, `63.183.90.0/24`; + - verification: после `routes/update` подсети присутствуют в `inet/agvpn/agvpn4`. +- Расширено покрытие PostHog-хостов для selective: + - в wildcard state добавлены `app-static.eu.posthog.com` и `internal-j.posthog.com`; + - добавлен временный static fallback для `internal-j.posthog.com`: `3.88.247.0/24`, `3.95.129.0/24`, `54.90.36.0/24`; + - verification: по packet capture (`tcpdump`) HTTPS к `internal-j.posthog.com` уходит через `tun0` (маркировка срабатывает). +- Финальная проверка: пользователь подтвердил, что `eu.posthog.com` в режиме `selective` открывается и работает штатно. +- Реализован backend cache/SWR для `/api/v1/vpn/locations`: + - мгновенный ответ из кеша (`/var/lib/selective-vpn/vpn-locations-cache.json`), + - фоновый refresh `list-locations` с single-flight lock, + - метаданные ответа: `updated_at`, `stale`, `refresh_in_progress`, `last_error`, `next_retry_at`, + - backoff на ошибках, SSE-событие `vpn_locations_changed`. +- Реализована стабилизация GUI по локациям: + - загрузка списка через отдельный `QThread` (без блокировки главного потока), + - убрана кнопка `Apply & restart loop`, включён auto-apply при выборе локации в `QComboBox`, + - добавлена status/meta строка для состояния кеша/обновления/ошибок. +- Реализован V2 "умный поиск" без отдельного поля ввода: + - typed-buffer в списке локаций с live-фильтрацией и сортировкой по релевантности, + - матч по началу ISO/строки/слова, backspace уменьшает фильтр, + - таймаут-сброс буфера. +- Исправлена точность применения VPN-локации: + - вместо `ISO` в `connect -l` теперь передаётся точный `target` локации (например `United States Los Angeles`), + - устранён кейс, когда в UI выбран один город, а autoloop подключается к другому дефолтному городу страны. +- Усилен safety flow для `set location` (L1 hardening): + - backend теперь резолвит пользовательский выбор в безопасный `connect`-аргумент (`city` или `ISO`) по каталогу локаций, + - при нераспознанной локации возвращается `422` без рестарта autoloop (текущий туннель сохраняется), + - в GUI при apply передаётся `target + iso + label`, чтобы повысить точность матчинга и убрать ложные реконнекты. +- Добавлены проверки для блока локаций: + - `tests/vpn_locations_swr.sh`, + - unit-тесты parser-а `selective-vpn-api/app/vpn_locations_cache_test.go`. +- Smoke-набор `tests/run_all.sh` обновлён и прогнан успешно (включая новый `vpn_locations_swr`). +- В GUI локаций `Sort/Refresh` оставлены снаружи списка (рядом с `Location`), popup содержит только локации. +- `Refresh` отправляет trigger `GET /api/v1/vpn/locations?refresh=1` в фоне через `LocationsThread`, UI не блокируется. + +## Следующие шаги +- M1 (priority): закрыть backend multi-interface foundation: + - interface orchestrator (`E3.3`), ownership registry (`E3.4`), anti-mixing guard (`E3.5`), transaction pipeline (`E3.6`); + - критерий: минимум 2 engine работают одновременно без пересечения table/mark/pref и без ложного egress mixing. +- M2 (priority): довести runtime наблюдаемость (`E6.6`) для всех engine в едином контракте ядра. +- M3 (priority): после backend foundation включить GUI-флоу выбора/переключения engine-профилей поверх нового orchestration-слоя (тонкий UI, без бизнес-логики в GUI). +- E5.4: реализовать GUI-блок протоколов в `SingBox` вкладке (list/editor/validate/preview/apply/rollback). +- B3.1: реализовать resolver status snapshot API + SSE (`resolver_status_changed`, `resolver_refresh_completed`). +- B3.2: реализовать adaptive upstream scoring + negative cache TTL policy. +- B3.3: внедрить `domain_owner_map` и conflict guard для multi-client DNS ownership. +- D4.2 (supporting track): поддерживать backend-ready состояние `dnstt-client` и `phoenix` без UI-расширения в текущем этапе. +- P1.1: спроектировать `service profiles` (например, `PostHog`) как пресеты доменов/wildcard/static-fallback/policy. +- P1.2: добавить API для профилей (`list/apply/remove/export`) и idempotent apply в Go-ядре. +- P1.3: добавить в GUI чекбоксы/тогглы профилей с применением в 1 клик и rollback. +- P1.4: подготовить переносимый формат профилей для web + iOS + Android (единый JSON schema). +- V2.2: собрать пользовательский отчёт по UX поиска/автоприменения локаций в desktop (latency, false matches, edge-cases). +- V2.3: при подтверждении UX перенести одинаковую модель локаций (`cache/SWR + typed-buffer + auto-apply`) в web/mobile клиенты. +- Доработать Phase B (описание контроллеров, зависимости, условия запуска). +- Завершить Phase D (матрица endpoint со статусом `web-ready` и явные блокеры для веб-прототипа). +- Формализовать реализацию D3.1-D3.4 (gateway/auth, web старт, mobile transport profile, запуск iOS/Android на общем API). +- E4.4: перенести foundation state-machine в web prototype UI (`React + TypeScript` на `Vite`) без изменения API-контракта Go. +- E4.5: добавить настройки видимости protocol tabs (включать/выключать вкладки `SingBox/DNSTT/Phoenix`) через UI settings/profile. +- F1.4: выполнить декомпозицию `selective-vpn-gui/vpn_dashboard_qt.py` на tab/modules и вынести event/locations сервисы. +- F1.11: вынести переиспользуемую transport-логику в подпакеты (`app/transport/*`) для частей без циклических зависимостей (через facade+deps). +- F1.11.r1: продолжить resolver-декомпозицию в отдельной папке `app/resolver/*` через bridge-слой (`domain cache`, `adaptive live-batch`, `io/json helpers`) без смены внешнего поведения. +- F2.1 (post-refactor): перейти на `singbox@.service` как целевой production-runtime (instance-per-profile + без лавины unit-файлов). +- F2.2 (post-refactor): сделать управляемую миграцию/cleanup старых `singbox-*.service` и зафиксировать runbook для деплоя/отката. + +## Дальний backlog (после завершения текущего приложения) +- L1.1: `AmneziaWG` интеграция как отдельный transport executor/backend kind (`amneziawg`) через единый Go control-plane API. +- L1.2: поддержка `AmneziaWG` в GUI как отдельный engine/profile workflow (без смешивания с текущим `wireguard`/`singbox` этапом). +- L1.3: обновление capability-матрицы, runbook/e2e и packaging-профилей для `amneziawg` после полного закрытия приоритетных задач desktop (`SingBox` + core stability). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d5a319e --- /dev/null +++ b/docs/README.md @@ -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@.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. diff --git a/docs/phase-a/A1_API_AUDIT.md b/docs/phase-a/A1_API_AUDIT.md new file mode 100644 index 0000000..339868f --- /dev/null +++ b/docs/phase-a/A1_API_AUDIT.md @@ -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` diff --git a/docs/phase-b/B1_CORE_VERIFICATION.md b/docs/phase-b/B1_CORE_VERIFICATION.md new file mode 100644 index 0000000..47478e0 --- /dev/null +++ b/docs/phase-b/B1_CORE_VERIFICATION.md @@ -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. diff --git a/docs/phase-b/B2_RESOLVER_DIFF_AND_IMPROVEMENT_PLAN.md b/docs/phase-b/B2_RESOLVER_DIFF_AND_IMPROVEMENT_PLAN.md new file mode 100644 index 0000000..69bb2bd --- /dev/null +++ b/docs/phase-b/B2_RESOLVER_DIFF_AND_IMPROVEMENT_PLAN.md @@ -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 трека. diff --git a/docs/phase-b/B4_RUNTIME_DEPENDENCIES_AND_PREFLIGHT.md b/docs/phase-b/B4_RUNTIME_DEPENDENCIES_AND_PREFLIGHT.md new file mode 100644 index 0000000..5962919 --- /dev/null +++ b/docs/phase-b/B4_RUNTIME_DEPENDENCIES_AND_PREFLIGHT.md @@ -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.*`) быстро проверяем среду и снижаем ложные регрессии. diff --git a/docs/phase-b/B5_SINGBOX_TEMPLATE_MIGRATION_ROLLBACK_RUNBOOK.md b/docs/phase-b/B5_SINGBOX_TEMPLATE_MIGRATION_ROLLBACK_RUNBOOK.md new file mode 100644 index 0000000..fd278d2 --- /dev/null +++ b/docs/phase-b/B5_SINGBOX_TEMPLATE_MIGRATION_ROLLBACK_RUNBOOK.md @@ -0,0 +1,61 @@ +# B5 SingBox Template Migration And Rollback Runbook + +## Цель +Операционный runbook для миграции `legacy singbox-.service` к template-модели `singbox@.service` и для аварийного отката при инциденте деплоя. + +## Контекст текущей модели +- Целевой runtime: `singbox@.service` + per-instance drop-in `singbox@.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=`. + +## 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@.service`. +3. Проверить: + - `systemctl status 'singbox@.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-.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`. diff --git a/docs/phase-c/C1_WEB_READINESS.md b/docs/phase-c/C1_WEB_READINESS.md new file mode 100644 index 0000000..c756fde --- /dev/null +++ b/docs/phase-c/C1_WEB_READINESS.md @@ -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. diff --git a/docs/phase-c/C2_WEB_STACK_DECISION.md b/docs/phase-c/C2_WEB_STACK_DECISION.md new file mode 100644 index 0000000..0862f37 --- /dev/null +++ b/docs/phase-c/C2_WEB_STACK_DECISION.md @@ -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. diff --git a/docs/phase-d/D1_GO_READINESS_DOCS.md b/docs/phase-d/D1_GO_READINESS_DOCS.md new file mode 100644 index 0000000..b02fdab --- /dev/null +++ b/docs/phase-d/D1_GO_READINESS_DOCS.md @@ -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. diff --git a/docs/phase-d/D4_PLATFORM_COMPATIBILITY_MATRIX.md b/docs/phase-d/D4_PLATFORM_COMPATIBILITY_MATRIX.md new file mode 100644 index 0000000..3a36784 --- /dev/null +++ b/docs/phase-d/D4_PLATFORM_COMPATIBILITY_MATRIX.md @@ -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. diff --git a/docs/phase-d/D5_NETNS_RUNTIME_CASE.md b/docs/phase-d/D5_NETNS_RUNTIME_CASE.md new file mode 100644 index 0000000..2c29c10 --- /dev/null +++ b/docs/phase-d/D5_NETNS_RUNTIME_CASE.md @@ -0,0 +1,34 @@ +# D5: NetNS Runtime Case (Ready) + +Дата: 2026-03-09 + +## Что зафиксировано +- `SingBox` может работать в отдельном `netns` без влияния на основной VPN-контур. +- Для запуска внутри namespace используется адаптивный exec-режим: + - default: `nsenter --net=/var/run/netns/ -- ...` + - fallback: `ip netns exec ...` +- Подготовка `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//restart` -> `ok=true`. +2. `GET /api/v1/transport/clients//health` -> `status=up`. +3. `systemctl status ` -> `active (running)`. +4. `systemctl show -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) diff --git a/docs/phase-e/E1_MULTI_CLIENT_PBR_DESIGN.md b/docs/phase-e/E1_MULTI_CLIENT_PBR_DESIGN.md new file mode 100644 index 0000000..7019110 --- /dev/null +++ b/docs/phase-e/E1_MULTI_CLIENT_PBR_DESIGN.md @@ -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_`). +- `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`. diff --git a/docs/phase-e/E2_TRANSPORT_API_CONTRACT.md b/docs/phase-e/E2_TRANSPORT_API_CONTRACT.md new file mode 100644 index 0000000..9997552 --- /dev/null +++ b/docs/phase-e/E2_TRANSPORT_API_CONTRACT.md @@ -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`: ` run -c `; + - `phoenix`: ` -config `. + +Примечание для `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). diff --git a/docs/phase-e/E3_MULTI_INTERFACE_EXECUTION_PLAN.md b/docs/phase-e/E3_MULTI_INTERFACE_EXECUTION_PLAN.md new file mode 100644 index 0000000..cb78c97 --- /dev/null +++ b/docs/phase-e/E3_MULTI_INTERFACE_EXECUTION_PLAN.md @@ -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`). diff --git a/docs/phase-e/E4_2_MULTI_INTERFACE_GUI_DESIGN.md b/docs/phase-e/E4_2_MULTI_INTERFACE_GUI_DESIGN.md new file mode 100644 index 0000000..0b1bf57 --- /dev/null +++ b/docs/phase-e/E4_2_MULTI_INTERFACE_GUI_DESIGN.md @@ -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:`. + +### 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. diff --git a/docs/phase-e/E4_VALIDATE_CONFIRM_APPLY_UX.md b/docs/phase-e/E4_VALIDATE_CONFIRM_APPLY_UX.md new file mode 100644 index 0000000..7d4c30d --- /dev/null +++ b/docs/phase-e/E4_VALIDATE_CONFIRM_APPLY_UX.md @@ -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`). diff --git a/docs/phase-e/E5_2_SINGBOX_DESKTOP_DASHBOARD_SPEC.md b/docs/phase-e/E5_2_SINGBOX_DESKTOP_DASHBOARD_SPEC.md new file mode 100644 index 0000000..517ebf2 --- /dev/null +++ b/docs/phase-e/E5_2_SINGBOX_DESKTOP_DASHBOARD_SPEC.md @@ -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. diff --git a/docs/phase-e/E5_SINGBOX_CLIENT_FORM_MATRIX.md b/docs/phase-e/E5_SINGBOX_CLIENT_FORM_MATRIX.md new file mode 100644 index 0000000..ba35cbb --- /dev/null +++ b/docs/phase-e/E5_SINGBOX_CLIENT_FORM_MATRIX.md @@ -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`. +- То есть форма будет модульной, а не отдельное "окно под каждый протокол". + diff --git a/docs/phase-e/E5_SINGBOX_PROTOCOLS_MANIFEST.example.json b/docs/phase-e/E5_SINGBOX_PROTOCOLS_MANIFEST.example.json new file mode 100644 index 0000000..bfb7a7f --- /dev/null +++ b/docs/phase-e/E5_SINGBOX_PROTOCOLS_MANIFEST.example.json @@ -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" + } + ] + } + ] +} + diff --git a/docs/phase-e/E5_SINGBOX_PROTOCOLS_MATRIX.md b/docs/phase-e/E5_SINGBOX_PROTOCOLS_MATRIX.md new file mode 100644 index 0000000..1d60c86 --- /dev/null +++ b/docs/phase-e/E5_SINGBOX_PROTOCOLS_MATRIX.md @@ -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 манифест для последующей генерации форм. + diff --git a/docs/phase-e/E5_SINGBOX_PROTOCOLS_REQUIREMENTS.md b/docs/phase-e/E5_SINGBOX_PROTOCOLS_REQUIREMENTS.md new file mode 100644 index 0000000..15fb176 --- /dev/null +++ b/docs/phase-e/E5_SINGBOX_PROTOCOLS_REQUIREMENTS.md @@ -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). diff --git a/docs/phase-e/E5_SINGBOX_PROTOCOL_MATRIX_TEMPLATE.md b/docs/phase-e/E5_SINGBOX_PROTOCOL_MATRIX_TEMPLATE.md new file mode 100644 index 0000000..cd799da --- /dev/null +++ b/docs/phase-e/E5_SINGBOX_PROTOCOL_MATRIX_TEMPLATE.md @@ -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`: `` +- `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 | `` | - | 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. + diff --git a/docs/phase-e/E6_EGRESS_IDENTITY_API_CONTRACT.md b/docs/phase-e/E6_EGRESS_IDENTITY_API_CONTRACT.md new file mode 100644 index 0000000..3644750 --- /dev/null +++ b/docs/phase-e/E6_EGRESS_IDENTITY_API_CONTRACT.md @@ -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:` + - `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:|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`. diff --git a/docs/phase-f/F1_REFACTOR_MODULARITY_PLAN.md b/docs/phase-f/F1_REFACTOR_MODULARITY_PLAN.md new file mode 100644 index 0000000..358804a --- /dev/null +++ b/docs/phase-f/F1_REFACTOR_MODULARITY_PLAN.md @@ -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. diff --git a/docs/phase-f/F3_CORE_MODULE_LIBRARY_PLAN.md b/docs/phase-f/F3_CORE_MODULE_LIBRARY_PLAN.md new file mode 100644 index 0000000..150f6cb --- /dev/null +++ b/docs/phase-f/F3_CORE_MODULE_LIBRARY_PLAN.md @@ -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 ./...` зелёный после миграции. diff --git a/scripts/check_runtime_dependencies.sh b/scripts/check_runtime_dependencies.sh new file mode 100755 index 0000000..67a99fc --- /dev/null +++ b/scripts/check_runtime_dependencies.sh @@ -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 diff --git a/scripts/transport-packaging/README.md b/scripts/transport-packaging/README.md new file mode 100644 index 0000000..655233d --- /dev/null +++ b/scripts/transport-packaging/README.md @@ -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/.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..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. diff --git a/scripts/transport-packaging/auto_update.sh b/scripts/transport-packaging/auto_update.sh new file mode 100755 index 0000000..558fb02 --- /dev/null +++ b/scripts/transport-packaging/auto_update.sh @@ -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" diff --git a/scripts/transport-packaging/manifest.example.json b/scripts/transport-packaging/manifest.example.json new file mode 100644 index 0000000..fd57e46 --- /dev/null +++ b/scripts/transport-packaging/manifest.example.json @@ -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" + } + } + } + } + } +} diff --git a/scripts/transport-packaging/manifest.production.json b/scripts/transport-packaging/manifest.production.json new file mode 100644 index 0000000..26b8acd --- /dev/null +++ b/scripts/transport-packaging/manifest.production.json @@ -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 + } + } + } + } + } +} diff --git a/scripts/transport-packaging/rollback.sh b/scripts/transport-packaging/rollback.sh new file mode 100755 index 0000000..5bd4906 --- /dev/null +++ b/scripts/transport-packaging/rollback.sh @@ -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" diff --git a/scripts/transport-packaging/source_policy.example.json b/scripts/transport-packaging/source_policy.example.json new file mode 100644 index 0000000..cb52f0c --- /dev/null +++ b/scripts/transport-packaging/source_policy.example.json @@ -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" + } + } +} diff --git a/scripts/transport-packaging/source_policy.production.json b/scripts/transport-packaging/source_policy.production.json new file mode 100644 index 0000000..343e294 --- /dev/null +++ b/scripts/transport-packaging/source_policy.production.json @@ -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" + } + } +} diff --git a/scripts/transport-packaging/systemd/transport-packaging-auto-update.env.example b/scripts/transport-packaging/systemd/transport-packaging-auto-update.env.example new file mode 100644 index 0000000..9109974 --- /dev/null +++ b/scripts/transport-packaging/systemd/transport-packaging-auto-update.env.example @@ -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 diff --git a/scripts/transport-packaging/systemd/transport-packaging-auto-update.service b/scripts/transport-packaging/systemd/transport-packaging-auto-update.service new file mode 100644 index 0000000..8a105eb --- /dev/null +++ b/scripts/transport-packaging/systemd/transport-packaging-auto-update.service @@ -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 diff --git a/scripts/transport-packaging/systemd/transport-packaging-auto-update.timer b/scripts/transport-packaging/systemd/transport-packaging-auto-update.timer new file mode 100644 index 0000000..851371d --- /dev/null +++ b/scripts/transport-packaging/systemd/transport-packaging-auto-update.timer @@ -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 diff --git a/scripts/transport-packaging/update.sh b/scripts/transport-packaging/update.sh new file mode 100755 index 0000000..45e1dc1 --- /dev/null +++ b/scripts/transport-packaging/update.sh @@ -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' 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" diff --git a/scripts/transport_recovery_runbook.py b/scripts/transport_recovery_runbook.py new file mode 100755 index 0000000..115e0c0 --- /dev/null +++ b/scripts/transport_recovery_runbook.py @@ -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()) diff --git a/scripts/transport_runbook.py b/scripts/transport_runbook.py new file mode 100755 index 0000000..f660263 --- /dev/null +++ b/scripts/transport_runbook.py @@ -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()) diff --git a/selective-vpn-api/app/api_bootstrap.go b/selective-vpn-api/app/api_bootstrap.go new file mode 100644 index 0000000..bc7f385 --- /dev/null +++ b/selective-vpn-api/app/api_bootstrap.go @@ -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) + } +} diff --git a/selective-vpn-api/app/api_routes.go b/selective-vpn-api/app/api_routes.go new file mode 100644 index 0000000..987514c --- /dev/null +++ b/selective-vpn-api/app/api_routes.go @@ -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, + }) +} diff --git a/selective-vpn-api/app/apiroutes/register.go b/selective-vpn-api/app/apiroutes/register.go new file mode 100644 index 0000000..e793b6a --- /dev/null +++ b/selective-vpn-api/app/apiroutes/register.go @@ -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) +} diff --git a/selective-vpn-api/app/autoloop.go b/selective-vpn-api/app/autoloop.go index 88c89bb..9e85fd7 100644 --- a/selective-vpn-api/app/autoloop.go +++ b/selective-vpn-api/app/autoloop.go @@ -4,7 +4,6 @@ import ( "fmt" "log" "os" - "regexp" "strings" "time" ) @@ -44,28 +43,7 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string) } writeLoginState := func(state, email, msg string) { - ts := time.Now().Format(time.RFC3339) - payload := fmt.Sprintf(`{"ts":"%s","state":"%s","email":"%s","msg":"%s"}`, ts, escapeJSON(state), escapeJSON(email), escapeJSON(msg)) - _ = os.WriteFile(loginStateFile, []byte(payload), 0o644) - } - - getLocation := func() string { - if data, err := os.ReadFile(locFile); err == nil { - for _, ln := range strings.Split(string(data), "\n") { - t := strings.TrimSpace(ln) - if t != "" && !strings.HasPrefix(t, "#") { - return t - } - } - } - return defaultLoc - } - - isConnected := func(out string) bool { - low := strings.ToLower(out) - return strings.Contains(low, "vpn is connected") || - strings.Contains(low, "connected to") || - strings.Contains(low, "after connect: connected") + writeAutoloopLoginState(loginStateFile, state, email, msg) } fixPolicy := func() { @@ -83,45 +61,9 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string) " mtu " + fmt.Sprintf("%d", mtu) + " OK") } } - var emailRe = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+`) - parseEmail := func(text string) string { - return emailRe.FindString(text) - } - - isLoginRequired := func(t string) bool { - low := strings.ToLower(t) - return strings.Contains(low, "please log in") || - strings.Contains(low, "not logged in") || - strings.Contains(low, "login required") || - strings.Contains(low, "sign in") - } updateLoginStateFromText := func(text string) { - if isLoginRequired(text) { - writeLoginState("no_login", "", "NOT LOGGED IN") - logLine("login: NO (detected from output)") - return - } - if em := parseEmail(text); em != "" { - writeLoginState("ok", em, "logged in") - logLine("login: OK email=" + em) - return - } - low := strings.ToLower(text) - if strings.Contains(low, "not logged in") || - strings.Contains(low, "expired") || - strings.Contains(low, "no active license") { - writeLoginState("no_login", "", "NOT LOGGED IN (license)") - logLine("login: NO (license says not logged in)") - return - } - - if strings.Contains(low, "license") && - (strings.Contains(low, "active") || strings.Contains(low, "valid")) { - writeLoginState("ok", "", "logged in (license ok)") - logLine("login: OK (license ok, email not found)") - return - } + updateAutoloopLoginStateFromText(text, writeLoginState, logLine) } updateLicense := func() { @@ -144,7 +86,7 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string) if err != nil { logLine(fmt.Sprintf("status: ERROR exit=%d err=%v raw=%q", exitCode, err, statusOut)) } - if isConnected(statusOut) { + if isAutoloopConnected(statusOut) { logLine("status: CONNECTED; raw: " + statusOut) fixPolicy() updateLicense() @@ -163,18 +105,30 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string) }) updateLoginStateFromText(statusOut) - loc := getLocation() - logLine("reconnecting to " + loc) + loc := resolveAutoloopLocationSpec(locFile, defaultLoc) + primary := strings.TrimSpace(loc.Primary) + if primary == "" { + primary = strings.TrimSpace(defaultLoc) + } + logLine("reconnecting to " + primary) _, _, _, _ = runCommandTimeout(disconnectTimeout, adgvpnCLI, "disconnect") - connectOut, _, _, _ := runCommandTimeout(connectTimeout, adgvpnCLI, "connect", "-l", loc, "--log-to-file") + connectOut, _, _, _ := runCommandTimeout(connectTimeout, adgvpnCLI, "connect", "-l", primary, "--log-to-file") connectOut = stripANSI(connectOut) logLine("connect raw: " + connectOut) updateLoginStateFromText(connectOut) + if !isAutoloopConnected(connectOut) && loc.ISO != "" && !strings.EqualFold(loc.ISO, primary) { + logLine("connect fallback to ISO: " + loc.ISO) + fallbackOut, _, _, _ := runCommandTimeout(connectTimeout, adgvpnCLI, "connect", "-l", loc.ISO, "--log-to-file") + fallbackOut = stripANSI(fallbackOut) + logLine("connect fallback raw: " + fallbackOut) + updateLoginStateFromText(fallbackOut) + } + statusAfter, _, _, _ := runCommandTimeout(statusTimeout, adgvpnCLI, "status") statusAfter = stripANSI(statusAfter) - if isConnected(statusAfter) { + if isAutoloopConnected(statusAfter) { logLine("after connect: CONNECTED; raw: " + statusAfter) fixPolicy() updateLicense() @@ -190,15 +144,3 @@ func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string) time.Sleep(10 * time.Second) } } - -// --------------------------------------------------------------------- -// autoloop helpers -// --------------------------------------------------------------------- - -func escapeJSON(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `"`, `\\"`) - s = strings.ReplaceAll(s, "\n", "\\n") - s = strings.ReplaceAll(s, "\r", "") - return s -} diff --git a/selective-vpn-api/app/autoloop_helpers.go b/selective-vpn-api/app/autoloop_helpers.go new file mode 100644 index 0000000..98cafa3 --- /dev/null +++ b/selective-vpn-api/app/autoloop_helpers.go @@ -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.\-]+`) diff --git a/selective-vpn-api/app/autoloop_helpers_location.go b/selective-vpn-api/app/autoloop_helpers_location.go new file mode 100644 index 0000000..bb01f2f --- /dev/null +++ b/selective-vpn-api/app/autoloop_helpers_location.go @@ -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 "" +} diff --git a/selective-vpn-api/app/autoloop_helpers_login.go b/selective-vpn-api/app/autoloop_helpers_login.go new file mode 100644 index 0000000..f8a3ffb --- /dev/null +++ b/selective-vpn-api/app/autoloop_helpers_login.go @@ -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 +} diff --git a/selective-vpn-api/app/bootstrap/server_runner.go b/selective-vpn-api/app/bootstrap/server_runner.go new file mode 100644 index 0000000..4c76c97 --- /dev/null +++ b/selective-vpn-api/app/bootstrap/server_runner.go @@ -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 +} diff --git a/selective-vpn-api/app/cli/autoloop.go b/selective-vpn-api/app/cli/autoloop.go new file mode 100644 index 0000000..fd3b32a --- /dev/null +++ b/selective-vpn-api/app/cli/autoloop.go @@ -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 +} diff --git a/selective-vpn-api/app/cli/routes_clear.go b/selective-vpn-api/app/cli/routes_clear.go new file mode 100644 index 0000000..fc074f1 --- /dev/null +++ b/selective-vpn-api/app/cli/routes_clear.go @@ -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 +} diff --git a/selective-vpn-api/app/cli/routes_update.go b/selective-vpn-api/app/cli/routes_update.go new file mode 100644 index 0000000..6d2bc4d --- /dev/null +++ b/selective-vpn-api/app/cli/routes_update.go @@ -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 +} diff --git a/selective-vpn-api/app/config.go b/selective-vpn-api/app/config.go index 8825cf4..b17e80d 100644 --- a/selective-vpn-api/app/config.go +++ b/selective-vpn-api/app/config.go @@ -12,28 +12,40 @@ import "embed" // --------------------------------------------------------------------- const ( - stateDir = "/var/lib/selective-vpn" - statusFilePath = stateDir + "/status.json" - dnsModePath = stateDir + "/dns-mode.json" - trafficModePath = stateDir + "/traffic-mode.json" - trafficAppMarksPath = stateDir + "/traffic-appmarks.json" - trafficAppProfilesPath = stateDir + "/traffic-app-profiles.json" + stateDir = "/var/lib/selective-vpn" + statusFilePath = stateDir + "/status.json" + dnsModePath = stateDir + "/dns-mode.json" + trafficModePath = stateDir + "/traffic-mode.json" + trafficAppMarksPath = stateDir + "/traffic-appmarks.json" + trafficAppProfilesPath = stateDir + "/traffic-app-profiles.json" + transportClientsPath = stateDir + "/transport-clients.json" + transportInterfacesPath = stateDir + "/transport-interfaces.json" + transportPolicyPath = stateDir + "/transport-policies.json" + transportPolicyPlanPath = stateDir + "/transport-policies.plan.json" + transportPolicyRuntimePath = stateDir + "/transport-policies.runtime.json" + transportPolicyRuntimeSnap = stateDir + "/transport-policies.runtime.prev.json" + transportOwnershipPath = stateDir + "/transport-ownership.json" + transportOwnerLocksPath = stateDir + "/transport-owner-locks.json" + transportConflictsPath = stateDir + "/transport-conflicts.json" + transportPolicySnap = stateDir + "/transport-policies.prev.json" + transportBootstrapPath = stateDir + "/transport-bootstrap-routes.json" - traceLogPath = stateDir + "/trace.log" - smartdnsLogPath = stateDir + "/smartdns.log" - lastIPsPath = stateDir + "/last-ips.txt" - lastIPsMapPath = stateDir + "/last-ips-map.txt" - lastIPsDirect = stateDir + "/last-ips-direct.txt" - lastIPsDyn = stateDir + "/last-ips-dyn.txt" - lastIPsMapDirect = stateDir + "/last-ips-map-direct.txt" - lastIPsMapDyn = stateDir + "/last-ips-map-wildcard.txt" - routesCacheMeta = stateDir + "/routes-clear-cache.json" - routesCacheIPs = stateDir + "/routes-clear-cache-ips.txt" - routesCacheDyn = stateDir + "/routes-clear-cache-ips-dyn.txt" - routesCacheMap = stateDir + "/routes-clear-cache-ips-map.txt" - routesCacheMapD = stateDir + "/routes-clear-cache-ips-map-direct.txt" - routesCacheMapW = stateDir + "/routes-clear-cache-ips-map-wildcard.txt" - routesCacheRT = stateDir + "/routes-clear-cache-routes.txt" + traceLogPath = stateDir + "/trace.log" + smartdnsLogPath = stateDir + "/smartdns.log" + lastIPsPath = stateDir + "/last-ips.txt" + lastIPsMapPath = stateDir + "/last-ips-map.txt" + lastIPsDirect = stateDir + "/last-ips-direct.txt" + lastIPsDyn = stateDir + "/last-ips-dyn.txt" + lastIPsMapDirect = stateDir + "/last-ips-map-direct.txt" + lastIPsMapDyn = stateDir + "/last-ips-map-wildcard.txt" + routesCacheMeta = stateDir + "/routes-clear-cache.json" + routesCacheIPs = stateDir + "/routes-clear-cache-ips.txt" + routesCacheDyn = stateDir + "/routes-clear-cache-ips-dyn.txt" + routesCacheMap = stateDir + "/routes-clear-cache-ips-map.txt" + routesCacheMapD = stateDir + "/routes-clear-cache-ips-map-direct.txt" + routesCacheMapW = stateDir + "/routes-clear-cache-ips-map-wildcard.txt" + routesCacheRT = stateDir + "/routes-clear-cache-routes.txt" + precheckForcePath = stateDir + "/precheck-force.once" autoloopLogPath = stateDir + "/adguard-autoloop.log" loginStatePath = stateDir + "/adguard-login.json" diff --git a/selective-vpn-api/app/dns_settings.go b/selective-vpn-api/app/dns_settings.go index 1239ded..2baa602 100644 --- a/selective-vpn-api/app/dns_settings.go +++ b/selective-vpn-api/app/dns_settings.go @@ -1,1400 +1,6 @@ package app -import ( - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -// --------------------------------------------------------------------- -// DNS settings + SmartDNS control -// --------------------------------------------------------------------- - -// EN: DNS control-plane handlers and storage helpers. -// EN: This unit keeps resolver mode, SmartDNS address, SmartDNS service control, -// EN: and dns-upstreams.conf in one place for GUI and backend consistency. -// RU: Обработчики DNS control-plane и helper-функции хранения. -// RU: Этот модуль держит в одном месте режим резолвера, адрес SmartDNS, -// RU: управление сервисом SmartDNS и dns-upstreams.conf для консистентности GUI и backend. - -// --------------------------------------------------------------------- -// 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) - } -} - -// --------------------------------------------------------------------- -// 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)) -} - -var dnsBenchmarkDefaultDomains = []string{ - "cloudflare.com", - "google.com", - "telegram.org", - "github.com", - "youtube.com", - "twitter.com", -} - -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 = normalizeBenchmarkUpstreamStrings([]string{ - cfg.Default1, - cfg.Default2, - cfg.Meta1, - cfg.Meta2, - }) - } - } - if len(upstreams) == 0 { - http.Error(w, "no upstreams", http.StatusBadRequest) - return - } - - domains := 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 - } - - 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) - } - - 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) - 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, - } - resp.RecommendedDefault = benchmarkTopN(results, 2, upstreams) - resp.RecommendedMeta = benchmarkTopN(results, 2, upstreams) - writeJSON(w, http.StatusOK, resp) -} - -func normalizeBenchmarkUpstreams(in []DNSBenchmarkUpstream) []string { - if len(in) == 0 { - return nil - } - out := make([]string, 0, len(in)) - seen := map[string]struct{}{} - for _, item := range in { - n := normalizeDNSUpstream(item.Addr, "53") - if n == "" { - continue - } - if _, ok := seen[n]; ok { - continue - } - seen[n] = struct{}{} - out = append(out, n) - } - return out -} - -func normalizeBenchmarkUpstreamStrings(in []string) []string { - out := make([]string, 0, len(in)) - seen := map[string]struct{}{} - for _, raw := range in { - n := normalizeDNSUpstream(raw, "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) DNSBenchmarkResult { - res := DNSBenchmarkResult{Upstream: upstream} - durations := make([]int, 0, len(domains)*attempts) - - for _, host := range domains { - for i := 0; i < attempts; i++ { - start := time.Now() - _, err := dnsLookupAOnce(host, upstream, timeout) - elapsed := int(time.Since(start).Milliseconds()) - if elapsed < 1 { - elapsed = 1 - } - res.Attempts++ - if err != nil { - res.Fail++ - switch classifyDNSError(err) { - case dnsErrorNXDomain: - res.NXDomain++ - case dnsErrorTimeout: - res.Timeout++ - case dnsErrorTemporary: - res.Temporary++ - default: - res.Other++ - } - continue - } - res.OK++ - durations = append(durations, elapsed) - } - } - - 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) - timeoutRate := float64(res.Timeout) / float64(total) - nxRate := float64(res.NXDomain) / float64(total) - avg := float64(res.AvgMS) - if avg <= 0 { - avg = float64(timeout.Milliseconds()) - } - res.Score = okRate*100.0 - timeoutRate*45.0 - nxRate*12.0 - (avg / 30.0) - } - - timeoutRate := 0.0 - if res.Attempts > 0 { - timeoutRate = float64(res.Timeout) / float64(res.Attempts) - } - switch { - case res.OK == 0 || timeoutRate >= 0.15 || res.AvgMS > 400: - res.Color = "red" - case res.AvgMS < 200 && timeoutRate == 0: - res.Color = "green" - default: - res.Color = "yellow" - } - - return res -} - -func dnsLookupAOnce(host string, upstream string, timeout time.Duration) ([]string, error) { - 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(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 []DNSBenchmarkResult, 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 -} - -// --------------------------------------------------------------------- -// 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)) -} - -// --------------------------------------------------------------------- -// 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, - }) -} - -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 -} - -// --------------------------------------------------------------------- -// 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) - } -} - -// --------------------------------------------------------------------- -// smartdns runtime accelerator state -// --------------------------------------------------------------------- - -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) - } -} - -// --------------------------------------------------------------------- -// 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() - } - if smartdnsAddr == "" { - return cmdResult{OK: false, Message: "SmartDNS address is empty"} - } - - wildcards := loadSmartDNSWildcardDomains(nil) - if len(wildcards) == 0 { - msg := "prewarm skipped: wildcard list is empty" - appendTraceLineTo(smartdnsLogPath, "smartdns", msg) - return cmdResult{OK: true, Message: msg} - } - - aggressive := aggressiveSubs || prewarmAggressiveFromEnv() - - // Default prewarm is wildcard-only (no subs fan-out). - subs := []string{} - subsPerBaseLimit := 0 - if aggressive { - subs = loadList(domainDir + "/subs.txt") - subsPerBaseLimit = envInt("RESOLVE_SUBS_PER_BASE_LIMIT", 0) - if subsPerBaseLimit < 0 { - subsPerBaseLimit = 0 - } - } - domainSet := make(map[string]struct{}, len(wildcards)*(len(subs)+1)) - for _, d := range wildcards { - d = strings.TrimSpace(d) - if d == "" { - continue - } - domainSet[d] = struct{}{} - if aggressive && !isGoogleLike(d) { - 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 limit > 0 && len(domains) > limit { - domains = domains[:limit] - } - if len(domains) == 0 { - msg := "prewarm skipped: expanded wildcard list is empty" - appendTraceLineTo(smartdnsLogPath, "smartdns", msg) - return cmdResult{OK: true, Message: msg} - } - - if workers <= 0 { - workers = envInt("SMARTDNS_PREWARM_WORKERS", 24) - } - if workers < 1 { - workers = 1 - } - if workers > 200 { - workers = 200 - } - - if timeoutMS <= 0 { - timeoutMS = envInt("SMARTDNS_PREWARM_TIMEOUT_MS", 1800) - } - if timeoutMS < 200 { - timeoutMS = 200 - } - if timeoutMS > 15000 { - timeoutMS = 15000 - } - timeout := time.Duration(timeoutMS) * time.Millisecond - - // Ensure runtime set exists before prewarm queries hit SmartDNS nftset hook. - _, _, _, _ = 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", ";", "}") - - appendTraceLineTo( - smartdnsLogPath, - "smartdns", - 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", - mode.Mode, source, runtimeEnabled, smartdnsAddr, len(wildcards), len(domains), aggressive, workers, timeoutMS, - ), - ) - - type prewarmItem struct { - host string - ips []string - stats dnsMetrics - } - 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 := digA(host, []string{smartdnsAddr}, timeout, nil) - results <- prewarmItem{host: host, ips: ips, stats: stats} - } - }() - } - for _, host := range domains { - jobs <- host - } - close(jobs) - - resolvedHosts := 0 - totalIPs := 0 - errorHosts := 0 - stats := dnsMetrics{} - resolvedIPSet := map[string]struct{}{} - loggedHosts := 0 - const 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 { - appendTraceLineTo(smartdnsLogPath, "smartdns", fmt.Sprintf("prewarm add: %s -> %s", item.host, strings.Join(item.ips, ", "))) - loggedHosts++ - } - } - - manualAdded := 0 - totalDyn := 0 - totalDynText := "n/a" - if !runtimeEnabled { - existing, _ := readNftSetElements("agvpn_dyn4") - 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) - } - totalDyn = len(merged) - totalDynText = fmt.Sprintf("%d", totalDyn) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - if err := nftUpdateSetIPsSmart(ctx, "agvpn_dyn4", merged, nil); err != nil { - msg := fmt.Sprintf("prewarm manual apply failed: %v", err) - appendTraceLineTo(smartdnsLogPath, "smartdns", msg) - return cmdResult{OK: false, Message: msg} - } - appendTraceLineTo( - smartdnsLogPath, - "smartdns", - fmt.Sprintf("prewarm manual merge: existing=%d resolved=%d added=%d total_dyn=%d", len(existing), len(resolvedIPSet), manualAdded, totalDyn), - ) - } - if len(domains) > loggedHosts { - appendTraceLineTo( - smartdnsLogPath, - "smartdns", - fmt.Sprintf( - "prewarm add: trace truncated, omitted=%d hosts (full wildcard map: %s)", - len(domains)-loggedHosts, - lastIPsMapDyn, - ), - ) - } - - 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", - source, - len(domains), - resolvedHosts, - totalIPs, - errorHosts, - stats.Attempts, - stats.OK, - stats.totalErrors(), - manualAdded, - totalDynText, - ) - appendTraceLineTo(smartdnsLogPath, "smartdns", msg) - if perUpstream := stats.formatPerUpstream(); perUpstream != "" { - appendTraceLineTo(smartdnsLogPath, "smartdns", "prewarm dns upstreams: "+perUpstream) - } - - return cmdResult{ - OK: true, - Message: msg, - ExitCode: resolvedHosts, - } -} - -func prewarmAggressiveFromEnv() bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv("SMARTDNS_PREWARM_AGGRESSIVE"))) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func loadDNSUpstreamsConfFile() DNSUpstreams { - cfg := DNSUpstreams{ - Default1: defaultDNS1, - Default2: defaultDNS2, - Meta1: defaultMeta1, - Meta2: defaultMeta2, - } - - data, err := os.ReadFile(dnsUpstreamsConf) - if err != nil { - return cfg - } - - for _, ln := range strings.Split(string(data), "\n") { - s := strings.TrimSpace(ln) - if s == "" || strings.HasPrefix(s, "#") { - continue - } - parts := strings.Fields(s) - if len(parts) < 2 { - continue - } - key := strings.ToLower(parts[0]) - vals := parts[1:] - switch key { - case "default": - if len(vals) > 0 { - cfg.Default1 = normalizeDNSUpstream(vals[0], "53") - } - if len(vals) > 1 { - cfg.Default2 = normalizeDNSUpstream(vals[1], "53") - } - case "meta": - if len(vals) > 0 { - cfg.Meta1 = normalizeDNSUpstream(vals[0], "53") - } - if len(vals) > 1 { - cfg.Meta2 = normalizeDNSUpstream(vals[1], "53") - } - } - } - - if cfg.Default1 == "" { - cfg.Default1 = defaultDNS1 - } - if cfg.Default2 == "" { - cfg.Default2 = defaultDNS2 - } - if cfg.Meta1 == "" { - cfg.Meta1 = defaultMeta1 - } - if cfg.Meta2 == "" { - cfg.Meta2 = defaultMeta2 - } - return cfg -} - -func normalizeDNSUpstreamPoolItems(items []DNSUpstreamPoolItem) []DNSUpstreamPoolItem { - out := make([]DNSUpstreamPoolItem, 0, len(items)) - seen := map[string]struct{}{} - for _, item := range items { - addr := normalizeDNSUpstream(item.Addr, "53") - if addr == "" { - continue - } - if _, ok := seen[addr]; ok { - continue - } - seen[addr] = struct{}{} - out = append(out, DNSUpstreamPoolItem{ - Addr: addr, - Enabled: item.Enabled, - }) - } - return out -} - -func dnsUpstreamPoolFromLegacy(cfg DNSUpstreams) []DNSUpstreamPoolItem { - raw := []string{cfg.Default1, cfg.Default2, cfg.Meta1, cfg.Meta2} - out := make([]DNSUpstreamPoolItem, 0, len(raw)) - for _, item := range raw { - n := normalizeDNSUpstream(item, "53") - if n == "" { - continue - } - out = append(out, DNSUpstreamPoolItem{Addr: n, Enabled: true}) - } - return normalizeDNSUpstreamPoolItems(out) -} - -func dnsUpstreamPoolToLegacy(items []DNSUpstreamPoolItem) DNSUpstreams { - enabled := make([]string, 0, len(items)) - all := make([]string, 0, len(items)) - for _, item := range items { - n := normalizeDNSUpstream(item.Addr, "53") - if n == "" { - continue - } - all = append(all, n) - if item.Enabled { - enabled = append(enabled, n) - } - } - list := enabled - if len(list) == 0 { - list = all - } - if len(list) == 0 { - list = []string{defaultDNS1, defaultDNS2, defaultMeta1, defaultMeta2} - } - pick := func(idx int, fallback string) string { - if len(list) == 0 { - return fallback - } - if idx < len(list) { - return list[idx] - } - return list[idx%len(list)] - } - return DNSUpstreams{ - Default1: pick(0, defaultDNS1), - Default2: pick(1, defaultDNS2), - Meta1: pick(2, defaultMeta1), - Meta2: pick(3, defaultMeta2), - } -} - -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)) -} - -func loadEnabledDNSUpstreamPool() []string { - items := loadDNSUpstreamPoolState() - out := make([]string, 0, len(items)) - for _, item := range items { - if !item.Enabled { - continue - } - n := normalizeDNSUpstream(item.Addr, "53") - if n == "" { - continue - } - out = append(out, n) - } - return uniqueStrings(out) -} - -func saveDNSUpstreamsConfFile(cfg DNSUpstreams) error { - cfg.Default1 = normalizeDNSUpstream(cfg.Default1, "53") - cfg.Default2 = normalizeDNSUpstream(cfg.Default2, "53") - cfg.Meta1 = normalizeDNSUpstream(cfg.Meta1, "53") - cfg.Meta2 = normalizeDNSUpstream(cfg.Meta2, "53") - - if cfg.Default1 == "" { - cfg.Default1 = defaultDNS1 - } - if cfg.Default2 == "" { - cfg.Default2 = defaultDNS2 - } - if cfg.Meta1 == "" { - cfg.Meta1 = defaultMeta1 - } - if cfg.Meta2 == "" { - cfg.Meta2 = defaultMeta2 - } - - content := fmt.Sprintf( - "default %s %s\nmeta %s %s\n", - cfg.Default1, cfg.Default2, cfg.Meta1, cfg.Meta2, - ) - - 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 -} - -// --------------------------------------------------------------------- -// 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)) -} - -// --------------------------------------------------------------------- -// EN: `loadDNSMode` loads dns mode from storage or config. -// RU: `loadDNSMode` - загружает dns mode из хранилища или конфига. -// --------------------------------------------------------------------- -func loadDNSMode() DNSMode { - mode := DNSMode{ - ViaSmartDNS: false, - SmartDNSAddr: resolveDefaultSmartDNSAddr(), - Mode: DNSModeDirect, - } - needPersist := false - - data, err := os.ReadFile(dnsModePath) - switch { - case err == nil: - var stored DNSMode - if err := json.Unmarshal(data, &stored); err == nil { - mode.Mode = normalizeDNSResolverMode(stored.Mode, stored.ViaSmartDNS) - mode.ViaSmartDNS = mode.Mode != DNSModeDirect - if strings.TrimSpace(string(stored.Mode)) == "" || stored.ViaSmartDNS != mode.ViaSmartDNS { - needPersist = true - } - if addr := normalizeSmartDNSAddr(stored.SmartDNSAddr); addr != "" { - mode.SmartDNSAddr = addr - } else { - needPersist = true - } - } else { - needPersist = true - } - case os.IsNotExist(err): - needPersist = true - } - - if mode.SmartDNSAddr == "" { - mode.SmartDNSAddr = smartDNSDefaultAddr - needPersist = true - } - mode.Mode = normalizeDNSResolverMode(mode.Mode, mode.ViaSmartDNS) - mode.ViaSmartDNS = mode.Mode != DNSModeDirect - - if needPersist { - _ = saveDNSMode(mode) - } - return mode -} - -// --------------------------------------------------------------------- -// EN: `saveDNSMode` saves dns mode to persistent storage. -// RU: `saveDNSMode` - сохраняет dns mode в постоянное хранилище. -// --------------------------------------------------------------------- -func saveDNSMode(mode DNSMode) error { - mode.Mode = normalizeDNSResolverMode(mode.Mode, mode.ViaSmartDNS) - mode.ViaSmartDNS = mode.Mode != DNSModeDirect - mode.SmartDNSAddr = normalizeSmartDNSAddr(mode.SmartDNSAddr) - if mode.SmartDNSAddr == "" { - mode.SmartDNSAddr = resolveDefaultSmartDNSAddr() - } - - if err := os.MkdirAll(stateDir, 0o755); err != nil { - return err - } - tmp := dnsModePath + ".tmp" - b, err := json.MarshalIndent(mode, "", " ") - if err != nil { - return err - } - if err := os.WriteFile(tmp, b, 0o644); err != nil { - return err - } - return os.Rename(tmp, dnsModePath) -} - -// --------------------------------------------------------------------- -// EN: `normalizeDNSResolverMode` normalizes dns resolver mode values. -// RU: `normalizeDNSResolverMode` - нормализует значения режима dns резолвера. -// --------------------------------------------------------------------- -func normalizeDNSResolverMode(mode DNSResolverMode, viaSmartDNS bool) DNSResolverMode { - switch DNSResolverMode(strings.ToLower(strings.TrimSpace(string(mode)))) { - case DNSModeDirect: - return DNSModeDirect - case DNSModeSmartDNS: - // Legacy value: map old SmartDNS-only selection into hybrid wildcard mode. - return DNSModeHybridWildcard - case DNSModeHybridWildcard, DNSResolverMode("hybrid"): - return DNSModeHybridWildcard - default: - if viaSmartDNS { - return DNSModeHybridWildcard - } - return DNSModeDirect - } -} - -// --------------------------------------------------------------------- -// 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 { - v := strings.TrimSpace(strings.ToLower(os.Getenv(smartDNSForceEnv))) - switch v { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -// --------------------------------------------------------------------- -// EN: `smartdnsUnitState` contains core logic for smartdns unit state. -// RU: `smartdnsUnitState` - содержит основную логику для smartdns unit state. -// --------------------------------------------------------------------- -func smartdnsUnitState() string { - stdout, _, _, _ := runCommand("systemctl", "is-active", "smartdns-local.service") - st := strings.TrimSpace(stdout) - if st == "" { - return "unknown" - } - return st -} - -// --------------------------------------------------------------------- -// EN: `runSmartdnsUnitAction` runs the workflow for smartdns unit action. -// RU: `runSmartdnsUnitAction` - запускает рабочий процесс для smartdns unit action. -// --------------------------------------------------------------------- -func runSmartdnsUnitAction(action string) cmdResult { - stdout, stderr, exitCode, err := runCommand("systemctl", action, "smartdns-local.service") - res := cmdResult{ - OK: err == nil && exitCode == 0, - ExitCode: exitCode, - Stdout: stdout, - Stderr: stderr, - } - if err != nil { - res.Message = err.Error() - } else { - res.Message = "smartdns " + action + " done" - } - return res -} - -// --------------------------------------------------------------------- -// EN: `resolveDefaultSmartDNSAddr` resolves default smart d n s addr into concrete values. -// RU: `resolveDefaultSmartDNSAddr` - резолвит default smart d n s addr в конкретные значения. -// --------------------------------------------------------------------- -func resolveDefaultSmartDNSAddr() string { - if v := strings.TrimSpace(os.Getenv(smartDNSAddrEnv)); v != "" { - if addr := normalizeSmartDNSAddr(v); addr != "" { - return addr - } - } - for _, path := range []string{ - "/opt/stack/adguardapp/smartdns.conf", - "/etc/selective-vpn/smartdns.conf", - } { - if addr := smartDNSAddrFromConfig(path); addr != "" { - return addr - } - } - return smartDNSDefaultAddr -} - -// --------------------------------------------------------------------- -// EN: `smartDNSAddrFromConfig` loads smart d n s addr from config. -// RU: `smartDNSAddrFromConfig` - загружает smart d n s addr из конфига. -// --------------------------------------------------------------------- -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 "" -} - -// --------------------------------------------------------------------- -// EN: `normalizeDNSUpstream` parses dns upstream and returns normalized values. -// RU: `normalizeDNSUpstream` - парсит dns upstream и возвращает нормализованные значения. -// --------------------------------------------------------------------- -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 -} - -// --------------------------------------------------------------------- -// EN: `normalizeSmartDNSAddr` parses smart d n s addr and returns normalized values. -// RU: `normalizeSmartDNSAddr` - парсит smart d n s addr и возвращает нормализованные значения. -// --------------------------------------------------------------------- -func normalizeSmartDNSAddr(raw string) string { - s := normalizeDNSUpstream(raw, "6053") - if s == "" { - return "" - } - if strings.Contains(s, "#") { - return s - } - return s + "#6053" -} +// DNS settings + SmartDNS control handlers are split by role: +// - upstreams/pool endpoints: dns_settings_handlers_upstreams.go +// - mode/status endpoints: dns_settings_handlers_mode.go +// - smartdns service control: dns_settings_handlers_smartdns_service.go diff --git a/selective-vpn-api/app/dns_settings_benchmark.go b/selective-vpn-api/app/dns_settings_benchmark.go new file mode 100644 index 0000000..b35ce35 --- /dev/null +++ b/selective-vpn-api/app/dns_settings_benchmark.go @@ -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) +} diff --git a/selective-vpn-api/app/dns_settings_benchmark_handler.go b/selective-vpn-api/app/dns_settings_benchmark_handler.go new file mode 100644 index 0000000..718232a --- /dev/null +++ b/selective-vpn-api/app/dns_settings_benchmark_handler.go @@ -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) +} diff --git a/selective-vpn-api/app/dns_settings_handlers_mode.go b/selective-vpn-api/app/dns_settings_handlers_mode.go new file mode 100644 index 0000000..8eced1d --- /dev/null +++ b/selective-vpn-api/app/dns_settings_handlers_mode.go @@ -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 +} diff --git a/selective-vpn-api/app/dns_settings_handlers_smartdns_service.go b/selective-vpn-api/app/dns_settings_handlers_smartdns_service.go new file mode 100644 index 0000000..1867bf4 --- /dev/null +++ b/selective-vpn-api/app/dns_settings_handlers_smartdns_service.go @@ -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) + } +} diff --git a/selective-vpn-api/app/dns_settings_handlers_upstreams.go b/selective-vpn-api/app/dns_settings_handlers_upstreams.go new file mode 100644 index 0000000..6524ab5 --- /dev/null +++ b/selective-vpn-api/app/dns_settings_handlers_upstreams.go @@ -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) + } +} diff --git a/selective-vpn-api/app/dns_settings_state_mode.go b/selective-vpn-api/app/dns_settings_state_mode.go new file mode 100644 index 0000000..d03539e --- /dev/null +++ b/selective-vpn-api/app/dns_settings_state_mode.go @@ -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), + ), + ) +} diff --git a/selective-vpn-api/app/dns_settings_state_smartdns.go b/selective-vpn-api/app/dns_settings_state_smartdns.go new file mode 100644 index 0000000..ff2d93f --- /dev/null +++ b/selective-vpn-api/app/dns_settings_state_smartdns.go @@ -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) +} diff --git a/selective-vpn-api/app/dns_settings_state_upstreams.go b/selective-vpn-api/app/dns_settings_state_upstreams.go new file mode 100644 index 0000000..5d5876a --- /dev/null +++ b/selective-vpn-api/app/dns_settings_state_upstreams.go @@ -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)) +} diff --git a/selective-vpn-api/app/dns_settings_state_upstreams_conf_store.go b/selective-vpn-api/app/dns_settings_state_upstreams_conf_store.go new file mode 100644 index 0000000..3282d82 --- /dev/null +++ b/selective-vpn-api/app/dns_settings_state_upstreams_conf_store.go @@ -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 +} diff --git a/selective-vpn-api/app/dns_settings_state_upstreams_pool_store.go b/selective-vpn-api/app/dns_settings_state_upstreams_pool_store.go new file mode 100644 index 0000000..04156d5 --- /dev/null +++ b/selective-vpn-api/app/dns_settings_state_upstreams_pool_store.go @@ -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)) +} diff --git a/selective-vpn-api/app/dns_smartdns_handlers.go b/selective-vpn-api/app/dns_smartdns_handlers.go new file mode 100644 index 0000000..215dd48 --- /dev/null +++ b/selective-vpn-api/app/dns_smartdns_handlers.go @@ -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 diff --git a/selective-vpn-api/app/dns_smartdns_handlers_prewarm.go b/selective-vpn-api/app/dns_smartdns_handlers_prewarm.go new file mode 100644 index 0000000..a29e9c1 --- /dev/null +++ b/selective-vpn-api/app/dns_smartdns_handlers_prewarm.go @@ -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 +} diff --git a/selective-vpn-api/app/dns_smartdns_handlers_runtime.go b/selective-vpn-api/app/dns_smartdns_handlers_runtime.go new file mode 100644 index 0000000..d9e35a5 --- /dev/null +++ b/selective-vpn-api/app/dns_smartdns_handlers_runtime.go @@ -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) + } +} diff --git a/selective-vpn-api/app/dnscfg/benchmark.go b/selective-vpn-api/app/dnscfg/benchmark.go new file mode 100644 index 0000000..6910e6a --- /dev/null +++ b/selective-vpn-api/app/dnscfg/benchmark.go @@ -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 +} diff --git a/selective-vpn-api/app/dnscfg/mode.go b/selective-vpn-api/app/dnscfg/mode.go new file mode 100644 index 0000000..8107a57 --- /dev/null +++ b/selective-vpn-api/app/dnscfg/mode.go @@ -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)) +} diff --git a/selective-vpn-api/app/dnscfg/pool.go b/selective-vpn-api/app/dnscfg/pool.go new file mode 100644 index 0000000..4bb28d1 --- /dev/null +++ b/selective-vpn-api/app/dnscfg/pool.go @@ -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 +} diff --git a/selective-vpn-api/app/dnscfg/prewarm.go b/selective-vpn-api/app/dnscfg/prewarm.go new file mode 100644 index 0000000..0444d03 --- /dev/null +++ b/selective-vpn-api/app/dnscfg/prewarm.go @@ -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 +} diff --git a/selective-vpn-api/app/dnscfg/smartdns.go b/selective-vpn-api/app/dnscfg/smartdns.go new file mode 100644 index 0000000..7e5ceeb --- /dev/null +++ b/selective-vpn-api/app/dnscfg/smartdns.go @@ -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" +} diff --git a/selective-vpn-api/app/dnscfg/systemd.go b/selective-vpn-api/app/dnscfg/systemd.go new file mode 100644 index 0000000..ba60308 --- /dev/null +++ b/selective-vpn-api/app/dnscfg/systemd.go @@ -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 +} diff --git a/selective-vpn-api/app/dnscfg/upstreams.go b/selective-vpn-api/app/dnscfg/upstreams.go new file mode 100644 index 0000000..f615418 --- /dev/null +++ b/selective-vpn-api/app/dnscfg/upstreams.go @@ -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, + ) +} diff --git a/selective-vpn-api/app/domains_handlers.go b/selective-vpn-api/app/domains_handlers.go index 8448777..6718c39 100644 --- a/selective-vpn-api/app/domains_handlers.go +++ b/selective-vpn-api/app/domains_handlers.go @@ -1,16 +1,5 @@ package app -import ( - "encoding/json" - "io" - "io/fs" - "net/http" - "os" - "path/filepath" - "sort" - "strings" -) - // --------------------------------------------------------------------- // domains editor + smartdns wildcards // --------------------------------------------------------------------- @@ -31,210 +20,3 @@ var domainFiles = map[string]string{ "last-ips-map-direct": lastIPsMapDirect, "last-ips-map-wildcard": lastIPsMapDyn, } - -// --------------------------------------------------------------------- -// domains table -// --------------------------------------------------------------------- - -// GET /api/v1/domains/table -> { "lines": [ ... ] } -func handleDomainsTable(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - lines := []string{} - for _, setName := range []string{"agvpn4", "agvpn_dyn4"} { - stdout, _, code, _ := runCommand("nft", "list", "set", "inet", "agvpn", setName) - if code == 0 { - for _, l := range strings.Split(stdout, "\n") { - l = strings.TrimRight(l, "\r") - if l != "" { - lines = append(lines, l) - } - } - continue - } - - // Backward-compatible fallback for legacy hosts that still have ipset. - stdout, _, code, _ = runCommand("ipset", "list", setName) - if code != 0 { - continue - } - for _, l := range strings.Split(stdout, "\n") { - l = strings.TrimRight(l, "\r") - if l != "" { - lines = append(lines, l) - } - } - } - writeJSON(w, http.StatusOK, map[string]any{"lines": lines}) -} - -// --------------------------------------------------------------------- -// domains file -// --------------------------------------------------------------------- - -// GET /api/v1/domains/file?name=bases|meta|subs|static|smartdns|last-ips-map|last-ips-map-direct|last-ips-map-wildcard|wildcard-observed-hosts -// POST /api/v1/domains/file { "name": "...", "content": "..." } -func handleDomainsFile(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - name := strings.TrimSpace(r.URL.Query().Get("name")) - if name == "smartdns" { - domains, source := loadSmartDNSWildcardDomainsState(nil) - writeJSON(w, http.StatusOK, map[string]string{ - "content": renderSmartDNSDomainsContent(domains), - "source": source, - }) - return - } - if name == "wildcard-observed-hosts" { - writeJSON(w, http.StatusOK, map[string]string{ - "content": readWildcardObservedHostsContent(), - "source": "derived", - }) - return - } - path, ok := domainFiles[name] - if !ok { - http.Error(w, "unknown file name", http.StatusBadRequest) - return - } - source := "file" - if strings.HasPrefix(name, "last-ips-map") { - source = "artifact" - } - data, err := os.ReadFile(path) - if err != nil { - if !os.IsNotExist(err) { - http.Error(w, "read error", http.StatusInternalServerError) - return - } - switch name { - case "bases", "meta", "subs": - // fallback to embedded seed - embedName := name + ".txt" - if name == "meta" { - embedName = "meta-special.txt" - } - data, _ = fs.ReadFile(embeddedDomains, "assets/domains/"+embedName) - source = "embedded" - default: - data = []byte{} - } - } - writeJSON(w, http.StatusOK, map[string]string{ - "content": string(data), - "source": source, - }) - case http.MethodPost: - var body struct { - Name string `json:"name"` - Content string `json:"content"` - } - if r.Body != nil { - defer r.Body.Close() - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { - http.Error(w, "bad json", http.StatusBadRequest) - return - } - } - if strings.TrimSpace(body.Name) == "smartdns" { - domains := parseSmartDNSDomainsContent(body.Content) - if err := saveSmartDNSWildcardDomainsState(domains); err != nil { - http.Error(w, "write error", http.StatusInternalServerError) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) - return - } - if body.Name == "last-ips-map-direct" || body.Name == "last-ips-map-wildcard" || body.Name == "wildcard-observed-hosts" { - http.Error(w, "read-only file name", http.StatusBadRequest) - return - } - path, ok := domainFiles[strings.TrimSpace(body.Name)] - if !ok { - http.Error(w, "unknown file name", http.StatusBadRequest) - return - } - _ = os.MkdirAll(filepath.Dir(path), 0o755) - if err := os.WriteFile(path, []byte(body.Content), 0o644); err != nil { - http.Error(w, "write error", http.StatusInternalServerError) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func readWildcardObservedHostsContent() string { - data, err := os.ReadFile(lastIPsMapDyn) - if err != nil { - return "" - } - seen := make(map[string]struct{}) - out := make([]string, 0, 256) - for _, ln := range strings.Split(string(data), "\n") { - ln = strings.TrimSpace(ln) - if ln == "" || strings.HasPrefix(ln, "#") { - continue - } - fields := strings.Fields(ln) - if len(fields) < 2 { - continue - } - host := strings.TrimSpace(fields[1]) - if host == "" || strings.HasPrefix(host, "[") { - continue - } - if _, ok := seen[host]; ok { - continue - } - seen[host] = struct{}{} - out = append(out, host) - } - sort.Strings(out) - if len(out) == 0 { - return "" - } - return strings.Join(out, "\n") + "\n" -} - -// --------------------------------------------------------------------- -// smartdns wildcards -// --------------------------------------------------------------------- - -func handleSmartdnsWildcards(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - payload := struct { - Domains []string `json:"domains"` - }{Domains: readSmartDNSWildcardDomains()} - writeJSON(w, http.StatusOK, payload) - case http.MethodPost: - var payload struct { - Domains []string `json:"domains"` - } - if r.Body != nil { - defer r.Body.Close() - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&payload); err != nil { - http.Error(w, "bad json", http.StatusBadRequest) - return - } - } - if err := saveSmartDNSWildcardDomainsState(payload.Domains); err != nil { - http.Error(w, "write error", http.StatusInternalServerError) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func readSmartDNSWildcardDomains() []string { - domains, _ := loadSmartDNSWildcardDomainsState(nil) - return domains -} diff --git a/selective-vpn-api/app/domains_handlers_file.go b/selective-vpn-api/app/domains_handlers_file.go new file mode 100644 index 0000000..b0a226f --- /dev/null +++ b/selective-vpn-api/app/domains_handlers_file.go @@ -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) + } +} diff --git a/selective-vpn-api/app/domains_handlers_helpers.go b/selective-vpn-api/app/domains_handlers_helpers.go new file mode 100644 index 0000000..26d9c56 --- /dev/null +++ b/selective-vpn-api/app/domains_handlers_helpers.go @@ -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" +} diff --git a/selective-vpn-api/app/domains_handlers_smartdns.go b/selective-vpn-api/app/domains_handlers_smartdns.go new file mode 100644 index 0000000..5238a89 --- /dev/null +++ b/selective-vpn-api/app/domains_handlers_smartdns.go @@ -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 +} diff --git a/selective-vpn-api/app/domains_handlers_table.go b/selective-vpn-api/app/domains_handlers_table.go new file mode 100644 index 0000000..30c878b --- /dev/null +++ b/selective-vpn-api/app/domains_handlers_table.go @@ -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}) +} diff --git a/selective-vpn-api/app/egress_identity.go b/selective-vpn-api/app/egress_identity.go new file mode 100644 index 0000000..6e14d8a --- /dev/null +++ b/selective-vpn-api/app/egress_identity.go @@ -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{}, + } +} diff --git a/selective-vpn-api/app/egress_identity_geo.go b/selective-vpn-api/app/egress_identity_geo.go new file mode 100644 index 0000000..1ab4b57 --- /dev/null +++ b/selective-vpn-api/app/egress_identity_geo.go @@ -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) +} diff --git a/selective-vpn-api/app/egress_identity_handlers.go b/selective-vpn-api/app/egress_identity_handlers.go new file mode 100644 index 0000000..8df6555 --- /dev/null +++ b/selective-vpn-api/app/egress_identity_handlers.go @@ -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) +} diff --git a/selective-vpn-api/app/egress_identity_probe.go b/selective-vpn-api/app/egress_identity_probe.go new file mode 100644 index 0000000..4879f7a --- /dev/null +++ b/selective-vpn-api/app/egress_identity_probe.go @@ -0,0 +1 @@ +package app diff --git a/selective-vpn-api/app/egress_identity_probe_external.go b/selective-vpn-api/app/egress_identity_probe_external.go new file mode 100644 index 0000000..f682004 --- /dev/null +++ b/selective-vpn-api/app/egress_identity_probe_external.go @@ -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, "; ")) +} diff --git a/selective-vpn-api/app/egress_identity_probe_helpers.go b/selective-vpn-api/app/egress_identity_probe_helpers.go new file mode 100644 index 0000000..365af81 --- /dev/null +++ b/selective-vpn-api/app/egress_identity_probe_helpers.go @@ -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 "" +} diff --git a/selective-vpn-api/app/egress_identity_probe_netns.go b/selective-vpn-api/app/egress_identity_probe_netns.go new file mode 100644 index 0000000..57207e3 --- /dev/null +++ b/selective-vpn-api/app/egress_identity_probe_netns.go @@ -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) +} diff --git a/selective-vpn-api/app/egress_identity_providers.go b/selective-vpn-api/app/egress_identity_providers.go new file mode 100644 index 0000000..45ccf14 --- /dev/null +++ b/selective-vpn-api/app/egress_identity_providers.go @@ -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() +} diff --git a/selective-vpn-api/app/egress_identity_refresh.go b/selective-vpn-api/app/egress_identity_refresh.go new file mode 100644 index 0000000..ca8db93 --- /dev/null +++ b/selective-vpn-api/app/egress_identity_refresh.go @@ -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, "" +} diff --git a/selective-vpn-api/app/egress_identity_refresh_runner.go b/selective-vpn-api/app/egress_identity_refresh_runner.go new file mode 100644 index 0000000..12d0a8e --- /dev/null +++ b/selective-vpn-api/app/egress_identity_refresh_runner.go @@ -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, + ) + } + } +} diff --git a/selective-vpn-api/app/egress_identity_refresh_state.go b/selective-vpn-api/app/egress_identity_refresh_state.go new file mode 100644 index 0000000..7be61a0 --- /dev/null +++ b/selective-vpn-api/app/egress_identity_refresh_state.go @@ -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: + } +} diff --git a/selective-vpn-api/app/egress_identity_scope.go b/selective-vpn-api/app/egress_identity_scope.go new file mode 100644 index 0000000..c9721c1 --- /dev/null +++ b/selective-vpn-api/app/egress_identity_scope.go @@ -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, + } +} diff --git a/selective-vpn-api/app/egress_identity_test.go b/selective-vpn-api/app/egress_identity_test.go new file mode 100644 index 0000000..276314c --- /dev/null +++ b/selective-vpn-api/app/egress_identity_test.go @@ -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) + } +} diff --git a/selective-vpn-api/app/egressutil/http.go b/selective-vpn-api/app/egressutil/http.go new file mode 100644 index 0000000..29b24f2 --- /dev/null +++ b/selective-vpn-api/app/egressutil/http.go @@ -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 +} diff --git a/selective-vpn-api/app/egressutil/identity.go b/selective-vpn-api/app/egressutil/identity.go new file mode 100644 index 0000000..f3563fc --- /dev/null +++ b/selective-vpn-api/app/egressutil/identity.go @@ -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) +} diff --git a/selective-vpn-api/app/egressutil/probe.go b/selective-vpn-api/app/egressutil/probe.go new file mode 100644 index 0000000..d1413ce --- /dev/null +++ b/selective-vpn-api/app/egressutil/probe.go @@ -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 +} diff --git a/selective-vpn-api/app/egressutil/scope.go b/selective-vpn-api/app/egressutil/scope.go new file mode 100644 index 0000000..de3fe18 --- /dev/null +++ b/selective-vpn-api/app/egressutil/scope.go @@ -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:") + } +} diff --git a/selective-vpn-api/app/egressutil/util.go b/selective-vpn-api/app/egressutil/util.go new file mode 100644 index 0000000..fc365a2 --- /dev/null +++ b/selective-vpn-api/app/egressutil/util.go @@ -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 + } +} diff --git a/selective-vpn-api/app/entrypoints.go b/selective-vpn-api/app/entrypoints.go new file mode 100644 index 0000000..5c8eaa6 --- /dev/null +++ b/selective-vpn-api/app/entrypoints.go @@ -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 + } +} diff --git a/selective-vpn-api/app/events_bus.go b/selective-vpn-api/app/events_bus.go index 1204930..75490a2 100644 --- a/selective-vpn-api/app/events_bus.go +++ b/selective-vpn-api/app/events_bus.go @@ -4,97 +4,35 @@ import ( "os" "strconv" "strings" - "sync" - "time" + + eventsbuspkg "selective-vpn-api/app/eventsbus" ) -// --------------------------------------------------------------------- -// события / event bus -// --------------------------------------------------------------------- - -// EN: In-memory bounded event bus used for SSE replay and polling watchers. -// EN: It keeps only the latest N events and assigns monotonically increasing IDs. -// RU: Ограниченная in-memory шина событий для SSE-реплея и фоновых вотчеров. -// RU: Хранит только последние N событий и присваивает монотонно растущие ID. type eventBus struct { - mu sync.Mutex - cond *sync.Cond - buf []Event - cap int - next int64 + inner *eventsbuspkg.Bus } -// --------------------------------------------------------------------- -// EN: `newEventBus` creates a new instance for event bus. -// RU: `newEventBus` - создает новый экземпляр для event bus. -// --------------------------------------------------------------------- func newEventBus(capacity int) *eventBus { - if capacity < 16 { - capacity = 16 - } - b := &eventBus{ - cap: capacity, - buf: make([]Event, 0, capacity), - } - b.cond = sync.NewCond(&b.mu) - return b + return &eventBus{inner: eventsbuspkg.New(capacity)} } -// --------------------------------------------------------------------- -// EN: `push` contains core logic for push. -// RU: `push` - содержит основную логику для push. -// --------------------------------------------------------------------- func (b *eventBus) push(kind string, data interface{}) Event { - b.mu.Lock() - defer b.mu.Unlock() - - b.next++ - evt := Event{ - ID: b.next, - Kind: kind, - Ts: time.Now().UTC().Format(time.RFC3339Nano), - Data: data, - } - - if len(b.buf) >= b.cap { - b.buf = b.buf[1:] - } - b.buf = append(b.buf, evt) - b.cond.Broadcast() - return evt + ev := b.inner.Push(kind, data) + return Event{ID: ev.ID, Kind: ev.Kind, Ts: ev.Ts, Data: ev.Data} } -// --------------------------------------------------------------------- -// EN: `since` contains core logic for since. -// RU: `since` - содержит основную логику для since. -// --------------------------------------------------------------------- func (b *eventBus) since(id int64) []Event { - b.mu.Lock() - defer b.mu.Unlock() - return b.sinceLocked(id) -} - -// --------------------------------------------------------------------- -// EN: `sinceLocked` contains core logic for since locked. -// RU: `sinceLocked` - содержит основную логику для since locked. -// --------------------------------------------------------------------- -func (b *eventBus) sinceLocked(id int64) []Event { - if len(b.buf) == 0 { + raw := b.inner.Since(id) + if len(raw) == 0 { return nil } - var out []Event - for _, ev := range b.buf { - if ev.ID > id { - out = append(out, ev) - } + out := make([]Event, 0, len(raw)) + for _, ev := range raw { + out = append(out, Event{ID: ev.ID, Kind: ev.Kind, Ts: ev.Ts, Data: ev.Data}) } return out } -// --------------------------------------------------------------------- -// env helpers -// --------------------------------------------------------------------- - // EN: Positive integer env reader with safe default fallback. // RU: Чтение положительного целого из env с безопасным fallback на дефолт. func envInt(key string, def int) int { diff --git a/selective-vpn-api/app/events_handlers.go b/selective-vpn-api/app/events_handlers.go index 4feac10..3d6aba5 100644 --- a/selective-vpn-api/app/events_handlers.go +++ b/selective-vpn-api/app/events_handlers.go @@ -1,111 +1,32 @@ package app import ( - "encoding/json" - "fmt" - "io" "net/http" - "strconv" - "strings" + eventstreampkg "selective-vpn-api/app/eventstream" "time" ) -// --------------------------------------------------------------------- -// events (SSE) -// --------------------------------------------------------------------- - -// EN: Server-Sent Events transport with replay support via Last-Event-ID/since, -// EN: heartbeat pings, and periodic polling of the in-memory event buffer. -// RU: Транспорт Server-Sent Events с поддержкой реплея через Last-Event-ID/since, -// RU: heartbeat-пингами и периодическим опросом in-memory буфера событий. - -// --------------------------------------------------------------------- -// SSE helpers -// --------------------------------------------------------------------- - func parseSinceID(r *http.Request) int64 { - sinceStr := strings.TrimSpace(r.URL.Query().Get("since")) - if sinceStr == "" { - sinceStr = strings.TrimSpace(r.Header.Get("Last-Event-ID")) - } - if sinceStr == "" { - return 0 - } - if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v >= 0 { - return v - } - return 0 + return eventstreampkg.ParseSinceID(r) } -// --------------------------------------------------------------------- -// SSE stream handler -// --------------------------------------------------------------------- - func handleEventsStream(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming unsupported", http.StatusBadRequest) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - - ctx := r.Context() - since := parseSinceID(r) - - send := func(ev Event) error { - payload, err := json.Marshal(ev) - if err != nil { - return err - } - if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Kind, string(payload)); err != nil { - return err - } - flusher.Flush() - return nil - } - - // initial replay - for _, ev := range events.since(since) { - if err := send(ev); err != nil { - return - } - since = ev.ID - } - - // polling loop; lightweight for localhost - pollEvery := 500 * time.Millisecond heartbeat := time.Duration(envInt("SVPN_EVENTS_HEARTBEAT_SEC", defaultHeartbeatSeconds)) * time.Second - pollTicker := time.NewTicker(pollEvery) - pingTicker := time.NewTicker(heartbeat) - defer pollTicker.Stop() - defer pingTicker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-pingTicker.C: - _, _ = io.WriteString(w, ": ping\n\n") - flusher.Flush() - case <-pollTicker.C: - evs := events.since(since) - if len(evs) == 0 { - continue + eventstreampkg.Serve( + w, + r, + 500*time.Millisecond, + heartbeat, + func(since int64) []eventstreampkg.Event { + raw := events.since(since) + if len(raw) == 0 { + return nil } - for _, ev := range evs { - if err := send(ev); err != nil { - return - } - since = ev.ID + out := make([]eventstreampkg.Event, 0, len(raw)) + for _, ev := range raw { + out = append(out, eventstreampkg.Event{ID: ev.ID, Kind: ev.Kind, Data: ev}) } - } - } + return out + }, + ) } diff --git a/selective-vpn-api/app/eventsbus/bus.go b/selective-vpn-api/app/eventsbus/bus.go new file mode 100644 index 0000000..e640bc9 --- /dev/null +++ b/selective-vpn-api/app/eventsbus/bus.go @@ -0,0 +1,68 @@ +package eventsbus + +import ( + "sync" + "time" +) + +type Event struct { + ID int64 + Kind string + Ts string + Data any +} + +type Bus struct { + mu sync.Mutex + cond *sync.Cond + buf []Event + cap int + next int64 +} + +func New(capacity int) *Bus { + if capacity < 16 { + capacity = 16 + } + b := &Bus{ + cap: capacity, + buf: make([]Event, 0, capacity), + } + b.cond = sync.NewCond(&b.mu) + return b +} + +func (b *Bus) Push(kind string, data any) Event { + b.mu.Lock() + defer b.mu.Unlock() + + b.next++ + evt := Event{ + ID: b.next, + Kind: kind, + Ts: time.Now().UTC().Format(time.RFC3339Nano), + Data: data, + } + + if len(b.buf) >= b.cap { + b.buf = b.buf[1:] + } + b.buf = append(b.buf, evt) + b.cond.Broadcast() + return evt +} + +func (b *Bus) Since(id int64) []Event { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.buf) == 0 { + return nil + } + out := make([]Event, 0, len(b.buf)) + for _, ev := range b.buf { + if ev.ID > id { + out = append(out, ev) + } + } + return out +} diff --git a/selective-vpn-api/app/eventstream/stream.go b/selective-vpn-api/app/eventstream/stream.go new file mode 100644 index 0000000..acede1f --- /dev/null +++ b/selective-vpn-api/app/eventstream/stream.go @@ -0,0 +1,107 @@ +package eventstream + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +type Event struct { + ID int64 + Kind string + Data any +} + +func ParseSinceID(r *http.Request) int64 { + sinceStr := strings.TrimSpace(r.URL.Query().Get("since")) + if sinceStr == "" { + sinceStr = strings.TrimSpace(r.Header.Get("Last-Event-ID")) + } + if sinceStr == "" { + return 0 + } + if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v >= 0 { + return v + } + return 0 +} + +func Serve(w http.ResponseWriter, r *http.Request, pollEvery, heartbeat time.Duration, loadSince func(int64) []Event) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + if pollEvery <= 0 { + pollEvery = 500 * time.Millisecond + } + if heartbeat <= 0 { + heartbeat = 15 * time.Second + } + + ctx := r.Context() + since := ParseSinceID(r) + + send := func(ev Event) error { + payload, err := json.Marshal(ev.Data) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Kind, string(payload)); err != nil { + return err + } + flusher.Flush() + return nil + } + + if loadSince != nil { + for _, ev := range loadSince(since) { + if err := send(ev); err != nil { + return + } + since = ev.ID + } + } + + pollTicker := time.NewTicker(pollEvery) + pingTicker := time.NewTicker(heartbeat) + defer pollTicker.Stop() + defer pingTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-pingTicker.C: + _, _ = io.WriteString(w, ": ping\n\n") + flusher.Flush() + case <-pollTicker.C: + if loadSince == nil { + continue + } + evs := loadSince(since) + if len(evs) == 0 { + continue + } + for _, ev := range evs { + if err := send(ev); err != nil { + return + } + since = ev.ID + } + } + } +} diff --git a/selective-vpn-api/app/helpers_values.go b/selective-vpn-api/app/helpers_values.go new file mode 100644 index 0000000..184e0a7 --- /dev/null +++ b/selective-vpn-api/app/helpers_values.go @@ -0,0 +1,30 @@ +package app + +import ( + "fmt" + "strconv" + "strings" +) + +func parsePort(raw string) int { + p := strings.TrimSpace(raw) + if p == "" { + return 0 + } + n, err := strconv.Atoi(p) + if err != nil || n <= 0 || n > 65535 { + return 0 + } + return n +} + +func asString(v any) string { + switch vv := v.(type) { + case string: + return strings.TrimSpace(vv) + case nil: + return "" + default: + return strings.TrimSpace(fmt.Sprint(vv)) + } +} diff --git a/selective-vpn-api/app/http_helpers.go b/selective-vpn-api/app/http_helpers.go index 95d132f..b1eac36 100644 --- a/selective-vpn-api/app/http_helpers.go +++ b/selective-vpn-api/app/http_helpers.go @@ -1,59 +1,18 @@ package app import ( - "encoding/json" - "log" "net/http" - "time" + httpxpkg "selective-vpn-api/app/httpx" ) -// --------------------------------------------------------------------- -// HTTP helpers -// --------------------------------------------------------------------- - -// EN: Common HTTP helpers used by all endpoint groups for consistent JSON output, -// EN: lightweight request timing logs, and health probing. -// RU: Общие HTTP-хелперы для всех групп эндпоинтов: единый JSON-ответ, -// RU: лёгкое логирование длительности запросов и health-check. - -// --------------------------------------------------------------------- -// request logging -// --------------------------------------------------------------------- - func logRequests(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - next.ServeHTTP(w, r) - log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) - }) + return httpxpkg.LogRequests(next) } -// --------------------------------------------------------------------- -// JSON response helper -// --------------------------------------------------------------------- - func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - if v == nil { - return - } - if err := json.NewEncoder(w).Encode(v); err != nil { - log.Printf("writeJSON error: %v", err) - } + httpxpkg.WriteJSON(w, status, v) } -// --------------------------------------------------------------------- -// health endpoint -// --------------------------------------------------------------------- - func handleHealthz(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - writeJSON(w, http.StatusOK, map[string]string{ - "status": "ok", - "time": time.Now().Format(time.RFC3339), - }) + httpxpkg.HandleHealthz(w, r) } diff --git a/selective-vpn-api/app/httpx/helpers.go b/selective-vpn-api/app/httpx/helpers.go new file mode 100644 index 0000000..09b60a9 --- /dev/null +++ b/selective-vpn-api/app/httpx/helpers.go @@ -0,0 +1,38 @@ +package httpx + +import ( + "encoding/json" + "log" + "net/http" + "time" +) + +func LogRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) + }) +} + +func WriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if v == nil { + return + } + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("writeJSON error: %v", err) + } +} + +func HandleHealthz(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + WriteJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "time": time.Now().Format(time.RFC3339), + }) +} diff --git a/selective-vpn-api/app/nft_update.go b/selective-vpn-api/app/nft_update.go index 4fab701..534ae03 100644 --- a/selective-vpn-api/app/nft_update.go +++ b/selective-vpn-api/app/nft_update.go @@ -1,400 +1,40 @@ package app import ( - "bytes" "context" - "errors" "fmt" - "net/netip" - "os/exec" - "sort" - "strings" - "time" - - "github.com/cenkalti/backoff/v4" + nftupdatepkg "selective-vpn-api/app/nftupdate" ) -// --------------------------------------------------------------------- -// nft update helpers -// --------------------------------------------------------------------- - -// EN: NFT set update strategy with interval compression and two execution modes: -// EN: atomic transaction first, then chunked fallback with per-IP recovery. -// RU: Стратегия обновления NFT-набора с компрессией интервалов и двумя режимами: -// RU: сначала атомарная транзакция, затем chunked fallback с поштучным восстановлением. - func nftLog(format string, args ...any) { appendTraceLine("routes", fmt.Sprintf(format, args...)) } -// --------------------------------------------------------------------- -// interval compression -// --------------------------------------------------------------------- - -// compressIPIntervals убирает: -// - дубликаты строк -// - подсети, целиком покрытые более широкими подсетями -// - одиночные IP, попадающие в уже имеющиеся подсети -func compressIPIntervals(ips []string) []string { - // чтобы не гонять дубликаты строк - seen := make(map[string]struct{}) - - type prefixItem struct { - p netip.Prefix - raw string - } - type addrItem struct { - a netip.Addr - raw string - } - - var prefixes []prefixItem - var addrs []addrItem - - for _, s := range ips { - s = strings.TrimSpace(s) - if s == "" { - continue - } - if _, ok := seen[s]; ok { - continue - } - seen[s] = struct{}{} - - if strings.Contains(s, "/") { - p, err := netip.ParsePrefix(s) - if err != nil { - // если формат кривой — просто пропускаем - continue - } - prefixes = append(prefixes, prefixItem{p: p, raw: s}) - } else { - a, err := netip.ParseAddr(s) - if err != nil { - continue - } - addrs = append(addrs, addrItem{a: a, raw: s}) - } - } - - // 1) Убираем подсети, полностью покрытые более крупными подсетями. - // - // Сначала сортируем по: - // - адресу - // - длине префикса (меньший Bits = более широкая сеть) — раньше - sort.Slice(prefixes, func(i, j int) bool { - ai := prefixes[i].p.Addr() - aj := prefixes[j].p.Addr() - if ai == aj { - return prefixes[i].p.Bits() < prefixes[j].p.Bits() - } - return ai.Less(aj) - }) - - var keptPrefixes []prefixItem - for _, pi := range prefixes { - covered := false - for _, kp := range keptPrefixes { - // если более крупная сеть kp уже покрывает эту — пропускаем - if kp.p.Bits() <= pi.p.Bits() && kp.p.Contains(pi.p.Addr()) { - covered = true - break - } - } - if !covered { - keptPrefixes = append(keptPrefixes, pi) - } - } - - var keptAddrs []addrItem - for _, ai := range addrs { - inNet := false - for _, kp := range keptPrefixes { - if kp.p.Contains(ai.a) { - inNet = true - break - } - } - if !inNet { - keptAddrs = append(keptAddrs, ai) - } - } - - // 3) Собираем финальный список строк - out := make([]string, 0, len(keptPrefixes)+len(keptAddrs)) - for _, ai := range keptAddrs { - out = append(out, ai.raw) - } - for _, pi := range keptPrefixes { - out = append(out, pi.raw) - } - - return out -} - -// --------------------------------------------------------------------- -// smart update strategy -// --------------------------------------------------------------------- - -// умный апдейтер: сначала atomic, при фейле – fallback на chunked func nftUpdateIPsSmart(ctx context.Context, ips []string, progressCb ProgressCallback) error { - return nftUpdateSetIPsSmart(ctx, "agvpn4", ips, progressCb) + return nftupdatepkg.UpdateIPsSmart( + ctx, + ips, + func(percent int, message string) { + if progressCb != nil { + progressCb(percent, message) + } + }, + runCommandTimeout, + nftLog, + ) } -// nftUpdateSetIPsSmart — тот же апдейтер, но для произвольного nft set. func nftUpdateSetIPsSmart(ctx context.Context, setName string, ips []string, progressCb ProgressCallback) error { - setName = strings.TrimSpace(setName) - if setName == "" { - setName = "agvpn4" - } - - if len(ips) == 0 { - if progressCb != nil { - progressCb(100, "nothing to update") - } - return nil - } - - // Сжимаем IP / подсети, убираем пересечения и дубликаты - origCount := len(ips) - ips = compressIPIntervals(ips) - if len(ips) != origCount { - nftLog( - "compress(%s): %d -> %d IP elements (removed %d covered/duplicate entries)", - setName, origCount, len(ips), origCount-len(ips), - ) - } - - if len(ips) == 0 { - if progressCb != nil { - progressCb(100, "nothing to update after compression") - } - return nil - } - - nftLog("nftUpdateSetIPsSmart(%s): start, ips=%d", setName, len(ips)) - - // 1) atomic транзакция через nft -f - - if err := nftAtomicUpdateWithProgress(ctx, setName, ips, progressCb); err == nil { - return nil - } else { - // если контекст умер – дальше не идём - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - nftLog("atomic update cancelled (%s): %v", setName, err) - return err - } - nftLog("atomic nft update failed (%s): %v; falling back to chunked mode", setName, err) - if progressCb != nil { - progressCb(0, "Falling back to non-atomic update") - } - } - - // 2) fallback: flush + chunked с поштучным фолбэком - return nftChunkedUpdateWithFallback(ctx, setName, ips, progressCb) -} - -// --------------------------------------------------------------------- -// atomic updater -// --------------------------------------------------------------------- - -// атомарный апдейт через один nft-транзакционный скрипт -func nftAtomicUpdateWithProgress(ctx context.Context, setName string, ips []string, progressCb ProgressCallback) error { - if len(ips) == 0 { - if progressCb != nil { - progressCb(100, "nothing to update") - } - return nil - } - - sort.Strings(ips) // стабильность - - total := len(ips) - chunkSize := 500 // старт - - bo := backoff.NewExponentialBackOff() - bo.InitialInterval = 500 * time.Millisecond - bo.MaxInterval = 10 * time.Second - bo.MaxElapsedTime = 2 * time.Minute - - return backoff.Retry(func() error { - select { - case <-ctx.Done(): - if progressCb != nil { - progressCb(0, "Cancelled by context") - } - return ctx.Err() - default: - } - - var script strings.Builder - script.WriteString("flush set inet agvpn " + setName + "\n") - - processed := 0 - chunksTotal := (len(ips) + chunkSize - 1) / chunkSize - - for i := 0; i < len(ips); i += chunkSize { - end := i + chunkSize - if end > len(ips) { - end = len(ips) - } - chunk := ips[i:end] - - script.WriteString("add element inet agvpn " + setName + " { ") - script.WriteString(strings.Join(chunk, ", ")) - script.WriteString(" }\n") - - processed += len(chunk) - if progressCb != nil { - percent := processed * 100 / total - progressCb(percent, fmt.Sprintf( - "Preparing chunk %d/%d (%d/%d IPs)", - i/chunkSize+1, chunksTotal, processed, total, - )) - } - } - - if progressCb != nil { - progressCb(90, "Executing nft transaction...") - } - - cmd := exec.CommandContext(ctx, "nft", "-f", "-") - cmd.Stdin = strings.NewReader(script.String()) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - if err == nil { - nftLog("nft atomic transaction success (%s): %d IPs added", setName, len(ips)) - if progressCb != nil { - progressCb(100, "Update complete") - } - return nil - } - - errStr := stderr.String() - nftLog("nft atomic transaction failed (%s): err=%v, stderr=%q", setName, err, errStr) - - // Ошибки, требующие уменьшения чанка - if strings.Contains(errStr, "too many elements") || - strings.Contains(errStr, "out of memory") || - strings.Contains(errStr, "interval overlaps") || - strings.Contains(errStr, "conflicting intervals") { - - newSize := chunkSize / 2 - if newSize < 100 { - newSize = 100 - } - if newSize == chunkSize { - // дальше делить некуда — Permanent → fallback - return backoff.Permanent(fmt.Errorf("atomic nft cannot shrink further: %w", err)) - } - - nftLog("reducing atomic chunk size from %d to %d and retrying", chunkSize, newSize) - chunkSize = newSize - - if progressCb != nil { - progressCb(0, fmt.Sprintf("Retrying atomic with smaller chunks (%d IPs)", chunkSize)) - } - - return fmt.Errorf("retry atomic with smaller chunks") - } - - // Другие ошибки — Permanent (переход к chunked) - return backoff.Permanent(fmt.Errorf("nft atomic transaction failed: %w", err)) - }, bo) -} - -// --------------------------------------------------------------------- -// chunked fallback updater -// --------------------------------------------------------------------- - -// nftChunkedUpdateWithFallback — fallback-режим: flush + чанки + поштучно при ошибках -func nftChunkedUpdateWithFallback(ctx context.Context, setName string, ips []string, progressCb ProgressCallback) error { - if len(ips) == 0 { - if progressCb != nil { - progressCb(100, "nothing to update") - } - return nil - } - - sort.Strings(ips) - - total := len(ips) - chunkSize := 500 - - // flush - _, stderr, _, err := runCommandTimeout(10*time.Second, - "nft", "flush", "set", "inet", "agvpn", setName) - if err != nil { - return fmt.Errorf("flush set failed: %v (%s)", err, stderr) - } - - processed := 0 - - for i := 0; i < len(ips); i += chunkSize { - select { - case <-ctx.Done(): - if progressCb != nil { - progressCb(0, "Cancelled during chunked update") - } - return ctx.Err() - default: - } - - end := i + chunkSize - if end > len(ips) { - end = len(ips) - } - chunk := ips[i:end] - - cmdArgs := []string{ - "nft", "add", "element", "inet", "agvpn", setName, - "{ " + strings.Join(chunk, ", ") + " }", - } - - cmdName := cmdArgs[0] - cmdRest := cmdArgs[1:] - - _, stderr, _, err := runCommandTimeout(15*time.Second, cmdName, cmdRest...) - if err != nil { - // типичные ошибки → поштучно - if strings.Contains(stderr, "interval overlaps") || - strings.Contains(stderr, "too many elements") || - strings.Contains(stderr, "out of memory") || - strings.Contains(stderr, "conflicting intervals") { - - nftLog("chunk failed (%d IPs), fallback per-ip", len(chunk)) - if progressCb != nil { - progressCb(processed*100/total, - fmt.Sprintf("Chunk failed -> adding %d IPs one by one", len(chunk))) - } - - for _, ip := range chunk { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - _, _, _, _ = runCommandTimeout(5*time.Second, - "nft", "add", "element", "inet", "agvpn", setName, "{ "+ip+" }") - } - } else { - return fmt.Errorf("nft chunk add failed: %v (%s)", err, stderr) - } - } - - processed += len(chunk) - if progressCb != nil { - percent := processed * 100 / total - progressCb(percent, fmt.Sprintf("Added %d/%d IPs", processed, total)) - } - } - - if progressCb != nil { - progressCb(100, "chunked update complete") - } - nftLog("nft chunked update success (%s): %d IPs", setName, len(ips)) - return nil + return nftupdatepkg.UpdateSetIPsSmart( + ctx, + setName, + ips, + func(percent int, message string) { + if progressCb != nil { + progressCb(percent, message) + } + }, + runCommandTimeout, + nftLog, + ) } diff --git a/selective-vpn-api/app/nftupdate/update.go b/selective-vpn-api/app/nftupdate/update.go new file mode 100644 index 0000000..7120aa0 --- /dev/null +++ b/selective-vpn-api/app/nftupdate/update.go @@ -0,0 +1,338 @@ +package nftupdate + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/netip" + "os/exec" + "sort" + "strings" + "time" + + "github.com/cenkalti/backoff/v4" +) + +type ProgressCallback func(percent int, message string) + +type CmdRunner func(timeout time.Duration, name string, args ...string) (stdout, stderr string, exitCode int, err error) + +type Logger func(format string, args ...any) + +func UpdateIPsSmart(ctx context.Context, ips []string, progressCb ProgressCallback, runCmd CmdRunner, logf Logger) error { + return UpdateSetIPsSmart(ctx, "agvpn4", ips, progressCb, runCmd, logf) +} + +func UpdateSetIPsSmart(ctx context.Context, setName string, ips []string, progressCb ProgressCallback, runCmd CmdRunner, logf Logger) error { + setName = strings.TrimSpace(setName) + if setName == "" { + setName = "agvpn4" + } + if runCmd == nil { + return fmt.Errorf("run command function is not configured") + } + + if len(ips) == 0 { + if progressCb != nil { + progressCb(100, "nothing to update") + } + return nil + } + + origCount := len(ips) + ips = compressIPIntervals(ips) + if len(ips) != origCount { + log(logf, "compress(%s): %d -> %d IP elements (removed %d covered/duplicate entries)", setName, origCount, len(ips), origCount-len(ips)) + } + + if len(ips) == 0 { + if progressCb != nil { + progressCb(100, "nothing to update after compression") + } + return nil + } + + log(logf, "nft UpdateSetIPsSmart(%s): start, ips=%d", setName, len(ips)) + + if err := atomicUpdateWithProgress(ctx, setName, ips, progressCb, logf); err == nil { + return nil + } else { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + log(logf, "atomic update cancelled (%s): %v", setName, err) + return err + } + log(logf, "atomic nft update failed (%s): %v; falling back to chunked mode", setName, err) + if progressCb != nil { + progressCb(0, "Falling back to non-atomic update") + } + } + + return chunkedUpdateWithFallback(ctx, setName, ips, progressCb, runCmd, logf) +} + +func compressIPIntervals(ips []string) []string { + seen := make(map[string]struct{}) + + type prefixItem struct { + p netip.Prefix + raw string + } + type addrItem struct { + a netip.Addr + raw string + } + + var prefixes []prefixItem + var addrs []addrItem + + for _, s := range ips { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + + if strings.Contains(s, "/") { + p, err := netip.ParsePrefix(s) + if err != nil { + continue + } + prefixes = append(prefixes, prefixItem{p: p, raw: s}) + } else { + a, err := netip.ParseAddr(s) + if err != nil { + continue + } + addrs = append(addrs, addrItem{a: a, raw: s}) + } + } + + sort.Slice(prefixes, func(i, j int) bool { + ai := prefixes[i].p.Addr() + aj := prefixes[j].p.Addr() + if ai == aj { + return prefixes[i].p.Bits() < prefixes[j].p.Bits() + } + return ai.Less(aj) + }) + + var keptPrefixes []prefixItem + for _, pi := range prefixes { + covered := false + for _, kp := range keptPrefixes { + if kp.p.Bits() <= pi.p.Bits() && kp.p.Contains(pi.p.Addr()) { + covered = true + break + } + } + if !covered { + keptPrefixes = append(keptPrefixes, pi) + } + } + + var keptAddrs []addrItem + for _, ai := range addrs { + inNet := false + for _, kp := range keptPrefixes { + if kp.p.Contains(ai.a) { + inNet = true + break + } + } + if !inNet { + keptAddrs = append(keptAddrs, ai) + } + } + + out := make([]string, 0, len(keptPrefixes)+len(keptAddrs)) + for _, ai := range keptAddrs { + out = append(out, ai.raw) + } + for _, pi := range keptPrefixes { + out = append(out, pi.raw) + } + + return out +} + +func atomicUpdateWithProgress(ctx context.Context, setName string, ips []string, progressCb ProgressCallback, logf Logger) error { + if len(ips) == 0 { + if progressCb != nil { + progressCb(100, "nothing to update") + } + return nil + } + + sort.Strings(ips) + total := len(ips) + chunkSize := 500 + + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = 500 * time.Millisecond + bo.MaxInterval = 10 * time.Second + bo.MaxElapsedTime = 2 * time.Minute + + return backoff.Retry(func() error { + select { + case <-ctx.Done(): + if progressCb != nil { + progressCb(0, "Cancelled by context") + } + return ctx.Err() + default: + } + + var script strings.Builder + script.WriteString("flush set inet agvpn " + setName + "\n") + + processed := 0 + chunksTotal := (len(ips) + chunkSize - 1) / chunkSize + + for i := 0; i < len(ips); i += chunkSize { + end := i + chunkSize + if end > len(ips) { + end = len(ips) + } + chunk := ips[i:end] + + script.WriteString("add element inet agvpn " + setName + " { ") + script.WriteString(strings.Join(chunk, ", ")) + script.WriteString(" }\n") + + processed += len(chunk) + if progressCb != nil { + percent := processed * 100 / total + progressCb(percent, fmt.Sprintf("Preparing chunk %d/%d (%d/%d IPs)", i/chunkSize+1, chunksTotal, processed, total)) + } + } + + if progressCb != nil { + progressCb(90, "Executing nft transaction...") + } + + cmd := exec.CommandContext(ctx, "nft", "-f", "-") + cmd.Stdin = strings.NewReader(script.String()) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err == nil { + log(logf, "nft atomic transaction success (%s): %d IPs added", setName, len(ips)) + if progressCb != nil { + progressCb(100, "Update complete") + } + return nil + } + + errStr := stderr.String() + log(logf, "nft atomic transaction failed (%s): err=%v, stderr=%q", setName, err, errStr) + + if strings.Contains(errStr, "too many elements") || + strings.Contains(errStr, "out of memory") || + strings.Contains(errStr, "interval overlaps") || + strings.Contains(errStr, "conflicting intervals") { + + newSize := chunkSize / 2 + if newSize < 100 { + newSize = 100 + } + if newSize == chunkSize { + return backoff.Permanent(fmt.Errorf("atomic nft cannot shrink further: %w", err)) + } + + log(logf, "reducing atomic chunk size from %d to %d and retrying", chunkSize, newSize) + chunkSize = newSize + if progressCb != nil { + progressCb(0, fmt.Sprintf("Retrying atomic with smaller chunks (%d IPs)", chunkSize)) + } + return fmt.Errorf("retry atomic with smaller chunks") + } + + return backoff.Permanent(fmt.Errorf("nft atomic transaction failed: %w", err)) + }, bo) +} + +func chunkedUpdateWithFallback(ctx context.Context, setName string, ips []string, progressCb ProgressCallback, runCmd CmdRunner, logf Logger) error { + if len(ips) == 0 { + if progressCb != nil { + progressCb(100, "nothing to update") + } + return nil + } + + sort.Strings(ips) + total := len(ips) + chunkSize := 500 + + _, stderr, _, err := runCmd(10*time.Second, "nft", "flush", "set", "inet", "agvpn", setName) + if err != nil { + return fmt.Errorf("flush set failed: %v (%s)", err, stderr) + } + + processed := 0 + for i := 0; i < len(ips); i += chunkSize { + select { + case <-ctx.Done(): + if progressCb != nil { + progressCb(0, "Cancelled during chunked update") + } + return ctx.Err() + default: + } + + end := i + chunkSize + if end > len(ips) { + end = len(ips) + } + chunk := ips[i:end] + + _, stderr, _, err := runCmd(15*time.Second, "nft", "add", "element", "inet", "agvpn", setName, "{ "+strings.Join(chunk, ", ")+" }") + if err != nil { + if strings.Contains(stderr, "interval overlaps") || + strings.Contains(stderr, "too many elements") || + strings.Contains(stderr, "out of memory") || + strings.Contains(stderr, "conflicting intervals") { + + log(logf, "chunk failed (%d IPs), fallback per-ip", len(chunk)) + if progressCb != nil { + progressCb(processed*100/total, fmt.Sprintf("Chunk failed -> adding %d IPs one by one", len(chunk))) + } + + for _, ip := range chunk { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + _, _, _, _ = runCmd(5*time.Second, "nft", "add", "element", "inet", "agvpn", setName, "{ "+ip+" }") + } + } else { + return fmt.Errorf("nft chunk add failed: %v (%s)", err, stderr) + } + } + + processed += len(chunk) + if progressCb != nil { + percent := processed * 100 / total + progressCb(percent, fmt.Sprintf("Added %d/%d IPs", processed, total)) + } + } + + if progressCb != nil { + progressCb(100, "chunked update complete") + } + log(logf, "nft chunked update success (%s): %d IPs", setName, len(ips)) + return nil +} + +func log(logf Logger, format string, args ...any) { + if logf != nil { + logf(format, args...) + } +} diff --git a/selective-vpn-api/app/refresh_coordinator.go b/selective-vpn-api/app/refresh_coordinator.go new file mode 100644 index 0000000..d49cbd0 --- /dev/null +++ b/selective-vpn-api/app/refresh_coordinator.go @@ -0,0 +1,64 @@ +package app + +import ( + refreshcoordpkg "selective-vpn-api/app/refreshcoord" + "time" +) + +type refreshStateSnapshot = refreshcoordpkg.Snapshot + +type refreshCoordinator struct { + inner refreshcoordpkg.Coordinator +} + +func newRefreshCoordinator(freshTTL, backoffMin, backoffMax time.Duration) refreshCoordinator { + return refreshCoordinator{inner: refreshcoordpkg.New(freshTTL, backoffMin, backoffMax)} +} + +func (c *refreshCoordinator) setUpdatedAt(at time.Time) { + c.inner.SetUpdatedAt(at) +} + +func (c *refreshCoordinator) beginRefresh(now time.Time, force bool, hasData bool) bool { + return c.inner.BeginRefresh(now, force, hasData) +} + +func (c *refreshCoordinator) shouldRefresh(now time.Time, force bool, hasData bool) bool { + return c.inner.ShouldRefresh(now, force, hasData) +} + +func (c *refreshCoordinator) isStale(now time.Time) bool { + return c.inner.IsStale(now) +} + +func (c *refreshCoordinator) finishSuccess(now time.Time) { + c.inner.FinishSuccess(now) +} + +func (c *refreshCoordinator) finishError(msg string, now time.Time) { + c.inner.FinishError(msg, now) +} + +func (c *refreshCoordinator) snapshot(now time.Time) refreshStateSnapshot { + return c.inner.Snapshot(now) +} + +func (c *refreshCoordinator) refreshInProgress() bool { + return c.inner.RefreshInProgress() +} + +func (c *refreshCoordinator) nextRetryAt() time.Time { + return c.inner.NextRetryAt() +} + +func (c *refreshCoordinator) clearBackoff() { + c.inner.ClearBackoff() +} + +func (c *refreshCoordinator) consecutiveErrors() int { + return c.inner.ConsecutiveErrors() +} + +func (c *refreshCoordinator) lastError() string { + return c.inner.LastError() +} diff --git a/selective-vpn-api/app/refresh_coordinator_test.go b/selective-vpn-api/app/refresh_coordinator_test.go new file mode 100644 index 0000000..098d8b4 --- /dev/null +++ b/selective-vpn-api/app/refresh_coordinator_test.go @@ -0,0 +1,80 @@ +package app + +import ( + "testing" + "time" +) + +func TestRefreshCoordinatorLifecycle(t *testing.T) { + now := time.Date(2026, time.March, 9, 21, 0, 0, 0, time.UTC) + rc := newRefreshCoordinator(10*time.Minute, 2*time.Second, 60*time.Second) + + if !rc.shouldRefresh(now, false, false) { + t.Fatalf("expected refresh when cache is empty") + } + if !rc.beginRefresh(now, false, false) { + t.Fatalf("expected beginRefresh=true for empty cache") + } + if rc.shouldRefresh(now, false, false) { + t.Fatalf("must not refresh while refresh is in progress") + } + + rc.finishSuccess(now) + snap := rc.snapshot(now) + if snap.RefreshInProgress { + t.Fatalf("refresh must be finished after success") + } + if snap.Stale { + t.Fatalf("fresh snapshot must not be stale") + } + if snap.LastError != "" || snap.NextRetryAt != "" { + t.Fatalf("success must clear error and retry metadata: %#v", snap) + } + + if rc.shouldRefresh(now.Add(5*time.Minute), false, true) { + t.Fatalf("fresh cache should not refresh yet") + } + if !rc.shouldRefresh(now.Add(11*time.Minute), false, true) { + t.Fatalf("stale cache should refresh") + } +} + +func TestRefreshCoordinatorBackoffAndReset(t *testing.T) { + start := time.Date(2026, time.March, 9, 21, 5, 0, 0, time.UTC) + rc := newRefreshCoordinator(10*time.Minute, 2*time.Second, 60*time.Second) + + type step struct { + at time.Time + expected time.Duration + } + steps := []step{ + {at: start, expected: 2 * time.Second}, + {at: start.Add(2 * time.Second), expected: 4 * time.Second}, + {at: start.Add(6 * time.Second), expected: 8 * time.Second}, + {at: start.Add(14 * time.Second), expected: 16 * time.Second}, + {at: start.Add(30 * time.Second), expected: 32 * time.Second}, + {at: start.Add(62 * time.Second), expected: 60 * time.Second}, + } + + for i, st := range steps { + rc.finishError("probe failed", st.at) + got := rc.nextRetryAt().Sub(st.at) + if got != st.expected { + t.Fatalf("step=%d unexpected backoff: got=%s want=%s", i+1, got, st.expected) + } + if rc.lastError() == "" { + t.Fatalf("step=%d expected non-empty lastError", i+1) + } + } + + rc.finishSuccess(start.Add(2 * time.Minute)) + if rc.consecutiveErrors() != 0 { + t.Fatalf("expected consecutiveErrors reset, got %d", rc.consecutiveErrors()) + } + if !rc.nextRetryAt().IsZero() { + t.Fatalf("expected nextRetryAt reset") + } + if rc.lastError() != "" { + t.Fatalf("expected lastError reset, got %q", rc.lastError()) + } +} diff --git a/selective-vpn-api/app/refreshcoord/coordinator.go b/selective-vpn-api/app/refreshcoord/coordinator.go new file mode 100644 index 0000000..5c9b0c4 --- /dev/null +++ b/selective-vpn-api/app/refreshcoord/coordinator.go @@ -0,0 +1,164 @@ +package refreshcoord + +import ( + "strings" + "time" +) + +type Snapshot struct { + UpdatedAt string + Stale bool + RefreshInProgress bool + LastError string + NextRetryAt string +} + +// Coordinator is a small reusable SWR-like state machine: +// stale-while-refresh + single-flight + exponential backoff after failures. +// +// All methods are intentionally lock-free; caller owns synchronization. +type Coordinator struct { + freshTTL time.Duration + backoffMin time.Duration + backoffMax time.Duration + + updatedAt time.Time + + lastError string + + refreshInProgress bool + consecutiveErrors int + nextRetryAt time.Time +} + +func New(freshTTL, backoffMin, backoffMax time.Duration) Coordinator { + if freshTTL <= 0 { + freshTTL = 10 * time.Minute + } + if backoffMin <= 0 { + backoffMin = 2 * time.Second + } + if backoffMax <= 0 { + backoffMax = 60 * time.Second + } + if backoffMax < backoffMin { + backoffMax = backoffMin + } + return Coordinator{ + freshTTL: freshTTL, + backoffMin: backoffMin, + backoffMax: backoffMax, + } +} + +func (c *Coordinator) SetUpdatedAt(at time.Time) { + c.updatedAt = at +} + +func (c *Coordinator) BeginRefresh(now time.Time, force bool, hasData bool) bool { + if !c.ShouldRefresh(now, force, hasData) { + return false + } + c.refreshInProgress = true + return true +} + +func (c *Coordinator) ShouldRefresh(now time.Time, force bool, hasData bool) bool { + if c.refreshInProgress { + return false + } + if !c.nextRetryAt.IsZero() && now.Before(c.nextRetryAt) { + return false + } + if force { + return true + } + if !hasData { + return true + } + return c.IsStale(now) +} + +func (c *Coordinator) IsStale(now time.Time) bool { + if c.updatedAt.IsZero() { + return true + } + return now.Sub(c.updatedAt) > c.freshTTL +} + +func (c *Coordinator) FinishSuccess(now time.Time) { + c.updatedAt = now + c.lastError = "" + c.refreshInProgress = false + c.consecutiveErrors = 0 + c.nextRetryAt = time.Time{} +} + +func (c *Coordinator) FinishError(msg string, now time.Time) { + c.lastError = strings.TrimSpace(msg) + c.refreshInProgress = false + c.consecutiveErrors++ + c.nextRetryAt = now.Add(c.nextBackoff()) +} + +func (c *Coordinator) Snapshot(now time.Time) Snapshot { + out := Snapshot{ + Stale: c.IsStale(now), + RefreshInProgress: c.refreshInProgress, + LastError: strings.TrimSpace(c.lastError), + } + if !c.updatedAt.IsZero() { + out.UpdatedAt = c.updatedAt.UTC().Format(time.RFC3339) + } + if !c.nextRetryAt.IsZero() { + out.NextRetryAt = c.nextRetryAt.UTC().Format(time.RFC3339) + } + return out +} + +func (c *Coordinator) RefreshInProgress() bool { + return c.refreshInProgress +} + +func (c *Coordinator) NextRetryAt() time.Time { + return c.nextRetryAt +} + +func (c *Coordinator) ConsecutiveErrors() int { + return c.consecutiveErrors +} + +func (c *Coordinator) LastError() string { + return c.lastError +} + +func (c *Coordinator) ClearBackoff() { + c.nextRetryAt = time.Time{} +} + +func (c *Coordinator) nextBackoff() time.Duration { + backoff := c.backoffMin + if backoff <= 0 { + backoff = 2 * time.Second + } + maxBackoff := c.backoffMax + if maxBackoff <= 0 { + maxBackoff = backoff + } + if maxBackoff < backoff { + maxBackoff = backoff + } + for i := 1; i < c.consecutiveErrors; i++ { + if backoff >= maxBackoff { + return maxBackoff + } + if backoff > maxBackoff/2 { + return maxBackoff + } + backoff *= 2 + } + if backoff > maxBackoff { + backoff = maxBackoff + } + return backoff +} diff --git a/selective-vpn-api/app/resolver.go b/selective-vpn-api/app/resolver.go index c2581f7..14a8631 100644 --- a/selective-vpn-api/app/resolver.go +++ b/selective-vpn-api/app/resolver.go @@ -1,22 +1,5 @@ package app -import ( - "context" - "encoding/json" - "errors" - "fmt" - "hash/fnv" - "net" - "net/netip" - "os" - "regexp" - "sort" - "strconv" - "strings" - "sync" - "time" -) - // --------------------------------------------------------------------- // Go resolver // --------------------------------------------------------------------- @@ -28,278 +11,10 @@ import ( // RU: Обрабатывает кэш, конкурентные DNS-запросы, PTR-лейблы для static entries // RU: и возвращает дедуплицированный список IP и IP-to-label mapping. -type dnsErrorKind string - -const ( - dnsErrorNXDomain dnsErrorKind = "nxdomain" - dnsErrorTimeout dnsErrorKind = "timeout" - dnsErrorTemporary dnsErrorKind = "temporary" - dnsErrorOther dnsErrorKind = "other" -) - -type dnsUpstreamMetrics struct { - Attempts int - OK int - NXDomain int - Timeout int - Temporary int - Other int - Skipped int -} - -type dnsMetrics struct { - Attempts int - OK int - NXDomain int - Timeout int - Temporary int - Other int - Skipped int - - PerUpstream map[string]*dnsUpstreamMetrics -} - -func (m *dnsMetrics) ensureUpstream(upstream string) *dnsUpstreamMetrics { - if m.PerUpstream == nil { - m.PerUpstream = map[string]*dnsUpstreamMetrics{} - } - if us, ok := m.PerUpstream[upstream]; ok { - return us - } - us := &dnsUpstreamMetrics{} - m.PerUpstream[upstream] = us - return us -} - -func (m *dnsMetrics) addSuccess(upstream string) { - m.Attempts++ - m.OK++ - us := m.ensureUpstream(upstream) - us.Attempts++ - us.OK++ -} - -func (m *dnsMetrics) addError(upstream string, kind dnsErrorKind) { - m.Attempts++ - us := m.ensureUpstream(upstream) - us.Attempts++ - switch kind { - case dnsErrorNXDomain: - m.NXDomain++ - us.NXDomain++ - case dnsErrorTimeout: - m.Timeout++ - us.Timeout++ - case dnsErrorTemporary: - m.Temporary++ - us.Temporary++ - default: - m.Other++ - us.Other++ - } -} - -func (m *dnsMetrics) addCooldownSkip(upstream string) { - m.Skipped++ - us := m.ensureUpstream(upstream) - us.Skipped++ -} - -func (m *dnsMetrics) merge(other dnsMetrics) { - m.Attempts += other.Attempts - m.OK += other.OK - m.NXDomain += other.NXDomain - m.Timeout += other.Timeout - m.Temporary += other.Temporary - m.Other += other.Other - - for upstream, src := range other.PerUpstream { - dst := m.ensureUpstream(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 - } -} - -func (m dnsMetrics) totalErrors() int { - return m.NXDomain + m.Timeout + m.Temporary + m.Other -} - -func (m dnsMetrics) 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, "; ") -} - -func (m dnsMetrics) formatResolverHealth() 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] - if v == nil || v.Attempts <= 0 { - continue - } - okRate := float64(v.OK) / float64(v.Attempts) - timeoutRate := float64(v.Timeout) / float64(v.Attempts) - score := okRate*100.0 - timeoutRate*50.0 - state := "bad" - switch { - case score >= 70 && timeoutRate <= 0.05: - state = "good" - case score >= 35: - state = "degraded" - default: - state = "bad" - } - parts = append(parts, fmt.Sprintf("%s{score=%.1f state=%s attempts=%d ok=%d timeout=%d nxdomain=%d temporary=%d other=%d skipped=%d}", k, score, state, v.Attempts, v.OK, v.Timeout, v.NXDomain, v.Temporary, v.Other, v.Skipped)) - } - return strings.Join(parts, "; ") -} - -type wildcardMatcher struct { - exact map[string]struct{} - suffix []string -} - -type dnsAttemptPolicy struct { - TryLimit int - DomainBudget time.Duration - StopOnNX bool -} - -const ( - domainStateActive = "active" - domainStateStable = "stable" - domainStateSuspect = "suspect" - domainStateQuarantine = "quarantine" - domainStateHardQuar = "hard_quarantine" - domainScoreMin = -100 - domainScoreMax = 100 - defaultQuarantineTTL = 24 * 3600 - defaultHardQuarantineTT = 7 * 24 * 3600 -) - -type resolverTimeoutRecheckStats struct { - Checked int - Recovered int - RecoveredIPs int - StillTimeout int - NowNXDomain int - NowTemporary int - NowOther int - NoSignal int -} - -type resolverLiveBatchStats struct { - Target int - Total int - Deferred int - P1 int - P2 int - P3 int - NXHeavyPct int - NXHeavyTotal int - NXHeavySkip int - NextTarget int - NextReason string - NextNXPct int - NextNXReason string - DNSAttempts int - DNSTimeout int - DNSCoolSkips int -} - -type dnsCooldownState struct { - Attempts int - TimeoutLike int - FailStreak int - BanUntil int64 - BanLevel int -} - -type dnsRunCooldown struct { - mu sync.Mutex - enabled bool - minAttempts int - timeoutRatePct int - failStreak int - banSec int - maxBanSec int - temporaryAsError bool - byUpstream map[string]*dnsCooldownState -} - // Empty by default: primary resolver pool comes from DNS upstream pool state. // Optional fallback list can still be provided via RESOLVE_DNS_FALLBACKS env. var resolverFallbackDNS []string -func normalizeWildcardDomain(raw string) string { - d := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0]) - d = strings.ToLower(d) - d = strings.TrimPrefix(d, "*.") - d = strings.TrimPrefix(d, ".") - d = strings.TrimSuffix(d, ".") - return d -} - -func newWildcardMatcher(domains []string) wildcardMatcher { - seen := map[string]struct{}{} - m := wildcardMatcher{exact: map[string]struct{}{}} - for _, raw := range domains { - d := normalizeWildcardDomain(raw) - if d == "" { - continue - } - if _, ok := seen[d]; ok { - continue - } - seen[d] = struct{}{} - m.exact[d] = struct{}{} - m.suffix = append(m.suffix, "."+d) - } - return m -} - -func (m wildcardMatcher) match(host string) bool { - if len(m.exact) == 0 { - return false - } - h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") - if h == "" { - return false - } - if _, ok := m.exact[h]; ok { - return true - } - for _, suffix := range m.suffix { - if strings.HasSuffix(h, suffix) { - return true - } - } - return false -} - // --------------------------------------------------------------------- // EN: `runResolverJob` runs the workflow for resolver job. // RU: `runResolverJob` - запускает рабочий процесс для resolver job. @@ -309,2675 +24,18 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul DomainCache: map[string]any{}, PtrCache: map[string]any{}, } - - domains := loadList(opts.DomainsPath) - metaSpecial := loadList(opts.MetaPath) - staticLines := readLinesAllowMissing(opts.StaticPath) - wildcards := newWildcardMatcher(opts.SmartDNSWildcards) - - cfg := loadDNSConfig(opts.DNSConfigPath, logf) - if !smartDNSForced() { - cfg.Mode = normalizeDNSResolverMode(opts.Mode, opts.ViaSmartDNS) - } - if addr := normalizeSmartDNSAddr(opts.SmartDNSAddr); addr != "" { - cfg.SmartDNS = addr - } - if cfg.SmartDNS == "" { - cfg.SmartDNS = smartDNSAddr() - } - if cfg.Mode == DNSModeSmartDNS && cfg.SmartDNS != "" { - cfg.Default = []string{cfg.SmartDNS} - cfg.Meta = []string{cfg.SmartDNS} - } - if logf != nil { - switch cfg.Mode { - case DNSModeSmartDNS: - logf("resolver dns mode: SmartDNS-only (%s)", cfg.SmartDNS) - case DNSModeHybridWildcard: - logf("resolver dns mode: hybrid_wildcard smartdns=%s wildcards=%d default=%v meta=%v", cfg.SmartDNS, len(wildcards.exact), cfg.Default, cfg.Meta) - default: - logf("resolver dns mode: direct default=%v meta=%v", cfg.Default, cfg.Meta) - } - } - - ttl := opts.TTL - if ttl <= 0 { - ttl = 24 * 3600 - } - // safety clamp: 60s .. 24h - if ttl < 60 { - ttl = 60 - } - if ttl > 24*3600 { - ttl = 24 * 3600 - } - workers := opts.Workers - if workers <= 0 { - workers = 80 - } - // safety clamp: 1..500 - if workers < 1 { - workers = 1 - } - if workers > 500 { - workers = 500 - } - dnsTimeoutMs := envInt("RESOLVE_DNS_TIMEOUT_MS", 1800) - if dnsTimeoutMs < 300 { - dnsTimeoutMs = 300 - } - if dnsTimeoutMs > 5000 { - dnsTimeoutMs = 5000 - } - dnsTimeout := time.Duration(dnsTimeoutMs) * time.Millisecond - - domainCache := loadDomainCacheState(opts.CachePath, logf) - ptrCache := loadJSONMap(opts.PtrCachePath) - now := int(time.Now().Unix()) - precheckEverySec := envInt("RESOLVE_PRECHECK_EVERY_SEC", 24*3600) - if precheckEverySec < 0 { - precheckEverySec = 0 - } - precheckMaxDomains := envInt("RESOLVE_PRECHECK_MAX_DOMAINS", 3000) - if precheckMaxDomains < 0 { - precheckMaxDomains = 0 - } - if precheckMaxDomains > 50000 { - precheckMaxDomains = 50000 - } - timeoutRecheckMax := envInt("RESOLVE_TIMEOUT_RECHECK_MAX", precheckMaxDomains) - if timeoutRecheckMax < 0 { - timeoutRecheckMax = 0 - } - if timeoutRecheckMax > 50000 { - timeoutRecheckMax = 50000 - } - precheckStatePath := opts.CachePath + ".precheck.json" - precheckLastRun := loadResolverPrecheckLastRun(precheckStatePath) - liveBatchMin := envInt("RESOLVE_LIVE_BATCH_MIN", 800) - liveBatchMax := envInt("RESOLVE_LIVE_BATCH_MAX", 3000) - liveBatchDefault := envInt("RESOLVE_LIVE_BATCH_DEFAULT", 1800) - if liveBatchMin < 200 { - liveBatchMin = 200 - } - if liveBatchMin > 50000 { - liveBatchMin = 50000 - } - if liveBatchMax < liveBatchMin { - liveBatchMax = liveBatchMin - } - if liveBatchMax > 50000 { - liveBatchMax = 50000 - } - if liveBatchDefault < liveBatchMin { - liveBatchDefault = liveBatchMin - } - if liveBatchDefault > liveBatchMax { - liveBatchDefault = liveBatchMax - } - liveBatchTarget := loadResolverLiveBatchTarget(precheckStatePath, liveBatchDefault, liveBatchMin, liveBatchMax) - liveBatchNXHeavyMin := envInt("RESOLVE_LIVE_BATCH_NX_HEAVY_MIN_PCT", 5) - liveBatchNXHeavyMax := envInt("RESOLVE_LIVE_BATCH_NX_HEAVY_MAX_PCT", 35) - liveBatchNXHeavyDefault := envInt("RESOLVE_LIVE_BATCH_NX_HEAVY_PCT", 10) - if liveBatchNXHeavyMin < 0 { - liveBatchNXHeavyMin = 0 - } - if liveBatchNXHeavyMin > 100 { - liveBatchNXHeavyMin = 100 - } - if liveBatchNXHeavyMax < liveBatchNXHeavyMin { - liveBatchNXHeavyMax = liveBatchNXHeavyMin - } - if liveBatchNXHeavyMax > 100 { - liveBatchNXHeavyMax = 100 - } - if liveBatchNXHeavyDefault < liveBatchNXHeavyMin { - liveBatchNXHeavyDefault = liveBatchNXHeavyMin - } - if liveBatchNXHeavyDefault > liveBatchNXHeavyMax { - liveBatchNXHeavyDefault = liveBatchNXHeavyMax - } - liveBatchNXHeavyPct := loadResolverLiveBatchNXHeavyPct(precheckStatePath, liveBatchNXHeavyDefault, liveBatchNXHeavyMin, liveBatchNXHeavyMax) - precheckEnvForced := resolvePrecheckForceEnvEnabled() - precheckFileForced := resolvePrecheckForceFileEnabled(precheckForcePath) - precheckDue := precheckEnvForced || precheckFileForced || (precheckEverySec > 0 && (precheckLastRun <= 0 || now-precheckLastRun >= precheckEverySec)) - precheckScheduled := 0 - staleKeepSec := envInt("RESOLVE_STALE_KEEP_SEC", 48*3600) - if staleKeepSec < 0 { - staleKeepSec = 0 - } - if staleKeepSec > 7*24*3600 { - staleKeepSec = 7 * 24 * 3600 - } - negTTLNX := envInt("RESOLVE_NEGATIVE_TTL_NX", 6*3600) - negTTLTimeout := envInt("RESOLVE_NEGATIVE_TTL_TIMEOUT", 15*60) - negTTLTemporary := envInt("RESOLVE_NEGATIVE_TTL_TEMPORARY", 10*60) - negTTLOther := envInt("RESOLVE_NEGATIVE_TTL_OTHER", 10*60) - clampTTL := func(v int) int { - if v < 0 { - return 0 - } - if v > 24*3600 { - return 24 * 3600 - } - return v - } - negTTLNX = clampTTL(negTTLNX) - negTTLTimeout = clampTTL(negTTLTimeout) - negTTLTemporary = clampTTL(negTTLTemporary) - negTTLOther = clampTTL(negTTLOther) - - cacheSourceForHost := func(host string) domainCacheSource { - switch cfg.Mode { - case DNSModeSmartDNS: - return domainCacheSourceWildcard - case DNSModeHybridWildcard: - if wildcards.match(host) { - return domainCacheSourceWildcard - } - } - return domainCacheSourceDirect - } - cooldown := newDNSRunCooldown() - - timeoutRecheck := resolverTimeoutRecheckStats{} - if precheckDue && timeoutRecheckMax > 0 { - timeoutRecheck = runTimeoutQuarantineRecheck( - domains, - cfg, - metaSpecial, - wildcards, - dnsTimeout, - &domainCache, - cacheSourceForHost, - now, - timeoutRecheckMax, - workers, - ) - } - - if logf != nil { - logf("resolver start: domains=%d ttl=%ds workers=%d dns_timeout_ms=%d", len(domains), ttl, workers, dnsTimeoutMs) - directPolicy := directDNSAttemptPolicy(len(cfg.Default)) - wildcardPolicy := wildcardDNSAttemptPolicy(1) - cEnabled, cMin, cRate, cStreak, cBan, cMaxBan := cooldown.configSnapshot() - logf( - "resolver policy: direct_try=%d direct_budget_ms=%d wildcard_try=%d wildcard_budget_ms=%d nx_early_stop=%t nx_hard_quarantine=%t cooldown_enabled=%t cooldown_min_attempts=%d cooldown_timeout_rate=%d cooldown_fail_streak=%d cooldown_ban_sec=%d cooldown_max_ban_sec=%d live_batch_target=%d live_batch_min=%d live_batch_max=%d live_batch_nx_heavy_pct=%d live_batch_nx_heavy_min=%d live_batch_nx_heavy_max=%d stale_keep_sec=%d precheck_every_sec=%d precheck_max=%d precheck_forced_env=%t precheck_forced_file=%t", - directPolicy.TryLimit, - directPolicy.DomainBudget.Milliseconds(), - wildcardPolicy.TryLimit, - wildcardPolicy.DomainBudget.Milliseconds(), - resolveNXEarlyStopEnabled(), - resolveNXHardQuarantineEnabled(), - cEnabled, - cMin, - cRate, - cStreak, - cBan, - cMaxBan, - liveBatchTarget, - liveBatchMin, - liveBatchMax, - liveBatchNXHeavyPct, - liveBatchNXHeavyMin, - liveBatchNXHeavyMax, - staleKeepSec, - precheckEverySec, - precheckMaxDomains, - precheckEnvForced, - precheckFileForced, - ) - } - start := time.Now() - - fresh := map[string][]string{} - cacheNegativeHits := 0 - quarantineHits := 0 - staleHits := 0 - var toResolve []string - for _, d := range domains { - source := cacheSourceForHost(d) - if ips, ok := domainCache.get(d, source, now, ttl); ok { - fresh[d] = ips - if logf != nil { - logf("cache hit[%s]: %s -> %v", source, d, ips) - } - continue - } - // Quarantine has priority over negative TTL cache so 24h quarantine - // is not silently overridden by shorter negative cache windows. - if state, age, ok := domainCache.getQuarantine(d, source, now); ok { - kind, hasKind := domainCache.getLastErrorKind(d, source) - timeoutKind := hasKind && kind == dnsErrorTimeout - if precheckDue && precheckScheduled < precheckMaxDomains { - // Timeout-based quarantine is rechecked in background batch and should - // not flood trace with per-domain debug lines. - if timeoutKind { - quarantineHits++ - if staleKeepSec > 0 { - if staleIPs, staleAge, ok := domainCache.getStale(d, source, now, staleKeepSec); ok { - staleHits++ - fresh[d] = staleIPs - if logf != nil { - logf("cache stale-keep (quarantine)[age=%ds]: %s -> %v", staleAge, d, staleIPs) - } - } - } - continue - } - precheckScheduled++ - toResolve = append(toResolve, d) - if logf != nil { - logf("precheck schedule[quarantine/%s age=%ds]: %s (%s)", state, age, d, source) - } - continue - } - quarantineHits++ - if logf != nil { - logf("cache quarantine hit[%s age=%ds]: %s (%s)", state, age, d, source) - } - if staleKeepSec > 0 { - if staleIPs, staleAge, ok := domainCache.getStale(d, source, now, staleKeepSec); ok { - staleHits++ - fresh[d] = staleIPs - if logf != nil { - logf("cache stale-keep (quarantine)[age=%ds]: %s -> %v", staleAge, d, staleIPs) - } - } - } - continue - } - if kind, age, ok := domainCache.getNegative(d, source, now, negTTLNX, negTTLTimeout, negTTLTemporary, negTTLOther); ok { - if precheckDue && precheckScheduled < precheckMaxDomains { - if kind == dnsErrorTimeout { - cacheNegativeHits++ - continue - } - precheckScheduled++ - toResolve = append(toResolve, d) - if logf != nil { - logf("precheck schedule[negative/%s age=%ds]: %s (%s)", kind, age, d, source) - } - continue - } - cacheNegativeHits++ - if logf != nil { - logf("cache neg hit[%s/%s age=%ds]: %s", source, kind, age, d) - } - continue - } - toResolve = append(toResolve, d) - } - - resolved := map[string][]string{} - for k, v := range fresh { - resolved[k] = v - } - toResolveTotal := len(toResolve) - liveDeferred := 0 - liveP1 := 0 - liveP2 := 0 - liveP3 := 0 - liveNXHeavyTotal := 0 - liveNXHeavySkip := 0 - toResolve, liveP1, liveP2, liveP3, liveNXHeavyTotal, liveNXHeavySkip = pickAdaptiveLiveBatch( - toResolve, - liveBatchTarget, - now, - liveBatchNXHeavyPct, - domainCache, - cacheSourceForHost, - wildcards, - ) - liveDeferred = toResolveTotal - len(toResolve) - if liveDeferred < 0 { - liveDeferred = 0 - } - - if logf != nil { - logf("resolve: domains=%d cache_hits=%d cache_neg_hits=%d quarantine_hits=%d stale_hits=%d precheck_due=%t precheck_scheduled=%d to_resolve=%d to_resolve_total=%d deferred_by_live_batch=%d live_p1=%d live_p2=%d live_p3=%d live_nxheavy_total=%d live_nxheavy_skip=%d", len(domains), len(fresh), cacheNegativeHits, quarantineHits, staleHits, precheckDue, precheckScheduled, len(toResolve), toResolveTotal, liveDeferred, liveP1, liveP2, liveP3, liveNXHeavyTotal, liveNXHeavySkip) - } - - dnsStats := dnsMetrics{} - resolvedNowDNS := 0 - resolvedNowStale := 0 - unresolvedAfterAttempts := 0 - - if len(toResolve) > 0 { - type job struct { - host string - } - jobs := make(chan job, len(toResolve)) - results := make(chan struct { - host string - ips []string - stats dnsMetrics - }, len(toResolve)) - - for i := 0; i < workers; i++ { - go func() { - for j := range jobs { - ips, stats := resolveHostGo(j.host, cfg, metaSpecial, wildcards, dnsTimeout, cooldown, logf) - results <- struct { - host string - ips []string - stats dnsMetrics - }{j.host, ips, stats} - } - }() - } - for _, h := range toResolve { - jobs <- job{host: h} - } - close(jobs) - for i := 0; i < len(toResolve); i++ { - r := <-results - dnsStats.merge(r.stats) - hostErrors := r.stats.totalErrors() - if hostErrors > 0 && logf != nil { - logf("resolve errors for %s: total=%d nxdomain=%d timeout=%d temporary=%d other=%d", r.host, hostErrors, r.stats.NXDomain, r.stats.Timeout, r.stats.Temporary, r.stats.Other) - } - if len(r.ips) > 0 { - resolved[r.host] = r.ips - resolvedNowDNS++ - source := cacheSourceForHost(r.host) - domainCache.set(r.host, source, r.ips, now) - if logf != nil { - logf("%s -> %v", r.host, r.ips) - } - } else { - staleApplied := false - if hostErrors > 0 { - source := cacheSourceForHost(r.host) - domainCache.setErrorWithStats(r.host, source, r.stats, now) - if staleKeepSec > 0 && shouldUseStaleOnError(r.stats) { - if staleIPs, staleAge, ok := domainCache.getStale(r.host, source, now, staleKeepSec); ok { - staleHits++ - resolvedNowStale++ - staleApplied = true - resolved[r.host] = staleIPs - if logf != nil { - logf("cache stale-keep (error)[age=%ds]: %s -> %v", staleAge, r.host, staleIPs) - } - } - } - } - if !staleApplied { - unresolvedAfterAttempts++ - } - if logf != nil { - if _, ok := resolved[r.host]; !ok { - logf("%s: no IPs", r.host) - } - } - } - } - } - - staticEntries, staticSkipped := parseStaticEntriesGo(staticLines, logf) - staticLabels, ptrLookups, ptrErrors := resolveStaticLabels(staticEntries, cfg, ptrCache, ttl, logf) - - ipSetAll := map[string]struct{}{} - ipSetDirect := map[string]struct{}{} - ipSetWildcard := map[string]struct{}{} - - ipMapAll := map[string]map[string]struct{}{} - ipMapDirect := map[string]map[string]struct{}{} - ipMapWildcard := map[string]map[string]struct{}{} - - add := func(set map[string]struct{}, labels map[string]map[string]struct{}, ip, label string) { - if ip == "" { - return - } - set[ip] = struct{}{} - m := labels[ip] - if m == nil { - m = map[string]struct{}{} - labels[ip] = m - } - m[label] = struct{}{} - } - - isWildcardHost := func(host string) bool { - switch cfg.Mode { - case DNSModeSmartDNS: - return true - case DNSModeHybridWildcard: - return wildcards.match(host) - default: - return false - } - } - - for host, ips := range resolved { - wildcardHost := isWildcardHost(host) - for _, ip := range ips { - add(ipSetAll, ipMapAll, ip, host) - if wildcardHost { - add(ipSetWildcard, ipMapWildcard, ip, host) - } else { - add(ipSetDirect, ipMapDirect, ip, host) - } - } - } - for ipEntry, labels := range staticLabels { - for _, lbl := range labels { - add(ipSetAll, ipMapAll, ipEntry, lbl) - // Static entries are explicit operator rules; keep them in direct set. - add(ipSetDirect, ipMapDirect, ipEntry, lbl) - } - } - - appendMapPairs := func(dst *[][2]string, labelsByIP map[string]map[string]struct{}) { - for ip := range labelsByIP { - labels := labelsByIP[ip] - for lbl := range labels { - *dst = append(*dst, [2]string{ip, lbl}) - } - } - sort.Slice(*dst, func(i, j int) bool { - if (*dst)[i][0] == (*dst)[j][0] { - return (*dst)[i][1] < (*dst)[j][1] - } - return (*dst)[i][0] < (*dst)[j][0] - }) - } - appendIPs := func(dst *[]string, set map[string]struct{}) { - for ip := range set { - *dst = append(*dst, ip) - } - sort.Strings(*dst) - } - - appendMapPairs(&res.IPMap, ipMapAll) - appendMapPairs(&res.DirectIPMap, ipMapDirect) - appendMapPairs(&res.WildcardIPMap, ipMapWildcard) - appendIPs(&res.IPs, ipSetAll) - appendIPs(&res.DirectIPs, ipSetDirect) - appendIPs(&res.WildcardIPs, ipSetWildcard) - - res.DomainCache = domainCache.toMap() - res.PtrCache = ptrCache - - if logf != nil { - dnsErrors := dnsStats.totalErrors() - unresolvedSuppressed := cacheNegativeHits + quarantineHits + liveDeferred - logf( - "resolve summary: domains=%d cache_hits=%d cache_neg_hits=%d quarantine_hits=%d stale_hits=%d resolved_now=%d unresolved=%d unresolved_live=%d unresolved_suppressed=%d live_batch_target=%d live_batch_deferred=%d live_batch_p1=%d live_batch_p2=%d live_batch_p3=%d live_batch_nxheavy_pct=%d live_batch_nxheavy_total=%d live_batch_nxheavy_skip=%d static_entries=%d static_skipped=%d unique_ips=%d direct_ips=%d wildcard_ips=%d ptr_lookups=%d ptr_errors=%d dns_attempts=%d dns_ok=%d dns_nxdomain=%d dns_timeout=%d dns_temporary=%d dns_other=%d dns_cooldown_skips=%d dns_errors=%d timeout_recheck_checked=%d timeout_recheck_recovered=%d timeout_recheck_recovered_ips=%d timeout_recheck_still_timeout=%d timeout_recheck_now_nxdomain=%d timeout_recheck_now_temporary=%d timeout_recheck_now_other=%d timeout_recheck_no_signal=%d duration_ms=%d", - len(domains), - len(fresh), - cacheNegativeHits, - quarantineHits, - staleHits, - len(resolved)-len(fresh), - len(domains)-len(resolved), - unresolvedAfterAttempts, - unresolvedSuppressed, - liveBatchTarget, - liveDeferred, - liveP1, - liveP2, - liveP3, - liveBatchNXHeavyPct, - liveNXHeavyTotal, - liveNXHeavySkip, - len(staticEntries), - staticSkipped, - len(res.IPs), - len(res.DirectIPs), - len(res.WildcardIPs), - ptrLookups, - ptrErrors, - dnsStats.Attempts, - dnsStats.OK, - dnsStats.NXDomain, - dnsStats.Timeout, - dnsStats.Temporary, - dnsStats.Other, - dnsStats.Skipped, - dnsErrors, - timeoutRecheck.Checked, - timeoutRecheck.Recovered, - timeoutRecheck.RecoveredIPs, - timeoutRecheck.StillTimeout, - timeoutRecheck.NowNXDomain, - timeoutRecheck.NowTemporary, - timeoutRecheck.NowOther, - timeoutRecheck.NoSignal, - time.Since(start).Milliseconds(), - ) - if perUpstream := dnsStats.formatPerUpstream(); perUpstream != "" { - logf("resolve dns upstreams: %s", perUpstream) - } - if health := dnsStats.formatResolverHealth(); health != "" { - logf("resolve dns health: %s", health) - } - if stateSummary := domainCache.formatStateSummary(now); stateSummary != "" { - logf("resolve domain states: %s", stateSummary) - } - logf( - "resolve breakdown: resolved_now_total=%d resolved_now_dns=%d resolved_now_stale=%d skipped_neg=%d skipped_quarantine=%d deferred_live_batch=%d unresolved_after_attempts=%d", - len(resolved)-len(fresh), - resolvedNowDNS, - resolvedNowStale, - cacheNegativeHits, - quarantineHits, - liveDeferred, - unresolvedAfterAttempts, - ) - if precheckDue { - logf("resolve precheck done: scheduled=%d state=%s", precheckScheduled, precheckStatePath) - } - } - if precheckDue { - nextTarget, nextReason := computeNextLiveBatchTarget(liveBatchTarget, liveBatchMin, liveBatchMax, dnsStats, liveDeferred) - nextNXPct, nextNXReason := computeNextLiveBatchNXHeavyPct( - liveBatchNXHeavyPct, - liveBatchNXHeavyMin, - liveBatchNXHeavyMax, - dnsStats, - resolvedNowDNS, - liveP3, - liveNXHeavyTotal, - liveNXHeavySkip, - ) - if logf != nil { - logf( - "resolve live-batch nxheavy: pct=%d next=%d reason=%s selected=%d total=%d skipped=%d", - liveBatchNXHeavyPct, - nextNXPct, - nextNXReason, - liveP3, - liveNXHeavyTotal, - liveNXHeavySkip, - ) - } - saveResolverPrecheckState( - precheckStatePath, - now, - timeoutRecheck, - resolverLiveBatchStats{ - Target: liveBatchTarget, - Total: toResolveTotal, - Deferred: liveDeferred, - P1: liveP1, - P2: liveP2, - P3: liveP3, - NXHeavyPct: liveBatchNXHeavyPct, - NXHeavyTotal: liveNXHeavyTotal, - NXHeavySkip: liveNXHeavySkip, - NextTarget: nextTarget, - NextReason: nextReason, - NextNXPct: nextNXPct, - NextNXReason: nextNXReason, - DNSAttempts: dnsStats.Attempts, - DNSTimeout: dnsStats.Timeout, - DNSCoolSkips: dnsStats.Skipped, - }, - ) - } - if precheckFileForced { - _ = os.Remove(precheckForcePath) - if logf != nil { - logf("resolve precheck force-file consumed: %s", precheckForcePath) - } - } + ctx := buildResolverJobContext(opts, logf) + runResolverPipeline(&ctx, &res, logf) return res, nil } // --------------------------------------------------------------------- -// DNS resolve helpers -// --------------------------------------------------------------------- - -func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards wildcardMatcher, timeout time.Duration, cooldown *dnsRunCooldown, logf func(string, ...any)) ([]string, dnsMetrics) { - useMeta := false - for _, m := range metaSpecial { - if host == m { - useMeta = true - break - } - } - dnsList := cfg.Default - if useMeta { - dnsList = cfg.Meta - } - primaryViaSmartDNS := false - switch cfg.Mode { - case DNSModeSmartDNS: - if cfg.SmartDNS != "" { - dnsList = []string{cfg.SmartDNS} - primaryViaSmartDNS = true - } - case DNSModeHybridWildcard: - if cfg.SmartDNS != "" && wildcards.match(host) { - dnsList = []string{cfg.SmartDNS} - primaryViaSmartDNS = true - } - } - policy := directDNSAttemptPolicy(len(dnsList)) - if primaryViaSmartDNS { - policy = wildcardDNSAttemptPolicy(len(dnsList)) - } - ips, stats := digAWithPolicy(host, dnsList, timeout, logf, policy, cooldown) - if len(ips) == 0 && - !primaryViaSmartDNS && - cfg.SmartDNS != "" && - smartDNSFallbackForTimeoutEnabled() && - shouldFallbackToSmartDNS(stats) { - if logf != nil { - logf( - "dns fallback %s: trying smartdns=%s after errors nxdomain=%d timeout=%d temporary=%d other=%d", - host, - cfg.SmartDNS, - stats.NXDomain, - stats.Timeout, - stats.Temporary, - stats.Other, - ) - } - fallbackPolicy := wildcardDNSAttemptPolicy(1) - fallbackIPs, fallbackStats := digAWithPolicy(host, []string{cfg.SmartDNS}, timeout, logf, fallbackPolicy, cooldown) - stats.merge(fallbackStats) - if len(fallbackIPs) > 0 { - ips = fallbackIPs - if logf != nil { - logf("dns fallback %s: resolved via smartdns (%d ips)", host, len(fallbackIPs)) - } - } - } - out := []string{} - seen := map[string]struct{}{} - for _, ip := range ips { - if isPrivateIPv4(ip) { - continue - } - if _, ok := seen[ip]; !ok { - seen[ip] = struct{}{} - out = append(out, ip) - } - } - return out, stats -} - -// smartDNSFallbackForTimeoutEnabled controls direct->SmartDNS fallback behavior. -// Default is disabled to avoid overloading SmartDNS on large unresolved batches. -// Set RESOLVE_SMARTDNS_TIMEOUT_FALLBACK=1 to enable. -func smartDNSFallbackForTimeoutEnabled() bool { - v := strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_SMARTDNS_TIMEOUT_FALLBACK"))) - switch v { - case "1", "true", "yes", "on": - return true - case "0", "false", "no", "off": - return false - default: - return false - } -} - -// Fallback is useful only for transport-like errors. If we already got NXDOMAIN, -// SmartDNS fallback is unlikely to change result and only adds latency/noise. -func shouldFallbackToSmartDNS(stats dnsMetrics) bool { - if stats.OK > 0 { - return false - } - if stats.NXDomain > 0 { - return false - } - if stats.Timeout > 0 || stats.Temporary > 0 { - return true - } - return stats.Other > 0 -} - -func classifyHostErrorKind(stats dnsMetrics) (dnsErrorKind, bool) { - if stats.Timeout > 0 { - return dnsErrorTimeout, true - } - if stats.Temporary > 0 { - return dnsErrorTemporary, true - } - if stats.Other > 0 { - return dnsErrorOther, true - } - if stats.NXDomain > 0 { - return dnsErrorNXDomain, true - } - return "", false -} - -func shouldUseStaleOnError(stats dnsMetrics) bool { - if stats.OK > 0 { - return false - } - return stats.Timeout > 0 || stats.Temporary > 0 || stats.Other > 0 -} - -func runTimeoutQuarantineRecheck( - domains []string, - cfg dnsConfig, - metaSpecial []string, - wildcards wildcardMatcher, - timeout time.Duration, - domainCache *domainCacheState, - cacheSourceForHost func(string) domainCacheSource, - now int, - limit int, - workers int, -) resolverTimeoutRecheckStats { - stats := resolverTimeoutRecheckStats{} - if limit <= 0 || now <= 0 { - return stats - } - if workers < 1 { - workers = 1 - } - if workers > 200 { - workers = 200 - } - seen := map[string]struct{}{} - capHint := len(domains) - if capHint > limit { - capHint = limit - } - candidates := make([]string, 0, capHint) - for _, raw := range domains { - host := strings.TrimSpace(strings.ToLower(raw)) - if host == "" { - continue - } - if _, ok := seen[host]; ok { - continue - } - seen[host] = struct{}{} - source := cacheSourceForHost(host) - if _, _, ok := domainCache.getQuarantine(host, source, now); !ok { - continue - } - kind, ok := domainCache.getLastErrorKind(host, source) - if !ok || kind != dnsErrorTimeout { - continue - } - candidates = append(candidates, host) - if len(candidates) >= limit { - break - } - } - if len(candidates) == 0 { - return stats - } - recoveredIPSet := map[string]struct{}{} - - type result struct { - host string - source domainCacheSource - ips []string - dns dnsMetrics - } - jobs := make(chan string, len(candidates)) - results := make(chan result, len(candidates)) - for i := 0; i < workers; i++ { - go func() { - for host := range jobs { - src := cacheSourceForHost(host) - ips, dnsStats := resolveHostGo(host, cfg, metaSpecial, wildcards, timeout, nil, nil) - results <- result{host: host, source: src, ips: ips, dns: dnsStats} - } - }() - } - for _, host := range candidates { - jobs <- host - } - close(jobs) - - for i := 0; i < len(candidates); i++ { - r := <-results - stats.Checked++ - if len(r.ips) > 0 { - for _, ip := range r.ips { - ip = strings.TrimSpace(ip) - if ip == "" { - continue - } - recoveredIPSet[ip] = struct{}{} - } - domainCache.set(r.host, r.source, r.ips, now) - stats.Recovered++ - continue - } - if r.dns.totalErrors() > 0 { - domainCache.setErrorWithStats(r.host, r.source, r.dns, now) - } - kind, ok := classifyHostErrorKind(r.dns) - if !ok { - stats.NoSignal++ - continue - } - switch kind { - case dnsErrorTimeout: - stats.StillTimeout++ - case dnsErrorNXDomain: - stats.NowNXDomain++ - case dnsErrorTemporary: - stats.NowTemporary++ - default: - stats.NowOther++ - } - } - stats.RecoveredIPs = len(recoveredIPSet) - - return stats -} +// DNS resolve helpers moved to app/resolver/* (bridged via resolver_host_lookup_bridge.go) // --------------------------------------------------------------------- -// EN: `digA` contains core logic for dig a. -// RU: `digA` - содержит основную логику для dig a. -// --------------------------------------------------------------------- -func digA(host string, dnsList []string, timeout time.Duration, logf func(string, ...any)) ([]string, dnsMetrics) { - return digAWithPolicy(host, dnsList, timeout, logf, defaultDNSAttemptPolicy(len(dnsList)), nil) -} - -func digAWithPolicy(host string, dnsList []string, timeout time.Duration, logf func(string, ...any), policy dnsAttemptPolicy, cooldown *dnsRunCooldown) ([]string, dnsMetrics) { - stats := dnsMetrics{} - if len(dnsList) == 0 { - return nil, stats - } - - tryLimit := policy.TryLimit - if tryLimit <= 0 { - tryLimit = 1 - } - if tryLimit > len(dnsList) { - tryLimit = len(dnsList) - } - budget := policy.DomainBudget - if budget <= 0 { - budget = time.Duration(tryLimit) * timeout - } - if budget < 200*time.Millisecond { - budget = 200 * time.Millisecond - } - deadline := time.Now().Add(budget) - - start := pickDNSStartIndex(host, len(dnsList)) - for attempt := 0; attempt < tryLimit; attempt++ { - remaining := time.Until(deadline) - if remaining <= 0 { - if logf != nil { - logf("dns budget exhausted %s: attempts=%d budget_ms=%d", host, attempt, budget.Milliseconds()) - } - break - } - entry := dnsList[(start+attempt)%len(dnsList)] - server, port := splitDNS(entry) - if server == "" { - continue - } - if port == "" { - port = "53" - } - addr := net.JoinHostPort(server, port) - if cooldown != nil && cooldown.shouldSkip(addr, time.Now().Unix()) { - stats.addCooldownSkip(addr) - continue - } - r := &net.Resolver{ - PreferGo: true, - Dial: func(ctx context.Context, network, address string) (net.Conn, error) { - d := net.Dialer{} - return d.DialContext(ctx, "udp", addr) - }, - } - perAttemptTimeout := timeout - if remaining < perAttemptTimeout { - perAttemptTimeout = remaining - } - if perAttemptTimeout < 100*time.Millisecond { - perAttemptTimeout = 100 * time.Millisecond - } - ctx, cancel := context.WithTimeout(context.Background(), perAttemptTimeout) - records, err := r.LookupHost(ctx, host) - cancel() - if err != nil { - kind := classifyDNSError(err) - stats.addError(addr, kind) - if cooldown != nil { - if banned, banSec := cooldown.observeError(addr, kind, time.Now().Unix()); banned && logf != nil { - logf("dns cooldown ban %s: timeout-like failures; ban_sec=%d", addr, banSec) - } - } - if logf != nil { - logf("dns warn %s via %s: kind=%s attempt=%d/%d err=%v", host, addr, kind, attempt+1, tryLimit, err) - } - if policy.StopOnNX && kind == dnsErrorNXDomain { - if logf != nil { - logf("dns early-stop %s: nxdomain via %s (attempt=%d/%d)", host, addr, attempt+1, tryLimit) - } - break - } - continue - } - var ips []string - for _, ip := range records { - if isPrivateIPv4(ip) { - continue - } - ips = append(ips, ip) - } - if len(ips) == 0 { - stats.addError(addr, dnsErrorOther) - if cooldown != nil { - _, _ = cooldown.observeError(addr, dnsErrorOther, time.Now().Unix()) - } - if logf != nil { - logf("dns warn %s via %s: kind=other err=no_public_ips", host, addr) - } - continue - } - stats.addSuccess(addr) - if cooldown != nil { - cooldown.observeSuccess(addr) - } - return uniqueStrings(ips), stats - } - return nil, stats -} - -func defaultDNSAttemptPolicy(dnsCount int) dnsAttemptPolicy { - tryLimit := envInt("RESOLVE_DNS_TRY_LIMIT", 2) - if tryLimit < 1 { - tryLimit = 1 - } - if dnsCount > 0 && tryLimit > dnsCount { - tryLimit = dnsCount - } - budgetMS := envInt("RESOLVE_DNS_DOMAIN_BUDGET_MS", 1200) - if budgetMS < 200 { - budgetMS = 200 - } - if budgetMS > 15000 { - budgetMS = 15000 - } - return dnsAttemptPolicy{ - TryLimit: tryLimit, - DomainBudget: time.Duration(budgetMS) * time.Millisecond, - StopOnNX: resolveNXEarlyStopEnabled(), - } -} - -func directDNSAttemptPolicy(dnsCount int) dnsAttemptPolicy { - tryLimit := envInt("RESOLVE_DIRECT_TRY_LIMIT", 2) - if tryLimit < 1 { - tryLimit = 1 - } - if tryLimit > 3 { - tryLimit = 3 - } - if dnsCount > 0 && tryLimit > dnsCount { - tryLimit = dnsCount - } - budgetMS := envInt("RESOLVE_DIRECT_BUDGET_MS", 1200) - if budgetMS < 200 { - budgetMS = 200 - } - if budgetMS > 15000 { - budgetMS = 15000 - } - return dnsAttemptPolicy{ - TryLimit: tryLimit, - DomainBudget: time.Duration(budgetMS) * time.Millisecond, - StopOnNX: resolveNXEarlyStopEnabled(), - } -} - -func wildcardDNSAttemptPolicy(dnsCount int) dnsAttemptPolicy { - tryLimit := envInt("RESOLVE_WILDCARD_TRY_LIMIT", 1) - if tryLimit < 1 { - tryLimit = 1 - } - if tryLimit > 2 { - tryLimit = 2 - } - if dnsCount > 0 && tryLimit > dnsCount { - tryLimit = dnsCount - } - budgetMS := envInt("RESOLVE_WILDCARD_BUDGET_MS", 1200) - if budgetMS < 200 { - budgetMS = 200 - } - if budgetMS > 15000 { - budgetMS = 15000 - } - return dnsAttemptPolicy{ - TryLimit: tryLimit, - DomainBudget: time.Duration(budgetMS) * time.Millisecond, - StopOnNX: resolveNXEarlyStopEnabled(), - } -} - -func resolveNXEarlyStopEnabled() bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_NX_EARLY_STOP"))) { - case "0", "false", "no", "off": - return false - default: - return true - } -} - -func resolveNXHardQuarantineEnabled() bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_NX_HARD_QUARANTINE"))) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func newDNSRunCooldown() *dnsRunCooldown { - enabled := true - switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_DNS_COOLDOWN_ENABLED"))) { - case "0", "false", "no", "off": - enabled = false - } - c := &dnsRunCooldown{ - enabled: enabled, - minAttempts: envInt("RESOLVE_DNS_COOLDOWN_MIN_ATTEMPTS", 300), - timeoutRatePct: envInt("RESOLVE_DNS_COOLDOWN_TIMEOUT_RATE_PCT", 70), - failStreak: envInt("RESOLVE_DNS_COOLDOWN_FAIL_STREAK", 25), - banSec: envInt("RESOLVE_DNS_COOLDOWN_BAN_SEC", 60), - maxBanSec: envInt("RESOLVE_DNS_COOLDOWN_MAX_BAN_SEC", 300), - temporaryAsError: true, - byUpstream: map[string]*dnsCooldownState{}, - } - if c.minAttempts < 50 { - c.minAttempts = 50 - } - if c.minAttempts > 2000 { - c.minAttempts = 2000 - } - if c.timeoutRatePct < 40 { - c.timeoutRatePct = 40 - } - if c.timeoutRatePct > 95 { - c.timeoutRatePct = 95 - } - if c.failStreak < 8 { - c.failStreak = 8 - } - if c.failStreak > 200 { - c.failStreak = 200 - } - if c.banSec < 10 { - c.banSec = 10 - } - if c.banSec > 3600 { - c.banSec = 3600 - } - if c.maxBanSec < c.banSec { - c.maxBanSec = c.banSec - } - if c.maxBanSec > 3600 { - c.maxBanSec = 3600 - } - return c -} - -func (c *dnsRunCooldown) configSnapshot() (enabled bool, minAttempts, timeoutRatePct, failStreak, banSec, maxBanSec int) { - if c == nil { - return false, 0, 0, 0, 0, 0 - } - return c.enabled, c.minAttempts, c.timeoutRatePct, c.failStreak, c.banSec, c.maxBanSec -} - -func (c *dnsRunCooldown) stateFor(upstream string) *dnsCooldownState { - if c.byUpstream == nil { - c.byUpstream = map[string]*dnsCooldownState{} - } - st, ok := c.byUpstream[upstream] - if ok { - return st - } - st = &dnsCooldownState{} - c.byUpstream[upstream] = st - return st -} - -func (c *dnsRunCooldown) shouldSkip(upstream string, now int64) bool { - if c == nil || !c.enabled { - return false - } - c.mu.Lock() - defer c.mu.Unlock() - st := c.stateFor(upstream) - return st.BanUntil > now -} - -func (c *dnsRunCooldown) observeSuccess(upstream string) { - if c == nil || !c.enabled { - return - } - c.mu.Lock() - defer c.mu.Unlock() - st := c.stateFor(upstream) - st.Attempts++ - st.FailStreak = 0 -} - -func (c *dnsRunCooldown) observeError(upstream string, kind dnsErrorKind, now int64) (bool, int) { - if c == nil || !c.enabled { - return false, 0 - } - c.mu.Lock() - defer c.mu.Unlock() - st := c.stateFor(upstream) - st.Attempts++ - - timeoutLike := kind == dnsErrorTimeout || (c.temporaryAsError && kind == dnsErrorTemporary) - if timeoutLike { - st.TimeoutLike++ - st.FailStreak++ - } else { - st.FailStreak = 0 - return false, 0 - } - if st.BanUntil > now { - return false, 0 - } - - rateBan := st.Attempts >= c.minAttempts && (st.TimeoutLike*100 >= c.timeoutRatePct*st.Attempts) - streakBan := st.FailStreak >= c.failStreak - if !rateBan && !streakBan { - return false, 0 - } - - st.BanLevel++ - dur := c.banSec - if st.BanLevel > 1 { - for i := 1; i < st.BanLevel; i++ { - dur *= 2 - if dur >= c.maxBanSec { - dur = c.maxBanSec - break - } - } - } - if dur > c.maxBanSec { - dur = c.maxBanSec - } - st.BanUntil = now + int64(dur) - st.FailStreak = 0 - return true, dur -} - -func resolvePrecheckForceEnvEnabled() bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_PRECHECK_FORCE"))) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func resolvePrecheckForceFileEnabled(path string) bool { - if strings.TrimSpace(path) == "" { - return false - } - _, err := os.Stat(path) - return err == nil -} - -func classifyDNSError(err error) dnsErrorKind { - if err == nil { - return dnsErrorOther - } - var dnsErr *net.DNSError - if errors.As(err, &dnsErr) { - if dnsErr.IsNotFound { - return dnsErrorNXDomain - } - if dnsErr.IsTimeout { - return dnsErrorTimeout - } - if dnsErr.IsTemporary { - return dnsErrorTemporary - } - } - msg := strings.ToLower(err.Error()) - switch { - case strings.Contains(msg, "no such host"), strings.Contains(msg, "nxdomain"): - return dnsErrorNXDomain - case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "timeout"): - return dnsErrorTimeout - case strings.Contains(msg, "temporary"): - return dnsErrorTemporary - default: - return dnsErrorOther - } -} - -// --------------------------------------------------------------------- -// EN: `splitDNS` splits dns into structured parts. -// RU: `splitDNS` - разделяет dns на структурированные части. -// --------------------------------------------------------------------- -func splitDNS(dns string) (string, string) { - if strings.Contains(dns, "#") { - parts := strings.SplitN(dns, "#", 2) - host := strings.TrimSpace(parts[0]) - port := strings.TrimSpace(parts[1]) - if host == "" { - host = "127.0.0.1" - } - if port == "" { - port = "53" - } - return host, port - } - return strings.TrimSpace(dns), "" -} - -// --------------------------------------------------------------------- -// static entries + PTR labels -// --------------------------------------------------------------------- - -func parseStaticEntriesGo(lines []string, logf func(string, ...any)) (entries [][3]string, skipped int) { - for _, ln := range lines { - s := strings.TrimSpace(ln) - if s == "" || strings.HasPrefix(s, "#") { - continue - } - comment := "" - if idx := strings.Index(s, "#"); idx >= 0 { - comment = strings.TrimSpace(s[idx+1:]) - s = strings.TrimSpace(s[:idx]) - } - if s == "" || isPrivateIPv4(s) { - continue - } - - // validate ip/prefix - rawBase := strings.SplitN(s, "/", 2)[0] - if strings.Contains(s, "/") { - if _, err := netip.ParsePrefix(s); err != nil { - skipped++ - if logf != nil { - logf("static skip invalid prefix %q: %v", s, err) - } - continue - } - } else { - if _, err := netip.ParseAddr(rawBase); err != nil { - skipped++ - if logf != nil { - logf("static skip invalid ip %q: %v", s, err) - } - continue - } - } - - entries = append(entries, [3]string{s, rawBase, comment}) - } - return entries, skipped -} - -// --------------------------------------------------------------------- -// EN: `resolveStaticLabels` resolves static labels into concrete values. -// RU: `resolveStaticLabels` - резолвит static labels в конкретные значения. -// --------------------------------------------------------------------- -func resolveStaticLabels(entries [][3]string, cfg dnsConfig, ptrCache map[string]any, ttl int, logf func(string, ...any)) (map[string][]string, int, int) { - now := int(time.Now().Unix()) - result := map[string][]string{} - ptrLookups := 0 - ptrErrors := 0 - dnsForPtr := "" - if len(cfg.Default) > 0 { - dnsForPtr = cfg.Default[0] - } else { - dnsForPtr = defaultDNS1 - } - for _, e := range entries { - ipEntry, baseIP, comment := e[0], e[1], e[2] - var labels []string - if comment != "" { - labels = append(labels, "*"+comment) - } - if comment == "" { - if cached, ok := ptrCache[baseIP].(map[string]any); ok { - names, _ := cached["names"].([]any) - last, _ := cached["last_resolved"].(float64) - if len(names) > 0 && last > 0 && now-int(last) <= ttl { - for _, n := range names { - if s, ok := n.(string); ok && s != "" { - labels = append(labels, "*"+s) - } - } - } - } - if len(labels) == 0 { - ptrLookups++ - names, err := digPTR(baseIP, dnsForPtr, 3*time.Second, logf) - if err != nil { - ptrErrors++ - } - if len(names) > 0 { - ptrCache[baseIP] = map[string]any{"names": names, "last_resolved": now} - for _, n := range names { - labels = append(labels, "*"+n) - } - } - } - } - if len(labels) == 0 { - labels = []string{"*[STATIC-IP]"} - } - result[ipEntry] = labels - if logf != nil { - logf("static %s -> %v", ipEntry, labels) - } - } - return result, ptrLookups, ptrErrors -} - -// --------------------------------------------------------------------- -// DNS config + cache helpers -// --------------------------------------------------------------------- - -type domainCacheSource string - -const ( - domainCacheSourceDirect domainCacheSource = "direct" - domainCacheSourceWildcard domainCacheSource = "wildcard" -) - -type domainCacheEntry struct { - IPs []string `json:"ips,omitempty"` - LastResolved int `json:"last_resolved,omitempty"` - LastErrorKind string `json:"last_error_kind,omitempty"` - LastErrorAt int `json:"last_error_at,omitempty"` - Score int `json:"score,omitempty"` - State string `json:"state,omitempty"` - QuarantineUntil int `json:"quarantine_until,omitempty"` -} - -type domainCacheRecord struct { - Direct *domainCacheEntry `json:"direct,omitempty"` - Wildcard *domainCacheEntry `json:"wildcard,omitempty"` -} - -type domainCacheState struct { - Version int `json:"version"` - Domains map[string]domainCacheRecord `json:"domains"` -} - -func newDomainCacheState() domainCacheState { - return domainCacheState{ - Version: 4, - Domains: map[string]domainCacheRecord{}, - } -} - -func normalizeCacheIPs(raw []string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(raw)) - for _, ip := range raw { - ip = strings.TrimSpace(ip) - if ip == "" || isPrivateIPv4(ip) { - continue - } - if _, ok := seen[ip]; ok { - continue - } - seen[ip] = struct{}{} - out = append(out, ip) - } - sort.Strings(out) - return out -} - -func normalizeCacheErrorKind(raw string) (dnsErrorKind, bool) { - switch strings.ToLower(strings.TrimSpace(raw)) { - case string(dnsErrorNXDomain): - return dnsErrorNXDomain, true - case string(dnsErrorTimeout): - return dnsErrorTimeout, true - case string(dnsErrorTemporary): - return dnsErrorTemporary, true - case string(dnsErrorOther): - return dnsErrorOther, true - default: - return "", false - } -} - -func normalizeDomainCacheEntry(in *domainCacheEntry) *domainCacheEntry { - if in == nil { - return nil - } - out := &domainCacheEntry{} - ips := normalizeCacheIPs(in.IPs) - if len(ips) > 0 && in.LastResolved > 0 { - out.IPs = ips - out.LastResolved = in.LastResolved - } - if kind, ok := normalizeCacheErrorKind(in.LastErrorKind); ok && in.LastErrorAt > 0 { - out.LastErrorKind = string(kind) - out.LastErrorAt = in.LastErrorAt - } - out.Score = clampDomainScore(in.Score) - if st := normalizeDomainState(in.State, out.Score); st != "" { - out.State = st - } - if in.QuarantineUntil > 0 { - out.QuarantineUntil = in.QuarantineUntil - } - if out.LastResolved <= 0 && out.LastErrorAt <= 0 { - if out.Score == 0 && out.QuarantineUntil <= 0 { - return nil - } - } - return out -} - -func parseAnyStringSlice(raw any) []string { - switch v := raw.(type) { - case []string: - return append([]string(nil), v...) - case []any: - out := make([]string, 0, len(v)) - for _, x := range v { - if s, ok := x.(string); ok { - out = append(out, s) - } - } - return out - default: - return nil - } -} - -func parseAnyInt(raw any) (int, bool) { - switch v := raw.(type) { - case int: - return v, true - case int64: - return int(v), true - case float64: - return int(v), true - case json.Number: - n, err := v.Int64() - if err != nil { - return 0, false - } - return int(n), true - default: - return 0, false - } -} - -func parseLegacyDomainCacheEntry(raw any) (domainCacheEntry, bool) { - m, ok := raw.(map[string]any) - if !ok { - return domainCacheEntry{}, false - } - ips := normalizeCacheIPs(parseAnyStringSlice(m["ips"])) - if len(ips) == 0 { - return domainCacheEntry{}, false - } - ts, ok := parseAnyInt(m["last_resolved"]) - if !ok || ts <= 0 { - return domainCacheEntry{}, false - } - return domainCacheEntry{IPs: ips, LastResolved: ts}, true -} - -func loadDomainCacheState(path string, logf func(string, ...any)) domainCacheState { - data, err := os.ReadFile(path) - if err != nil || len(data) == 0 { - return newDomainCacheState() - } - - var st domainCacheState - if err := json.Unmarshal(data, &st); err == nil && st.Domains != nil { - if st.Version <= 0 { - st.Version = 4 - } - normalized := newDomainCacheState() - for host, rec := range st.Domains { - host = strings.TrimSpace(strings.ToLower(host)) - if host == "" { - continue - } - nrec := domainCacheRecord{} - nrec.Direct = normalizeDomainCacheEntry(rec.Direct) - nrec.Wildcard = normalizeDomainCacheEntry(rec.Wildcard) - if nrec.Direct != nil || nrec.Wildcard != nil { - normalized.Domains[host] = nrec - } - } - return normalized - } - - // Legacy shape: { "domain.tld": {"ips":[...], "last_resolved":...}, ... } - var legacy map[string]any - if err := json.Unmarshal(data, &legacy); err != nil { - if logf != nil { - logf("domain-cache: invalid json at %s, ignore", path) - } - return newDomainCacheState() - } - - out := newDomainCacheState() - migrated := 0 - for host, raw := range legacy { - host = strings.TrimSpace(strings.ToLower(host)) - if host == "" || host == "version" || host == "domains" { - continue - } - entry, ok := parseLegacyDomainCacheEntry(raw) - if !ok { - continue - } - rec := out.Domains[host] - rec.Direct = &entry - out.Domains[host] = rec - migrated++ - } - if logf != nil && migrated > 0 { - logf("domain-cache: migrated legacy entries=%d into split cache (direct bucket)", migrated) - } - return out -} - -func (s domainCacheState) get(domain string, source domainCacheSource, now, ttl int) ([]string, bool) { - rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] - if !ok { - return nil, false - } - var entry *domainCacheEntry - switch source { - case domainCacheSourceWildcard: - entry = rec.Wildcard - default: - entry = rec.Direct - } - if entry == nil || entry.LastResolved <= 0 { - return nil, false - } - if now-entry.LastResolved > ttl { - return nil, false - } - ips := normalizeCacheIPs(entry.IPs) - if len(ips) == 0 { - return nil, false - } - return ips, true -} - -func (s domainCacheState) getNegative(domain string, source domainCacheSource, now, nxTTL, timeoutTTL, temporaryTTL, otherTTL int) (dnsErrorKind, int, bool) { - rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] - if !ok { - return "", 0, false - } - var entry *domainCacheEntry - switch source { - case domainCacheSourceWildcard: - entry = rec.Wildcard - default: - entry = rec.Direct - } - if entry == nil || entry.LastErrorAt <= 0 { - return "", 0, false - } - kind, ok := normalizeCacheErrorKind(entry.LastErrorKind) - if !ok { - return "", 0, false - } - age := now - entry.LastErrorAt - if age < 0 { - return "", 0, false - } - cacheTTL := 0 - switch kind { - case dnsErrorNXDomain: - cacheTTL = nxTTL - case dnsErrorTimeout: - cacheTTL = timeoutTTL - case dnsErrorTemporary: - cacheTTL = temporaryTTL - case dnsErrorOther: - cacheTTL = otherTTL - } - if cacheTTL <= 0 || age > cacheTTL { - return "", 0, false - } - return kind, age, true -} - -func (s domainCacheState) getStoredIPs(domain string, source domainCacheSource) []string { - rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] - if !ok { - return nil - } - entry := getCacheEntryBySource(rec, source) - if entry == nil { - return nil - } - return normalizeCacheIPs(entry.IPs) -} - -func (s domainCacheState) getLastErrorKind(domain string, source domainCacheSource) (dnsErrorKind, bool) { - rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] - if !ok { - return "", false - } - entry := getCacheEntryBySource(rec, source) - if entry == nil || entry.LastErrorAt <= 0 { - return "", false - } - return normalizeCacheErrorKind(entry.LastErrorKind) -} - -func (s domainCacheState) getQuarantine(domain string, source domainCacheSource, now int) (string, int, bool) { - rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] - if !ok { - return "", 0, false - } - entry := getCacheEntryBySource(rec, source) - if entry == nil || entry.QuarantineUntil <= 0 { - return "", 0, false - } - if now >= entry.QuarantineUntil { - return "", 0, false - } - state := normalizeDomainState(entry.State, entry.Score) - if state == "" { - state = domainStateQuarantine - } - age := 0 - if entry.LastErrorAt > 0 { - age = now - entry.LastErrorAt - } - return state, age, true -} - -func (s domainCacheState) getStale(domain string, source domainCacheSource, now, maxAge int) ([]string, int, bool) { - if maxAge <= 0 { - return nil, 0, false - } - rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] - if !ok { - return nil, 0, false - } - entry := getCacheEntryBySource(rec, source) - if entry == nil || entry.LastResolved <= 0 { - return nil, 0, false - } - age := now - entry.LastResolved - if age < 0 || age > maxAge { - return nil, 0, false - } - ips := normalizeCacheIPs(entry.IPs) - if len(ips) == 0 { - return nil, 0, false - } - return ips, age, true -} - -func (s *domainCacheState) set(domain string, source domainCacheSource, ips []string, now int) { - host := strings.TrimSpace(strings.ToLower(domain)) - if host == "" || now <= 0 { - return - } - norm := normalizeCacheIPs(ips) - if len(norm) == 0 { - return - } - if s.Domains == nil { - s.Domains = map[string]domainCacheRecord{} - } - rec := s.Domains[host] - prev := getCacheEntryBySource(rec, source) - prevScore := 0 - if prev != nil { - prevScore = prev.Score - } - entry := &domainCacheEntry{ - IPs: norm, - LastResolved: now, - LastErrorKind: "", - LastErrorAt: 0, - Score: clampDomainScore(prevScore + envInt("RESOLVE_DOMAIN_SCORE_OK", 8)), - QuarantineUntil: 0, - } - entry.State = domainStateFromScore(entry.Score) - switch source { - case domainCacheSourceWildcard: - rec.Wildcard = entry - default: - rec.Direct = entry - } - s.Domains[host] = rec -} - -func getCacheEntryBySource(rec domainCacheRecord, source domainCacheSource) *domainCacheEntry { - switch source { - case domainCacheSourceWildcard: - return rec.Wildcard - default: - return rec.Direct - } -} - -func clampDomainScore(v int) int { - if v < domainScoreMin { - return domainScoreMin - } - if v > domainScoreMax { - return domainScoreMax - } - return v -} - -func domainStateFromScore(score int) string { - switch { - case score >= 20: - return domainStateActive - case score >= 5: - return domainStateStable - case score >= -10: - return domainStateSuspect - case score >= -30: - return domainStateQuarantine - default: - return domainStateHardQuar - } -} - -func normalizeDomainState(raw string, score int) string { - switch strings.TrimSpace(strings.ToLower(raw)) { - case domainStateActive: - return domainStateActive - case domainStateStable: - return domainStateStable - case domainStateSuspect: - return domainStateSuspect - case domainStateQuarantine: - return domainStateQuarantine - case domainStateHardQuar: - return domainStateHardQuar - default: - if score == 0 { - return "" - } - return domainStateFromScore(score) - } -} - -func domainScorePenalty(stats dnsMetrics) int { - if stats.NXDomain >= 2 { - return envInt("RESOLVE_DOMAIN_SCORE_NX_CONFIRMED", -15) - } - if stats.NXDomain > 0 { - return envInt("RESOLVE_DOMAIN_SCORE_NX_SINGLE", -7) - } - if stats.Timeout > 0 { - return envInt("RESOLVE_DOMAIN_SCORE_TIMEOUT", -3) - } - if stats.Temporary > 0 { - return envInt("RESOLVE_DOMAIN_SCORE_TEMPORARY", -2) - } - return envInt("RESOLVE_DOMAIN_SCORE_OTHER", -2) -} - -func (s *domainCacheState) setErrorWithStats(domain string, source domainCacheSource, stats dnsMetrics, now int) { - host := strings.TrimSpace(strings.ToLower(domain)) - if host == "" || now <= 0 { - return - } - kind, ok := classifyHostErrorKind(stats) - if !ok { - return - } - normKind, ok := normalizeCacheErrorKind(string(kind)) - if !ok { - return - } - penalty := domainScorePenalty(stats) - quarantineTTL := envInt("RESOLVE_QUARANTINE_TTL_SEC", defaultQuarantineTTL) - if quarantineTTL < 0 { - quarantineTTL = 0 - } - hardQuarantineTTL := envInt("RESOLVE_HARD_QUARANTINE_TTL_SEC", defaultHardQuarantineTT) - if hardQuarantineTTL < 0 { - hardQuarantineTTL = 0 - } - if s.Domains == nil { - s.Domains = map[string]domainCacheRecord{} - } - rec := s.Domains[host] - entry := getCacheEntryBySource(rec, source) - if entry == nil { - entry = &domainCacheEntry{} - } - prevKind, _ := normalizeCacheErrorKind(entry.LastErrorKind) - entry.Score = clampDomainScore(entry.Score + penalty) - entry.State = domainStateFromScore(entry.Score) - - // Timeout-only failures are treated as transient transport noise by default. - // Keep them in suspect bucket (no quarantine) unless we have NX signal. - if normKind == dnsErrorTimeout && prevKind != dnsErrorNXDomain { - if entry.Score < -10 { - entry.Score = -10 - } - entry.State = domainStateSuspect - } - // NXDOMAIN-heavy synthetic subdomains create large false "hard quarantine" pools. - // By default, keep NX failures in regular quarantine (24h), not hard quarantine. - if normKind == dnsErrorNXDomain && !resolveNXHardQuarantineEnabled() && entry.State == domainStateHardQuar { - entry.State = domainStateQuarantine - if entry.Score < -30 { - entry.Score = -30 - } - } - entry.LastErrorKind = string(normKind) - entry.LastErrorAt = now - switch entry.State { - case domainStateHardQuar: - entry.QuarantineUntil = now + hardQuarantineTTL - case domainStateQuarantine: - entry.QuarantineUntil = now + quarantineTTL - default: - entry.QuarantineUntil = 0 - } - switch source { - case domainCacheSourceWildcard: - rec.Wildcard = entry - default: - rec.Direct = entry - } - s.Domains[host] = rec -} - -func (s domainCacheState) toMap() map[string]any { - out := map[string]any{ - "version": 4, - "domains": map[string]any{}, - } - domainsAny := out["domains"].(map[string]any) - hosts := make([]string, 0, len(s.Domains)) - for host := range s.Domains { - hosts = append(hosts, host) - } - sort.Strings(hosts) - for _, host := range hosts { - rec := s.Domains[host] - recOut := map[string]any{} - if rec.Direct != nil { - directOut := map[string]any{} - if len(rec.Direct.IPs) > 0 && rec.Direct.LastResolved > 0 { - directOut["ips"] = rec.Direct.IPs - directOut["last_resolved"] = rec.Direct.LastResolved - } - if kind, ok := normalizeCacheErrorKind(rec.Direct.LastErrorKind); ok && rec.Direct.LastErrorAt > 0 { - directOut["last_error_kind"] = string(kind) - directOut["last_error_at"] = rec.Direct.LastErrorAt - } - if rec.Direct.Score != 0 { - directOut["score"] = rec.Direct.Score - } - if st := normalizeDomainState(rec.Direct.State, rec.Direct.Score); st != "" { - directOut["state"] = st - } - if rec.Direct.QuarantineUntil > 0 { - directOut["quarantine_until"] = rec.Direct.QuarantineUntil - } - if len(directOut) > 0 { - recOut["direct"] = directOut - } - } - if rec.Wildcard != nil { - wildOut := map[string]any{} - if len(rec.Wildcard.IPs) > 0 && rec.Wildcard.LastResolved > 0 { - wildOut["ips"] = rec.Wildcard.IPs - wildOut["last_resolved"] = rec.Wildcard.LastResolved - } - if kind, ok := normalizeCacheErrorKind(rec.Wildcard.LastErrorKind); ok && rec.Wildcard.LastErrorAt > 0 { - wildOut["last_error_kind"] = string(kind) - wildOut["last_error_at"] = rec.Wildcard.LastErrorAt - } - if rec.Wildcard.Score != 0 { - wildOut["score"] = rec.Wildcard.Score - } - if st := normalizeDomainState(rec.Wildcard.State, rec.Wildcard.Score); st != "" { - wildOut["state"] = st - } - if rec.Wildcard.QuarantineUntil > 0 { - wildOut["quarantine_until"] = rec.Wildcard.QuarantineUntil - } - if len(wildOut) > 0 { - recOut["wildcard"] = wildOut - } - } - if len(recOut) > 0 { - domainsAny[host] = recOut - } - } - return out -} - -func (s domainCacheState) formatStateSummary(now int) string { - type counters struct { - active int - stable int - suspect int - quarantine int - hardQuar int - } - add := func(c *counters, entry *domainCacheEntry) { - if entry == nil { - return - } - st := normalizeDomainState(entry.State, entry.Score) - if entry.QuarantineUntil > now { - // Keep hard quarantine state if explicitly marked, - // otherwise active quarantine bucket. - if st == domainStateHardQuar { - c.hardQuar++ - return - } - c.quarantine++ - return - } - switch st { - case domainStateActive: - c.active++ - case domainStateStable: - c.stable++ - case domainStateSuspect: - c.suspect++ - case domainStateQuarantine: - c.quarantine++ - case domainStateHardQuar: - c.hardQuar++ - } - } - var c counters - for _, rec := range s.Domains { - add(&c, rec.Direct) - add(&c, rec.Wildcard) - } - total := c.active + c.stable + c.suspect + c.quarantine + c.hardQuar - if total == 0 { - return "" - } - return fmt.Sprintf( - "active=%d stable=%d suspect=%d quarantine=%d hard_quarantine=%d total=%d", - c.active, c.stable, c.suspect, c.quarantine, c.hardQuar, total, - ) -} - -func digPTR(ip, upstream string, timeout time.Duration, logf func(string, ...any)) ([]string, error) { - server, port := splitDNS(upstream) - if server == "" { - return nil, fmt.Errorf("upstream empty") - } - if port == "" { - port = "53" - } - addr := net.JoinHostPort(server, port) - r := &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) - names, err := r.LookupAddr(ctx, ip) - cancel() - if err != nil { - if logf != nil { - logf("ptr error %s via %s: %v", ip, addr, err) - } - return nil, err - } - seen := map[string]struct{}{} - var out []string - for _, n := range names { - n = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(n)), ".") - if n == "" { - continue - } - if _, ok := seen[n]; !ok { - seen[n] = struct{}{} - out = append(out, n) - } - } - return out, nil -} - -// --------------------------------------------------------------------- -// EN: `loadDNSConfig` loads dns config from storage or config. -// RU: `loadDNSConfig` - загружает dns config из хранилища или конфига. -// --------------------------------------------------------------------- -func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig { - cfg := dnsConfig{ - Default: []string{defaultDNS1, defaultDNS2}, - Meta: []string{defaultMeta1, defaultMeta2}, - SmartDNS: smartDNSAddr(), - Mode: DNSModeDirect, - } - activePool := loadEnabledDNSUpstreamPool() - if len(activePool) > 0 { - cfg.Default = activePool - cfg.Meta = activePool - } - - // 1) Если форсируем SmartDNS — вообще игнорим файл и ходим только через локальный резолвер. - if smartDNSForced() { - addr := smartDNSAddr() - cfg.Default = []string{addr} - cfg.Meta = []string{addr} - cfg.SmartDNS = addr - cfg.Mode = DNSModeSmartDNS - - if logf != nil { - logf("dns-config: SmartDNS forced (%s), ignore %s", addr, path) - } - return cfg - } - - // 2) Читаем dns-upstreams.conf для legacy-совместимости и smartdns/mode значений. - data, err := os.ReadFile(path) - if err != nil { - if logf != nil { - logf("dns-config: can't read %s: %v", path, err) - } - cfg.Default = mergeDNSUpstreamPools(cfg.Default, resolverFallbackPool()) - cfg.Meta = mergeDNSUpstreamPools(cfg.Meta, resolverFallbackPool()) - return cfg - } - - var def, meta []string - lines := strings.Split(string(data), "\n") - for _, ln := range lines { - s := strings.TrimSpace(ln) - if s == "" || strings.HasPrefix(s, "#") { - continue - } - parts := strings.Fields(s) - if len(parts) < 2 { - continue - } - key := strings.ToLower(parts[0]) - vals := parts[1:] - switch key { - case "default": - for _, v := range vals { - if n := normalizeDNSUpstream(v, "53"); n != "" { - def = append(def, n) - } - } - case "meta": - for _, v := range vals { - if n := normalizeDNSUpstream(v, "53"); n != "" { - meta = append(meta, n) - } - } - case "smartdns": - if len(vals) > 0 { - if n := normalizeSmartDNSAddr(vals[0]); n != "" { - cfg.SmartDNS = n - } - } - case "mode": - if len(vals) > 0 { - cfg.Mode = normalizeDNSResolverMode(DNSResolverMode(vals[0]), false) - } - } - } - if len(activePool) == 0 { - if len(def) > 0 { - cfg.Default = def - } - if len(meta) > 0 { - cfg.Meta = meta - } - } - cfg.Default = mergeDNSUpstreamPools(cfg.Default, resolverFallbackPool()) - cfg.Meta = mergeDNSUpstreamPools(cfg.Meta, resolverFallbackPool()) - if logf != nil { - logf("dns-config: accept %s: mode=%s smartdns=%s default=%v; meta=%v", path, cfg.Mode, cfg.SmartDNS, cfg.Default, cfg.Meta) - } - return cfg -} - -// --------------------------------------------------------------------- -// EN: `readLinesAllowMissing` reads lines allow missing from input data. -// RU: `readLinesAllowMissing` - читает lines allow missing из входных данных. -// --------------------------------------------------------------------- -func readLinesAllowMissing(path string) []string { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - return strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") -} - -// --------------------------------------------------------------------- -// EN: `loadJSONMap` loads json map from storage or config. -// RU: `loadJSONMap` - загружает json map из хранилища или конфига. -// --------------------------------------------------------------------- -func loadJSONMap(path string) map[string]any { - data, err := os.ReadFile(path) - if err != nil { - return map[string]any{} - } - var out map[string]any - if err := json.Unmarshal(data, &out); err != nil { - return map[string]any{} - } - return out -} - -func loadResolverPrecheckLastRun(path string) int { - m := loadJSONMap(path) - if len(m) == 0 { - return 0 - } - v, ok := parseAnyInt(m["last_run"]) - if !ok || v <= 0 { - return 0 - } - return v -} - -func loadResolverLiveBatchTarget(path string, fallback, minV, maxV int) int { - if fallback < minV { - fallback = minV - } - if fallback > maxV { - fallback = maxV - } - m := loadJSONMap(path) - if len(m) == 0 { - return fallback - } - raw := m["live_batch_next_target"] - if raw == nil { - raw = m["live_batch_target"] - } - v, ok := parseAnyInt(raw) - if !ok || v <= 0 { - return fallback - } - if v < minV { - v = minV - } - if v > maxV { - v = maxV - } - return v -} - -func loadResolverLiveBatchNXHeavyPct(path string, fallback, minV, maxV int) int { - if fallback < minV { - fallback = minV - } - if fallback > maxV { - fallback = maxV - } - m := loadJSONMap(path) - if len(m) == 0 { - return fallback - } - raw := m["live_batch_nxheavy_next_pct"] - if raw == nil { - raw = m["live_batch_nxheavy_pct"] - } - v, ok := parseAnyInt(raw) - if !ok { - return fallback - } - if v < minV { - v = minV - } - if v > maxV { - v = maxV - } - return v -} - -func computeNextLiveBatchTarget(current, minV, maxV int, dnsStats dnsMetrics, deferred int) (int, string) { - if current < minV { - current = minV - } - if current > maxV { - current = maxV - } - next := current - reason := "stable" - attempts := dnsStats.Attempts - timeoutRate := 0.0 - if attempts > 0 { - timeoutRate = float64(dnsStats.Timeout) / float64(attempts) - } - - switch { - case attempts == 0: - reason = "no_dns_attempts" - case dnsStats.Skipped > 0 || timeoutRate >= 0.15: - next = int(float64(current) * 0.75) - reason = "timeout_high_or_cooldown" - case timeoutRate >= 0.08: - next = int(float64(current) * 0.90) - reason = "timeout_medium" - case timeoutRate <= 0.03 && deferred > 0: - next = int(float64(current) * 1.15) - reason = "timeout_low_expand" - case timeoutRate <= 0.03: - next = int(float64(current) * 1.10) - reason = "timeout_low" - } - - if next < minV { - next = minV - } - if next > maxV { - next = maxV - } - if next == current && reason == "timeout_low" { - reason = "stable" - } - return next, reason -} - -func computeNextLiveBatchNXHeavyPct( - current, minV, maxV int, - dnsStats dnsMetrics, - resolvedNowDNS int, - liveP3 int, - liveNXHeavyTotal int, - liveNXHeavySkip int, -) (int, string) { - if current < minV { - current = minV - } - if current > maxV { - current = maxV - } - next := current - reason := "stable" - attempts := dnsStats.Attempts - timeoutRate := 0.0 - nxRate := 0.0 - okRate := 0.0 - if attempts > 0 { - timeoutRate = float64(dnsStats.Timeout) / float64(attempts) - nxRate = float64(dnsStats.NXDomain) / float64(attempts) - okRate = float64(dnsStats.OK) / float64(attempts) - } - nxSelectedRatio := 0.0 - if liveNXHeavyTotal > 0 { - nxSelectedRatio = float64(liveP3) / float64(liveNXHeavyTotal) - } - - switch { - case attempts == 0: - reason = "no_dns_attempts" - case timeoutRate >= 0.20 || dnsStats.Skipped > 0: - next = current - 3 - reason = "timeout_very_high_or_cooldown" - case timeoutRate >= 0.12: - next = current - 2 - reason = "timeout_high" - case dnsStats.OK == 0 && dnsStats.NXDomain > 0: - next = current - 2 - reason = "no_success_nx_only" - case nxRate >= 0.90 && resolvedNowDNS == 0: - next = current - 2 - reason = "nx_dominant_no_resolve" - case nxSelectedRatio >= 0.95 && resolvedNowDNS == 0: - next = current - 1 - reason = "nxheavy_selected_dominant" - case timeoutRate <= 0.02 && okRate >= 0.10 && liveNXHeavySkip > 0: - next = current + 2 - reason = "healthy_fast_reintroduce_nxheavy" - case timeoutRate <= 0.04 && resolvedNowDNS > 0 && liveNXHeavySkip > 0: - next = current + 1 - reason = "healthy_reintroduce_nxheavy" - } - if next < minV { - next = minV - } - if next > maxV { - next = maxV - } - if next == current && reason != "no_dns_attempts" { - reason = "stable" - } - return next, reason -} - -func classifyLiveBatchHost( - host string, - cache domainCacheState, - cacheSourceForHost func(string) domainCacheSource, - wildcards wildcardMatcher, -) (priority int, nxHeavy bool) { - h := strings.TrimSpace(strings.ToLower(host)) - if h == "" { - return 2, false - } - if _, ok := wildcards.exact[h]; ok { - return 1, false - } - source := cacheSourceForHost(h) - rec, ok := cache.Domains[h] - if !ok { - return 2, false - } - entry := getCacheEntryBySource(rec, source) - if entry == nil { - return 2, false - } - stored := normalizeCacheIPs(entry.IPs) - state := normalizeDomainState(entry.State, entry.Score) - errKind, hasErr := normalizeCacheErrorKind(entry.LastErrorKind) - nxHeavy = hasErr && errKind == dnsErrorNXDomain && (state == domainStateQuarantine || state == domainStateHardQuar || entry.Score <= -10) - - switch { - case len(stored) > 0: - return 1, false - case state == domainStateActive || state == domainStateStable || state == domainStateSuspect: - return 1, false - case nxHeavy: - return 3, true - default: - return 2, false - } -} - -func splitLiveBatchCandidates( - candidates []string, - cache domainCacheState, - cacheSourceForHost func(string) domainCacheSource, - wildcards wildcardMatcher, -) (p1, p2, p3 []string, nxHeavyTotal int) { - for _, host := range candidates { - h := strings.TrimSpace(strings.ToLower(host)) - if h == "" { - continue - } - prio, nxHeavy := classifyLiveBatchHost(h, cache, cacheSourceForHost, wildcards) - switch prio { - case 1: - p1 = append(p1, h) - case 3: - nxHeavyTotal++ - p3 = append(p3, h) - case 2: - p2 = append(p2, h) - default: - if nxHeavy { - nxHeavyTotal++ - p3 = append(p3, h) - } else { - p2 = append(p2, h) - } - } - } - return p1, p2, p3, nxHeavyTotal -} - -func pickAdaptiveLiveBatch( - candidates []string, - target int, - now int, - nxHeavyPct int, - cache domainCacheState, - cacheSourceForHost func(string) domainCacheSource, - wildcards wildcardMatcher, -) ([]string, int, int, int, int, int) { - if len(candidates) == 0 { - return nil, 0, 0, 0, 0, 0 - } - if target <= 0 { - p1, p2, p3, nxTotal := splitLiveBatchCandidates(candidates, cache, cacheSourceForHost, wildcards) - return append([]string(nil), candidates...), len(p1), len(p2), len(p3), nxTotal, 0 - } - if target > len(candidates) { - target = len(candidates) - } - if nxHeavyPct < 0 { - nxHeavyPct = 0 - } - if nxHeavyPct > 100 { - nxHeavyPct = 100 - } - - start := now % len(candidates) - if start < 0 { - start = 0 - } - rotated := make([]string, 0, len(candidates)) - for i := 0; i < len(candidates); i++ { - idx := (start + i) % len(candidates) - rotated = append(rotated, candidates[idx]) - } - p1, p2, p3, nxTotal := splitLiveBatchCandidates(rotated, cache, cacheSourceForHost, wildcards) - out := make([]string, 0, target) - selectedP1 := 0 - selectedP2 := 0 - selectedP3 := 0 - - take := func(src []string, n int) ([]string, int) { - if n <= 0 || len(src) == 0 { - return src, 0 - } - if n > len(src) { - n = len(src) - } - out = append(out, src[:n]...) - return src[n:], n - } - - remain := target - var took int - p1, took = take(p1, remain) - selectedP1 += took - remain = target - len(out) - p2, took = take(p2, remain) - selectedP2 += took - remain = target - len(out) - - p3Cap := (target * nxHeavyPct) / 100 - if nxHeavyPct > 0 && p3Cap == 0 { - p3Cap = 1 - } - if len(out) == 0 && len(p3) > 0 && p3Cap == 0 { - p3Cap = 1 - } - if p3Cap > remain { - p3Cap = remain - } - p3, took = take(p3, p3Cap) - selectedP3 += took - - // Keep forward progress if every candidate is in NX-heavy bucket. - if len(out) == 0 && len(p3) > 0 && target > 0 { - remain = target - len(out) - p3, took = take(p3, remain) - selectedP3 += took - } - - nxSkipped := nxTotal - selectedP3 - if nxSkipped < 0 { - nxSkipped = 0 - } - return out, selectedP1, selectedP2, selectedP3, nxTotal, nxSkipped -} - -func saveResolverPrecheckState(path string, ts int, timeoutStats resolverTimeoutRecheckStats, live resolverLiveBatchStats) { - if path == "" || ts <= 0 { - return - } - state := loadJSONMap(path) - if state == nil { - state = map[string]any{} - } - state["last_run"] = ts - state["timeout_recheck"] = map[string]any{ - "checked": timeoutStats.Checked, - "recovered": timeoutStats.Recovered, - "recovered_ips": timeoutStats.RecoveredIPs, - "still_timeout": timeoutStats.StillTimeout, - "now_nxdomain": timeoutStats.NowNXDomain, - "now_temporary": timeoutStats.NowTemporary, - "now_other": timeoutStats.NowOther, - "no_signal": timeoutStats.NoSignal, - } - state["live_batch_target"] = live.Target - state["live_batch_total"] = live.Total - state["live_batch_deferred"] = live.Deferred - state["live_batch_p1"] = live.P1 - state["live_batch_p2"] = live.P2 - state["live_batch_p3"] = live.P3 - state["live_batch_nxheavy_pct"] = live.NXHeavyPct - state["live_batch_nxheavy_total"] = live.NXHeavyTotal - state["live_batch_nxheavy_skip"] = live.NXHeavySkip - state["live_batch_nxheavy_next_pct"] = live.NextNXPct - state["live_batch_nxheavy_next_reason"] = live.NextNXReason - state["live_batch_next_target"] = live.NextTarget - state["live_batch_next_reason"] = live.NextReason - state["live_batch_dns_attempts"] = live.DNSAttempts - state["live_batch_dns_timeout"] = live.DNSTimeout - state["live_batch_dns_cooldown_skips"] = live.DNSCoolSkips - saveJSON(state, path) -} +// DNS config + cache helpers moved to app/resolver/* (bridged via resolver_domain_cache_bridge.go + resolver_static_labels_bridge.go + resolver_dns_config_bridge.go) // --------------------------------------------------------------------- // EN: `saveJSON` saves json to persistent storage. // RU: `saveJSON` - сохраняет json в постоянное хранилище. // --------------------------------------------------------------------- -func saveJSON(data any, path string) { - tmp := path + ".tmp" - b, err := json.MarshalIndent(data, "", " ") - if err != nil { - return - } - _ = os.WriteFile(tmp, b, 0o644) - _ = os.Rename(tmp, path) -} - -// --------------------------------------------------------------------- -// EN: `uniqueStrings` contains core logic for unique strings. -// RU: `uniqueStrings` - содержит основную логику для unique strings. -// --------------------------------------------------------------------- -func uniqueStrings(in []string) []string { - seen := map[string]struct{}{} - var out []string - for _, v := range in { - if _, ok := seen[v]; !ok { - seen[v] = struct{}{} - out = append(out, v) - } - } - return out -} - -func pickDNSStartIndex(host string, size int) int { - if size <= 1 { - return 0 - } - h := fnv.New32a() - _, _ = h.Write([]byte(strings.ToLower(strings.TrimSpace(host)))) - return int(h.Sum32() % uint32(size)) -} - -func resolverFallbackPool() []string { - raw := strings.TrimSpace(os.Getenv("RESOLVE_DNS_FALLBACKS")) - switch strings.ToLower(raw) { - case "off", "none", "0": - return nil - } - - candidates := resolverFallbackDNS - if raw != "" { - candidates = nil - fields := strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' - }) - for _, f := range fields { - if n := normalizeDNSUpstream(f, "53"); n != "" { - candidates = append(candidates, n) - } - } - } - return uniqueStrings(candidates) -} - -func mergeDNSUpstreamPools(primary, fallback []string) []string { - maxUpstreams := envInt("RESOLVE_DNS_MAX_UPSTREAMS", 12) - if maxUpstreams < 1 { - maxUpstreams = 1 - } - out := make([]string, 0, len(primary)+len(fallback)) - seen := map[string]struct{}{} - add := func(items []string) { - for _, item := range items { - if len(out) >= maxUpstreams { - return - } - n := normalizeDNSUpstream(item, "53") - if n == "" { - continue - } - if _, ok := seen[n]; ok { - continue - } - seen[n] = struct{}{} - out = append(out, n) - } - } - add(primary) - add(fallback) - return out -} - -// --------------------------------------------------------------------- -// text cleanup + IP classifiers -// --------------------------------------------------------------------- - -var reANSI = regexp.MustCompile(`\x1B\[[0-9;]*[A-Za-z]`) - -func stripANSI(s string) string { - return reANSI.ReplaceAllString(s, "") -} - -// --------------------------------------------------------------------- -// EN: `isPrivateIPv4` checks whether private i pv4 is true. -// RU: `isPrivateIPv4` - проверяет, является ли private i pv4 истинным условием. -// --------------------------------------------------------------------- -func isPrivateIPv4(ip string) bool { - parts := strings.Split(strings.Split(ip, "/")[0], ".") - if len(parts) != 4 { - return true - } - vals := make([]int, 4) - for i, p := range parts { - n, err := strconv.Atoi(p) - if err != nil || n < 0 || n > 255 { - return true - } - vals[i] = n - } - if vals[0] == 10 || vals[0] == 127 || vals[0] == 0 { - return true - } - if vals[0] == 192 && vals[1] == 168 { - return true - } - if vals[0] == 172 && vals[1] >= 16 && vals[1] <= 31 { - return true - } - return false -} diff --git a/selective-vpn-api/app/resolver/artifacts.go b/selective-vpn-api/app/resolver/artifacts.go new file mode 100644 index 0000000..f1798dc --- /dev/null +++ b/selective-vpn-api/app/resolver/artifacts.go @@ -0,0 +1,88 @@ +package resolver + +import "sort" + +type ResolverArtifacts struct { + IPs []string + DirectIPs []string + WildcardIPs []string + IPMap [][2]string + DirectIPMap [][2]string + WildcardIPMap [][2]string +} + +func BuildResolverArtifacts(resolved map[string][]string, staticLabels map[string][]string, isWildcardHost func(string) bool) ResolverArtifacts { + ipSetAll := map[string]struct{}{} + ipSetDirect := map[string]struct{}{} + ipSetWildcard := map[string]struct{}{} + + ipMapAll := map[string]map[string]struct{}{} + ipMapDirect := map[string]map[string]struct{}{} + ipMapWildcard := map[string]map[string]struct{}{} + + add := func(set map[string]struct{}, labels map[string]map[string]struct{}, ip, label string) { + if ip == "" { + return + } + set[ip] = struct{}{} + m := labels[ip] + if m == nil { + m = map[string]struct{}{} + labels[ip] = m + } + m[label] = struct{}{} + } + + for host, ips := range resolved { + wildcardHost := false + if isWildcardHost != nil { + wildcardHost = isWildcardHost(host) + } + for _, ip := range ips { + add(ipSetAll, ipMapAll, ip, host) + if wildcardHost { + add(ipSetWildcard, ipMapWildcard, ip, host) + } else { + add(ipSetDirect, ipMapDirect, ip, host) + } + } + } + for ipEntry, labels := range staticLabels { + for _, lbl := range labels { + add(ipSetAll, ipMapAll, ipEntry, lbl) + add(ipSetDirect, ipMapDirect, ipEntry, lbl) + } + } + + var out ResolverArtifacts + + appendMapPairs := func(dst *[][2]string, labelsByIP map[string]map[string]struct{}) { + for ip := range labelsByIP { + labels := labelsByIP[ip] + for lbl := range labels { + *dst = append(*dst, [2]string{ip, lbl}) + } + } + sort.Slice(*dst, func(i, j int) bool { + if (*dst)[i][0] == (*dst)[j][0] { + return (*dst)[i][1] < (*dst)[j][1] + } + return (*dst)[i][0] < (*dst)[j][0] + }) + } + appendIPs := func(dst *[]string, set map[string]struct{}) { + for ip := range set { + *dst = append(*dst, ip) + } + sort.Strings(*dst) + } + + appendMapPairs(&out.IPMap, ipMapAll) + appendMapPairs(&out.DirectIPMap, ipMapDirect) + appendMapPairs(&out.WildcardIPMap, ipMapWildcard) + appendIPs(&out.IPs, ipSetAll) + appendIPs(&out.DirectIPs, ipSetDirect) + appendIPs(&out.WildcardIPs, ipSetWildcard) + + return out +} diff --git a/selective-vpn-api/app/resolver/common.go b/selective-vpn-api/app/resolver/common.go new file mode 100644 index 0000000..60fcd2e --- /dev/null +++ b/selective-vpn-api/app/resolver/common.go @@ -0,0 +1,60 @@ +package resolver + +import ( + "hash/fnv" + "regexp" + "strconv" + "strings" +) + +var reANSI = regexp.MustCompile(`\x1B\[[0-9;]*[A-Za-z]`) + +func UniqueStrings(in []string) []string { + seen := map[string]struct{}{} + var out []string + for _, v := range in { + if _, ok := seen[v]; !ok { + seen[v] = struct{}{} + out = append(out, v) + } + } + return out +} + +func PickDNSStartIndex(host string, size int) int { + if size <= 1 { + return 0 + } + h := fnv.New32a() + _, _ = h.Write([]byte(strings.ToLower(strings.TrimSpace(host)))) + return int(h.Sum32() % uint32(size)) +} + +func StripANSI(s string) string { + return reANSI.ReplaceAllString(s, "") +} + +func IsPrivateIPv4(ip string) bool { + parts := strings.Split(strings.Split(ip, "/")[0], ".") + if len(parts) != 4 { + return true + } + vals := make([]int, 4) + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 || n > 255 { + return true + } + vals[i] = n + } + if vals[0] == 10 || vals[0] == 127 || vals[0] == 0 { + return true + } + if vals[0] == 192 && vals[1] == 168 { + return true + } + if vals[0] == 172 && vals[1] >= 16 && vals[1] <= 31 { + return true + } + return false +} diff --git a/selective-vpn-api/app/resolver/dns_config.go b/selective-vpn-api/app/resolver/dns_config.go new file mode 100644 index 0000000..cb2e9da --- /dev/null +++ b/selective-vpn-api/app/resolver/dns_config.go @@ -0,0 +1,150 @@ +package resolver + +import ( + "os" + "strings" +) + +type DNSConfig struct { + Default []string + Meta []string + SmartDNS string + Mode string +} + +type DNSConfigDeps struct { + ActivePool []string + IsSmartDNSForced bool + SmartDNSAddr string + SmartDNSForcedMode string + ResolveFallbackPool func() []string + MergeDNSUpstreamPools func(primary, fallback []string) []string + NormalizeDNSUpstream func(raw string, defaultPort string) string + NormalizeSmartDNSAddr func(raw string) string + NormalizeDNSResolverMode func(raw string) string +} + +func LoadDNSConfig(path string, base DNSConfig, deps DNSConfigDeps, logf func(string, ...any)) DNSConfig { + cfg := DNSConfig{ + Default: append([]string(nil), base.Default...), + Meta: append([]string(nil), base.Meta...), + SmartDNS: strings.TrimSpace(base.SmartDNS), + Mode: strings.TrimSpace(base.Mode), + } + if cfg.Mode == "" { + cfg.Mode = "direct" + } + if len(deps.ActivePool) > 0 { + cfg.Default = append([]string(nil), deps.ActivePool...) + cfg.Meta = append([]string(nil), deps.ActivePool...) + } + + if deps.IsSmartDNSForced { + addr := strings.TrimSpace(deps.SmartDNSAddr) + if deps.NormalizeSmartDNSAddr != nil { + if n := deps.NormalizeSmartDNSAddr(addr); n != "" { + addr = n + } + } + if addr == "" { + addr = cfg.SmartDNS + } + cfg.Default = []string{addr} + cfg.Meta = []string{addr} + cfg.SmartDNS = addr + if strings.TrimSpace(deps.SmartDNSForcedMode) != "" { + cfg.Mode = deps.SmartDNSForcedMode + } else { + cfg.Mode = "smartdns" + } + if logf != nil { + logf("dns-config: SmartDNS forced (%s), ignore %s", addr, path) + } + return cfg + } + + data, err := os.ReadFile(path) + if err != nil { + if logf != nil { + logf("dns-config: can't read %s: %v", path, err) + } + fallback := []string(nil) + if deps.ResolveFallbackPool != nil { + fallback = deps.ResolveFallbackPool() + } + if deps.MergeDNSUpstreamPools != nil { + cfg.Default = deps.MergeDNSUpstreamPools(cfg.Default, fallback) + cfg.Meta = deps.MergeDNSUpstreamPools(cfg.Meta, fallback) + } + return cfg + } + + var def, meta []string + lines := strings.Split(string(data), "\n") + for _, ln := range lines { + s := strings.TrimSpace(ln) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + parts := strings.Fields(s) + if len(parts) < 2 { + continue + } + key := strings.ToLower(parts[0]) + vals := parts[1:] + switch key { + case "default": + for _, v := range vals { + if deps.NormalizeDNSUpstream != nil { + if n := deps.NormalizeDNSUpstream(v, "53"); n != "" { + def = append(def, n) + } + } + } + case "meta": + for _, v := range vals { + if deps.NormalizeDNSUpstream != nil { + if n := deps.NormalizeDNSUpstream(v, "53"); n != "" { + meta = append(meta, n) + } + } + } + case "smartdns": + if len(vals) > 0 && deps.NormalizeSmartDNSAddr != nil { + if n := deps.NormalizeSmartDNSAddr(vals[0]); n != "" { + cfg.SmartDNS = n + } + } + case "mode": + if len(vals) > 0 { + rawMode := vals[0] + if deps.NormalizeDNSResolverMode != nil { + cfg.Mode = deps.NormalizeDNSResolverMode(rawMode) + } else { + cfg.Mode = strings.ToLower(strings.TrimSpace(rawMode)) + } + } + } + } + if len(deps.ActivePool) == 0 { + if len(def) > 0 { + cfg.Default = def + } + if len(meta) > 0 { + cfg.Meta = meta + } + } + + fallback := []string(nil) + if deps.ResolveFallbackPool != nil { + fallback = deps.ResolveFallbackPool() + } + if deps.MergeDNSUpstreamPools != nil { + cfg.Default = deps.MergeDNSUpstreamPools(cfg.Default, fallback) + cfg.Meta = deps.MergeDNSUpstreamPools(cfg.Meta, fallback) + } + if logf != nil { + logf("dns-config: accept %s: mode=%s smartdns=%s default=%v; meta=%v", path, cfg.Mode, cfg.SmartDNS, cfg.Default, cfg.Meta) + } + return cfg +} diff --git a/selective-vpn-api/app/resolver/dns_helpers.go b/selective-vpn-api/app/resolver/dns_helpers.go new file mode 100644 index 0000000..2275a39 --- /dev/null +++ b/selective-vpn-api/app/resolver/dns_helpers.go @@ -0,0 +1,52 @@ +package resolver + +import ( + "errors" + "net" + "strings" +) + +func ClassifyDNSError(err error) string { + if err == nil { + return "other" + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + if dnsErr.IsNotFound { + return "nxdomain" + } + if dnsErr.IsTimeout { + return "timeout" + } + if dnsErr.IsTemporary { + return "temporary" + } + } + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "no such host"), strings.Contains(msg, "nxdomain"): + return "nxdomain" + case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "timeout"): + return "timeout" + case strings.Contains(msg, "temporary"): + return "temporary" + default: + return "other" + } +} + +func SplitDNS(dns string) (string, string) { + if strings.Contains(dns, "#") { + parts := strings.SplitN(dns, "#", 2) + host := strings.TrimSpace(parts[0]) + port := strings.TrimSpace(parts[1]) + if host == "" { + host = "127.0.0.1" + } + if port == "" { + port = "53" + } + return host, port + } + return strings.TrimSpace(dns), "" +} diff --git a/selective-vpn-api/app/resolver/dns_metrics.go b/selective-vpn-api/app/resolver/dns_metrics.go new file mode 100644 index 0000000..ca5965e --- /dev/null +++ b/selective-vpn-api/app/resolver/dns_metrics.go @@ -0,0 +1,158 @@ +package resolver + +import ( + "fmt" + "sort" + "strings" +) + +type DNSErrorKind string + +const ( + DNSErrorNXDomain DNSErrorKind = "nxdomain" + DNSErrorTimeout DNSErrorKind = "timeout" + DNSErrorTemporary DNSErrorKind = "temporary" + DNSErrorOther DNSErrorKind = "other" +) + +type DNSUpstreamMetrics struct { + Attempts int + OK int + NXDomain int + Timeout int + Temporary int + Other int + Skipped int +} + +type DNSMetrics struct { + Attempts int + OK int + NXDomain int + Timeout int + Temporary int + Other int + Skipped int + + PerUpstream map[string]*DNSUpstreamMetrics +} + +func (m *DNSMetrics) EnsureUpstream(upstream string) *DNSUpstreamMetrics { + if m.PerUpstream == nil { + m.PerUpstream = map[string]*DNSUpstreamMetrics{} + } + if us, ok := m.PerUpstream[upstream]; ok { + return us + } + us := &DNSUpstreamMetrics{} + m.PerUpstream[upstream] = us + return us +} + +func (m *DNSMetrics) AddSuccess(upstream string) { + m.Attempts++ + m.OK++ + us := m.EnsureUpstream(upstream) + us.Attempts++ + us.OK++ +} + +func (m *DNSMetrics) AddError(upstream string, kind DNSErrorKind) { + m.Attempts++ + us := m.EnsureUpstream(upstream) + us.Attempts++ + switch kind { + case DNSErrorNXDomain: + m.NXDomain++ + us.NXDomain++ + case DNSErrorTimeout: + m.Timeout++ + us.Timeout++ + case DNSErrorTemporary: + m.Temporary++ + us.Temporary++ + default: + m.Other++ + us.Other++ + } +} + +func (m *DNSMetrics) AddCooldownSkip(upstream string) { + m.Skipped++ + us := m.EnsureUpstream(upstream) + us.Skipped++ +} + +func (m *DNSMetrics) Merge(other DNSMetrics) { + 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 + + for upstream, src := range other.PerUpstream { + dst := m.EnsureUpstream(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 + } +} + +func (m DNSMetrics) TotalErrors() int { + return m.NXDomain + m.Timeout + m.Temporary + m.Other +} + +func (m DNSMetrics) 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, "; ") +} + +func (m DNSMetrics) FormatResolverHealth() 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] + if v == nil || v.Attempts <= 0 { + continue + } + okRate := float64(v.OK) / float64(v.Attempts) + timeoutRate := float64(v.Timeout) / float64(v.Attempts) + score := okRate*100.0 - timeoutRate*50.0 + state := "bad" + switch { + case score >= 70 && timeoutRate <= 0.05: + state = "good" + case score >= 35: + state = "degraded" + default: + state = "bad" + } + parts = append(parts, fmt.Sprintf("%s{score=%.1f state=%s attempts=%d ok=%d timeout=%d nxdomain=%d temporary=%d other=%d skipped=%d}", k, score, state, v.Attempts, v.OK, v.Timeout, v.NXDomain, v.Temporary, v.Other, v.Skipped)) + } + return strings.Join(parts, "; ") +} diff --git a/selective-vpn-api/app/resolver/dns_upstreams.go b/selective-vpn-api/app/resolver/dns_upstreams.go new file mode 100644 index 0000000..e861a0a --- /dev/null +++ b/selective-vpn-api/app/resolver/dns_upstreams.go @@ -0,0 +1,57 @@ +package resolver + +import "strings" + +func BuildResolverFallbackPool(raw string, fallbackDefaults []string, normalize func(string) string) []string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "off", "none", "0": + return nil + } + + candidates := fallbackDefaults + if strings.TrimSpace(raw) != "" { + candidates = nil + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' + }) + for _, f := range fields { + if normalize == nil { + continue + } + if n := normalize(f); n != "" { + candidates = append(candidates, n) + } + } + } + return UniqueStrings(candidates) +} + +func MergeDNSUpstreamPools(primary, fallback []string, maxUpstreams int, normalize func(string) string) []string { + if maxUpstreams < 1 { + maxUpstreams = 1 + } + out := make([]string, 0, len(primary)+len(fallback)) + seen := map[string]struct{}{} + add := func(items []string) { + for _, item := range items { + if len(out) >= maxUpstreams { + return + } + if normalize == nil { + continue + } + n := normalize(item) + if n == "" { + continue + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + } + add(primary) + add(fallback) + return out +} diff --git a/selective-vpn-api/app/resolver/domain_cache.go b/selective-vpn-api/app/resolver/domain_cache.go new file mode 100644 index 0000000..d3e7f49 --- /dev/null +++ b/selective-vpn-api/app/resolver/domain_cache.go @@ -0,0 +1,649 @@ +package resolver + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" +) + +type DomainCacheSource string + +const ( + DomainCacheSourceDirect DomainCacheSource = "direct" + DomainCacheSourceWildcard DomainCacheSource = "wildcard" +) + +const ( + DomainStateActive = "active" + DomainStateStable = "stable" + DomainStateSuspect = "suspect" + DomainStateQuarantine = "quarantine" + DomainStateHardQuar = "hard_quarantine" + DomainScoreMin = -100 + DomainScoreMax = 100 + DomainCacheVersion = 4 + DefaultQuarantineTTL = 24 * 3600 + DefaultHardQuarTTL = 7 * 24 * 3600 +) + +var EnvInt = func(key string, def int) int { return def } +var NXHardQuarantineEnabled = func() bool { return false } + +type DomainCacheEntry struct { + IPs []string `json:"ips,omitempty"` + LastResolved int `json:"last_resolved,omitempty"` + LastErrorKind string `json:"last_error_kind,omitempty"` + LastErrorAt int `json:"last_error_at,omitempty"` + Score int `json:"score,omitempty"` + State string `json:"state,omitempty"` + QuarantineUntil int `json:"quarantine_until,omitempty"` +} + +type DomainCacheRecord struct { + Direct *DomainCacheEntry `json:"direct,omitempty"` + Wildcard *DomainCacheEntry `json:"wildcard,omitempty"` +} + +type DomainCacheState struct { + Version int `json:"version"` + Domains map[string]DomainCacheRecord `json:"domains"` +} + +func NewDomainCacheState() DomainCacheState { + return DomainCacheState{ + Version: DomainCacheVersion, + Domains: map[string]DomainCacheRecord{}, + } +} + +func NormalizeCacheIPs(raw []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(raw)) + for _, ip := range raw { + ip = strings.TrimSpace(ip) + if ip == "" || IsPrivateIPv4(ip) { + continue + } + if _, ok := seen[ip]; ok { + continue + } + seen[ip] = struct{}{} + out = append(out, ip) + } + sort.Strings(out) + return out +} + +func NormalizeCacheErrorKind(raw string) (DNSErrorKind, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case string(DNSErrorNXDomain): + return DNSErrorNXDomain, true + case string(DNSErrorTimeout): + return DNSErrorTimeout, true + case string(DNSErrorTemporary): + return DNSErrorTemporary, true + case string(DNSErrorOther): + return DNSErrorOther, true + default: + return "", false + } +} + +func NormalizeDomainCacheEntry(in *DomainCacheEntry) *DomainCacheEntry { + if in == nil { + return nil + } + out := &DomainCacheEntry{} + ips := NormalizeCacheIPs(in.IPs) + if len(ips) > 0 && in.LastResolved > 0 { + out.IPs = ips + out.LastResolved = in.LastResolved + } + if kind, ok := NormalizeCacheErrorKind(in.LastErrorKind); ok && in.LastErrorAt > 0 { + out.LastErrorKind = string(kind) + out.LastErrorAt = in.LastErrorAt + } + out.Score = ClampDomainScore(in.Score) + if st := NormalizeDomainState(in.State, out.Score); st != "" { + out.State = st + } + if in.QuarantineUntil > 0 { + out.QuarantineUntil = in.QuarantineUntil + } + if out.LastResolved <= 0 && out.LastErrorAt <= 0 { + if out.Score == 0 && out.QuarantineUntil <= 0 { + return nil + } + } + return out +} + +func parseAnyStringSlice(raw any) []string { + switch v := raw.(type) { + case []string: + return append([]string(nil), v...) + case []any: + out := make([]string, 0, len(v)) + for _, x := range v { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func parseLegacyDomainCacheEntry(raw any) (DomainCacheEntry, bool) { + m, ok := raw.(map[string]any) + if !ok { + return DomainCacheEntry{}, false + } + ips := NormalizeCacheIPs(parseAnyStringSlice(m["ips"])) + if len(ips) == 0 { + return DomainCacheEntry{}, false + } + ts, ok := parseAnyInt(m["last_resolved"]) + if !ok || ts <= 0 { + return DomainCacheEntry{}, false + } + return DomainCacheEntry{IPs: ips, LastResolved: ts}, true +} + +func LoadDomainCacheState(path string, logf func(string, ...any)) DomainCacheState { + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return NewDomainCacheState() + } + + var st DomainCacheState + if err := json.Unmarshal(data, &st); err == nil && st.Domains != nil { + if st.Version <= 0 { + st.Version = DomainCacheVersion + } + normalized := NewDomainCacheState() + for host, rec := range st.Domains { + host = strings.TrimSpace(strings.ToLower(host)) + if host == "" { + continue + } + nrec := DomainCacheRecord{} + nrec.Direct = NormalizeDomainCacheEntry(rec.Direct) + nrec.Wildcard = NormalizeDomainCacheEntry(rec.Wildcard) + if nrec.Direct != nil || nrec.Wildcard != nil { + normalized.Domains[host] = nrec + } + } + return normalized + } + + var legacy map[string]any + if err := json.Unmarshal(data, &legacy); err != nil { + if logf != nil { + logf("domain-cache: invalid json at %s, ignore", path) + } + return NewDomainCacheState() + } + + out := NewDomainCacheState() + migrated := 0 + for host, raw := range legacy { + host = strings.TrimSpace(strings.ToLower(host)) + if host == "" || host == "version" || host == "domains" { + continue + } + entry, ok := parseLegacyDomainCacheEntry(raw) + if !ok { + continue + } + rec := out.Domains[host] + rec.Direct = &entry + out.Domains[host] = rec + migrated++ + } + if logf != nil && migrated > 0 { + logf("domain-cache: migrated legacy entries=%d into split cache (direct bucket)", migrated) + } + return out +} + +func (s DomainCacheState) Get(domain string, source DomainCacheSource, now, ttl int) ([]string, bool) { + rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] + if !ok { + return nil, false + } + var entry *DomainCacheEntry + switch source { + case DomainCacheSourceWildcard: + entry = rec.Wildcard + default: + entry = rec.Direct + } + if entry == nil || entry.LastResolved <= 0 { + return nil, false + } + if now-entry.LastResolved > ttl { + return nil, false + } + ips := NormalizeCacheIPs(entry.IPs) + if len(ips) == 0 { + return nil, false + } + return ips, true +} + +func (s DomainCacheState) GetNegative(domain string, source DomainCacheSource, now, nxTTL, timeoutTTL, temporaryTTL, otherTTL int) (DNSErrorKind, int, bool) { + rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] + if !ok { + return "", 0, false + } + var entry *DomainCacheEntry + switch source { + case DomainCacheSourceWildcard: + entry = rec.Wildcard + default: + entry = rec.Direct + } + if entry == nil || entry.LastErrorAt <= 0 { + return "", 0, false + } + kind, ok := NormalizeCacheErrorKind(entry.LastErrorKind) + if !ok { + return "", 0, false + } + age := now - entry.LastErrorAt + if age < 0 { + return "", 0, false + } + cacheTTL := 0 + switch kind { + case DNSErrorNXDomain: + cacheTTL = nxTTL + case DNSErrorTimeout: + cacheTTL = timeoutTTL + case DNSErrorTemporary: + cacheTTL = temporaryTTL + case DNSErrorOther: + cacheTTL = otherTTL + } + if cacheTTL <= 0 || age > cacheTTL { + return "", 0, false + } + return kind, age, true +} + +func (s DomainCacheState) GetStoredIPs(domain string, source DomainCacheSource) []string { + rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] + if !ok { + return nil + } + entry := GetCacheEntryBySource(rec, source) + if entry == nil { + return nil + } + return NormalizeCacheIPs(entry.IPs) +} + +func (s DomainCacheState) GetLastErrorKind(domain string, source DomainCacheSource) (DNSErrorKind, bool) { + rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] + if !ok { + return "", false + } + entry := GetCacheEntryBySource(rec, source) + if entry == nil || entry.LastErrorAt <= 0 { + return "", false + } + return NormalizeCacheErrorKind(entry.LastErrorKind) +} + +func (s DomainCacheState) GetQuarantine(domain string, source DomainCacheSource, now int) (string, int, bool) { + rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] + if !ok { + return "", 0, false + } + entry := GetCacheEntryBySource(rec, source) + if entry == nil || entry.QuarantineUntil <= 0 { + return "", 0, false + } + if now >= entry.QuarantineUntil { + return "", 0, false + } + state := NormalizeDomainState(entry.State, entry.Score) + if state == "" { + state = DomainStateQuarantine + } + age := 0 + if entry.LastErrorAt > 0 { + age = now - entry.LastErrorAt + } + return state, age, true +} + +func (s DomainCacheState) GetStale(domain string, source DomainCacheSource, now, maxAge int) ([]string, int, bool) { + if maxAge <= 0 { + return nil, 0, false + } + rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))] + if !ok { + return nil, 0, false + } + entry := GetCacheEntryBySource(rec, source) + if entry == nil || entry.LastResolved <= 0 { + return nil, 0, false + } + age := now - entry.LastResolved + if age < 0 || age > maxAge { + return nil, 0, false + } + ips := NormalizeCacheIPs(entry.IPs) + if len(ips) == 0 { + return nil, 0, false + } + return ips, age, true +} + +func (s *DomainCacheState) Set(domain string, source DomainCacheSource, ips []string, now int) { + host := strings.TrimSpace(strings.ToLower(domain)) + if host == "" || now <= 0 { + return + } + norm := NormalizeCacheIPs(ips) + if len(norm) == 0 { + return + } + if s.Domains == nil { + s.Domains = map[string]DomainCacheRecord{} + } + rec := s.Domains[host] + prev := GetCacheEntryBySource(rec, source) + prevScore := 0 + if prev != nil { + prevScore = prev.Score + } + entry := &DomainCacheEntry{ + IPs: norm, + LastResolved: now, + LastErrorKind: "", + LastErrorAt: 0, + Score: ClampDomainScore(prevScore + EnvInt("RESOLVE_DOMAIN_SCORE_OK", 8)), + QuarantineUntil: 0, + } + entry.State = DomainStateFromScore(entry.Score) + switch source { + case DomainCacheSourceWildcard: + rec.Wildcard = entry + default: + rec.Direct = entry + } + s.Domains[host] = rec +} + +func GetCacheEntryBySource(rec DomainCacheRecord, source DomainCacheSource) *DomainCacheEntry { + switch source { + case DomainCacheSourceWildcard: + return rec.Wildcard + default: + return rec.Direct + } +} + +func ClampDomainScore(v int) int { + if v < DomainScoreMin { + return DomainScoreMin + } + if v > DomainScoreMax { + return DomainScoreMax + } + return v +} + +func DomainStateFromScore(score int) string { + switch { + case score >= 20: + return DomainStateActive + case score >= 5: + return DomainStateStable + case score >= -10: + return DomainStateSuspect + case score >= -30: + return DomainStateQuarantine + default: + return DomainStateHardQuar + } +} + +func NormalizeDomainState(raw string, score int) string { + switch strings.TrimSpace(strings.ToLower(raw)) { + case DomainStateActive: + return DomainStateActive + case DomainStateStable: + return DomainStateStable + case DomainStateSuspect: + return DomainStateSuspect + case DomainStateQuarantine: + return DomainStateQuarantine + case DomainStateHardQuar: + return DomainStateHardQuar + default: + if score == 0 { + return "" + } + return DomainStateFromScore(score) + } +} + +func DomainScorePenalty(stats DNSMetrics) int { + if stats.NXDomain >= 2 { + return EnvInt("RESOLVE_DOMAIN_SCORE_NX_CONFIRMED", -15) + } + if stats.NXDomain > 0 { + return EnvInt("RESOLVE_DOMAIN_SCORE_NX_SINGLE", -7) + } + if stats.Timeout > 0 { + return EnvInt("RESOLVE_DOMAIN_SCORE_TIMEOUT", -3) + } + if stats.Temporary > 0 { + return EnvInt("RESOLVE_DOMAIN_SCORE_TEMPORARY", -2) + } + return EnvInt("RESOLVE_DOMAIN_SCORE_OTHER", -2) +} + +func classifyHostErrorKind(stats DNSMetrics) (DNSErrorKind, bool) { + if stats.Timeout > 0 { + return DNSErrorTimeout, true + } + if stats.Temporary > 0 { + return DNSErrorTemporary, true + } + if stats.Other > 0 { + return DNSErrorOther, true + } + if stats.NXDomain > 0 { + return DNSErrorNXDomain, true + } + return "", false +} + +func (s *DomainCacheState) SetErrorWithStats(domain string, source DomainCacheSource, stats DNSMetrics, now int) { + host := strings.TrimSpace(strings.ToLower(domain)) + if host == "" || now <= 0 { + return + } + kind, ok := classifyHostErrorKind(stats) + if !ok { + return + } + normKind, ok := NormalizeCacheErrorKind(string(kind)) + if !ok { + return + } + penalty := DomainScorePenalty(stats) + quarantineTTL := EnvInt("RESOLVE_QUARANTINE_TTL_SEC", DefaultQuarantineTTL) + if quarantineTTL < 0 { + quarantineTTL = 0 + } + hardQuarantineTTL := EnvInt("RESOLVE_HARD_QUARANTINE_TTL_SEC", DefaultHardQuarTTL) + if hardQuarantineTTL < 0 { + hardQuarantineTTL = 0 + } + if s.Domains == nil { + s.Domains = map[string]DomainCacheRecord{} + } + rec := s.Domains[host] + entry := GetCacheEntryBySource(rec, source) + if entry == nil { + entry = &DomainCacheEntry{} + } + prevKind, _ := NormalizeCacheErrorKind(entry.LastErrorKind) + entry.Score = ClampDomainScore(entry.Score + penalty) + entry.State = DomainStateFromScore(entry.Score) + + if normKind == DNSErrorTimeout && prevKind != DNSErrorNXDomain { + if entry.Score < -10 { + entry.Score = -10 + } + entry.State = DomainStateSuspect + } + if normKind == DNSErrorNXDomain && !NXHardQuarantineEnabled() && entry.State == DomainStateHardQuar { + entry.State = DomainStateQuarantine + if entry.Score < -30 { + entry.Score = -30 + } + } + entry.LastErrorKind = string(normKind) + entry.LastErrorAt = now + switch entry.State { + case DomainStateHardQuar: + entry.QuarantineUntil = now + hardQuarantineTTL + case DomainStateQuarantine: + entry.QuarantineUntil = now + quarantineTTL + default: + entry.QuarantineUntil = 0 + } + switch source { + case DomainCacheSourceWildcard: + rec.Wildcard = entry + default: + rec.Direct = entry + } + s.Domains[host] = rec +} + +func (s DomainCacheState) ToMap() map[string]any { + out := map[string]any{ + "version": DomainCacheVersion, + "domains": map[string]any{}, + } + domainsAny := out["domains"].(map[string]any) + hosts := make([]string, 0, len(s.Domains)) + for host := range s.Domains { + hosts = append(hosts, host) + } + sort.Strings(hosts) + for _, host := range hosts { + rec := s.Domains[host] + recOut := map[string]any{} + if rec.Direct != nil { + directOut := map[string]any{} + if len(rec.Direct.IPs) > 0 && rec.Direct.LastResolved > 0 { + directOut["ips"] = rec.Direct.IPs + directOut["last_resolved"] = rec.Direct.LastResolved + } + if kind, ok := NormalizeCacheErrorKind(rec.Direct.LastErrorKind); ok && rec.Direct.LastErrorAt > 0 { + directOut["last_error_kind"] = string(kind) + directOut["last_error_at"] = rec.Direct.LastErrorAt + } + if rec.Direct.Score != 0 { + directOut["score"] = rec.Direct.Score + } + if st := NormalizeDomainState(rec.Direct.State, rec.Direct.Score); st != "" { + directOut["state"] = st + } + if rec.Direct.QuarantineUntil > 0 { + directOut["quarantine_until"] = rec.Direct.QuarantineUntil + } + if len(directOut) > 0 { + recOut["direct"] = directOut + } + } + if rec.Wildcard != nil { + wildOut := map[string]any{} + if len(rec.Wildcard.IPs) > 0 && rec.Wildcard.LastResolved > 0 { + wildOut["ips"] = rec.Wildcard.IPs + wildOut["last_resolved"] = rec.Wildcard.LastResolved + } + if kind, ok := NormalizeCacheErrorKind(rec.Wildcard.LastErrorKind); ok && rec.Wildcard.LastErrorAt > 0 { + wildOut["last_error_kind"] = string(kind) + wildOut["last_error_at"] = rec.Wildcard.LastErrorAt + } + if rec.Wildcard.Score != 0 { + wildOut["score"] = rec.Wildcard.Score + } + if st := NormalizeDomainState(rec.Wildcard.State, rec.Wildcard.Score); st != "" { + wildOut["state"] = st + } + if rec.Wildcard.QuarantineUntil > 0 { + wildOut["quarantine_until"] = rec.Wildcard.QuarantineUntil + } + if len(wildOut) > 0 { + recOut["wildcard"] = wildOut + } + } + if len(recOut) > 0 { + domainsAny[host] = recOut + } + } + return out +} + +func (s DomainCacheState) FormatStateSummary(now int) string { + type counters struct { + active int + stable int + suspect int + quarantine int + hardQuar int + } + add := func(c *counters, entry *DomainCacheEntry) { + if entry == nil { + return + } + st := NormalizeDomainState(entry.State, entry.Score) + if entry.QuarantineUntil > now { + if st == DomainStateHardQuar { + c.hardQuar++ + return + } + c.quarantine++ + return + } + switch st { + case DomainStateActive: + c.active++ + case DomainStateStable: + c.stable++ + case DomainStateSuspect: + c.suspect++ + case DomainStateQuarantine: + c.quarantine++ + case DomainStateHardQuar: + c.hardQuar++ + } + } + var c counters + for _, rec := range s.Domains { + add(&c, rec.Direct) + add(&c, rec.Wildcard) + } + total := c.active + c.stable + c.suspect + c.quarantine + c.hardQuar + if total == 0 { + return "" + } + return fmt.Sprintf( + "active=%d stable=%d suspect=%d quarantine=%d hard_quarantine=%d total=%d", + c.active, c.stable, c.suspect, c.quarantine, c.hardQuar, total, + ) +} diff --git a/selective-vpn-api/app/resolver/error_policy.go b/selective-vpn-api/app/resolver/error_policy.go new file mode 100644 index 0000000..414b322 --- /dev/null +++ b/selective-vpn-api/app/resolver/error_policy.go @@ -0,0 +1,53 @@ +package resolver + +import ( + "os" + "strings" +) + +func SmartDNSFallbackForTimeoutEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_SMARTDNS_TIMEOUT_FALLBACK"))) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return false + } +} + +func ShouldFallbackToSmartDNS(stats DNSMetrics) bool { + if stats.OK > 0 { + return false + } + if stats.NXDomain > 0 { + return false + } + if stats.Timeout > 0 || stats.Temporary > 0 { + return true + } + return stats.Other > 0 +} + +func ClassifyHostErrorKind(stats DNSMetrics) (DNSErrorKind, bool) { + if stats.Timeout > 0 { + return DNSErrorTimeout, true + } + if stats.Temporary > 0 { + return DNSErrorTemporary, true + } + if stats.Other > 0 { + return DNSErrorOther, true + } + if stats.NXDomain > 0 { + return DNSErrorNXDomain, true + } + return "", false +} + +func ShouldUseStaleOnError(stats DNSMetrics) bool { + if stats.OK > 0 { + return false + } + return stats.Timeout > 0 || stats.Temporary > 0 || stats.Other > 0 +} diff --git a/selective-vpn-api/app/resolver/host_lookup.go b/selective-vpn-api/app/resolver/host_lookup.go new file mode 100644 index 0000000..90bf9fc --- /dev/null +++ b/selective-vpn-api/app/resolver/host_lookup.go @@ -0,0 +1,234 @@ +package resolver + +import ( + "context" + "net" + "time" +) + +const ( + dnsModeSmartDNS = "smartdns" + dnsModeHybridWildcard = "hybrid_wildcard" +) + +type DNSAttemptPolicy struct { + TryLimit int + DomainBudget time.Duration + StopOnNX bool +} + +type DNSCooldown interface { + ShouldSkip(upstream string, now int64) bool + ObserveSuccess(upstream string) + ObserveError(upstream string, kind DNSErrorKind, now int64) (bool, int) +} + +func ResolveHost( + host string, + cfg DNSConfig, + metaSpecial []string, + isWildcard func(string) bool, + timeout time.Duration, + cooldown DNSCooldown, + directPolicyFor func(int) DNSAttemptPolicy, + wildcardPolicyFor func(int) DNSAttemptPolicy, + smartDNSFallbackEnabled bool, + logf func(string, ...any), +) ([]string, DNSMetrics) { + useMeta := false + for _, m := range metaSpecial { + if host == m { + useMeta = true + break + } + } + dnsList := cfg.Default + if useMeta { + dnsList = cfg.Meta + } + primaryViaSmartDNS := false + switch cfg.Mode { + case dnsModeSmartDNS: + if cfg.SmartDNS != "" { + dnsList = []string{cfg.SmartDNS} + primaryViaSmartDNS = true + } + case dnsModeHybridWildcard: + if cfg.SmartDNS != "" && isWildcard != nil && isWildcard(host) { + dnsList = []string{cfg.SmartDNS} + primaryViaSmartDNS = true + } + } + policy := safePolicy(directPolicyFor, len(dnsList), timeout) + if primaryViaSmartDNS { + policy = safePolicy(wildcardPolicyFor, len(dnsList), timeout) + } + ips, stats := DigAWithPolicy(host, dnsList, timeout, policy, cooldown, logf) + if len(ips) == 0 && + !primaryViaSmartDNS && + cfg.SmartDNS != "" && + smartDNSFallbackEnabled && + ShouldFallbackToSmartDNS(stats) { + if logf != nil { + logf( + "dns fallback %s: trying smartdns=%s after errors nxdomain=%d timeout=%d temporary=%d other=%d", + host, + cfg.SmartDNS, + stats.NXDomain, + stats.Timeout, + stats.Temporary, + stats.Other, + ) + } + fallbackPolicy := safePolicy(wildcardPolicyFor, 1, timeout) + fallbackIPs, fallbackStats := DigAWithPolicy(host, []string{cfg.SmartDNS}, timeout, fallbackPolicy, cooldown, logf) + stats.Merge(fallbackStats) + if len(fallbackIPs) > 0 { + ips = fallbackIPs + if logf != nil { + logf("dns fallback %s: resolved via smartdns (%d ips)", host, len(fallbackIPs)) + } + } + } + out := make([]string, 0, len(ips)) + seen := map[string]struct{}{} + for _, ip := range ips { + if IsPrivateIPv4(ip) { + continue + } + if _, ok := seen[ip]; ok { + continue + } + seen[ip] = struct{}{} + out = append(out, ip) + } + return out, stats +} + +func DigAWithPolicy( + host string, + dnsList []string, + timeout time.Duration, + policy DNSAttemptPolicy, + cooldown DNSCooldown, + logf func(string, ...any), +) ([]string, DNSMetrics) { + stats := DNSMetrics{} + if len(dnsList) == 0 { + return nil, stats + } + + tryLimit := policy.TryLimit + if tryLimit <= 0 { + tryLimit = 1 + } + if tryLimit > len(dnsList) { + tryLimit = len(dnsList) + } + budget := policy.DomainBudget + if budget <= 0 { + budget = time.Duration(tryLimit) * timeout + } + if budget < 200*time.Millisecond { + budget = 200 * time.Millisecond + } + deadline := time.Now().Add(budget) + + start := PickDNSStartIndex(host, len(dnsList)) + for attempt := 0; attempt < tryLimit; attempt++ { + remaining := time.Until(deadline) + if remaining <= 0 { + if logf != nil { + logf("dns budget exhausted %s: attempts=%d budget_ms=%d", host, attempt, budget.Milliseconds()) + } + break + } + entry := dnsList[(start+attempt)%len(dnsList)] + server, port := SplitDNS(entry) + if server == "" { + continue + } + if port == "" { + port = "53" + } + addr := net.JoinHostPort(server, port) + if cooldown != nil && cooldown.ShouldSkip(addr, time.Now().Unix()) { + stats.AddCooldownSkip(addr) + continue + } + r := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{} + return d.DialContext(ctx, "udp", addr) + }, + } + perAttemptTimeout := timeout + if remaining < perAttemptTimeout { + perAttemptTimeout = remaining + } + if perAttemptTimeout < 100*time.Millisecond { + perAttemptTimeout = 100 * time.Millisecond + } + ctx, cancel := context.WithTimeout(context.Background(), perAttemptTimeout) + records, err := r.LookupHost(ctx, host) + cancel() + if err != nil { + kindRaw := ClassifyDNSError(err) + kind, ok := NormalizeCacheErrorKind(kindRaw) + if !ok { + kind = DNSErrorOther + } + stats.AddError(addr, kind) + if cooldown != nil { + if banned, banSec := cooldown.ObserveError(addr, kind, time.Now().Unix()); banned && logf != nil { + logf("dns cooldown ban %s: timeout-like failures; ban_sec=%d", addr, banSec) + } + } + if logf != nil { + logf("dns warn %s via %s: kind=%s attempt=%d/%d err=%v", host, addr, kind, attempt+1, tryLimit, err) + } + if policy.StopOnNX && kind == DNSErrorNXDomain { + if logf != nil { + logf("dns early-stop %s: nxdomain via %s (attempt=%d/%d)", host, addr, attempt+1, tryLimit) + } + break + } + continue + } + var ips []string + for _, ip := range records { + if IsPrivateIPv4(ip) { + continue + } + ips = append(ips, ip) + } + if len(ips) == 0 { + stats.AddError(addr, DNSErrorOther) + if cooldown != nil { + _, _ = cooldown.ObserveError(addr, DNSErrorOther, time.Now().Unix()) + } + if logf != nil { + logf("dns warn %s via %s: kind=other err=no_public_ips", host, addr) + } + continue + } + stats.AddSuccess(addr) + if cooldown != nil { + cooldown.ObserveSuccess(addr) + } + return UniqueStrings(ips), stats + } + return nil, stats +} + +func safePolicy(factory func(int) DNSAttemptPolicy, count int, timeout time.Duration) DNSAttemptPolicy { + if factory != nil { + return factory(count) + } + return DNSAttemptPolicy{ + TryLimit: 1, + DomainBudget: timeout, + StopOnNX: true, + } +} diff --git a/selective-vpn-api/app/resolver/io_helpers.go b/selective-vpn-api/app/resolver/io_helpers.go new file mode 100644 index 0000000..f35be98 --- /dev/null +++ b/selective-vpn-api/app/resolver/io_helpers.go @@ -0,0 +1,135 @@ +package resolver + +import ( + "encoding/json" + "os" + "strconv" + "strings" +) + +func ReadLinesAllowMissing(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + return strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") +} + +func LoadJSONMap(path string) map[string]any { + data, err := os.ReadFile(path) + if err != nil { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return map[string]any{} + } + return out +} + +func SaveJSON(data any, path string) { + tmp := path + ".tmp" + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + return + } + _ = os.WriteFile(tmp, b, 0o644) + _ = os.Rename(tmp, path) +} + +func parseAnyInt(raw any) (int, bool) { + switch v := raw.(type) { + case int: + return v, true + case int64: + return int(v), true + case float64: + return int(v), true + case json.Number: + n, err := v.Int64() + if err != nil { + return 0, false + } + return int(n), true + case string: + s := strings.TrimSpace(v) + if s == "" { + return 0, false + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + return n, true + default: + return 0, false + } +} + +func LoadResolverPrecheckLastRun(path string) int { + m := LoadJSONMap(path) + if len(m) == 0 { + return 0 + } + v, ok := parseAnyInt(m["last_run"]) + if !ok || v <= 0 { + return 0 + } + return v +} + +func LoadResolverLiveBatchTarget(path string, fallback, minV, maxV int) int { + if fallback < minV { + fallback = minV + } + if fallback > maxV { + fallback = maxV + } + m := LoadJSONMap(path) + if len(m) == 0 { + return fallback + } + raw := m["live_batch_next_target"] + if raw == nil { + raw = m["live_batch_target"] + } + v, ok := parseAnyInt(raw) + if !ok || v <= 0 { + return fallback + } + if v < minV { + v = minV + } + if v > maxV { + v = maxV + } + return v +} + +func LoadResolverLiveBatchNXHeavyPct(path string, fallback, minV, maxV int) int { + if fallback < minV { + fallback = minV + } + if fallback > maxV { + fallback = maxV + } + m := LoadJSONMap(path) + if len(m) == 0 { + return fallback + } + raw := m["live_batch_nxheavy_next_pct"] + if raw == nil { + raw = m["live_batch_nxheavy_pct"] + } + v, ok := parseAnyInt(raw) + if !ok { + return fallback + } + if v < minV { + v = minV + } + if v > maxV { + v = maxV + } + return v +} diff --git a/selective-vpn-api/app/resolver/live_batch.go b/selective-vpn-api/app/resolver/live_batch.go new file mode 100644 index 0000000..0fbf217 --- /dev/null +++ b/selective-vpn-api/app/resolver/live_batch.go @@ -0,0 +1,113 @@ +package resolver + +func ComputeNextLiveBatchTarget(current, minV, maxV int, dnsStats DNSMetrics, deferred int) (int, string) { + if current < minV { + current = minV + } + if current > maxV { + current = maxV + } + next := current + reason := "stable" + attempts := dnsStats.Attempts + timeoutRate := 0.0 + if attempts > 0 { + timeoutRate = float64(dnsStats.Timeout) / float64(attempts) + } + + switch { + case attempts == 0: + reason = "no_dns_attempts" + case dnsStats.Skipped > 0 || timeoutRate >= 0.15: + next = int(float64(current) * 0.75) + reason = "timeout_high_or_cooldown" + case timeoutRate >= 0.08: + next = int(float64(current) * 0.90) + reason = "timeout_medium" + case timeoutRate <= 0.03 && deferred > 0: + next = int(float64(current) * 1.15) + reason = "timeout_low_expand" + case timeoutRate <= 0.03: + next = int(float64(current) * 1.10) + reason = "timeout_low" + } + + if next < minV { + next = minV + } + if next > maxV { + next = maxV + } + if next == current && reason == "timeout_low" { + reason = "stable" + } + return next, reason +} + +func ComputeNextLiveBatchNXHeavyPct( + current, minV, maxV int, + dnsStats DNSMetrics, + resolvedNowDNS int, + selectedP3 int, + nxTotal int, + liveNXHeavySkip int, +) (int, string) { + if current < minV { + current = minV + } + if current > maxV { + current = maxV + } + next := current + reason := "stable" + + attempts := dnsStats.Attempts + timeoutRate := 0.0 + okRate := 0.0 + nxRate := 0.0 + if attempts > 0 { + timeoutRate = float64(dnsStats.Timeout) / float64(attempts) + okRate = float64(dnsStats.OK) / float64(attempts) + nxRate = float64(dnsStats.NXDomain) / float64(attempts) + } + nxSelectedRatio := 0.0 + if nxTotal > 0 { + nxSelectedRatio = float64(selectedP3) / float64(nxTotal) + } + + switch { + case attempts == 0: + reason = "no_dns_attempts" + case timeoutRate >= 0.20 || dnsStats.Skipped > 0: + next = current - 3 + reason = "timeout_very_high_or_cooldown" + case timeoutRate >= 0.12: + next = current - 2 + reason = "timeout_high" + case dnsStats.OK == 0 && dnsStats.NXDomain > 0: + next = current - 2 + reason = "no_success_nx_only" + case nxRate >= 0.90 && resolvedNowDNS == 0: + next = current - 2 + reason = "nx_dominant_no_resolve" + case nxSelectedRatio >= 0.95 && resolvedNowDNS == 0: + next = current - 1 + reason = "nxheavy_selected_dominant" + case timeoutRate <= 0.02 && okRate >= 0.10 && liveNXHeavySkip > 0: + next = current + 2 + reason = "healthy_fast_reintroduce_nxheavy" + case timeoutRate <= 0.04 && resolvedNowDNS > 0 && liveNXHeavySkip > 0: + next = current + 1 + reason = "healthy_reintroduce_nxheavy" + } + if next < minV { + next = minV + } + if next > maxV { + next = maxV + } + if next == current && reason != "no_dns_attempts" { + reason = "stable" + } + return next, reason +} diff --git a/selective-vpn-api/app/resolver/live_batch_select.go b/selective-vpn-api/app/resolver/live_batch_select.go new file mode 100644 index 0000000..7a8bc9e --- /dev/null +++ b/selective-vpn-api/app/resolver/live_batch_select.go @@ -0,0 +1,161 @@ +package resolver + +import "strings" + +func ClassifyLiveBatchHost( + host string, + cache DomainCacheState, + cacheSourceForHost func(string) DomainCacheSource, + wildcards WildcardMatcher, +) (priority int, nxHeavy bool) { + h := strings.TrimSpace(strings.ToLower(host)) + if h == "" { + return 2, false + } + if wildcards.IsExact(h) { + return 1, false + } + source := cacheSourceForHost(h) + rec, ok := cache.Domains[h] + if !ok { + return 2, false + } + entry := GetCacheEntryBySource(rec, source) + if entry == nil { + return 2, false + } + stored := NormalizeCacheIPs(entry.IPs) + state := NormalizeDomainState(entry.State, entry.Score) + errKind, hasErr := NormalizeCacheErrorKind(entry.LastErrorKind) + nxHeavy = hasErr && errKind == DNSErrorNXDomain && (state == DomainStateQuarantine || state == DomainStateHardQuar || entry.Score <= -10) + + switch { + case len(stored) > 0: + return 1, false + case state == DomainStateActive || state == DomainStateStable || state == DomainStateSuspect: + return 1, false + case nxHeavy: + return 3, true + default: + return 2, false + } +} + +func SplitLiveBatchCandidates( + candidates []string, + cache DomainCacheState, + cacheSourceForHost func(string) DomainCacheSource, + wildcards WildcardMatcher, +) (p1, p2, p3 []string, nxHeavyTotal int) { + for _, host := range candidates { + h := strings.TrimSpace(strings.ToLower(host)) + if h == "" { + continue + } + prio, nxHeavy := ClassifyLiveBatchHost(h, cache, cacheSourceForHost, wildcards) + switch prio { + case 1: + p1 = append(p1, h) + case 3: + nxHeavyTotal++ + p3 = append(p3, h) + case 2: + p2 = append(p2, h) + default: + if nxHeavy { + nxHeavyTotal++ + p3 = append(p3, h) + } else { + p2 = append(p2, h) + } + } + } + return p1, p2, p3, nxHeavyTotal +} + +func PickAdaptiveLiveBatch( + candidates []string, + target int, + now int, + nxHeavyPct int, + cache DomainCacheState, + cacheSourceForHost func(string) DomainCacheSource, + wildcards WildcardMatcher, +) ([]string, int, int, int, int, int) { + if len(candidates) == 0 { + return nil, 0, 0, 0, 0, 0 + } + if target <= 0 { + p1, p2, p3, nxTotal := SplitLiveBatchCandidates(candidates, cache, cacheSourceForHost, wildcards) + return append([]string(nil), candidates...), len(p1), len(p2), len(p3), nxTotal, 0 + } + if target > len(candidates) { + target = len(candidates) + } + if nxHeavyPct < 0 { + nxHeavyPct = 0 + } + if nxHeavyPct > 100 { + nxHeavyPct = 100 + } + + start := now % len(candidates) + if start < 0 { + start = 0 + } + rotated := make([]string, 0, len(candidates)) + for i := 0; i < len(candidates); i++ { + idx := (start + i) % len(candidates) + rotated = append(rotated, candidates[idx]) + } + p1, p2, p3, nxTotal := SplitLiveBatchCandidates(rotated, cache, cacheSourceForHost, wildcards) + out := make([]string, 0, target) + selectedP1 := 0 + selectedP2 := 0 + selectedP3 := 0 + + take := func(src []string, n int) ([]string, int) { + if n <= 0 || len(src) == 0 { + return src, 0 + } + if n > len(src) { + n = len(src) + } + out = append(out, src[:n]...) + return src[n:], n + } + + remain := target + var took int + p1, took = take(p1, remain) + selectedP1 += took + remain = target - len(out) + p2, took = take(p2, remain) + selectedP2 += took + remain = target - len(out) + + p3Cap := (target * nxHeavyPct) / 100 + if nxHeavyPct > 0 && p3Cap == 0 { + p3Cap = 1 + } + if len(out) == 0 && len(p3) > 0 && p3Cap == 0 { + p3Cap = 1 + } + if p3Cap > remain { + p3Cap = remain + } + p3, took = take(p3, p3Cap) + selectedP3 += took + + if len(out) == 0 && len(p3) > 0 && target > 0 { + remain = target - len(out) + p3, took = take(p3, remain) + selectedP3 += took + } + + nxSkipped := nxTotal - selectedP3 + if nxSkipped < 0 { + nxSkipped = 0 + } + return out, selectedP1, selectedP2, selectedP3, nxTotal, nxSkipped +} diff --git a/selective-vpn-api/app/resolver/mode_runtime.go b/selective-vpn-api/app/resolver/mode_runtime.go new file mode 100644 index 0000000..d07046d --- /dev/null +++ b/selective-vpn-api/app/resolver/mode_runtime.go @@ -0,0 +1,62 @@ +package resolver + +import "strings" + +type DNSModeRuntimeInput struct { + Config DNSConfig + Mode string + ViaSmartDNS bool + SmartDNSAddr string + SmartDNSForced bool + SmartDNSDefault string + NormalizeMode func(mode string, viaSmartDNS bool) string + NormalizeSmartDNSAddr func(raw string) string +} + +func ApplyDNSModeRuntime(in DNSModeRuntimeInput) DNSConfig { + cfg := DNSConfig{ + Default: append([]string(nil), in.Config.Default...), + Meta: append([]string(nil), in.Config.Meta...), + SmartDNS: strings.TrimSpace(in.Config.SmartDNS), + Mode: strings.TrimSpace(in.Config.Mode), + } + + if !in.SmartDNSForced && in.NormalizeMode != nil { + if mode := strings.TrimSpace(in.NormalizeMode(in.Mode, in.ViaSmartDNS)); mode != "" { + cfg.Mode = mode + } + } + + if in.NormalizeSmartDNSAddr != nil { + if addr := strings.TrimSpace(in.NormalizeSmartDNSAddr(in.SmartDNSAddr)); addr != "" { + cfg.SmartDNS = addr + } + } else if addr := strings.TrimSpace(in.SmartDNSAddr); addr != "" { + cfg.SmartDNS = addr + } + + if cfg.SmartDNS == "" { + cfg.SmartDNS = strings.TrimSpace(in.SmartDNSDefault) + } + + if cfg.Mode == "smartdns" && cfg.SmartDNS != "" { + cfg.Default = []string{cfg.SmartDNS} + cfg.Meta = []string{cfg.SmartDNS} + } + + return cfg +} + +func LogDNSMode(cfg DNSConfig, wildcardCount int, logf func(string, ...any)) { + if logf == nil { + return + } + switch cfg.Mode { + case "smartdns": + logf("resolver dns mode: SmartDNS-only (%s)", cfg.SmartDNS) + case "hybrid_wildcard": + logf("resolver dns mode: hybrid_wildcard smartdns=%s wildcards=%d default=%v meta=%v", cfg.SmartDNS, wildcardCount, cfg.Default, cfg.Meta) + default: + logf("resolver dns mode: direct default=%v meta=%v", cfg.Default, cfg.Meta) + } +} diff --git a/selective-vpn-api/app/resolver/planning.go b/selective-vpn-api/app/resolver/planning.go new file mode 100644 index 0000000..33bc17a --- /dev/null +++ b/selective-vpn-api/app/resolver/planning.go @@ -0,0 +1,119 @@ +package resolver + +type ResolvePlanningInput struct { + Domains []string + Now int + TTL int + PrecheckDue bool + PrecheckMaxDomains int + StaleKeepSec int + NegTTLNX int + NegTTLTimeout int + NegTTLTemporary int + NegTTLOther int +} + +type ResolvePlanningResult struct { + Fresh map[string][]string + ToResolve []string + CacheNegativeHits int + QuarantineHits int + StaleHits int + PrecheckScheduled int +} + +func BuildResolvePlanning( + in ResolvePlanningInput, + domainCache *DomainCacheState, + cacheSourceForHost func(string) DomainCacheSource, + logf func(string, ...any), +) ResolvePlanningResult { + result := ResolvePlanningResult{ + Fresh: map[string][]string{}, + } + if domainCache == nil { + result.ToResolve = append(result.ToResolve, in.Domains...) + return result + } + + resolveSource := cacheSourceForHost + if resolveSource == nil { + resolveSource = func(string) DomainCacheSource { return DomainCacheSourceDirect } + } + + for _, d := range in.Domains { + source := resolveSource(d) + if ips, ok := domainCache.Get(d, source, in.Now, in.TTL); ok { + result.Fresh[d] = ips + if logf != nil { + logf("cache hit[%s]: %s -> %v", source, d, ips) + } + continue + } + // Quarantine has priority over negative TTL cache so 24h quarantine + // is not silently overridden by shorter negative cache windows. + if state, age, ok := domainCache.GetQuarantine(d, source, in.Now); ok { + kind, hasKind := domainCache.GetLastErrorKind(d, source) + timeoutKind := hasKind && kind == DNSErrorTimeout + if in.PrecheckDue && result.PrecheckScheduled < in.PrecheckMaxDomains { + // Timeout-based quarantine is rechecked in background batch and should + // not flood trace with per-domain debug lines. + if timeoutKind { + result.QuarantineHits++ + if in.StaleKeepSec > 0 { + if staleIPs, staleAge, ok := domainCache.GetStale(d, source, in.Now, in.StaleKeepSec); ok { + result.StaleHits++ + result.Fresh[d] = staleIPs + if logf != nil { + logf("cache stale-keep (quarantine)[age=%ds]: %s -> %v", staleAge, d, staleIPs) + } + } + } + continue + } + result.PrecheckScheduled++ + result.ToResolve = append(result.ToResolve, d) + if logf != nil { + logf("precheck schedule[quarantine/%s age=%ds]: %s (%s)", state, age, d, source) + } + continue + } + result.QuarantineHits++ + if logf != nil { + logf("cache quarantine hit[%s age=%ds]: %s (%s)", state, age, d, source) + } + if in.StaleKeepSec > 0 { + if staleIPs, staleAge, ok := domainCache.GetStale(d, source, in.Now, in.StaleKeepSec); ok { + result.StaleHits++ + result.Fresh[d] = staleIPs + if logf != nil { + logf("cache stale-keep (quarantine)[age=%ds]: %s -> %v", staleAge, d, staleIPs) + } + } + } + continue + } + if kind, age, ok := domainCache.GetNegative(d, source, in.Now, in.NegTTLNX, in.NegTTLTimeout, in.NegTTLTemporary, in.NegTTLOther); ok { + if in.PrecheckDue && result.PrecheckScheduled < in.PrecheckMaxDomains { + if kind == DNSErrorTimeout { + result.CacheNegativeHits++ + continue + } + result.PrecheckScheduled++ + result.ToResolve = append(result.ToResolve, d) + if logf != nil { + logf("precheck schedule[negative/%s age=%ds]: %s (%s)", kind, age, d, source) + } + continue + } + result.CacheNegativeHits++ + if logf != nil { + logf("cache neg hit[%s/%s age=%ds]: %s", source, kind, age, d) + } + continue + } + result.ToResolve = append(result.ToResolve, d) + } + + return result +} diff --git a/selective-vpn-api/app/resolver/precheck_finalize.go b/selective-vpn-api/app/resolver/precheck_finalize.go new file mode 100644 index 0000000..4e96e00 --- /dev/null +++ b/selective-vpn-api/app/resolver/precheck_finalize.go @@ -0,0 +1,106 @@ +package resolver + +import ( + "os" + "strings" +) + +type ResolverPrecheckFinalizeInput struct { + PrecheckDue bool + PrecheckStatePath string + Now int + TimeoutRecheck ResolverTimeoutRecheckStats + LiveBatchTarget int + LiveBatchMin int + LiveBatchMax int + LiveBatchNXHeavyPct int + LiveBatchNXHeavyMin int + LiveBatchNXHeavyMax int + DNSStats DNSMetrics + LiveDeferred int + ResolvedNowDNS int + LiveP1 int + LiveP2 int + LiveP3 int + LiveNXHeavyTotal int + LiveNXHeavySkip int + ToResolveTotal int + PrecheckFileForced bool + PrecheckForcePath string +} + +type ResolverPrecheckFinalizeResult struct { + NextTarget int + NextReason string + NextNXPct int + NextNXReason string + Saved bool + ForceFileConsumed bool +} + +func FinalizeResolverPrecheck(in ResolverPrecheckFinalizeInput, logf func(string, ...any)) ResolverPrecheckFinalizeResult { + out := ResolverPrecheckFinalizeResult{} + + if in.PrecheckDue { + nextTarget, nextReason := ComputeNextLiveBatchTarget(in.LiveBatchTarget, in.LiveBatchMin, in.LiveBatchMax, in.DNSStats, in.LiveDeferred) + nextNXPct, nextNXReason := ComputeNextLiveBatchNXHeavyPct( + in.LiveBatchNXHeavyPct, + in.LiveBatchNXHeavyMin, + in.LiveBatchNXHeavyMax, + in.DNSStats, + in.ResolvedNowDNS, + in.LiveP3, + in.LiveNXHeavyTotal, + in.LiveNXHeavySkip, + ) + if logf != nil { + logf( + "resolve live-batch nxheavy: pct=%d next=%d reason=%s selected=%d total=%d skipped=%d", + in.LiveBatchNXHeavyPct, + nextNXPct, + nextNXReason, + in.LiveP3, + in.LiveNXHeavyTotal, + in.LiveNXHeavySkip, + ) + } + SaveResolverPrecheckState( + in.PrecheckStatePath, + in.Now, + in.TimeoutRecheck, + ResolverLiveBatchStats{ + Target: in.LiveBatchTarget, + Total: in.ToResolveTotal, + Deferred: in.LiveDeferred, + P1: in.LiveP1, + P2: in.LiveP2, + P3: in.LiveP3, + NXHeavyPct: in.LiveBatchNXHeavyPct, + NXHeavyTotal: in.LiveNXHeavyTotal, + NXHeavySkip: in.LiveNXHeavySkip, + NextTarget: nextTarget, + NextReason: nextReason, + NextNXPct: nextNXPct, + NextNXReason: nextNXReason, + DNSAttempts: in.DNSStats.Attempts, + DNSTimeout: in.DNSStats.Timeout, + DNSCoolSkips: in.DNSStats.Skipped, + }, + ) + out.NextTarget = nextTarget + out.NextReason = nextReason + out.NextNXPct = nextNXPct + out.NextNXReason = nextNXReason + out.Saved = true + } + + if in.PrecheckFileForced && strings.TrimSpace(in.PrecheckForcePath) != "" { + _ = os.Remove(in.PrecheckForcePath) + if logf != nil { + logf("resolve precheck force-file consumed: %s", in.PrecheckForcePath) + } + out.ForceFileConsumed = true + } + + return out +} diff --git a/selective-vpn-api/app/resolver/precheck_state.go b/selective-vpn-api/app/resolver/precheck_state.go new file mode 100644 index 0000000..aa35626 --- /dev/null +++ b/selective-vpn-api/app/resolver/precheck_state.go @@ -0,0 +1,39 @@ +package resolver + +func SaveResolverPrecheckState(path string, ts int, timeoutStats ResolverTimeoutRecheckStats, live ResolverLiveBatchStats) { + if path == "" || ts <= 0 { + return + } + state := LoadJSONMap(path) + if state == nil { + state = map[string]any{} + } + state["last_run"] = ts + state["timeout_recheck"] = map[string]any{ + "checked": timeoutStats.Checked, + "recovered": timeoutStats.Recovered, + "recovered_ips": timeoutStats.RecoveredIPs, + "still_timeout": timeoutStats.StillTimeout, + "now_nxdomain": timeoutStats.NowNXDomain, + "now_temporary": timeoutStats.NowTemporary, + "now_other": timeoutStats.NowOther, + "no_signal": timeoutStats.NoSignal, + } + state["live_batch_target"] = live.Target + state["live_batch_total"] = live.Total + state["live_batch_deferred"] = live.Deferred + state["live_batch_p1"] = live.P1 + state["live_batch_p2"] = live.P2 + state["live_batch_p3"] = live.P3 + state["live_batch_nxheavy_pct"] = live.NXHeavyPct + state["live_batch_nxheavy_total"] = live.NXHeavyTotal + state["live_batch_nxheavy_skip"] = live.NXHeavySkip + state["live_batch_nxheavy_next_pct"] = live.NextNXPct + state["live_batch_nxheavy_next_reason"] = live.NextNXReason + state["live_batch_next_target"] = live.NextTarget + state["live_batch_next_reason"] = live.NextReason + state["live_batch_dns_attempts"] = live.DNSAttempts + state["live_batch_dns_timeout"] = live.DNSTimeout + state["live_batch_dns_cooldown_skips"] = live.DNSCoolSkips + SaveJSON(state, path) +} diff --git a/selective-vpn-api/app/resolver/precheck_types.go b/selective-vpn-api/app/resolver/precheck_types.go new file mode 100644 index 0000000..42358f4 --- /dev/null +++ b/selective-vpn-api/app/resolver/precheck_types.go @@ -0,0 +1,31 @@ +package resolver + +type ResolverTimeoutRecheckStats struct { + Checked int + Recovered int + RecoveredIPs int + StillTimeout int + NowNXDomain int + NowTemporary int + NowOther int + NoSignal int +} + +type ResolverLiveBatchStats struct { + Target int + Total int + Deferred int + P1 int + P2 int + P3 int + NXHeavyPct int + NXHeavyTotal int + NXHeavySkip int + NextTarget int + NextReason string + NextNXPct int + NextNXReason string + DNSAttempts int + DNSTimeout int + DNSCoolSkips int +} diff --git a/selective-vpn-api/app/resolver/resolve_batch.go b/selective-vpn-api/app/resolver/resolve_batch.go new file mode 100644 index 0000000..c9875ee --- /dev/null +++ b/selective-vpn-api/app/resolver/resolve_batch.go @@ -0,0 +1,115 @@ +package resolver + +type ResolveBatchInput struct { + ToResolve []string + Workers int + Now int + StaleKeepSec int +} + +type ResolveBatchResult struct { + DNSStats DNSMetrics + ResolvedNowDNS int + ResolvedNowStale int + UnresolvedAfterAttempts int + StaleHitsDelta int +} + +func ExecuteResolveBatch( + in ResolveBatchInput, + resolved map[string][]string, + domainCache *DomainCacheState, + cacheSourceForHost func(string) DomainCacheSource, + resolveHost func(string) ([]string, DNSMetrics), + logf func(string, ...any), +) ResolveBatchResult { + out := ResolveBatchResult{} + if len(in.ToResolve) == 0 || resolveHost == nil || domainCache == nil { + return out + } + + workers := in.Workers + if workers < 1 { + workers = 1 + } + if workers > 500 { + workers = 500 + } + resolveSource := cacheSourceForHost + if resolveSource == nil { + resolveSource = func(string) DomainCacheSource { return DomainCacheSourceDirect } + } + + type result struct { + host string + ips []string + stats DNSMetrics + } + + jobs := make(chan string, len(in.ToResolve)) + results := make(chan result, len(in.ToResolve)) + + for i := 0; i < workers; i++ { + go func() { + for host := range jobs { + ips, stats := resolveHost(host) + results <- result{host: host, ips: ips, stats: stats} + } + }() + } + + for _, host := range in.ToResolve { + jobs <- host + } + close(jobs) + + for i := 0; i < len(in.ToResolve); i++ { + r := <-results + out.DNSStats.Merge(r.stats) + hostErrors := r.stats.TotalErrors() + if hostErrors > 0 && logf != nil { + logf("resolve errors for %s: total=%d nxdomain=%d timeout=%d temporary=%d other=%d", r.host, hostErrors, r.stats.NXDomain, r.stats.Timeout, r.stats.Temporary, r.stats.Other) + } + if len(r.ips) > 0 { + if resolved != nil { + resolved[r.host] = r.ips + } + out.ResolvedNowDNS++ + source := resolveSource(r.host) + domainCache.Set(r.host, source, r.ips, in.Now) + if logf != nil { + logf("%s -> %v", r.host, r.ips) + } + continue + } + + staleApplied := false + if hostErrors > 0 { + source := resolveSource(r.host) + domainCache.SetErrorWithStats(r.host, source, r.stats, in.Now) + if in.StaleKeepSec > 0 && ShouldUseStaleOnError(r.stats) { + if staleIPs, staleAge, ok := domainCache.GetStale(r.host, source, in.Now, in.StaleKeepSec); ok { + out.StaleHitsDelta++ + out.ResolvedNowStale++ + staleApplied = true + if resolved != nil { + resolved[r.host] = staleIPs + } + if logf != nil { + logf("cache stale-keep (error)[age=%ds]: %s -> %v", staleAge, r.host, staleIPs) + } + } + } + } + if !staleApplied { + out.UnresolvedAfterAttempts++ + } + if logf != nil && resolved != nil { + if _, ok := resolved[r.host]; !ok { + logf("%s: no IPs", r.host) + } + } + } + + return out +} diff --git a/selective-vpn-api/app/resolver/runtime_tuning.go b/selective-vpn-api/app/resolver/runtime_tuning.go new file mode 100644 index 0000000..4093d80 --- /dev/null +++ b/selective-vpn-api/app/resolver/runtime_tuning.go @@ -0,0 +1,205 @@ +package resolver + +import "time" + +type ResolverRuntimeTuningInput struct { + TTL int + Workers int + Now int + PrecheckStatePath string + PrecheckEnvForced bool + PrecheckFileForced bool +} + +type ResolverRuntimeTuning struct { + TTL int + Workers int + DNSTimeoutMS int + DNSTimeout time.Duration + PrecheckEverySec int + PrecheckMaxDomains int + TimeoutRecheckMax int + LiveBatchMin int + LiveBatchMax int + LiveBatchTarget int + LiveBatchNXHeavyMin int + LiveBatchNXHeavyMax int + LiveBatchNXHeavyPct int + PrecheckDue bool + StaleKeepSec int + NegTTLNX int + NegTTLTimeout int + NegTTLTemporary int + NegTTLOther int +} + +type ResolverRuntimeTuningDeps struct { + EnvInt func(string, int) int + LoadResolverPrecheckLastRun func(path string) int + LoadResolverLiveBatchTarget func(path string, fallback, minV, maxV int) int + LoadResolverLiveBatchNXHeavyPct func(path string, fallback, minV, maxV int) int +} + +func BuildResolverRuntimeTuning(in ResolverRuntimeTuningInput, deps ResolverRuntimeTuningDeps) ResolverRuntimeTuning { + envInt := deps.EnvInt + if envInt == nil { + envInt = func(_ string, def int) int { return def } + } + + ttl := in.TTL + if ttl <= 0 { + ttl = 24 * 3600 + } + if ttl < 60 { + ttl = 60 + } + if ttl > 24*3600 { + ttl = 24 * 3600 + } + + workers := in.Workers + if workers <= 0 { + workers = 80 + } + if workers < 1 { + workers = 1 + } + if workers > 500 { + workers = 500 + } + + dnsTimeoutMs := envInt("RESOLVE_DNS_TIMEOUT_MS", 1800) + if dnsTimeoutMs < 300 { + dnsTimeoutMs = 300 + } + if dnsTimeoutMs > 5000 { + dnsTimeoutMs = 5000 + } + + precheckEverySec := envInt("RESOLVE_PRECHECK_EVERY_SEC", 24*3600) + if precheckEverySec < 0 { + precheckEverySec = 0 + } + precheckMaxDomains := envInt("RESOLVE_PRECHECK_MAX_DOMAINS", 3000) + if precheckMaxDomains < 0 { + precheckMaxDomains = 0 + } + if precheckMaxDomains > 50000 { + precheckMaxDomains = 50000 + } + timeoutRecheckMax := envInt("RESOLVE_TIMEOUT_RECHECK_MAX", precheckMaxDomains) + if timeoutRecheckMax < 0 { + timeoutRecheckMax = 0 + } + if timeoutRecheckMax > 50000 { + timeoutRecheckMax = 50000 + } + + liveBatchMin := envInt("RESOLVE_LIVE_BATCH_MIN", 800) + liveBatchMax := envInt("RESOLVE_LIVE_BATCH_MAX", 3000) + liveBatchDefault := envInt("RESOLVE_LIVE_BATCH_DEFAULT", 1800) + if liveBatchMin < 200 { + liveBatchMin = 200 + } + if liveBatchMin > 50000 { + liveBatchMin = 50000 + } + if liveBatchMax < liveBatchMin { + liveBatchMax = liveBatchMin + } + if liveBatchMax > 50000 { + liveBatchMax = 50000 + } + if liveBatchDefault < liveBatchMin { + liveBatchDefault = liveBatchMin + } + if liveBatchDefault > liveBatchMax { + liveBatchDefault = liveBatchMax + } + + liveBatchTarget := liveBatchDefault + if deps.LoadResolverLiveBatchTarget != nil { + liveBatchTarget = deps.LoadResolverLiveBatchTarget(in.PrecheckStatePath, liveBatchDefault, liveBatchMin, liveBatchMax) + } + + liveBatchNXHeavyMin := envInt("RESOLVE_LIVE_BATCH_NX_HEAVY_MIN_PCT", 5) + liveBatchNXHeavyMax := envInt("RESOLVE_LIVE_BATCH_NX_HEAVY_MAX_PCT", 35) + liveBatchNXHeavyDefault := envInt("RESOLVE_LIVE_BATCH_NX_HEAVY_PCT", 10) + if liveBatchNXHeavyMin < 0 { + liveBatchNXHeavyMin = 0 + } + if liveBatchNXHeavyMin > 100 { + liveBatchNXHeavyMin = 100 + } + if liveBatchNXHeavyMax < liveBatchNXHeavyMin { + liveBatchNXHeavyMax = liveBatchNXHeavyMin + } + if liveBatchNXHeavyMax > 100 { + liveBatchNXHeavyMax = 100 + } + if liveBatchNXHeavyDefault < liveBatchNXHeavyMin { + liveBatchNXHeavyDefault = liveBatchNXHeavyMin + } + if liveBatchNXHeavyDefault > liveBatchNXHeavyMax { + liveBatchNXHeavyDefault = liveBatchNXHeavyMax + } + + liveBatchNXHeavyPct := liveBatchNXHeavyDefault + if deps.LoadResolverLiveBatchNXHeavyPct != nil { + liveBatchNXHeavyPct = deps.LoadResolverLiveBatchNXHeavyPct(in.PrecheckStatePath, liveBatchNXHeavyDefault, liveBatchNXHeavyMin, liveBatchNXHeavyMax) + } + + precheckLastRun := 0 + if deps.LoadResolverPrecheckLastRun != nil { + precheckLastRun = deps.LoadResolverPrecheckLastRun(in.PrecheckStatePath) + } + precheckDue := in.PrecheckEnvForced || in.PrecheckFileForced || (precheckEverySec > 0 && (precheckLastRun <= 0 || in.Now-precheckLastRun >= precheckEverySec)) + + staleKeepSec := envInt("RESOLVE_STALE_KEEP_SEC", 48*3600) + if staleKeepSec < 0 { + staleKeepSec = 0 + } + if staleKeepSec > 7*24*3600 { + staleKeepSec = 7 * 24 * 3600 + } + + negTTLNX := envInt("RESOLVE_NEGATIVE_TTL_NX", 6*3600) + negTTLTimeout := envInt("RESOLVE_NEGATIVE_TTL_TIMEOUT", 15*60) + negTTLTemporary := envInt("RESOLVE_NEGATIVE_TTL_TEMPORARY", 10*60) + negTTLOther := envInt("RESOLVE_NEGATIVE_TTL_OTHER", 10*60) + clampTTL := func(v int) int { + if v < 0 { + return 0 + } + if v > 24*3600 { + return 24 * 3600 + } + return v + } + negTTLNX = clampTTL(negTTLNX) + negTTLTimeout = clampTTL(negTTLTimeout) + negTTLTemporary = clampTTL(negTTLTemporary) + negTTLOther = clampTTL(negTTLOther) + + return ResolverRuntimeTuning{ + TTL: ttl, + Workers: workers, + DNSTimeoutMS: dnsTimeoutMs, + DNSTimeout: time.Duration(dnsTimeoutMs) * time.Millisecond, + PrecheckEverySec: precheckEverySec, + PrecheckMaxDomains: precheckMaxDomains, + TimeoutRecheckMax: timeoutRecheckMax, + LiveBatchMin: liveBatchMin, + LiveBatchMax: liveBatchMax, + LiveBatchTarget: liveBatchTarget, + LiveBatchNXHeavyMin: liveBatchNXHeavyMin, + LiveBatchNXHeavyMax: liveBatchNXHeavyMax, + LiveBatchNXHeavyPct: liveBatchNXHeavyPct, + PrecheckDue: precheckDue, + StaleKeepSec: staleKeepSec, + NegTTLNX: negTTLNX, + NegTTLTimeout: negTTLTimeout, + NegTTLTemporary: negTTLTemporary, + NegTTLOther: negTTLOther, + } +} diff --git a/selective-vpn-api/app/resolver/start_log.go b/selective-vpn-api/app/resolver/start_log.go new file mode 100644 index 0000000..0751efb --- /dev/null +++ b/selective-vpn-api/app/resolver/start_log.go @@ -0,0 +1,64 @@ +package resolver + +type ResolverStartLogInput struct { + DomainsTotal int + TTL int + Workers int + DNSTimeoutMS int + DirectTry int + DirectBudgetMS int64 + WildcardTry int + WildcardBudgetMS int64 + NXEarlyStop bool + NXHardQuarantine bool + CooldownEnabled bool + CooldownMinAttempts int + CooldownTimeoutRate int + CooldownFailStreak int + CooldownBanSec int + CooldownMaxBanSec int + LiveBatchTarget int + LiveBatchMin int + LiveBatchMax int + LiveBatchNXHeavyPct int + LiveBatchNXHeavyMin int + LiveBatchNXHeavyMax int + StaleKeepSec int + PrecheckEverySec int + PrecheckMaxDomains int + PrecheckForcedEnv bool + PrecheckForcedFile bool +} + +func LogResolverStart(in ResolverStartLogInput, logf func(string, ...any)) { + if logf == nil { + return + } + logf("resolver start: domains=%d ttl=%ds workers=%d dns_timeout_ms=%d", in.DomainsTotal, in.TTL, in.Workers, in.DNSTimeoutMS) + logf( + "resolver policy: direct_try=%d direct_budget_ms=%d wildcard_try=%d wildcard_budget_ms=%d nx_early_stop=%t nx_hard_quarantine=%t cooldown_enabled=%t cooldown_min_attempts=%d cooldown_timeout_rate=%d cooldown_fail_streak=%d cooldown_ban_sec=%d cooldown_max_ban_sec=%d live_batch_target=%d live_batch_min=%d live_batch_max=%d live_batch_nx_heavy_pct=%d live_batch_nx_heavy_min=%d live_batch_nx_heavy_max=%d stale_keep_sec=%d precheck_every_sec=%d precheck_max=%d precheck_forced_env=%t precheck_forced_file=%t", + in.DirectTry, + in.DirectBudgetMS, + in.WildcardTry, + in.WildcardBudgetMS, + in.NXEarlyStop, + in.NXHardQuarantine, + in.CooldownEnabled, + in.CooldownMinAttempts, + in.CooldownTimeoutRate, + in.CooldownFailStreak, + in.CooldownBanSec, + in.CooldownMaxBanSec, + in.LiveBatchTarget, + in.LiveBatchMin, + in.LiveBatchMax, + in.LiveBatchNXHeavyPct, + in.LiveBatchNXHeavyMin, + in.LiveBatchNXHeavyMax, + in.StaleKeepSec, + in.PrecheckEverySec, + in.PrecheckMaxDomains, + in.PrecheckForcedEnv, + in.PrecheckForcedFile, + ) +} diff --git a/selective-vpn-api/app/resolver/static_labels.go b/selective-vpn-api/app/resolver/static_labels.go new file mode 100644 index 0000000..2370a37 --- /dev/null +++ b/selective-vpn-api/app/resolver/static_labels.go @@ -0,0 +1,139 @@ +package resolver + +import ( + "context" + "fmt" + "net" + "net/netip" + "strings" + "time" +) + +func ParseStaticEntries(lines []string, logf func(string, ...any)) (entries [][3]string, skipped int) { + for _, ln := range lines { + s := strings.TrimSpace(ln) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + comment := "" + if idx := strings.Index(s, "#"); idx >= 0 { + comment = strings.TrimSpace(s[idx+1:]) + s = strings.TrimSpace(s[:idx]) + } + if s == "" || IsPrivateIPv4(s) { + continue + } + + rawBase := strings.SplitN(s, "/", 2)[0] + if strings.Contains(s, "/") { + if _, err := netip.ParsePrefix(s); err != nil { + skipped++ + if logf != nil { + logf("static skip invalid prefix %q: %v", s, err) + } + continue + } + } else { + if _, err := netip.ParseAddr(rawBase); err != nil { + skipped++ + if logf != nil { + logf("static skip invalid ip %q: %v", s, err) + } + continue + } + } + + entries = append(entries, [3]string{s, rawBase, comment}) + } + return entries, skipped +} + +func ResolveStaticLabels(entries [][3]string, dnsForPtr string, ptrCache map[string]any, ttl int, logf func(string, ...any)) (map[string][]string, int, int) { + now := int(time.Now().Unix()) + result := map[string][]string{} + ptrLookups := 0 + ptrErrors := 0 + + for _, e := range entries { + ipEntry, baseIP, comment := e[0], e[1], e[2] + var labels []string + if comment != "" { + labels = append(labels, "*"+comment) + } + if comment == "" { + if cached, ok := ptrCache[baseIP].(map[string]any); ok { + names, _ := cached["names"].([]any) + last, _ := cached["last_resolved"].(float64) + if len(names) > 0 && last > 0 && now-int(last) <= ttl { + for _, n := range names { + if s, ok := n.(string); ok && s != "" { + labels = append(labels, "*"+s) + } + } + } + } + if len(labels) == 0 { + ptrLookups++ + names, err := DigPTR(baseIP, dnsForPtr, 3*time.Second, logf) + if err != nil { + ptrErrors++ + } + if len(names) > 0 { + ptrCache[baseIP] = map[string]any{"names": names, "last_resolved": now} + for _, n := range names { + labels = append(labels, "*"+n) + } + } + } + } + if len(labels) == 0 { + labels = []string{"*[STATIC-IP]"} + } + result[ipEntry] = labels + if logf != nil { + logf("static %s -> %v", ipEntry, labels) + } + } + + return result, ptrLookups, ptrErrors +} + +func DigPTR(ip, upstream string, timeout time.Duration, logf func(string, ...any)) ([]string, error) { + server, port := SplitDNS(upstream) + if server == "" { + return nil, fmt.Errorf("upstream empty") + } + if port == "" { + port = "53" + } + addr := net.JoinHostPort(server, port) + r := &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) + names, err := r.LookupAddr(ctx, ip) + cancel() + if err != nil { + if logf != nil { + logf("ptr error %s via %s: %v", ip, addr, err) + } + return nil, err + } + seen := map[string]struct{}{} + var out []string + for _, n := range names { + n = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(n)), ".") + if n == "" { + continue + } + if _, ok := seen[n]; !ok { + seen[n] = struct{}{} + out = append(out, n) + } + } + return out, nil +} diff --git a/selective-vpn-api/app/resolver/summary_log.go b/selective-vpn-api/app/resolver/summary_log.go new file mode 100644 index 0000000..c097817 --- /dev/null +++ b/selective-vpn-api/app/resolver/summary_log.go @@ -0,0 +1,112 @@ +package resolver + +type ResolverSummaryLogInput struct { + DomainsTotal int + FreshCount int + CacheNegativeHits int + QuarantineHits int + StaleHits int + ResolvedTotal int + UnresolvedAfterAttempts int + LiveBatchTarget int + LiveDeferred int + LiveP1 int + LiveP2 int + LiveP3 int + LiveBatchNXHeavyPct int + LiveNXHeavyTotal int + LiveNXHeavySkip int + StaticEntries int + StaticSkipped int + UniqueIPs int + DirectIPs int + WildcardIPs int + PtrLookups int + PtrErrors int + DNSStats DNSMetrics + TimeoutRecheck ResolverTimeoutRecheckStats + DurationMS int64 + DomainStateSummary string + ResolvedNowDNS int + ResolvedNowStale int + PrecheckDue bool + PrecheckScheduled int + PrecheckStatePath string +} + +func LogResolverSummary(in ResolverSummaryLogInput, logf func(string, ...any)) { + if logf == nil { + return + } + + dnsErrors := in.DNSStats.TotalErrors() + unresolved := in.DomainsTotal - in.ResolvedTotal + unresolvedSuppressed := in.CacheNegativeHits + in.QuarantineHits + in.LiveDeferred + + logf( + "resolve summary: domains=%d cache_hits=%d cache_neg_hits=%d quarantine_hits=%d stale_hits=%d resolved_now=%d unresolved=%d unresolved_live=%d unresolved_suppressed=%d live_batch_target=%d live_batch_deferred=%d live_batch_p1=%d live_batch_p2=%d live_batch_p3=%d live_batch_nxheavy_pct=%d live_batch_nxheavy_total=%d live_batch_nxheavy_skip=%d static_entries=%d static_skipped=%d unique_ips=%d direct_ips=%d wildcard_ips=%d ptr_lookups=%d ptr_errors=%d dns_attempts=%d dns_ok=%d dns_nxdomain=%d dns_timeout=%d dns_temporary=%d dns_other=%d dns_cooldown_skips=%d dns_errors=%d timeout_recheck_checked=%d timeout_recheck_recovered=%d timeout_recheck_recovered_ips=%d timeout_recheck_still_timeout=%d timeout_recheck_now_nxdomain=%d timeout_recheck_now_temporary=%d timeout_recheck_now_other=%d timeout_recheck_no_signal=%d duration_ms=%d", + in.DomainsTotal, + in.FreshCount, + in.CacheNegativeHits, + in.QuarantineHits, + in.StaleHits, + in.ResolvedTotal-in.FreshCount, + unresolved, + in.UnresolvedAfterAttempts, + unresolvedSuppressed, + in.LiveBatchTarget, + in.LiveDeferred, + in.LiveP1, + in.LiveP2, + in.LiveP3, + in.LiveBatchNXHeavyPct, + in.LiveNXHeavyTotal, + in.LiveNXHeavySkip, + in.StaticEntries, + in.StaticSkipped, + in.UniqueIPs, + in.DirectIPs, + in.WildcardIPs, + in.PtrLookups, + in.PtrErrors, + in.DNSStats.Attempts, + in.DNSStats.OK, + in.DNSStats.NXDomain, + in.DNSStats.Timeout, + in.DNSStats.Temporary, + in.DNSStats.Other, + in.DNSStats.Skipped, + dnsErrors, + in.TimeoutRecheck.Checked, + in.TimeoutRecheck.Recovered, + in.TimeoutRecheck.RecoveredIPs, + in.TimeoutRecheck.StillTimeout, + in.TimeoutRecheck.NowNXDomain, + in.TimeoutRecheck.NowTemporary, + in.TimeoutRecheck.NowOther, + in.TimeoutRecheck.NoSignal, + in.DurationMS, + ) + if perUpstream := in.DNSStats.FormatPerUpstream(); perUpstream != "" { + logf("resolve dns upstreams: %s", perUpstream) + } + if health := in.DNSStats.FormatResolverHealth(); health != "" { + logf("resolve dns health: %s", health) + } + if in.DomainStateSummary != "" { + logf("resolve domain states: %s", in.DomainStateSummary) + } + logf( + "resolve breakdown: resolved_now_total=%d resolved_now_dns=%d resolved_now_stale=%d skipped_neg=%d skipped_quarantine=%d deferred_live_batch=%d unresolved_after_attempts=%d", + in.ResolvedTotal-in.FreshCount, + in.ResolvedNowDNS, + in.ResolvedNowStale, + in.CacheNegativeHits, + in.QuarantineHits, + in.LiveDeferred, + in.UnresolvedAfterAttempts, + ) + if in.PrecheckDue { + logf("resolve precheck done: scheduled=%d state=%s", in.PrecheckScheduled, in.PrecheckStatePath) + } +} diff --git a/selective-vpn-api/app/resolver/timeout_recheck.go b/selective-vpn-api/app/resolver/timeout_recheck.go new file mode 100644 index 0000000..f8618d2 --- /dev/null +++ b/selective-vpn-api/app/resolver/timeout_recheck.go @@ -0,0 +1,127 @@ +package resolver + +import "strings" + +func RunTimeoutQuarantineRecheck( + domains []string, + now int, + limit int, + workers int, + domainCache *DomainCacheState, + cacheSourceForHost func(string) DomainCacheSource, + resolveHost func(string) ([]string, DNSMetrics), +) ResolverTimeoutRecheckStats { + stats := ResolverTimeoutRecheckStats{} + if limit <= 0 || now <= 0 || domainCache == nil || resolveHost == nil { + return stats + } + if workers < 1 { + workers = 1 + } + if workers > 200 { + workers = 200 + } + + resolveSource := cacheSourceForHost + if resolveSource == nil { + resolveSource = func(string) DomainCacheSource { return DomainCacheSourceDirect } + } + + seen := map[string]struct{}{} + capHint := len(domains) + if capHint > limit { + capHint = limit + } + candidates := make([]string, 0, capHint) + for _, raw := range domains { + host := strings.TrimSpace(strings.ToLower(raw)) + if host == "" { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + + source := resolveSource(host) + if _, _, ok := domainCache.GetQuarantine(host, source, now); !ok { + continue + } + kind, ok := domainCache.GetLastErrorKind(host, source) + if !ok || kind != DNSErrorTimeout { + continue + } + candidates = append(candidates, host) + if len(candidates) >= limit { + break + } + } + if len(candidates) == 0 { + return stats + } + + recoveredIPSet := map[string]struct{}{} + + type result struct { + host string + source DomainCacheSource + ips []string + dns DNSMetrics + } + + jobs := make(chan string, len(candidates)) + results := make(chan result, len(candidates)) + + for i := 0; i < workers; i++ { + go func() { + for host := range jobs { + src := resolveSource(host) + ips, dnsStats := resolveHost(host) + results <- result{host: host, source: src, ips: ips, dns: dnsStats} + } + }() + } + + for _, host := range candidates { + jobs <- host + } + close(jobs) + + for i := 0; i < len(candidates); i++ { + r := <-results + stats.Checked++ + if len(r.ips) > 0 { + for _, ip := range r.ips { + ip = strings.TrimSpace(ip) + if ip == "" { + continue + } + recoveredIPSet[ip] = struct{}{} + } + domainCache.Set(r.host, r.source, r.ips, now) + stats.Recovered++ + continue + } + if r.dns.TotalErrors() > 0 { + domainCache.SetErrorWithStats(r.host, r.source, r.dns, now) + } + kind, ok := ClassifyHostErrorKind(r.dns) + if !ok { + stats.NoSignal++ + continue + } + switch kind { + case DNSErrorTimeout: + stats.StillTimeout++ + case DNSErrorNXDomain: + stats.NowNXDomain++ + case DNSErrorTemporary: + stats.NowTemporary++ + default: + stats.NowOther++ + } + } + + stats.RecoveredIPs = len(recoveredIPSet) + return stats +} diff --git a/selective-vpn-api/app/resolver/wildcard_matcher.go b/selective-vpn-api/app/resolver/wildcard_matcher.go new file mode 100644 index 0000000..ff745f2 --- /dev/null +++ b/selective-vpn-api/app/resolver/wildcard_matcher.go @@ -0,0 +1,70 @@ +package resolver + +import "strings" + +type WildcardMatcher struct { + exact map[string]struct{} + suffix []string +} + +func NormalizeWildcardDomain(raw string) string { + d := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0]) + d = strings.ToLower(d) + d = strings.TrimPrefix(d, "*.") + d = strings.TrimPrefix(d, ".") + d = strings.TrimSuffix(d, ".") + return d +} + +func NewWildcardMatcher(domains []string) WildcardMatcher { + seen := map[string]struct{}{} + m := WildcardMatcher{exact: map[string]struct{}{}} + for _, raw := range domains { + d := NormalizeWildcardDomain(raw) + if d == "" { + continue + } + if _, ok := seen[d]; ok { + continue + } + seen[d] = struct{}{} + m.exact[d] = struct{}{} + m.suffix = append(m.suffix, "."+d) + } + return m +} + +func (m WildcardMatcher) Match(host string) bool { + if len(m.exact) == 0 { + return false + } + h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + if h == "" { + return false + } + if _, ok := m.exact[h]; ok { + return true + } + for _, suffix := range m.suffix { + if strings.HasSuffix(h, suffix) { + return true + } + } + return false +} + +func (m WildcardMatcher) IsExact(host string) bool { + if len(m.exact) == 0 { + return false + } + h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + if h == "" { + return false + } + _, ok := m.exact[h] + return ok +} + +func (m WildcardMatcher) Count() int { + return len(m.exact) +} diff --git a/selective-vpn-api/app/resolver_bridge.go b/selective-vpn-api/app/resolver_bridge.go new file mode 100644 index 0000000..69536e8 --- /dev/null +++ b/selective-vpn-api/app/resolver_bridge.go @@ -0,0 +1,63 @@ +package app + +import resolverpkg "selective-vpn-api/app/resolver" + +// --------------------------------------------------------------------- +// resolver bridge layer (consolidated) +// --------------------------------------------------------------------- + +type dnsErrorKind = resolverpkg.DNSErrorKind + +const ( + dnsErrorNXDomain dnsErrorKind = resolverpkg.DNSErrorNXDomain + dnsErrorTimeout dnsErrorKind = resolverpkg.DNSErrorTimeout + dnsErrorTemporary dnsErrorKind = resolverpkg.DNSErrorTemporary + dnsErrorOther dnsErrorKind = resolverpkg.DNSErrorOther +) + +type dnsUpstreamMetrics = resolverpkg.DNSUpstreamMetrics +type dnsMetrics = resolverpkg.DNSMetrics + +type wildcardMatcher = resolverpkg.WildcardMatcher + +type domainCacheSource = resolverpkg.DomainCacheSource + +const ( + domainCacheSourceDirect domainCacheSource = resolverpkg.DomainCacheSourceDirect + domainCacheSourceWildcard domainCacheSource = resolverpkg.DomainCacheSourceWildcard +) + +const ( + domainStateActive = resolverpkg.DomainStateActive + domainStateStable = resolverpkg.DomainStateStable + domainStateSuspect = resolverpkg.DomainStateSuspect + domainStateQuarantine = resolverpkg.DomainStateQuarantine + domainStateHardQuar = resolverpkg.DomainStateHardQuar + domainScoreMin = resolverpkg.DomainScoreMin + domainScoreMax = resolverpkg.DomainScoreMax + defaultQuarantineTTL = resolverpkg.DefaultQuarantineTTL + defaultHardQuarantineTT = resolverpkg.DefaultHardQuarTTL +) + +type domainCacheEntry = resolverpkg.DomainCacheEntry +type domainCacheRecord = resolverpkg.DomainCacheRecord + +type domainCacheState resolverpkg.DomainCacheState + +type resolverPlanningResult = resolverpkg.ResolvePlanningResult + +type resolverTimeoutRecheckStats = resolverpkg.ResolverTimeoutRecheckStats +type resolverLiveBatchStats = resolverpkg.ResolverLiveBatchStats + +type resolverResolveBatchResult = resolverpkg.ResolveBatchResult + +type resolverRuntimeTuning = resolverpkg.ResolverRuntimeTuning + +type resolverStartLogInput = resolverpkg.ResolverStartLogInput + +type resolverSummaryLogInput = resolverpkg.ResolverSummaryLogInput + +func init() { + resolverpkg.EnvInt = envInt + resolverpkg.NXHardQuarantineEnabled = resolveNXHardQuarantineEnabled +} diff --git a/selective-vpn-api/app/resolver_bridge_cache.go b/selective-vpn-api/app/resolver_bridge_cache.go new file mode 100644 index 0000000..eb41c53 --- /dev/null +++ b/selective-vpn-api/app/resolver_bridge_cache.go @@ -0,0 +1,90 @@ +package app + +import resolverpkg "selective-vpn-api/app/resolver" + +func newDomainCacheState() domainCacheState { + return domainCacheState(resolverpkg.NewDomainCacheState()) +} + +func normalizeCacheIPs(raw []string) []string { + return resolverpkg.NormalizeCacheIPs(raw) +} + +func normalizeCacheErrorKind(raw string) (dnsErrorKind, bool) { + kind, ok := resolverpkg.NormalizeCacheErrorKind(raw) + return dnsErrorKind(kind), ok +} + +func normalizeDomainCacheEntry(in *domainCacheEntry) *domainCacheEntry { + return resolverpkg.NormalizeDomainCacheEntry(in) +} + +func loadDomainCacheState(path string, logf func(string, ...any)) domainCacheState { + return domainCacheState(resolverpkg.LoadDomainCacheState(path, logf)) +} + +func getCacheEntryBySource(rec domainCacheRecord, source domainCacheSource) *domainCacheEntry { + return resolverpkg.GetCacheEntryBySource(rec, source) +} + +func clampDomainScore(v int) int { + return resolverpkg.ClampDomainScore(v) +} + +func domainStateFromScore(score int) string { + return resolverpkg.DomainStateFromScore(score) +} + +func normalizeDomainState(raw string, score int) string { + return resolverpkg.NormalizeDomainState(raw, score) +} + +func domainScorePenalty(stats dnsMetrics) int { + return resolverpkg.DomainScorePenalty(stats) +} + +func (s domainCacheState) get(domain string, source domainCacheSource, now, ttl int) ([]string, bool) { + return resolverpkg.DomainCacheState(s).Get(domain, source, now, ttl) +} + +func (s domainCacheState) getNegative(domain string, source domainCacheSource, now, nxTTL, timeoutTTL, temporaryTTL, otherTTL int) (dnsErrorKind, int, bool) { + kind, age, ok := resolverpkg.DomainCacheState(s).GetNegative(domain, source, now, nxTTL, timeoutTTL, temporaryTTL, otherTTL) + return dnsErrorKind(kind), age, ok +} + +func (s domainCacheState) getStoredIPs(domain string, source domainCacheSource) []string { + return resolverpkg.DomainCacheState(s).GetStoredIPs(domain, source) +} + +func (s domainCacheState) getLastErrorKind(domain string, source domainCacheSource) (dnsErrorKind, bool) { + kind, ok := resolverpkg.DomainCacheState(s).GetLastErrorKind(domain, source) + return dnsErrorKind(kind), ok +} + +func (s domainCacheState) getQuarantine(domain string, source domainCacheSource, now int) (string, int, bool) { + return resolverpkg.DomainCacheState(s).GetQuarantine(domain, source, now) +} + +func (s domainCacheState) getStale(domain string, source domainCacheSource, now, maxAge int) ([]string, int, bool) { + return resolverpkg.DomainCacheState(s).GetStale(domain, source, now, maxAge) +} + +func (s *domainCacheState) set(domain string, source domainCacheSource, ips []string, now int) { + state := resolverpkg.DomainCacheState(*s) + state.Set(domain, source, ips, now) + *s = domainCacheState(state) +} + +func (s *domainCacheState) setErrorWithStats(domain string, source domainCacheSource, stats dnsMetrics, now int) { + state := resolverpkg.DomainCacheState(*s) + state.SetErrorWithStats(domain, source, stats, now) + *s = domainCacheState(state) +} + +func (s domainCacheState) toMap() map[string]any { + return resolverpkg.DomainCacheState(s).ToMap() +} + +func (s domainCacheState) formatStateSummary(now int) string { + return resolverpkg.DomainCacheState(s).FormatStateSummary(now) +} diff --git a/selective-vpn-api/app/resolver_bridge_dns.go b/selective-vpn-api/app/resolver_bridge_dns.go new file mode 100644 index 0000000..74bf31e --- /dev/null +++ b/selective-vpn-api/app/resolver_bridge_dns.go @@ -0,0 +1,171 @@ +package app + +import ( + "strings" + "time" + + resolverpkg "selective-vpn-api/app/resolver" +) + +func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig { + cfg := resolverpkg.LoadDNSConfig( + path, + resolverpkg.DNSConfig{ + Default: []string{defaultDNS1, defaultDNS2}, + Meta: []string{defaultMeta1, defaultMeta2}, + SmartDNS: smartDNSAddr(), + Mode: string(DNSModeDirect), + }, + resolverpkg.DNSConfigDeps{ + ActivePool: loadEnabledDNSUpstreamPool(), + IsSmartDNSForced: smartDNSForced(), + SmartDNSAddr: smartDNSAddr(), + SmartDNSForcedMode: string(DNSModeSmartDNS), + ResolveFallbackPool: func() []string { + return resolverFallbackPool() + }, + MergeDNSUpstreamPools: func(primary, fallback []string) []string { + return mergeDNSUpstreamPools(primary, fallback) + }, + NormalizeDNSUpstream: func(raw string, defaultPort string) string { + return normalizeDNSUpstream(raw, defaultPort) + }, + NormalizeSmartDNSAddr: normalizeSmartDNSAddr, + NormalizeDNSResolverMode: func(raw string) string { + return string(normalizeDNSResolverMode(DNSResolverMode(raw), false)) + }, + }, + logf, + ) + return dnsConfig{ + Default: cfg.Default, + Meta: cfg.Meta, + SmartDNS: cfg.SmartDNS, + Mode: DNSResolverMode(cfg.Mode), + } +} + +type resolverCooldownAdapter struct { + inner *dnsRunCooldown +} + +func (a resolverCooldownAdapter) ShouldSkip(upstream string, now int64) bool { + if a.inner == nil { + return false + } + return a.inner.shouldSkip(upstream, now) +} + +func (a resolverCooldownAdapter) ObserveSuccess(upstream string) { + if a.inner == nil { + return + } + a.inner.observeSuccess(upstream) +} + +func (a resolverCooldownAdapter) ObserveError(upstream string, kind resolverpkg.DNSErrorKind, now int64) (bool, int) { + if a.inner == nil { + return false, 0 + } + return a.inner.observeError(upstream, dnsErrorKind(kind), now) +} + +func toResolverPolicy(policy dnsAttemptPolicy) resolverpkg.DNSAttemptPolicy { + return resolverpkg.DNSAttemptPolicy{ + TryLimit: policy.TryLimit, + DomainBudget: policy.DomainBudget, + StopOnNX: policy.StopOnNX, + } +} + +func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards wildcardMatcher, timeout time.Duration, cooldown *dnsRunCooldown, logf func(string, ...any)) ([]string, dnsMetrics) { + return resolverpkg.ResolveHost( + host, + resolverpkg.DNSConfig{ + Default: cfg.Default, + Meta: cfg.Meta, + SmartDNS: cfg.SmartDNS, + Mode: string(cfg.Mode), + }, + metaSpecial, + func(h string) bool { return wildcards.Match(h) }, + timeout, + resolverCooldownAdapter{inner: cooldown}, + func(dnsCount int) resolverpkg.DNSAttemptPolicy { + return toResolverPolicy(directDNSAttemptPolicy(dnsCount)) + }, + func(dnsCount int) resolverpkg.DNSAttemptPolicy { + return toResolverPolicy(wildcardDNSAttemptPolicy(dnsCount)) + }, + smartDNSFallbackForTimeoutEnabled(), + logf, + ) +} + +func digA(host string, dnsList []string, timeout time.Duration, logf func(string, ...any)) ([]string, dnsMetrics) { + policy := toResolverPolicy(defaultDNSAttemptPolicy(len(dnsList))) + return resolverpkg.DigAWithPolicy(host, dnsList, timeout, policy, nil, logf) +} + +func digAWithPolicy(host string, dnsList []string, timeout time.Duration, logf func(string, ...any), policy dnsAttemptPolicy, cooldown *dnsRunCooldown) ([]string, dnsMetrics) { + return resolverpkg.DigAWithPolicy(host, dnsList, timeout, toResolverPolicy(policy), resolverCooldownAdapter{inner: cooldown}, logf) +} + +func applyResolverDNSModeRuntime(cfg dnsConfig, opts ResolverOpts) dnsConfig { + out := resolverpkg.ApplyDNSModeRuntime(resolverpkg.DNSModeRuntimeInput{ + Config: resolverpkg.DNSConfig{ + Default: cfg.Default, + Meta: cfg.Meta, + SmartDNS: cfg.SmartDNS, + Mode: string(cfg.Mode), + }, + Mode: string(opts.Mode), + ViaSmartDNS: opts.ViaSmartDNS, + SmartDNSAddr: opts.SmartDNSAddr, + SmartDNSForced: smartDNSForced(), + SmartDNSDefault: smartDNSAddr(), + NormalizeMode: func(mode string, viaSmartDNS bool) string { + return string(normalizeDNSResolverMode(DNSResolverMode(mode), viaSmartDNS)) + }, + NormalizeSmartDNSAddr: normalizeSmartDNSAddr, + }) + return dnsConfig{ + Default: out.Default, + Meta: out.Meta, + SmartDNS: out.SmartDNS, + Mode: DNSResolverMode(out.Mode), + } +} + +func logResolverDNSMode(cfg dnsConfig, wildcards wildcardMatcher, logf func(string, ...any)) { + resolverpkg.LogDNSMode( + resolverpkg.DNSConfig{ + Default: cfg.Default, + Meta: cfg.Meta, + SmartDNS: cfg.SmartDNS, + Mode: string(cfg.Mode), + }, + wildcards.Count(), + logf, + ) +} + +func parseStaticEntriesGo(lines []string, logf func(string, ...any)) (entries [][3]string, skipped int) { + return resolverpkg.ParseStaticEntries(lines, logf) +} + +func resolveStaticLabels(entries [][3]string, cfg dnsConfig, ptrCache map[string]any, ttl int, logf func(string, ...any)) (map[string][]string, int, int) { + dnsForPtr := defaultDNS1 + if len(cfg.Default) > 0 && strings.TrimSpace(cfg.Default[0]) != "" { + dnsForPtr = cfg.Default[0] + } + return resolverpkg.ResolveStaticLabels(entries, dnsForPtr, ptrCache, ttl, logf) +} + +func digPTR(ip, upstream string, timeout time.Duration, logf func(string, ...any)) ([]string, error) { + return resolverpkg.DigPTR(ip, upstream, timeout, logf) +} + +func logResolverSummary(input resolverpkg.ResolverSummaryLogInput, logf func(string, ...any)) { + resolverpkg.LogResolverSummary(input, logf) +} diff --git a/selective-vpn-api/app/resolver_bridge_pipeline.go b/selective-vpn-api/app/resolver_bridge_pipeline.go new file mode 100644 index 0000000..46796ce --- /dev/null +++ b/selective-vpn-api/app/resolver_bridge_pipeline.go @@ -0,0 +1,5 @@ +package app + +// Resolver bridge pipeline helpers are split by role: +// - planning/runtime tuning wrappers: resolver_bridge_pipeline_planning.go +// - execution/recheck/artifacts wrappers: resolver_bridge_pipeline_exec.go diff --git a/selective-vpn-api/app/resolver_bridge_pipeline_exec.go b/selective-vpn-api/app/resolver_bridge_pipeline_exec.go new file mode 100644 index 0000000..271587a --- /dev/null +++ b/selective-vpn-api/app/resolver_bridge_pipeline_exec.go @@ -0,0 +1,86 @@ +package app + +import ( + "time" + + resolverpkg "selective-vpn-api/app/resolver" +) + +func executeResolverBatch( + toResolve []string, + workers int, + now int, + staleKeepSec int, + resolved map[string][]string, + domainCache *domainCacheState, + cacheSourceForHost func(string) domainCacheSource, + resolveHost func(string) ([]string, dnsMetrics), + logf func(string, ...any), +) resolverResolveBatchResult { + if domainCache == nil { + return resolverResolveBatchResult{} + } + sourceFn := func(host string) resolverpkg.DomainCacheSource { + if cacheSourceForHost == nil { + return resolverpkg.DomainCacheSourceDirect + } + return resolverpkg.DomainCacheSource(cacheSourceForHost(host)) + } + resolveFn := func(host string) ([]string, resolverpkg.DNSMetrics) { + if resolveHost == nil { + return nil, resolverpkg.DNSMetrics{} + } + return resolveHost(host) + } + return resolverpkg.ExecuteResolveBatch( + resolverpkg.ResolveBatchInput{ + ToResolve: toResolve, + Workers: workers, + Now: now, + StaleKeepSec: staleKeepSec, + }, + resolved, + (*resolverpkg.DomainCacheState)(domainCache), + sourceFn, + resolveFn, + logf, + ) +} + +func runTimeoutQuarantineRecheck( + domains []string, + cfg dnsConfig, + metaSpecial []string, + wildcards wildcardMatcher, + timeout time.Duration, + domainCache *domainCacheState, + cacheSourceForHost func(string) domainCacheSource, + now int, + limit int, + workers int, +) resolverTimeoutRecheckStats { + if domainCache == nil { + return resolverTimeoutRecheckStats{} + } + sourceFn := func(host string) resolverpkg.DomainCacheSource { + if cacheSourceForHost == nil { + return resolverpkg.DomainCacheSourceDirect + } + return resolverpkg.DomainCacheSource(cacheSourceForHost(host)) + } + return resolverpkg.RunTimeoutQuarantineRecheck( + domains, + now, + limit, + workers, + (*resolverpkg.DomainCacheState)(domainCache), + sourceFn, + func(host string) ([]string, resolverpkg.DNSMetrics) { + return resolveHostGo(host, cfg, metaSpecial, wildcards, timeout, nil, nil) + }, + ) +} + +func buildResolverArtifacts(resolved map[string][]string, staticLabels map[string][]string, isWildcardHost func(string) bool) resolverpkg.ResolverArtifacts { + return resolverpkg.BuildResolverArtifacts(resolved, staticLabels, isWildcardHost) +} diff --git a/selective-vpn-api/app/resolver_bridge_pipeline_planning.go b/selective-vpn-api/app/resolver_bridge_pipeline_planning.go new file mode 100644 index 0000000..1483d90 --- /dev/null +++ b/selective-vpn-api/app/resolver_bridge_pipeline_planning.go @@ -0,0 +1,118 @@ +package app + +import resolverpkg "selective-vpn-api/app/resolver" + +func buildResolverPlanning( + domains []string, + now int, + ttl int, + precheckDue bool, + precheckMaxDomains int, + staleKeepSec int, + negTTLNX int, + negTTLTimeout int, + negTTLTemporary int, + negTTLOther int, + domainCache *domainCacheState, + cacheSourceForHost func(string) domainCacheSource, + logf func(string, ...any), +) resolverPlanningResult { + sourceFn := func(host string) resolverpkg.DomainCacheSource { + if cacheSourceForHost == nil { + return resolverpkg.DomainCacheSourceDirect + } + return resolverpkg.DomainCacheSource(cacheSourceForHost(host)) + } + return resolverpkg.BuildResolvePlanning( + resolverpkg.ResolvePlanningInput{ + Domains: domains, + Now: now, + TTL: ttl, + PrecheckDue: precheckDue, + PrecheckMaxDomains: precheckMaxDomains, + StaleKeepSec: staleKeepSec, + NegTTLNX: negTTLNX, + NegTTLTimeout: negTTLTimeout, + NegTTLTemporary: negTTLTemporary, + NegTTLOther: negTTLOther, + }, + (*resolverpkg.DomainCacheState)(domainCache), + sourceFn, + logf, + ) +} + +func finalizeResolverPrecheck( + precheckDue bool, + precheckStatePath string, + now int, + timeoutRecheck resolverTimeoutRecheckStats, + liveBatchTarget int, + liveBatchMin int, + liveBatchMax int, + liveBatchNXHeavyPct int, + liveBatchNXHeavyMin int, + liveBatchNXHeavyMax int, + dnsStats dnsMetrics, + liveDeferred int, + resolvedNowDNS int, + liveP1 int, + liveP2 int, + liveP3 int, + liveNXHeavyTotal int, + liveNXHeavySkip int, + toResolveTotal int, + precheckFileForced bool, + precheckForcePath string, + logf func(string, ...any), +) resolverpkg.ResolverPrecheckFinalizeResult { + return resolverpkg.FinalizeResolverPrecheck( + resolverpkg.ResolverPrecheckFinalizeInput{ + PrecheckDue: precheckDue, + PrecheckStatePath: precheckStatePath, + Now: now, + TimeoutRecheck: timeoutRecheck, + LiveBatchTarget: liveBatchTarget, + LiveBatchMin: liveBatchMin, + LiveBatchMax: liveBatchMax, + LiveBatchNXHeavyPct: liveBatchNXHeavyPct, + LiveBatchNXHeavyMin: liveBatchNXHeavyMin, + LiveBatchNXHeavyMax: liveBatchNXHeavyMax, + DNSStats: dnsStats, + LiveDeferred: liveDeferred, + ResolvedNowDNS: resolvedNowDNS, + LiveP1: liveP1, + LiveP2: liveP2, + LiveP3: liveP3, + LiveNXHeavyTotal: liveNXHeavyTotal, + LiveNXHeavySkip: liveNXHeavySkip, + ToResolveTotal: toResolveTotal, + PrecheckFileForced: precheckFileForced, + PrecheckForcePath: precheckForcePath, + }, + logf, + ) +} + +func buildResolverRuntimeTuning(opts ResolverOpts, now int, precheckStatePath string, precheckEnvForced bool, precheckFileForced bool) resolverRuntimeTuning { + return resolverpkg.BuildResolverRuntimeTuning( + resolverpkg.ResolverRuntimeTuningInput{ + TTL: opts.TTL, + Workers: opts.Workers, + Now: now, + PrecheckStatePath: precheckStatePath, + PrecheckEnvForced: precheckEnvForced, + PrecheckFileForced: precheckFileForced, + }, + resolverpkg.ResolverRuntimeTuningDeps{ + EnvInt: envInt, + LoadResolverPrecheckLastRun: loadResolverPrecheckLastRun, + LoadResolverLiveBatchTarget: loadResolverLiveBatchTarget, + LoadResolverLiveBatchNXHeavyPct: loadResolverLiveBatchNXHeavyPct, + }, + ) +} + +func logResolverStart(input resolverStartLogInput, logf func(string, ...any)) { + resolverpkg.LogResolverStart(input, logf) +} diff --git a/selective-vpn-api/app/resolver_bridge_utils.go b/selective-vpn-api/app/resolver_bridge_utils.go new file mode 100644 index 0000000..b47f927 --- /dev/null +++ b/selective-vpn-api/app/resolver_bridge_utils.go @@ -0,0 +1,171 @@ +package app + +import ( + "os" + "strings" + + resolverpkg "selective-vpn-api/app/resolver" +) + +func normalizeWildcardDomain(raw string) string { + return resolverpkg.NormalizeWildcardDomain(raw) +} + +func newWildcardMatcher(domains []string) wildcardMatcher { + return resolverpkg.NewWildcardMatcher(domains) +} + +func uniqueStrings(in []string) []string { + return resolverpkg.UniqueStrings(in) +} + +func pickDNSStartIndex(host string, size int) int { + return resolverpkg.PickDNSStartIndex(host, size) +} + +func stripANSI(s string) string { + return resolverpkg.StripANSI(s) +} + +func isPrivateIPv4(ip string) bool { + return resolverpkg.IsPrivateIPv4(ip) +} + +func readLinesAllowMissing(path string) []string { + return resolverpkg.ReadLinesAllowMissing(path) +} + +func loadJSONMap(path string) map[string]any { + return resolverpkg.LoadJSONMap(path) +} + +func saveJSON(data any, path string) { + resolverpkg.SaveJSON(data, path) +} + +func loadResolverPrecheckLastRun(path string) int { + return resolverpkg.LoadResolverPrecheckLastRun(path) +} + +func loadResolverLiveBatchTarget(path string, fallback, minV, maxV int) int { + return resolverpkg.LoadResolverLiveBatchTarget(path, fallback, minV, maxV) +} + +func loadResolverLiveBatchNXHeavyPct(path string, fallback, minV, maxV int) int { + return resolverpkg.LoadResolverLiveBatchNXHeavyPct(path, fallback, minV, maxV) +} + +func splitDNS(dns string) (string, string) { + return resolverpkg.SplitDNS(dns) +} + +func classifyDNSError(err error) dnsErrorKind { + return dnsErrorKind(resolverpkg.ClassifyDNSError(err)) +} + +func computeNextLiveBatchTarget(current, minV, maxV int, dnsStats dnsMetrics, deferred int) (int, string) { + return resolverpkg.ComputeNextLiveBatchTarget(current, minV, maxV, dnsStats, deferred) +} + +func computeNextLiveBatchNXHeavyPct( + current, minV, maxV int, + dnsStats dnsMetrics, + resolvedNowDNS int, + selectedP3 int, + nxTotal int, + liveNXHeavySkip int, +) (int, string) { + return resolverpkg.ComputeNextLiveBatchNXHeavyPct( + current, + minV, + maxV, + dnsStats, + resolvedNowDNS, + selectedP3, + nxTotal, + liveNXHeavySkip, + ) +} + +func classifyLiveBatchHost( + host string, + cache domainCacheState, + cacheSourceForHost func(string) domainCacheSource, + wildcards wildcardMatcher, +) (priority int, nxHeavy bool) { + return resolverpkg.ClassifyLiveBatchHost( + host, + resolverpkg.DomainCacheState(cache), + func(h string) resolverpkg.DomainCacheSource { return cacheSourceForHost(h) }, + wildcards, + ) +} + +func splitLiveBatchCandidates( + candidates []string, + cache domainCacheState, + cacheSourceForHost func(string) domainCacheSource, + wildcards wildcardMatcher, +) (p1, p2, p3 []string, nxHeavyTotal int) { + return resolverpkg.SplitLiveBatchCandidates( + candidates, + resolverpkg.DomainCacheState(cache), + func(h string) resolverpkg.DomainCacheSource { return cacheSourceForHost(h) }, + wildcards, + ) +} + +func pickAdaptiveLiveBatch( + candidates []string, + target int, + now int, + nxHeavyPct int, + cache domainCacheState, + cacheSourceForHost func(string) domainCacheSource, + wildcards wildcardMatcher, +) ([]string, int, int, int, int, int) { + return resolverpkg.PickAdaptiveLiveBatch( + candidates, + target, + now, + nxHeavyPct, + resolverpkg.DomainCacheState(cache), + func(h string) resolverpkg.DomainCacheSource { return cacheSourceForHost(h) }, + wildcards, + ) +} + +func smartDNSFallbackForTimeoutEnabled() bool { + return resolverpkg.SmartDNSFallbackForTimeoutEnabled() +} + +func shouldFallbackToSmartDNS(stats dnsMetrics) bool { + return resolverpkg.ShouldFallbackToSmartDNS(stats) +} + +func classifyHostErrorKind(stats dnsMetrics) (dnsErrorKind, bool) { + kind, ok := resolverpkg.ClassifyHostErrorKind(stats) + return dnsErrorKind(kind), ok +} + +func shouldUseStaleOnError(stats dnsMetrics) bool { + return resolverpkg.ShouldUseStaleOnError(stats) +} + +func resolverFallbackPool() []string { + raw := strings.TrimSpace(os.Getenv("RESOLVE_DNS_FALLBACKS")) + return resolverpkg.BuildResolverFallbackPool(raw, resolverFallbackDNS, func(item string) string { + return normalizeDNSUpstream(item, "53") + }) +} + +func mergeDNSUpstreamPools(primary, fallback []string) []string { + maxUpstreams := envInt("RESOLVE_DNS_MAX_UPSTREAMS", 12) + return resolverpkg.MergeDNSUpstreamPools(primary, fallback, maxUpstreams, func(item string) string { + return normalizeDNSUpstream(item, "53") + }) +} + +func saveResolverPrecheckState(path string, ts int, timeoutStats resolverTimeoutRecheckStats, live resolverLiveBatchStats) { + resolverpkg.SaveResolverPrecheckState(path, ts, timeoutStats, live) +} diff --git a/selective-vpn-api/app/resolver_dns_attempt_policy.go b/selective-vpn-api/app/resolver_dns_attempt_policy.go new file mode 100644 index 0000000..c181dfa --- /dev/null +++ b/selective-vpn-api/app/resolver_dns_attempt_policy.go @@ -0,0 +1,97 @@ +package app + +import ( + "os" + "strings" + "time" +) + +func defaultDNSAttemptPolicy(dnsCount int) dnsAttemptPolicy { + tryLimit := envInt("RESOLVE_DNS_TRY_LIMIT", 2) + if tryLimit < 1 { + tryLimit = 1 + } + if dnsCount > 0 && tryLimit > dnsCount { + tryLimit = dnsCount + } + budgetMS := envInt("RESOLVE_DNS_DOMAIN_BUDGET_MS", 1200) + if budgetMS < 200 { + budgetMS = 200 + } + if budgetMS > 15000 { + budgetMS = 15000 + } + return dnsAttemptPolicy{ + TryLimit: tryLimit, + DomainBudget: time.Duration(budgetMS) * time.Millisecond, + StopOnNX: resolveNXEarlyStopEnabled(), + } +} + +func directDNSAttemptPolicy(dnsCount int) dnsAttemptPolicy { + tryLimit := envInt("RESOLVE_DIRECT_TRY_LIMIT", 2) + if tryLimit < 1 { + tryLimit = 1 + } + if tryLimit > 3 { + tryLimit = 3 + } + if dnsCount > 0 && tryLimit > dnsCount { + tryLimit = dnsCount + } + budgetMS := envInt("RESOLVE_DIRECT_BUDGET_MS", 1200) + if budgetMS < 200 { + budgetMS = 200 + } + if budgetMS > 15000 { + budgetMS = 15000 + } + return dnsAttemptPolicy{ + TryLimit: tryLimit, + DomainBudget: time.Duration(budgetMS) * time.Millisecond, + StopOnNX: resolveNXEarlyStopEnabled(), + } +} + +func wildcardDNSAttemptPolicy(dnsCount int) dnsAttemptPolicy { + tryLimit := envInt("RESOLVE_WILDCARD_TRY_LIMIT", 1) + if tryLimit < 1 { + tryLimit = 1 + } + if tryLimit > 2 { + tryLimit = 2 + } + if dnsCount > 0 && tryLimit > dnsCount { + tryLimit = dnsCount + } + budgetMS := envInt("RESOLVE_WILDCARD_BUDGET_MS", 1200) + if budgetMS < 200 { + budgetMS = 200 + } + if budgetMS > 15000 { + budgetMS = 15000 + } + return dnsAttemptPolicy{ + TryLimit: tryLimit, + DomainBudget: time.Duration(budgetMS) * time.Millisecond, + StopOnNX: resolveNXEarlyStopEnabled(), + } +} + +func resolveNXEarlyStopEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_NX_EARLY_STOP"))) { + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func resolveNXHardQuarantineEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_NX_HARD_QUARANTINE"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/selective-vpn-api/app/resolver_dns_cooldown.go b/selective-vpn-api/app/resolver_dns_cooldown.go new file mode 100644 index 0000000..7ee0061 --- /dev/null +++ b/selective-vpn-api/app/resolver_dns_cooldown.go @@ -0,0 +1,142 @@ +package app + +import ( + "os" + "strings" +) + +func newDNSRunCooldown() *dnsRunCooldown { + enabled := true + switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_DNS_COOLDOWN_ENABLED"))) { + case "0", "false", "no", "off": + enabled = false + } + c := &dnsRunCooldown{ + enabled: enabled, + minAttempts: envInt("RESOLVE_DNS_COOLDOWN_MIN_ATTEMPTS", 300), + timeoutRatePct: envInt("RESOLVE_DNS_COOLDOWN_TIMEOUT_RATE_PCT", 70), + failStreak: envInt("RESOLVE_DNS_COOLDOWN_FAIL_STREAK", 25), + banSec: envInt("RESOLVE_DNS_COOLDOWN_BAN_SEC", 60), + maxBanSec: envInt("RESOLVE_DNS_COOLDOWN_MAX_BAN_SEC", 300), + temporaryAsError: true, + byUpstream: map[string]*dnsCooldownState{}, + } + if c.minAttempts < 50 { + c.minAttempts = 50 + } + if c.minAttempts > 2000 { + c.minAttempts = 2000 + } + if c.timeoutRatePct < 40 { + c.timeoutRatePct = 40 + } + if c.timeoutRatePct > 95 { + c.timeoutRatePct = 95 + } + if c.failStreak < 8 { + c.failStreak = 8 + } + if c.failStreak > 200 { + c.failStreak = 200 + } + if c.banSec < 10 { + c.banSec = 10 + } + if c.banSec > 3600 { + c.banSec = 3600 + } + if c.maxBanSec < c.banSec { + c.maxBanSec = c.banSec + } + if c.maxBanSec > 3600 { + c.maxBanSec = 3600 + } + return c +} + +func (c *dnsRunCooldown) configSnapshot() (enabled bool, minAttempts, timeoutRatePct, failStreak, banSec, maxBanSec int) { + if c == nil { + return false, 0, 0, 0, 0, 0 + } + return c.enabled, c.minAttempts, c.timeoutRatePct, c.failStreak, c.banSec, c.maxBanSec +} + +func (c *dnsRunCooldown) stateFor(upstream string) *dnsCooldownState { + if c.byUpstream == nil { + c.byUpstream = map[string]*dnsCooldownState{} + } + st, ok := c.byUpstream[upstream] + if ok { + return st + } + st = &dnsCooldownState{} + c.byUpstream[upstream] = st + return st +} + +func (c *dnsRunCooldown) shouldSkip(upstream string, now int64) bool { + if c == nil || !c.enabled { + return false + } + c.mu.Lock() + defer c.mu.Unlock() + st := c.stateFor(upstream) + return st.BanUntil > now +} + +func (c *dnsRunCooldown) observeSuccess(upstream string) { + if c == nil || !c.enabled { + return + } + c.mu.Lock() + defer c.mu.Unlock() + st := c.stateFor(upstream) + st.Attempts++ + st.FailStreak = 0 +} + +func (c *dnsRunCooldown) observeError(upstream string, kind dnsErrorKind, now int64) (bool, int) { + if c == nil || !c.enabled { + return false, 0 + } + c.mu.Lock() + defer c.mu.Unlock() + st := c.stateFor(upstream) + st.Attempts++ + + timeoutLike := kind == dnsErrorTimeout || (c.temporaryAsError && kind == dnsErrorTemporary) + if timeoutLike { + st.TimeoutLike++ + st.FailStreak++ + } else { + st.FailStreak = 0 + return false, 0 + } + if st.BanUntil > now { + return false, 0 + } + + rateBan := st.Attempts >= c.minAttempts && (st.TimeoutLike*100 >= c.timeoutRatePct*st.Attempts) + streakBan := st.FailStreak >= c.failStreak + if !rateBan && !streakBan { + return false, 0 + } + + st.BanLevel++ + dur := c.banSec + if st.BanLevel > 1 { + for i := 1; i < st.BanLevel; i++ { + dur *= 2 + if dur >= c.maxBanSec { + dur = c.maxBanSec + break + } + } + } + if dur > c.maxBanSec { + dur = c.maxBanSec + } + st.BanUntil = now + int64(dur) + st.FailStreak = 0 + return true, dur +} diff --git a/selective-vpn-api/app/resolver_dns_policy.go b/selective-vpn-api/app/resolver_dns_policy.go new file mode 100644 index 0000000..233037b --- /dev/null +++ b/selective-vpn-api/app/resolver_dns_policy.go @@ -0,0 +1,32 @@ +package app + +import ( + "sync" + "time" +) + +type dnsAttemptPolicy struct { + TryLimit int + DomainBudget time.Duration + StopOnNX bool +} + +type dnsCooldownState struct { + Attempts int + TimeoutLike int + FailStreak int + BanUntil int64 + BanLevel int +} + +type dnsRunCooldown struct { + mu sync.Mutex + enabled bool + minAttempts int + timeoutRatePct int + failStreak int + banSec int + maxBanSec int + temporaryAsError bool + byUpstream map[string]*dnsCooldownState +} diff --git a/selective-vpn-api/app/resolver_pipeline.go b/selective-vpn-api/app/resolver_pipeline.go new file mode 100644 index 0000000..dfe46fa --- /dev/null +++ b/selective-vpn-api/app/resolver_pipeline.go @@ -0,0 +1,184 @@ +package app + +import "time" + +type resolverJobContext struct { + domains []string + metaSpecial []string + staticLines []string + wildcards wildcardMatcher + cfg dnsConfig + domainCache domainCacheState + ptrCache map[string]any + now int + + precheckStatePath string + precheckEnvForced bool + precheckFileForced bool + tuning resolverRuntimeTuning + + cacheSourceForHost func(string) domainCacheSource + cooldown *dnsRunCooldown + timeoutRecheck resolverTimeoutRecheckStats +} + +func runResolverPipeline(ctx *resolverJobContext, res *resolverResult, logf func(string, ...any)) { + if ctx == nil || res == nil { + return + } + start := time.Now() + + planning := buildResolverPlanning( + ctx.domains, + ctx.now, + ctx.tuning.TTL, + ctx.tuning.PrecheckDue, + ctx.tuning.PrecheckMaxDomains, + ctx.tuning.StaleKeepSec, + ctx.tuning.NegTTLNX, + ctx.tuning.NegTTLTimeout, + ctx.tuning.NegTTLTemporary, + ctx.tuning.NegTTLOther, + &ctx.domainCache, + ctx.cacheSourceForHost, + logf, + ) + fresh := planning.Fresh + cacheNegativeHits := planning.CacheNegativeHits + quarantineHits := planning.QuarantineHits + staleHits := planning.StaleHits + precheckScheduled := planning.PrecheckScheduled + toResolve := planning.ToResolve + + resolved := map[string][]string{} + for k, v := range fresh { + resolved[k] = v + } + toResolveTotal := len(toResolve) + liveP1 := 0 + liveP2 := 0 + liveP3 := 0 + liveNXHeavyTotal := 0 + liveNXHeavySkip := 0 + toResolve, liveP1, liveP2, liveP3, liveNXHeavyTotal, liveNXHeavySkip = pickAdaptiveLiveBatch( + toResolve, + ctx.tuning.LiveBatchTarget, + ctx.now, + ctx.tuning.LiveBatchNXHeavyPct, + ctx.domainCache, + ctx.cacheSourceForHost, + ctx.wildcards, + ) + liveDeferred := toResolveTotal - len(toResolve) + if liveDeferred < 0 { + liveDeferred = 0 + } + + if logf != nil { + logf("resolve: domains=%d cache_hits=%d cache_neg_hits=%d quarantine_hits=%d stale_hits=%d precheck_due=%t precheck_scheduled=%d to_resolve=%d to_resolve_total=%d deferred_by_live_batch=%d live_p1=%d live_p2=%d live_p3=%d live_nxheavy_total=%d live_nxheavy_skip=%d", len(ctx.domains), len(fresh), cacheNegativeHits, quarantineHits, staleHits, ctx.tuning.PrecheckDue, precheckScheduled, len(toResolve), toResolveTotal, liveDeferred, liveP1, liveP2, liveP3, liveNXHeavyTotal, liveNXHeavySkip) + } + + resolveBatch := executeResolverBatch( + toResolve, + ctx.tuning.Workers, + ctx.now, + ctx.tuning.StaleKeepSec, + resolved, + &ctx.domainCache, + ctx.cacheSourceForHost, + func(host string) ([]string, dnsMetrics) { + return resolveHostGo(host, ctx.cfg, ctx.metaSpecial, ctx.wildcards, ctx.tuning.DNSTimeout, ctx.cooldown, logf) + }, + logf, + ) + dnsStats := resolveBatch.DNSStats + resolvedNowDNS := resolveBatch.ResolvedNowDNS + resolvedNowStale := resolveBatch.ResolvedNowStale + unresolvedAfterAttempts := resolveBatch.UnresolvedAfterAttempts + staleHits += resolveBatch.StaleHitsDelta + + staticEntries, staticSkipped := parseStaticEntriesGo(ctx.staticLines, logf) + staticLabels, ptrLookups, ptrErrors := resolveStaticLabels(staticEntries, ctx.cfg, ctx.ptrCache, ctx.tuning.TTL, logf) + + isWildcardHost := func(host string) bool { + switch ctx.cfg.Mode { + case DNSModeSmartDNS: + return true + case DNSModeHybridWildcard: + return ctx.wildcards.Match(host) + default: + return false + } + } + + artifacts := buildResolverArtifacts(resolved, staticLabels, isWildcardHost) + res.IPMap = artifacts.IPMap + res.DirectIPMap = artifacts.DirectIPMap + res.WildcardIPMap = artifacts.WildcardIPMap + res.IPs = artifacts.IPs + res.DirectIPs = artifacts.DirectIPs + res.WildcardIPs = artifacts.WildcardIPs + res.DomainCache = ctx.domainCache.toMap() + res.PtrCache = ctx.ptrCache + + logResolverSummary( + resolverSummaryLogInput{ + DomainsTotal: len(ctx.domains), + FreshCount: len(fresh), + CacheNegativeHits: cacheNegativeHits, + QuarantineHits: quarantineHits, + StaleHits: staleHits, + ResolvedTotal: len(resolved), + UnresolvedAfterAttempts: unresolvedAfterAttempts, + LiveBatchTarget: ctx.tuning.LiveBatchTarget, + LiveDeferred: liveDeferred, + LiveP1: liveP1, + LiveP2: liveP2, + LiveP3: liveP3, + LiveBatchNXHeavyPct: ctx.tuning.LiveBatchNXHeavyPct, + LiveNXHeavyTotal: liveNXHeavyTotal, + LiveNXHeavySkip: liveNXHeavySkip, + StaticEntries: len(staticEntries), + StaticSkipped: staticSkipped, + UniqueIPs: len(res.IPs), + DirectIPs: len(res.DirectIPs), + WildcardIPs: len(res.WildcardIPs), + PtrLookups: ptrLookups, + PtrErrors: ptrErrors, + DNSStats: dnsStats, + TimeoutRecheck: ctx.timeoutRecheck, + DurationMS: time.Since(start).Milliseconds(), + DomainStateSummary: ctx.domainCache.formatStateSummary(ctx.now), + ResolvedNowDNS: resolvedNowDNS, + ResolvedNowStale: resolvedNowStale, + PrecheckDue: ctx.tuning.PrecheckDue, + PrecheckScheduled: precheckScheduled, + PrecheckStatePath: ctx.precheckStatePath, + }, + logf, + ) + _ = finalizeResolverPrecheck( + ctx.tuning.PrecheckDue, + ctx.precheckStatePath, + ctx.now, + ctx.timeoutRecheck, + ctx.tuning.LiveBatchTarget, + ctx.tuning.LiveBatchMin, + ctx.tuning.LiveBatchMax, + ctx.tuning.LiveBatchNXHeavyPct, + ctx.tuning.LiveBatchNXHeavyMin, + ctx.tuning.LiveBatchNXHeavyMax, + dnsStats, + liveDeferred, + resolvedNowDNS, + liveP1, + liveP2, + liveP3, + liveNXHeavyTotal, + liveNXHeavySkip, + toResolveTotal, + ctx.precheckFileForced, + precheckForcePath, + logf, + ) +} diff --git a/selective-vpn-api/app/resolver_pipeline_context.go b/selective-vpn-api/app/resolver_pipeline_context.go new file mode 100644 index 0000000..643f9ac --- /dev/null +++ b/selective-vpn-api/app/resolver_pipeline_context.go @@ -0,0 +1,90 @@ +package app + +import "time" + +func buildResolverJobContext(opts ResolverOpts, logf func(string, ...any)) resolverJobContext { + ctx := resolverJobContext{} + + ctx.domains = loadList(opts.DomainsPath) + ctx.metaSpecial = loadList(opts.MetaPath) + ctx.staticLines = readLinesAllowMissing(opts.StaticPath) + ctx.wildcards = newWildcardMatcher(opts.SmartDNSWildcards) + + ctx.cfg = loadDNSConfig(opts.DNSConfigPath, logf) + ctx.cfg = applyResolverDNSModeRuntime(ctx.cfg, opts) + logResolverDNSMode(ctx.cfg, ctx.wildcards, logf) + + ctx.domainCache = loadDomainCacheState(opts.CachePath, logf) + ctx.ptrCache = loadJSONMap(opts.PtrCachePath) + ctx.now = int(time.Now().Unix()) + ctx.precheckStatePath = opts.CachePath + ".precheck.json" + ctx.precheckEnvForced = resolvePrecheckForceEnvEnabled() + ctx.precheckFileForced = resolvePrecheckForceFileEnabled(precheckForcePath) + ctx.tuning = buildResolverRuntimeTuning(opts, ctx.now, ctx.precheckStatePath, ctx.precheckEnvForced, ctx.precheckFileForced) + + ctx.cacheSourceForHost = func(host string) domainCacheSource { + switch ctx.cfg.Mode { + case DNSModeSmartDNS: + return domainCacheSourceWildcard + case DNSModeHybridWildcard: + if ctx.wildcards.Match(host) { + return domainCacheSourceWildcard + } + } + return domainCacheSourceDirect + } + ctx.cooldown = newDNSRunCooldown() + + if ctx.tuning.PrecheckDue && ctx.tuning.TimeoutRecheckMax > 0 { + ctx.timeoutRecheck = runTimeoutQuarantineRecheck( + ctx.domains, + ctx.cfg, + ctx.metaSpecial, + ctx.wildcards, + ctx.tuning.DNSTimeout, + &ctx.domainCache, + ctx.cacheSourceForHost, + ctx.now, + ctx.tuning.TimeoutRecheckMax, + ctx.tuning.Workers, + ) + } + + directPolicy := directDNSAttemptPolicy(len(ctx.cfg.Default)) + wildcardPolicy := wildcardDNSAttemptPolicy(1) + cEnabled, cMin, cRate, cStreak, cBan, cMaxBan := ctx.cooldown.configSnapshot() + logResolverStart( + resolverStartLogInput{ + DomainsTotal: len(ctx.domains), + TTL: ctx.tuning.TTL, + Workers: ctx.tuning.Workers, + DNSTimeoutMS: ctx.tuning.DNSTimeoutMS, + DirectTry: directPolicy.TryLimit, + DirectBudgetMS: directPolicy.DomainBudget.Milliseconds(), + WildcardTry: wildcardPolicy.TryLimit, + WildcardBudgetMS: wildcardPolicy.DomainBudget.Milliseconds(), + NXEarlyStop: resolveNXEarlyStopEnabled(), + NXHardQuarantine: resolveNXHardQuarantineEnabled(), + CooldownEnabled: cEnabled, + CooldownMinAttempts: cMin, + CooldownTimeoutRate: cRate, + CooldownFailStreak: cStreak, + CooldownBanSec: cBan, + CooldownMaxBanSec: cMaxBan, + LiveBatchTarget: ctx.tuning.LiveBatchTarget, + LiveBatchMin: ctx.tuning.LiveBatchMin, + LiveBatchMax: ctx.tuning.LiveBatchMax, + LiveBatchNXHeavyPct: ctx.tuning.LiveBatchNXHeavyPct, + LiveBatchNXHeavyMin: ctx.tuning.LiveBatchNXHeavyMin, + LiveBatchNXHeavyMax: ctx.tuning.LiveBatchNXHeavyMax, + StaleKeepSec: ctx.tuning.StaleKeepSec, + PrecheckEverySec: ctx.tuning.PrecheckEverySec, + PrecheckMaxDomains: ctx.tuning.PrecheckMaxDomains, + PrecheckForcedEnv: ctx.precheckEnvForced, + PrecheckForcedFile: ctx.precheckFileForced, + }, + logf, + ) + + return ctx +} diff --git a/selective-vpn-api/app/resolver_policy_test.go b/selective-vpn-api/app/resolver_policy_test.go new file mode 100644 index 0000000..277cc34 --- /dev/null +++ b/selective-vpn-api/app/resolver_policy_test.go @@ -0,0 +1,69 @@ +package app + +import "testing" + +func TestDomainStateFromScore(t *testing.T) { + tests := []struct { + score int + want string + }{ + {25, domainStateActive}, + {10, domainStateStable}, + {0, domainStateSuspect}, + {-12, domainStateQuarantine}, + {-40, domainStateHardQuar}, + } + for _, tc := range tests { + if got := domainStateFromScore(tc.score); got != tc.want { + t.Fatalf("domainStateFromScore(%d)=%q want=%q", tc.score, got, tc.want) + } + } +} + +func TestDomainScorePenalty(t *testing.T) { + if got := domainScorePenalty(dnsMetrics{NXDomain: 2}); got >= 0 { + t.Fatalf("expected negative penalty for confirmed nxdomain, got %d", got) + } + if got := domainScorePenalty(dnsMetrics{NXDomain: 1}); got >= 0 { + t.Fatalf("expected negative penalty for single nxdomain, got %d", got) + } + if got := domainScorePenalty(dnsMetrics{Timeout: 1}); got >= 0 { + t.Fatalf("expected negative penalty for timeout, got %d", got) + } +} + +func TestDomainCacheGetStale(t *testing.T) { + s := newDomainCacheState() + now := 2000 + s.set("example.com", domainCacheSourceDirect, []string{"1.2.3.4"}, now-100) + ips, age, ok := s.getStale("example.com", domainCacheSourceDirect, now, 300) + if !ok { + t.Fatalf("expected stale entry") + } + if age != 100 { + t.Fatalf("age=%d want=100", age) + } + if len(ips) != 1 || ips[0] != "1.2.3.4" { + t.Fatalf("unexpected ips: %v", ips) + } + if _, _, ok := s.getStale("example.com", domainCacheSourceDirect, now, 50); ok { + t.Fatalf("expected stale miss when maxAge is too small") + } +} + +func TestSetErrorWithStatsSetsQuarantine(t *testing.T) { + s := newDomainCacheState() + now := 3000 + s.set("bad.example", domainCacheSourceDirect, []string{"2.2.2.2"}, now-10) + for i := 0; i < 5; i++ { + s.setErrorWithStats("bad.example", domainCacheSourceDirect, dnsMetrics{NXDomain: 2}, now+i) + } + state, _, ok := s.getQuarantine("bad.example", domainCacheSourceDirect, now+5) + if !ok { + t.Fatalf("expected quarantine to be set") + } + if state != domainStateQuarantine && state != domainStateHardQuar { + t.Fatalf("unexpected quarantine state: %s", state) + } +} + diff --git a/selective-vpn-api/app/resolver_precheck_flags.go b/selective-vpn-api/app/resolver_precheck_flags.go new file mode 100644 index 0000000..65b8a0a --- /dev/null +++ b/selective-vpn-api/app/resolver_precheck_flags.go @@ -0,0 +1,23 @@ +package app + +import ( + "os" + "strings" +) + +func resolvePrecheckForceEnvEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_PRECHECK_FORCE"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func resolvePrecheckForceFileEnabled(path string) bool { + if strings.TrimSpace(path) == "" { + return false + } + _, err := os.Stat(path) + return err == nil +} diff --git a/selective-vpn-api/app/routes_cache.go b/selective-vpn-api/app/routes_cache.go index 7c57423..a5baf1c 100644 --- a/selective-vpn-api/app/routes_cache.go +++ b/selective-vpn-api/app/routes_cache.go @@ -1,15 +1,5 @@ package app -import ( - "context" - "encoding/json" - "fmt" - "os" - "sort" - "strings" - "time" -) - // --------------------------------------------------------------------- // routes clear cache (safe clear / fast restore) // --------------------------------------------------------------------- @@ -26,391 +16,3 @@ type routesClearCacheMeta struct { DynIPCount int `json:"dyn_ip_count"` HasIPMap bool `json:"has_ip_map"` } - -func saveRoutesClearCache() (routesClearCacheMeta, error) { - if err := os.MkdirAll(stateDir, 0o755); err != nil { - return routesClearCacheMeta{}, err - } - - routes, err := readCurrentRoutesTableLines() - if err != nil { - return routesClearCacheMeta{}, err - } - if err := writeLinesFile(routesCacheRT, routes); err != nil { - return routesClearCacheMeta{}, err - } - - var warns []string - - ipCount, err := snapshotNftSetToFile("agvpn4", routesCacheIPs) - if err != nil { - warns = append(warns, fmt.Sprintf("agvpn4 snapshot failed: %v", err)) - _ = cacheCopyOrEmpty(stateDir+"/last-ips.txt", routesCacheIPs) - ipCount = len(readNonEmptyLines(routesCacheIPs)) - } - - dynIPCount, err := snapshotNftSetToFile("agvpn_dyn4", routesCacheDyn) - if err != nil { - warns = append(warns, fmt.Sprintf("agvpn_dyn4 snapshot failed: %v", err)) - _ = os.WriteFile(routesCacheDyn, []byte{}, 0o644) - dynIPCount = 0 - } - - if err := cacheCopyOrEmpty(stateDir+"/last-ips-map.txt", routesCacheMap); err != nil { - warns = append(warns, fmt.Sprintf("last-ips-map cache copy failed: %v", err)) - } - if err := cacheCopyOrEmpty(lastIPsMapDirect, routesCacheMapD); err != nil { - warns = append(warns, fmt.Sprintf("last-ips-map-direct cache copy failed: %v", err)) - } - if err := cacheCopyOrEmpty(lastIPsMapDyn, routesCacheMapW); err != nil { - warns = append(warns, fmt.Sprintf("last-ips-map-wildcard cache copy failed: %v", err)) - } - - meta := routesClearCacheMeta{ - CreatedAt: time.Now().UTC().Format(time.RFC3339), - Iface: detectIfaceFromRoutes(routes), - RouteCount: len(routes), - IPCount: ipCount, - DynIPCount: dynIPCount, - HasIPMap: fileExists(routesCacheMap), - } - - data, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return routesClearCacheMeta{}, err - } - if err := os.WriteFile(routesCacheMeta, data, 0o644); err != nil { - return routesClearCacheMeta{}, err - } - if len(warns) > 0 { - return meta, fmt.Errorf("%s", strings.Join(warns, "; ")) - } - return meta, nil -} - -func restoreRoutesFromCache() cmdResult { - return withRoutesOpLock("routes restore", restoreRoutesFromCacheUnlocked) -} - -func restoreRoutesFromCacheUnlocked() cmdResult { - meta, err := loadRoutesClearCacheMeta() - if err != nil { - return cmdResult{ - OK: false, - Message: fmt.Sprintf("routes cache missing: %v", err), - } - } - - ips := readNonEmptyLines(routesCacheIPs) - dynIPs := readNonEmptyLines(routesCacheDyn) - routeLines, _ := readLinesFile(routesCacheRT) - - ensureRoutesTableEntry() - removeTrafficRulesForTable() - _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "route", "flush", "table", routesTableName()) - - ignoredRoutes := 0 - for _, ln := range routeLines { - if err := restoreRouteLine(ln); err != nil { - if shouldIgnoreRestoreRouteError(ln, err) { - ignoredRoutes++ - appendTraceLine("routes", fmt.Sprintf("restore route skipped (%q): %v", ln, err)) - continue - } - return cmdResult{ - OK: false, - Message: fmt.Sprintf("restore route failed (%q): %v", ln, err), - } - } - } - if ignoredRoutes > 0 { - appendTraceLine("routes", fmt.Sprintf("restore route: skipped non-critical routes=%d", ignoredRoutes)) - } - - if len(routeLines) == 0 && strings.TrimSpace(meta.Iface) != "" { - _, _, _, _ = runCommandTimeout( - 5*time.Second, - "ip", "-4", "route", "replace", - "default", "dev", meta.Iface, - "table", routesTableName(), - "mtu", policyRouteMTU, - ) - } - - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", "agvpn") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn_dyn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", "agvpn", "agvpn4") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", "agvpn", "agvpn_dyn4") - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) - defer cancel() - - if len(ips) > 0 { - if err := nftUpdateSetIPsSmart(ctx, "agvpn4", ips, nil); err != nil { - return cmdResult{ - OK: false, - Message: fmt.Sprintf("restore nft cache failed for agvpn4: %v", err), - } - } - } - if len(dynIPs) > 0 { - if err := nftUpdateSetIPsSmart(ctx, "agvpn_dyn4", dynIPs, nil); err != nil { - return cmdResult{ - OK: false, - Message: fmt.Sprintf("restore nft cache failed for agvpn_dyn4: %v", err), - } - } - } - - traffic := loadTrafficModeState() - iface := strings.TrimSpace(meta.Iface) - if iface == "" { - iface = detectIfaceFromRoutes(routeLines) - } - if iface == "" { - iface, _ = resolveTrafficIface(traffic.PreferredIface) - } - if iface != "" { - if err := applyTrafficMode(traffic, iface); err != nil { - return cmdResult{ - OK: false, - Message: fmt.Sprintf("cache restored, but traffic mode apply failed: %v", err), - } - } - } - - _ = cacheCopyOrEmpty(routesCacheIPs, stateDir+"/last-ips.txt") - if fileExists(routesCacheMap) { - _ = cacheCopyOrEmpty(routesCacheMap, stateDir+"/last-ips-map.txt") - } - if fileExists(routesCacheMapD) { - _ = cacheCopyOrEmpty(routesCacheMapD, lastIPsMapDirect) - } - if fileExists(routesCacheMapW) { - _ = cacheCopyOrEmpty(routesCacheMapW, lastIPsMapDyn) - } - _ = writeStatusSnapshot(len(ips)+len(dynIPs), iface) - - return cmdResult{ - OK: true, - Message: fmt.Sprintf( - "routes restored from cache: agvpn4=%d agvpn_dyn4=%d routes=%d iface=%s", - len(ips), len(dynIPs), len(routeLines), ifaceOrDash(iface), - ), - } -} - -func readCurrentRoutesTableLines() ([]string, error) { - out, _, code, err := runCommandTimeout(5*time.Second, "ip", "-4", "route", "show", "table", routesTableName()) - if err != nil && code != 0 { - return nil, err - } - lines := make([]string, 0, 32) - for _, raw := range strings.Split(out, "\n") { - ln := strings.TrimSpace(raw) - if ln == "" { - continue - } - lines = append(lines, ln) - } - return lines, nil -} - -func writeLinesFile(path string, lines []string) error { - if len(lines) == 0 { - return os.WriteFile(path, []byte{}, 0o644) - } - payload := strings.Join(lines, "\n") - if !strings.HasSuffix(payload, "\n") { - payload += "\n" - } - return os.WriteFile(path, []byte(payload), 0o644) -} - -func readLinesFile(path string) ([]string, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - lines := make([]string, 0, 64) - for _, raw := range strings.Split(string(data), "\n") { - ln := strings.TrimSpace(raw) - if ln == "" { - continue - } - lines = append(lines, ln) - } - return lines, nil -} - -func detectIfaceFromRoutes(lines []string) string { - for _, ln := range lines { - fields := strings.Fields(ln) - for i := 0; i+1 < len(fields); i++ { - if fields[i] == "dev" { - return strings.TrimSpace(fields[i+1]) - } - } - } - return "" -} - -func restoreRouteLine(line string) error { - fields := strings.Fields(strings.TrimSpace(line)) - if len(fields) == 0 { - return nil - } - args := []string{"-4", "route", "replace"} - args = append(args, fields...) - hasTable := false - for i := 0; i+1 < len(fields); i++ { - if fields[i] == "table" { - hasTable = true - break - } - } - if !hasTable { - args = append(args, "table", routesTableName()) - } - _, _, code, err := runCommandTimeout(5*time.Second, "ip", args...) - if err != nil || code != 0 { - if err == nil { - err = fmt.Errorf("exit code %d", code) - } - return err - } - return nil -} - -func shouldIgnoreRestoreRouteError(line string, err error) bool { - ln := strings.ToLower(strings.TrimSpace(line)) - if strings.Contains(ln, " linkdown") { - return true - } - - dev := routeLineDevice(ln) - if dev != "" && !strings.HasPrefix(ln, "default ") && !ifaceExists(dev) { - return true - } - - msg := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", err))) - if strings.HasPrefix(ln, "default ") { - return false - } - if strings.Contains(msg, "cannot find device") || - strings.Contains(msg, "no such device") || - strings.Contains(msg, "network is down") { - return true - } - return false -} - -func routeLineDevice(line string) string { - fields := strings.Fields(strings.TrimSpace(line)) - for i := 0; i+1 < len(fields); i++ { - if fields[i] == "dev" { - return strings.TrimSpace(fields[i+1]) - } - } - return "" -} - -func cacheCopyOrEmpty(src, dst string) error { - if err := copyFile(src, dst); err == nil { - return nil - } - return os.WriteFile(dst, []byte{}, 0o644) -} - -func snapshotNftSetToFile(setName, dst string) (int, error) { - elems, err := readNftSetElements(setName) - if err != nil { - return 0, err - } - if err := writeLinesFile(dst, elems); err != nil { - return 0, err - } - return len(elems), nil -} - -func readNftSetElements(setName string) ([]string, error) { - out, stderr, code, err := runCommandTimeout( - 8*time.Second, "nft", "list", "set", "inet", "agvpn", setName, - ) - if err != nil || code != 0 { - msg := strings.ToLower(strings.TrimSpace(out + " " + stderr)) - if strings.Contains(msg, "no such file") || - strings.Contains(msg, "not found") || - strings.Contains(msg, "does not exist") { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("nft list set %s failed: %w", setName, err) - } - return nil, fmt.Errorf("nft list set %s failed: %s", setName, strings.TrimSpace(stderr)) - } - return parseNftSetElementsText(out), nil -} - -func parseNftSetElementsText(raw string) []string { - idx := strings.Index(raw, "elements =") - if idx < 0 { - return nil - } - chunk := raw[idx:] - open := strings.Index(chunk, "{") - if open < 0 { - return nil - } - body := chunk[open+1:] - closeIdx := strings.Index(body, "}") - if closeIdx >= 0 { - body = body[:closeIdx] - } - body = strings.ReplaceAll(body, "\r", " ") - body = strings.ReplaceAll(body, "\n", " ") - - seen := map[string]struct{}{} - out := make([]string, 0, 1024) - for _, tok := range strings.Split(body, ",") { - val := strings.TrimSpace(tok) - if val == "" { - continue - } - if _, ok := seen[val]; ok { - continue - } - seen[val] = struct{}{} - out = append(out, val) - } - sort.Strings(out) - return out -} - -func loadRoutesClearCacheMeta() (routesClearCacheMeta, error) { - data, err := os.ReadFile(routesCacheMeta) - if err != nil { - return routesClearCacheMeta{}, err - } - var meta routesClearCacheMeta - if err := json.Unmarshal(data, &meta); err != nil { - return routesClearCacheMeta{}, err - } - return meta, nil -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - return !info.IsDir() -} - -func ifaceOrDash(iface string) string { - if strings.TrimSpace(iface) == "" { - return "-" - } - return iface -} diff --git a/selective-vpn-api/app/routes_cache_helpers.go b/selective-vpn-api/app/routes_cache_helpers.go new file mode 100644 index 0000000..02cbf6b --- /dev/null +++ b/selective-vpn-api/app/routes_cache_helpers.go @@ -0,0 +1,6 @@ +package app + +// Routes cache helpers are split by role: +// - file io/meta: routes_cache_helpers_files.go / routes_cache_helpers_meta.go +// - route table parse/restore: routes_cache_helpers_routes.go +// - nft set snapshot/parse: routes_cache_helpers_nft.go diff --git a/selective-vpn-api/app/routes_cache_helpers_files.go b/selective-vpn-api/app/routes_cache_helpers_files.go new file mode 100644 index 0000000..0ba8f94 --- /dev/null +++ b/selective-vpn-api/app/routes_cache_helpers_files.go @@ -0,0 +1,48 @@ +package app + +import ( + "os" + "strings" +) + +func writeLinesFile(path string, lines []string) error { + if len(lines) == 0 { + return os.WriteFile(path, []byte{}, 0o644) + } + payload := strings.Join(lines, "\n") + if !strings.HasSuffix(payload, "\n") { + payload += "\n" + } + return os.WriteFile(path, []byte(payload), 0o644) +} + +func readLinesFile(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + lines := make([]string, 0, 64) + for _, raw := range strings.Split(string(data), "\n") { + ln := strings.TrimSpace(raw) + if ln == "" { + continue + } + lines = append(lines, ln) + } + return lines, nil +} + +func cacheCopyOrEmpty(src, dst string) error { + if err := copyFile(src, dst); err == nil { + return nil + } + return os.WriteFile(dst, []byte{}, 0o644) +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} diff --git a/selective-vpn-api/app/routes_cache_helpers_meta.go b/selective-vpn-api/app/routes_cache_helpers_meta.go new file mode 100644 index 0000000..78f7a1c --- /dev/null +++ b/selective-vpn-api/app/routes_cache_helpers_meta.go @@ -0,0 +1,26 @@ +package app + +import ( + "encoding/json" + "os" + "strings" +) + +func loadRoutesClearCacheMeta() (routesClearCacheMeta, error) { + data, err := os.ReadFile(routesCacheMeta) + if err != nil { + return routesClearCacheMeta{}, err + } + var meta routesClearCacheMeta + if err := json.Unmarshal(data, &meta); err != nil { + return routesClearCacheMeta{}, err + } + return meta, nil +} + +func ifaceOrDash(iface string) string { + if strings.TrimSpace(iface) == "" { + return "-" + } + return iface +} diff --git a/selective-vpn-api/app/routes_cache_helpers_nft.go b/selective-vpn-api/app/routes_cache_helpers_nft.go new file mode 100644 index 0000000..7f65c7a --- /dev/null +++ b/selective-vpn-api/app/routes_cache_helpers_nft.go @@ -0,0 +1,73 @@ +package app + +import ( + "fmt" + "sort" + "strings" + "time" +) + +func snapshotNftSetToFile(setName, dst string) (int, error) { + elems, err := readNftSetElements(setName) + if err != nil { + return 0, err + } + if err := writeLinesFile(dst, elems); err != nil { + return 0, err + } + return len(elems), nil +} + +func readNftSetElements(setName string) ([]string, error) { + out, stderr, code, err := runCommandTimeout( + 8*time.Second, "nft", "list", "set", "inet", "agvpn", setName, + ) + if err != nil || code != 0 { + msg := strings.ToLower(strings.TrimSpace(out + " " + stderr)) + if strings.Contains(msg, "no such file") || + strings.Contains(msg, "not found") || + strings.Contains(msg, "does not exist") { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("nft list set %s failed: %w", setName, err) + } + return nil, fmt.Errorf("nft list set %s failed: %s", setName, strings.TrimSpace(stderr)) + } + return parseNftSetElementsText(out), nil +} + +func parseNftSetElementsText(raw string) []string { + idx := strings.Index(raw, "elements =") + if idx < 0 { + return nil + } + chunk := raw[idx:] + open := strings.Index(chunk, "{") + if open < 0 { + return nil + } + body := chunk[open+1:] + closeIdx := strings.Index(body, "}") + if closeIdx >= 0 { + body = body[:closeIdx] + } + body = strings.ReplaceAll(body, "\r", " ") + body = strings.ReplaceAll(body, "\n", " ") + + seen := map[string]struct{}{} + out := make([]string, 0, 1024) + for _, tok := range strings.Split(body, ",") { + val := strings.TrimSpace(tok) + if val == "" { + continue + } + if _, ok := seen[val]; ok { + continue + } + seen[val] = struct{}{} + out = append(out, val) + } + sort.Strings(out) + return out +} diff --git a/selective-vpn-api/app/routes_cache_helpers_routes.go b/selective-vpn-api/app/routes_cache_helpers_routes.go new file mode 100644 index 0000000..f9aa1e2 --- /dev/null +++ b/selective-vpn-api/app/routes_cache_helpers_routes.go @@ -0,0 +1,95 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +func readCurrentRoutesTableLines() ([]string, error) { + out, _, code, err := runCommandTimeout(5*time.Second, "ip", "-4", "route", "show", "table", routesTableName()) + if err != nil && code != 0 { + return nil, err + } + lines := make([]string, 0, 32) + for _, raw := range strings.Split(out, "\n") { + ln := strings.TrimSpace(raw) + if ln == "" { + continue + } + lines = append(lines, ln) + } + return lines, nil +} + +func detectIfaceFromRoutes(lines []string) string { + for _, ln := range lines { + fields := strings.Fields(ln) + for i := 0; i+1 < len(fields); i++ { + if fields[i] == "dev" { + return strings.TrimSpace(fields[i+1]) + } + } + } + return "" +} + +func restoreRouteLine(line string) error { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) == 0 { + return nil + } + args := []string{"-4", "route", "replace"} + args = append(args, fields...) + hasTable := false + for i := 0; i+1 < len(fields); i++ { + if fields[i] == "table" { + hasTable = true + break + } + } + if !hasTable { + args = append(args, "table", routesTableName()) + } + _, _, code, err := runCommandTimeout(5*time.Second, "ip", args...) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("exit code %d", code) + } + return err + } + return nil +} + +func shouldIgnoreRestoreRouteError(line string, err error) bool { + ln := strings.ToLower(strings.TrimSpace(line)) + if strings.Contains(ln, " linkdown") { + return true + } + + dev := routeLineDevice(ln) + if dev != "" && !strings.HasPrefix(ln, "default ") && !ifaceExists(dev) { + return true + } + + msg := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", err))) + if strings.HasPrefix(ln, "default ") { + return false + } + if strings.Contains(msg, "cannot find device") || + strings.Contains(msg, "no such device") || + strings.Contains(msg, "network is down") { + return true + } + return false +} + +func routeLineDevice(line string) string { + fields := strings.Fields(strings.TrimSpace(line)) + for i := 0; i+1 < len(fields); i++ { + if fields[i] == "dev" { + return strings.TrimSpace(fields[i+1]) + } + } + return "" +} diff --git a/selective-vpn-api/app/routes_cache_restore.go b/selective-vpn-api/app/routes_cache_restore.go new file mode 100644 index 0000000..fcab606 --- /dev/null +++ b/selective-vpn-api/app/routes_cache_restore.go @@ -0,0 +1,121 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" +) + +func restoreRoutesFromCache() cmdResult { + return withRoutesOpLock("routes restore", restoreRoutesFromCacheUnlocked) +} + +func restoreRoutesFromCacheUnlocked() cmdResult { + meta, err := loadRoutesClearCacheMeta() + if err != nil { + return cmdResult{ + OK: false, + Message: fmt.Sprintf("routes cache missing: %v", err), + } + } + + ips := readNonEmptyLines(routesCacheIPs) + dynIPs := readNonEmptyLines(routesCacheDyn) + routeLines, _ := readLinesFile(routesCacheRT) + + ensureRoutesTableEntry() + removeTrafficRulesForTable() + _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "route", "flush", "table", routesTableName()) + + ignoredRoutes := 0 + for _, ln := range routeLines { + if err := restoreRouteLine(ln); err != nil { + if shouldIgnoreRestoreRouteError(ln, err) { + ignoredRoutes++ + appendTraceLine("routes", fmt.Sprintf("restore route skipped (%q): %v", ln, err)) + continue + } + return cmdResult{ + OK: false, + Message: fmt.Sprintf("restore route failed (%q): %v", ln, err), + } + } + } + if ignoredRoutes > 0 { + appendTraceLine("routes", fmt.Sprintf("restore route: skipped non-critical routes=%d", ignoredRoutes)) + } + + if len(routeLines) == 0 && strings.TrimSpace(meta.Iface) != "" { + _, _, _, _ = runCommandTimeout( + 5*time.Second, + "ip", "-4", "route", "replace", + "default", "dev", meta.Iface, + "table", routesTableName(), + "mtu", policyRouteMTU, + ) + } + + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", "agvpn") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn_dyn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", "agvpn", "agvpn4") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", "agvpn", "agvpn_dyn4") + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + if len(ips) > 0 { + if err := nftUpdateSetIPsSmart(ctx, "agvpn4", ips, nil); err != nil { + return cmdResult{ + OK: false, + Message: fmt.Sprintf("restore nft cache failed for agvpn4: %v", err), + } + } + } + if len(dynIPs) > 0 { + if err := nftUpdateSetIPsSmart(ctx, "agvpn_dyn4", dynIPs, nil); err != nil { + return cmdResult{ + OK: false, + Message: fmt.Sprintf("restore nft cache failed for agvpn_dyn4: %v", err), + } + } + } + + traffic := loadTrafficModeState() + iface := strings.TrimSpace(meta.Iface) + if iface == "" { + iface = detectIfaceFromRoutes(routeLines) + } + if iface == "" { + iface, _ = resolveTrafficIface(traffic.PreferredIface) + } + if iface != "" { + if err := applyTrafficMode(traffic, iface); err != nil { + return cmdResult{ + OK: false, + Message: fmt.Sprintf("cache restored, but traffic mode apply failed: %v", err), + } + } + } + + _ = cacheCopyOrEmpty(routesCacheIPs, stateDir+"/last-ips.txt") + if fileExists(routesCacheMap) { + _ = cacheCopyOrEmpty(routesCacheMap, stateDir+"/last-ips-map.txt") + } + if fileExists(routesCacheMapD) { + _ = cacheCopyOrEmpty(routesCacheMapD, lastIPsMapDirect) + } + if fileExists(routesCacheMapW) { + _ = cacheCopyOrEmpty(routesCacheMapW, lastIPsMapDyn) + } + _ = writeStatusSnapshot(len(ips)+len(dynIPs), iface) + + return cmdResult{ + OK: true, + Message: fmt.Sprintf( + "routes restored from cache: agvpn4=%d agvpn_dyn4=%d routes=%d iface=%s", + len(ips), len(dynIPs), len(routeLines), ifaceOrDash(iface), + ), + } +} diff --git a/selective-vpn-api/app/routes_cache_save.go b/selective-vpn-api/app/routes_cache_save.go new file mode 100644 index 0000000..1573152 --- /dev/null +++ b/selective-vpn-api/app/routes_cache_save.go @@ -0,0 +1,70 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +func saveRoutesClearCache() (routesClearCacheMeta, error) { + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return routesClearCacheMeta{}, err + } + + routes, err := readCurrentRoutesTableLines() + if err != nil { + return routesClearCacheMeta{}, err + } + if err := writeLinesFile(routesCacheRT, routes); err != nil { + return routesClearCacheMeta{}, err + } + + var warns []string + + ipCount, err := snapshotNftSetToFile("agvpn4", routesCacheIPs) + if err != nil { + warns = append(warns, fmt.Sprintf("agvpn4 snapshot failed: %v", err)) + _ = cacheCopyOrEmpty(stateDir+"/last-ips.txt", routesCacheIPs) + ipCount = len(readNonEmptyLines(routesCacheIPs)) + } + + dynIPCount, err := snapshotNftSetToFile("agvpn_dyn4", routesCacheDyn) + if err != nil { + warns = append(warns, fmt.Sprintf("agvpn_dyn4 snapshot failed: %v", err)) + _ = os.WriteFile(routesCacheDyn, []byte{}, 0o644) + dynIPCount = 0 + } + + if err := cacheCopyOrEmpty(stateDir+"/last-ips-map.txt", routesCacheMap); err != nil { + warns = append(warns, fmt.Sprintf("last-ips-map cache copy failed: %v", err)) + } + if err := cacheCopyOrEmpty(lastIPsMapDirect, routesCacheMapD); err != nil { + warns = append(warns, fmt.Sprintf("last-ips-map-direct cache copy failed: %v", err)) + } + if err := cacheCopyOrEmpty(lastIPsMapDyn, routesCacheMapW); err != nil { + warns = append(warns, fmt.Sprintf("last-ips-map-wildcard cache copy failed: %v", err)) + } + + meta := routesClearCacheMeta{ + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Iface: detectIfaceFromRoutes(routes), + RouteCount: len(routes), + IPCount: ipCount, + DynIPCount: dynIPCount, + HasIPMap: fileExists(routesCacheMap), + } + + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return routesClearCacheMeta{}, err + } + if err := os.WriteFile(routesCacheMeta, data, 0o644); err != nil { + return routesClearCacheMeta{}, err + } + if len(warns) > 0 { + return meta, fmt.Errorf("%s", strings.Join(warns, "; ")) + } + return meta, nil +} diff --git a/selective-vpn-api/app/routes_handlers.go b/selective-vpn-api/app/routes_handlers.go index 94d3690..7c7f4b3 100644 --- a/selective-vpn-api/app/routes_handlers.go +++ b/selective-vpn-api/app/routes_handlers.go @@ -1,15 +1,7 @@ package app import ( - "encoding/json" - "fmt" - "io" - "log" "net/http" - "os" - "strings" - "syscall" - "time" ) // --------------------------------------------------------------------- @@ -21,41 +13,6 @@ import ( // RU: HTTP-обработчики control-plane для селективной маршрутизации: // RU: статус, управление service/timer через systemd, очистка, фиксация policy route и запуск обновления. -func handleGetStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - data, err := os.ReadFile(statusFilePath) - if err != nil { - if os.IsNotExist(err) { - http.Error(w, "status file not found", http.StatusNotFound) - return - } - http.Error(w, "failed to read status file", http.StatusInternalServerError) - return - } - - var st Status - if err := json.Unmarshal(data, &st); err != nil { - http.Error(w, "invalid status.json", http.StatusInternalServerError) - return - } - - if st.Iface != "" && st.Iface != "-" && st.Table != "" && st.Table != "-" { - ok, err := checkPolicyRoute(st.Iface, st.Table) - if err != nil { - log.Printf("checkPolicyRoute error: %v", err) - } else { - st.PolicyRouteOK = &ok - st.RouteOK = &ok - } - } - - writeJSON(w, http.StatusOK, st) -} - // --------------------------------------------------------------------- // routes service // --------------------------------------------------------------------- @@ -79,381 +36,3 @@ func makeCmdHandler(name string, args ...string) http.HandlerFunc { writeJSON(w, http.StatusOK, res) } } - -func runRoutesServiceAction(action string) cmdResult { - action = strings.ToLower(strings.TrimSpace(action)) - unit := routesServiceUnitName() - if unit == "" { - return cmdResult{ - OK: false, - Message: "routes service unit unresolved: set preferred iface or SELECTIVE_VPN_ROUTES_UNIT", - } - } - - var args []string - switch action { - case "start", "stop", "restart": - args = []string{"systemctl", action, unit} - default: - return cmdResult{ - OK: false, - Message: "unknown action (expected start|stop|restart)", - } - } - - stdout, stderr, exitCode, err := runCommand(args[0], args[1:]...) - res := cmdResult{ - OK: err == nil && exitCode == 0, - ExitCode: exitCode, - Stdout: stdout, - Stderr: stderr, - } - if err != nil { - res.Message = err.Error() - } else { - res.Message = fmt.Sprintf("%s done (%s)", action, unit) - } - return res -} - -func makeRoutesServiceActionHandler(action string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - res := runRoutesServiceAction(action) - writeJSON(w, http.StatusOK, res) - } -} - -// POST /api/v1/routes/service { "action": "start|stop|restart" } -// --------------------------------------------------------------------- -// EN: `handleRoutesService` is an HTTP handler for routes service. -// RU: `handleRoutesService` - HTTP-обработчик для routes service. -// --------------------------------------------------------------------- -func handleRoutesService(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) - } - - res := runRoutesServiceAction(body.Action) - if strings.Contains(res.Message, "unknown action") { - writeJSON(w, http.StatusBadRequest, res) - return - } - writeJSON(w, http.StatusOK, res) -} - -// --------------------------------------------------------------------- -// routes timer -// --------------------------------------------------------------------- - -// старый toggle (используем из GUI, если что) -func handleRoutesTimerToggle(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - enabled := isTimerEnabled() - res := runRoutesTimerSet(!enabled) - writeJSON(w, http.StatusOK, res) -} - -// новый API: GET → {enabled:bool}, POST {enabled:bool} -// --------------------------------------------------------------------- -// EN: `handleRoutesTimer` is an HTTP handler for routes timer. -// RU: `handleRoutesTimer` - HTTP-обработчик для routes timer. -// --------------------------------------------------------------------- -func handleRoutesTimer(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - enabled := isTimerEnabled() - writeJSON(w, http.StatusOK, map[string]any{ - "enabled": enabled, - }) - case http.MethodPost: - var body struct { - Enabled bool `json:"enabled"` - } - if r.Body != nil { - defer r.Body.Close() - _ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body) - } - res := runRoutesTimerSet(body.Enabled) - writeJSON(w, http.StatusOK, res) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -// --------------------------------------------------------------------- -// EN: `isTimerEnabled` checks whether timer enabled is true. -// RU: `isTimerEnabled` - проверяет, является ли timer enabled истинным условием. -// --------------------------------------------------------------------- -func isTimerEnabled() bool { - unit := routesTimerUnitName() - if unit == "" { - return false - } - _, _, code, _ := runCommand("systemctl", "is-enabled", unit) - return code == 0 -} - -func runRoutesTimerSet(enabled bool) cmdResult { - unit := routesTimerUnitName() - if unit == "" { - return cmdResult{ - OK: false, - Message: "routes timer unit unresolved: set preferred iface or SELECTIVE_VPN_ROUTES_TIMER", - } - } - cmd := []string{"systemctl", "disable", "--now", unit} - msg := "routes timer disabled" - if enabled { - cmd = []string{"systemctl", "enable", "--now", unit} - msg = "routes timer enabled" - } - stdout, stderr, exitCode, err := runCommand(cmd[0], cmd[1:]...) - res := cmdResult{ - OK: err == nil && exitCode == 0, - Message: fmt.Sprintf("%s (%s)", msg, unit), - ExitCode: exitCode, - Stdout: stdout, - Stderr: stderr, - } - if err != nil { - res.Message = fmt.Sprintf("%s (%s): %v", msg, unit, err) - } - return res -} - -// --------------------------------------------------------------------- -// rollback / clear -// --------------------------------------------------------------------- - -func handleRoutesClear(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - res := routesClear() - writeJSON(w, http.StatusOK, res) -} - -func handleRoutesCacheRestore(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - res := restoreRoutesFromCache() - writeJSON(w, http.StatusOK, res) -} - -// --------------------------------------------------------------------- -// EN: `routesClear` contains core logic for routes clear. -// RU: `routesClear` - содержит основную логику для routes clear. -// --------------------------------------------------------------------- -func routesClear() cmdResult { - return withRoutesOpLock("routes clear", routesClearUnlocked) -} - -func routesClearUnlocked() cmdResult { - cacheMeta, cacheErr := saveRoutesClearCache() - - stdout, stderr, _, err := runCommand("ip", "rule", "show") - if err == nil && stdout != "" { - removeTrafficRulesForTable() - } - - _, _, _, _ = runCommand("ip", "route", "flush", "table", routesTableName()) - _, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn4") - _, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn_dyn4") - iface := strings.TrimSpace(cacheMeta.Iface) - if iface == "" { - iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface) - } - _ = writeStatusSnapshot(0, iface) - - res := cmdResult{ - OK: true, - Message: "routes cleared", - ExitCode: 0, - Stdout: stdout, - Stderr: stderr, - } - if cacheErr != nil { - res.Message = fmt.Sprintf("%s (cache warning: %v)", res.Message, cacheErr) - } else { - res.Message = fmt.Sprintf( - "%s (cache saved: agvpn4=%d agvpn_dyn4=%d routes=%d iface=%s at=%s)", - res.Message, - cacheMeta.IPCount, - cacheMeta.DynIPCount, - cacheMeta.RouteCount, - ifaceOrDash(cacheMeta.Iface), - cacheMeta.CreatedAt, - ) - } - return res -} - -func withRoutesOpLock(opName string, fn func() cmdResult) cmdResult { - lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) - if err != nil { - return cmdResult{ - OK: false, - Message: fmt.Sprintf("%s lock open error: %v", opName, err), - } - } - defer lock.Close() - - if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - return cmdResult{ - OK: false, - Message: fmt.Sprintf("%s skipped: routes operation already running", opName), - } - } - defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) - - return fn() -} - -func writeStatusSnapshot(ipCount int, iface string) error { - if ipCount < 0 { - ipCount = 0 - } - iface = strings.TrimSpace(iface) - if iface == "" { - iface = "-" - } - st := Status{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - IPCount: ipCount, - DomainCount: countDomainsFromMap(lastIPsMapPath), - Iface: iface, - Table: routesTableName(), - Mark: MARK, - } - data, err := json.MarshalIndent(st, "", " ") - if err != nil { - return err - } - return os.WriteFile(statusFilePath, data, 0o644) -} - -// --------------------------------------------------------------------- -// policy route -// --------------------------------------------------------------------- - -func handleFixPolicyRoute(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - data, err := os.ReadFile(statusFilePath) - if err != nil { - http.Error(w, "status.json missing", http.StatusBadRequest) - return - } - var st Status - if err := json.Unmarshal(data, &st); err != nil { - http.Error(w, "invalid status.json", http.StatusBadRequest) - return - } - - iface := strings.TrimSpace(st.Iface) - table := strings.TrimSpace(st.Table) - if iface == "" || iface == "-" || table == "" || table == "-" { - http.Error(w, "iface/table unknown in status.json", http.StatusBadRequest) - return - } - - stdout, stderr, exitCode, err := runCommand( - "ip", "-4", "route", "replace", - "default", "dev", iface, "table", table, "mtu", policyRouteMTU, - ) - - ok := err == nil && exitCode == 0 - res := cmdResult{ - OK: ok, - ExitCode: exitCode, - Stdout: stdout, - Stderr: stderr, - } - if ok { - res.Message = fmt.Sprintf("policy route fixed: default dev %s table %s", iface, table) - } else if err != nil { - res.Message = err.Error() - } - - writeJSON(w, http.StatusOK, res) -} - -// --------------------------------------------------------------------- -// routes update (Go port of update-selective-routes2.sh) -// --------------------------------------------------------------------- - -func handleRoutesUpdate(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var body struct { - Iface string `json:"iface"` - } - if r.Body != nil { - defer r.Body.Close() - _ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body) - } - iface := strings.TrimSpace(body.Iface) - iface = normalizePreferredIface(iface) - if iface == "" { - iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface) - } - - lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) - if err != nil { - http.Error(w, "lock open error", http.StatusInternalServerError) - return - } - if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - writeJSON(w, http.StatusOK, map[string]any{ - "ok": false, - "message": "routes update already running", - }) - lock.Close() - return - } - - go func(iface string, lockFile *os.File) { - defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) - defer lockFile.Close() - - res := routesUpdate(iface) - evKind := "routes_update_done" - if !res.OK { - evKind = "routes_update_error" - } - events.push(evKind, map[string]any{ - "ok": res.OK, - "message": res.Message, - "ip_cnt": res.ExitCode, // reuse exitCode to pass ip_count if set - }) - }(iface, lock) - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "message": "routes update started", - }) -} diff --git a/selective-vpn-api/app/routes_handlers_ops.go b/selective-vpn-api/app/routes_handlers_ops.go new file mode 100644 index 0000000..b69d7e2 --- /dev/null +++ b/selective-vpn-api/app/routes_handlers_ops.go @@ -0,0 +1,86 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// --------------------------------------------------------------------- +// rollback / clear +// --------------------------------------------------------------------- + +func handleRoutesClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + res := routesClear() + writeJSON(w, http.StatusOK, res) +} + +func handleRoutesCacheRestore(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + res := restoreRoutesFromCache() + writeJSON(w, http.StatusOK, res) +} + +func handleRoutesPrecheckDebug(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var body struct { + RunNow bool `json:"run_now"` + } + runNow := true + if r.Body != nil { + defer r.Body.Close() + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err == nil { + runNow = body.RunNow + } + } + + now := time.Now().UTC().Format(time.RFC3339) + content := []byte("forced_at=" + now + "\n") + if err := os.WriteFile(precheckForcePath, content, 0o644); err != nil { + writeJSON(w, http.StatusOK, cmdResult{ + OK: false, + Message: fmt.Sprintf("precheck debug arm failed: %v", err), + }) + return + } + appendTraceLineTo(traceLogPath, "routes", fmt.Sprintf("debug precheck armed: %s", precheckForcePath)) + + if !runNow { + writeJSON(w, http.StatusOK, cmdResult{ + OK: true, + Message: "precheck debug armed (run_now=false)", + }) + return + } + + // Run restart asynchronously: this endpoint is debug/helper and should return + // immediately, otherwise GUI client can hit read-timeout while systemctl works. + go func() { + restartRes := runRoutesServiceAction("restart") + if restartRes.OK { + appendTraceLineTo(traceLogPath, "routes", "debug precheck: routes restart completed") + return + } + appendTraceLineTo(traceLogPath, "routes", "debug precheck: routes restart failed: "+strings.TrimSpace(restartRes.Message)) + }() + + writeJSON(w, http.StatusOK, cmdResult{ + OK: true, + Message: "precheck debug armed + async routes restart requested", + }) +} diff --git a/selective-vpn-api/app/routes_handlers_ops_core.go b/selective-vpn-api/app/routes_handlers_ops_core.go new file mode 100644 index 0000000..1516d3f --- /dev/null +++ b/selective-vpn-api/app/routes_handlers_ops_core.go @@ -0,0 +1,102 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "syscall" + "time" +) + +// --------------------------------------------------------------------- +// EN: `routesClear` contains core logic for routes clear. +// RU: `routesClear` - содержит основную логику для routes clear. +// --------------------------------------------------------------------- +func routesClear() cmdResult { + return withRoutesOpLock("routes clear", routesClearUnlocked) +} + +func routesClearUnlocked() cmdResult { + cacheMeta, cacheErr := saveRoutesClearCache() + + stdout, stderr, _, err := runCommand("ip", "rule", "show") + if err == nil && stdout != "" { + removeTrafficRulesForTable() + } + + _, _, _, _ = runCommand("ip", "route", "flush", "table", routesTableName()) + _, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn4") + _, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn_dyn4") + iface := strings.TrimSpace(cacheMeta.Iface) + if iface == "" { + iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface) + } + _ = writeStatusSnapshot(0, iface) + + res := cmdResult{ + OK: true, + Message: "routes cleared", + ExitCode: 0, + Stdout: stdout, + Stderr: stderr, + } + if cacheErr != nil { + res.Message = fmt.Sprintf("%s (cache warning: %v)", res.Message, cacheErr) + } else { + res.Message = fmt.Sprintf( + "%s (cache saved: agvpn4=%d agvpn_dyn4=%d routes=%d iface=%s at=%s)", + res.Message, + cacheMeta.IPCount, + cacheMeta.DynIPCount, + cacheMeta.RouteCount, + ifaceOrDash(cacheMeta.Iface), + cacheMeta.CreatedAt, + ) + } + return res +} + +func withRoutesOpLock(opName string, fn func() cmdResult) cmdResult { + lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return cmdResult{ + OK: false, + Message: fmt.Sprintf("%s lock open error: %v", opName, err), + } + } + defer lock.Close() + + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return cmdResult{ + OK: false, + Message: fmt.Sprintf("%s skipped: routes operation already running", opName), + } + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + + return fn() +} + +func writeStatusSnapshot(ipCount int, iface string) error { + if ipCount < 0 { + ipCount = 0 + } + iface = strings.TrimSpace(iface) + if iface == "" { + iface = "-" + } + st := Status{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + IPCount: ipCount, + DomainCount: countDomainsFromMap(lastIPsMapPath), + Iface: iface, + Table: routesTableName(), + Mark: MARK, + } + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + return os.WriteFile(statusFilePath, data, 0o644) +} diff --git a/selective-vpn-api/app/routes_handlers_policy_fix.go b/selective-vpn-api/app/routes_handlers_policy_fix.go new file mode 100644 index 0000000..3c93aa5 --- /dev/null +++ b/selective-vpn-api/app/routes_handlers_policy_fix.go @@ -0,0 +1,54 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" +) + +func handleFixPolicyRoute(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + data, err := os.ReadFile(statusFilePath) + if err != nil { + http.Error(w, "status.json missing", http.StatusBadRequest) + return + } + var st Status + if err := json.Unmarshal(data, &st); err != nil { + http.Error(w, "invalid status.json", http.StatusBadRequest) + return + } + + iface := strings.TrimSpace(st.Iface) + table := strings.TrimSpace(st.Table) + if iface == "" || iface == "-" || table == "" || table == "-" { + http.Error(w, "iface/table unknown in status.json", http.StatusBadRequest) + return + } + + stdout, stderr, exitCode, err := runCommand( + "ip", "-4", "route", "replace", + "default", "dev", iface, "table", table, "mtu", policyRouteMTU, + ) + + ok := err == nil && exitCode == 0 + res := cmdResult{ + OK: ok, + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + } + if ok { + res.Message = fmt.Sprintf("policy route fixed: default dev %s table %s", iface, table) + } else if err != nil { + res.Message = err.Error() + } + + writeJSON(w, http.StatusOK, res) +} diff --git a/selective-vpn-api/app/routes_handlers_service.go b/selective-vpn-api/app/routes_handlers_service.go new file mode 100644 index 0000000..6842b55 --- /dev/null +++ b/selective-vpn-api/app/routes_handlers_service.go @@ -0,0 +1,5 @@ +package app + +// Routes service/timer handlers are split by role: +// - service action endpoints/helpers: routes_handlers_service_action.go +// - timer endpoints/helpers: routes_handlers_service_timer.go diff --git a/selective-vpn-api/app/routes_handlers_service_action.go b/selective-vpn-api/app/routes_handlers_service_action.go new file mode 100644 index 0000000..30395da --- /dev/null +++ b/selective-vpn-api/app/routes_handlers_service_action.go @@ -0,0 +1,83 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +func runRoutesServiceAction(action string) cmdResult { + action = strings.ToLower(strings.TrimSpace(action)) + unit := routesServiceUnitName() + if unit == "" { + return cmdResult{ + OK: false, + Message: "routes service unit unresolved: set preferred iface or SELECTIVE_VPN_ROUTES_UNIT", + } + } + + var args []string + switch action { + case "start", "stop", "restart": + args = []string{"systemctl", action, unit} + default: + return cmdResult{ + OK: false, + Message: "unknown action (expected start|stop|restart)", + } + } + + stdout, stderr, exitCode, err := runCommand(args[0], args[1:]...) + res := cmdResult{ + OK: err == nil && exitCode == 0, + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + } + if err != nil { + res.Message = err.Error() + } else { + res.Message = fmt.Sprintf("%s done (%s)", action, unit) + } + return res +} + +func makeRoutesServiceActionHandler(action string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + res := runRoutesServiceAction(action) + writeJSON(w, http.StatusOK, res) + } +} + +// POST /api/v1/routes/service { "action": "start|stop|restart" } +// --------------------------------------------------------------------- +// EN: `handleRoutesService` is an HTTP handler for routes service. +// RU: `handleRoutesService` - HTTP-обработчик для routes service. +// --------------------------------------------------------------------- +func handleRoutesService(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) + } + + res := runRoutesServiceAction(body.Action) + if strings.Contains(res.Message, "unknown action") { + writeJSON(w, http.StatusBadRequest, res) + return + } + writeJSON(w, http.StatusOK, res) +} diff --git a/selective-vpn-api/app/routes_handlers_service_timer.go b/selective-vpn-api/app/routes_handlers_service_timer.go new file mode 100644 index 0000000..2e87afa --- /dev/null +++ b/selective-vpn-api/app/routes_handlers_service_timer.go @@ -0,0 +1,91 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// --------------------------------------------------------------------- +// routes timer +// --------------------------------------------------------------------- + +// старый toggle (используем из GUI, если что) +func handleRoutesTimerToggle(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + enabled := isTimerEnabled() + res := runRoutesTimerSet(!enabled) + writeJSON(w, http.StatusOK, res) +} + +// новый API: GET → {enabled:bool}, POST {enabled:bool} +// --------------------------------------------------------------------- +// EN: `handleRoutesTimer` is an HTTP handler for routes timer. +// RU: `handleRoutesTimer` - HTTP-обработчик для routes timer. +// --------------------------------------------------------------------- +func handleRoutesTimer(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + enabled := isTimerEnabled() + writeJSON(w, http.StatusOK, map[string]any{ + "enabled": enabled, + }) + case http.MethodPost: + var body struct { + Enabled bool `json:"enabled"` + } + if r.Body != nil { + defer r.Body.Close() + _ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body) + } + res := runRoutesTimerSet(body.Enabled) + writeJSON(w, http.StatusOK, res) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// --------------------------------------------------------------------- +// EN: `isTimerEnabled` checks whether timer enabled is true. +// RU: `isTimerEnabled` - проверяет, является ли timer enabled истинным условием. +// --------------------------------------------------------------------- +func isTimerEnabled() bool { + unit := routesTimerUnitName() + if unit == "" { + return false + } + _, _, code, _ := runCommand("systemctl", "is-enabled", unit) + return code == 0 +} + +func runRoutesTimerSet(enabled bool) cmdResult { + unit := routesTimerUnitName() + if unit == "" { + return cmdResult{ + OK: false, + Message: "routes timer unit unresolved: set preferred iface or SELECTIVE_VPN_ROUTES_TIMER", + } + } + cmd := []string{"systemctl", "disable", "--now", unit} + msg := "routes timer disabled" + if enabled { + cmd = []string{"systemctl", "enable", "--now", unit} + msg = "routes timer enabled" + } + stdout, stderr, exitCode, err := runCommand(cmd[0], cmd[1:]...) + res := cmdResult{ + OK: err == nil && exitCode == 0, + Message: fmt.Sprintf("%s (%s)", msg, unit), + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + } + if err != nil { + res.Message = fmt.Sprintf("%s (%s): %v", msg, unit, err) + } + return res +} diff --git a/selective-vpn-api/app/routes_handlers_status.go b/selective-vpn-api/app/routes_handlers_status.go new file mode 100644 index 0000000..4bd2089 --- /dev/null +++ b/selective-vpn-api/app/routes_handlers_status.go @@ -0,0 +1,43 @@ +package app + +import ( + "encoding/json" + "log" + "net/http" + "os" +) + +func handleGetStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + data, err := os.ReadFile(statusFilePath) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "status file not found", http.StatusNotFound) + return + } + http.Error(w, "failed to read status file", http.StatusInternalServerError) + return + } + + var st Status + if err := json.Unmarshal(data, &st); err != nil { + http.Error(w, "invalid status.json", http.StatusInternalServerError) + return + } + + if st.Iface != "" && st.Iface != "-" && st.Table != "" && st.Table != "-" { + ok, err := checkPolicyRoute(st.Iface, st.Table) + if err != nil { + log.Printf("checkPolicyRoute error: %v", err) + } else { + st.PolicyRouteOK = &ok + st.RouteOK = &ok + } + } + + writeJSON(w, http.StatusOK, st) +} diff --git a/selective-vpn-api/app/routes_handlers_update.go b/selective-vpn-api/app/routes_handlers_update.go new file mode 100644 index 0000000..844de53 --- /dev/null +++ b/selective-vpn-api/app/routes_handlers_update.go @@ -0,0 +1,64 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "os" + "strings" + "syscall" +) + +func handleRoutesUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Iface string `json:"iface"` + } + if r.Body != nil { + defer r.Body.Close() + _ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body) + } + iface := strings.TrimSpace(body.Iface) + iface = normalizePreferredIface(iface) + if iface == "" { + iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface) + } + + lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + http.Error(w, "lock open error", http.StatusInternalServerError) + return + } + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, + "message": "routes update already running", + }) + lock.Close() + return + } + + go func(iface string, lockFile *os.File) { + defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + defer lockFile.Close() + + res := routesUpdate(iface) + evKind := "routes_update_done" + if !res.OK { + evKind = "routes_update_error" + } + events.push(evKind, map[string]any{ + "ok": res.OK, + "message": res.Message, + "ip_cnt": res.ExitCode, // reuse exitCode to pass ip_count if set + }) + }(iface, lock) + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "message": "routes update started", + }) +} diff --git a/selective-vpn-api/app/routes_update.go b/selective-vpn-api/app/routes_update.go index 7a0c846..1ba4b5c 100644 --- a/selective-vpn-api/app/routes_update.go +++ b/selective-vpn-api/app/routes_update.go @@ -1,17 +1,8 @@ package app import ( - "context" - "encoding/json" "fmt" - "io/fs" - "net" "os" - "os/user" - "sort" - "strconv" - "strings" - "time" ) // --------------------------------------------------------------------- @@ -50,698 +41,51 @@ func routesUpdate(iface string) cmdResult { return res } - // ----------------------------------------------------------------- - // preflight - // ----------------------------------------------------------------- - - // ensure dirs - _ = os.MkdirAll(stateDir, 0o755) - _ = os.MkdirAll(domainDir, 0o755) - _ = os.MkdirAll("/etc/selective-vpn", 0o755) - - heartbeat() - - // wait iface up - up := false - for i := 0; i < 30; i++ { - if _, _, code, _ := runCommandTimeout(3*time.Second, "ip", "link", "show", iface); code == 0 { - up = true - break - } - time.Sleep(1 * time.Second) - heartbeat() - } - if !up { - logp("no %s, exit 0", iface) - res.OK = true - res.Message = "interface not found, skipped" - return res - } - - // wait DNS (like wait-for-dns.sh) - if err := waitDNS(15, 1*time.Second); err != nil { - logp("dns not ready: %v", err) - res.Message = "dns not ready" - return res - } - - // ----------------------------------------------------------------- - // policy routing setup - // ----------------------------------------------------------------- - - // rt_tables entry - ensureRoutesTableEntry() - - // ip rules: remove old rules pointing to table - if out, _, _, _ := runCommandTimeout(5*time.Second, "ip", "rule", "show"); out != "" { - for _, line := range strings.Split(out, "\n") { - if !strings.Contains(line, "lookup "+routesTableName()) { - continue - } - fields := strings.Fields(line) - if len(fields) == 0 { - continue - } - pref := strings.TrimSuffix(fields[0], ":") - if pref == "" { - continue - } - _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "rule", "del", "pref", pref) - } - } - - // clean table and set default route - _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "route", "flush", "table", routesTableName()) - _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "-4", "route", "replace", "default", "dev", iface, "table", routesTableName(), "mtu", policyRouteMTU) - // apply traffic mode rules (selective/full_tunnel/direct) over fresh table. - trafficState := loadTrafficModeState() - trafficIface, trafficIfaceReason := resolveTrafficIface(trafficState.PreferredIface) - if trafficIface == "" { - trafficIface = iface - trafficIfaceReason = "routes-update-iface" - } - if err := applyTrafficMode(trafficState, trafficIface); err != nil { - logp("traffic mode apply failed: mode=%s iface=%s err=%v", trafficState.Mode, iface, err) - res.Message = fmt.Sprintf("traffic mode apply failed: %v", err) - return res - } - trafficEval := evaluateTrafficMode(trafficState) - logp( - "traffic mode: desired=%s applied=%s healthy=%t iface=%s reason=%s", - trafficEval.DesiredMode, - trafficEval.AppliedMode, - trafficEval.Healthy, - trafficEval.ActiveIface, - trafficEval.Message+" (apply_iface_source="+trafficIfaceReason+")", - ) - - // ensure default exists - if out, _, _, _ := runCommandTimeout(5*time.Second, "ip", "route", "show", "table", routesTableName()); !strings.Contains(out, "default dev "+iface) { - _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "-4", "route", "replace", "default", "dev", iface, "table", routesTableName(), "mtu", policyRouteMTU) - } - - heartbeat() - - // ----------------------------------------------------------------- - // nft base objects - // ----------------------------------------------------------------- - - // nft setup - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", "agvpn") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn_dyn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") - - // EN: Output chain jumps into: - // EN: - output_apps: runtime per-app marks (MARK_DIRECT / MARK_APP) - // EN: - output_ips: selective domain IP sets (MARK) - // RU: Output chain прыгает в: - // RU: - output_apps: runtime per-app marks (MARK_DIRECT / MARK_APP) - // RU: - output_ips: селективные доменные IP сеты (MARK) - - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output_apps") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output_ips") - - // Base chain: stable jumps only. - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "jump", "output_apps") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "jump", "output_ips") - - // App chain: runtime rules are managed by traffic_appmarks.go (do not flush here). - - // Domain chain: selective IP sets (resolver output). - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output_ips") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_ips", "ip", "daddr", "@agvpn4", "meta", "mark", "set", MARK) - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_ips", "ip", "daddr", "@agvpn_dyn4", "meta", "mark", "set", MARK) - - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "prerouting", "{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "prerouting") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "prerouting", "iifname", "!=", iface, "ip", "daddr", "@agvpn4", "meta", "mark", "set", MARK) - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "prerouting", "iifname", "!=", iface, "ip", "daddr", "@agvpn_dyn4", "meta", "mark", "set", MARK) - - heartbeat() - - // ----------------------------------------------------------------- - // domains + resolver - // ----------------------------------------------------------------- - - // domain lists - bases := loadList(domainDir + "/bases.txt") - subs := loadList(domainDir + "/subs.txt") - wildcards := loadSmartDNSWildcardDomains(logp) - wildcardBaseSet := make(map[string]struct{}, len(wildcards)) - for _, d := range wildcards { - d = strings.TrimSpace(d) - if d != "" { - wildcardBaseSet[d] = struct{}{} - } - } - wildcardBasesAdded := 0 - for _, d := range wildcards { - d = strings.TrimSpace(d) - if d == "" { - continue - } - bases = append(bases, d) - wildcardBasesAdded++ - } - subsPerBaseLimit := envInt("RESOLVE_SUBS_PER_BASE_LIMIT", 0) - if subsPerBaseLimit < 0 { - subsPerBaseLimit = 0 - } - hardCap := envInt("RESOLVE_DOMAINS_HARD_CAP", 0) - if hardCap < 0 { - hardCap = 0 - } - - domainSet := make(map[string]struct{}) - expandedAdded := 0 - twitterAdded := 0 - for _, d := range bases { - domainSet[d] = struct{}{} - _, wildcardBase := wildcardBaseSet[d] - // Wildcard bases are now resolved "as-is" (no subs fan-out) to keep - // SmartDNS wildcard behavior transparent and avoid synthetic host noise. - if !wildcardBase && !isGoogleLike(d) { - limit := len(subs) - if subsPerBaseLimit > 0 && subsPerBaseLimit < limit { - limit = subsPerBaseLimit - } - for i := 0; i < limit; i++ { - fqdn := subs[i] + "." + d - if _, ok := domainSet[fqdn]; !ok { - expandedAdded++ - } - domainSet[fqdn] = struct{}{} - } - } - } - for _, spec := range twitterSpecial { - fqdn := spec + ".twitter.com" - if _, ok := domainSet[fqdn]; !ok { - twitterAdded++ - } - domainSet[fqdn] = struct{}{} - } - - domains := make([]string, 0, len(domainSet)) - for d := range domainSet { - if d != "" { - domains = append(domains, d) - } - } - sort.Strings(domains) - totalBeforeCap := len(domains) - if hardCap > 0 && len(domains) > hardCap { - domains = domains[:hardCap] - logp("domain cap applied: before=%d after=%d hard_cap=%d", totalBeforeCap, len(domains), hardCap) - } - logp( - "domains expanded: bases=%d subs_total=%d subs_per_base_limit=%d expanded_added=%d twitter_added=%d total_before_cap=%d total_used=%d", - len(bases), - len(subs), - subsPerBaseLimit, - expandedAdded, - twitterAdded, - totalBeforeCap, - len(domains), - ) - if wildcardBasesAdded > 0 { - logp("domains wildcard seed added: %d base domains from smartdns.conf state", wildcardBasesAdded) - appendTraceLineTo( - smartdnsLogPath, - "smartdns", - fmt.Sprintf( - "wildcard plan: base_domains=%d sub_expanded=0 (routes update uses pure wildcard bases; subs fan-out only in aggressive prewarm)", - wildcardBasesAdded, - ), - ) - } - - domTmp, _ := os.CreateTemp(stateDir, "domains-*.txt") - defer os.Remove(domTmp.Name()) - for _, d := range domains { - _, _ = domTmp.WriteString(d + "\n") - } - domTmp.Close() - - ipTmp, _ := os.CreateTemp(stateDir, "ips-*.txt") - ipTmp.Close() - ipMapTmp, _ := os.CreateTemp(stateDir, "ipmap-*.txt") - ipMapTmp.Close() - ipDirectTmp, _ := os.CreateTemp(stateDir, "ips-direct-*.txt") - ipDirectTmp.Close() - ipDynTmp, _ := os.CreateTemp(stateDir, "ips-dyn-*.txt") - ipDynTmp.Close() - ipMapDirectTmp, _ := os.CreateTemp(stateDir, "ipmap-direct-*.txt") - ipMapDirectTmp.Close() - ipMapDynTmp, _ := os.CreateTemp(stateDir, "ipmap-dyn-*.txt") - ipMapDynTmp.Close() - - heartbeat() - logp("using Go resolver for domains -> IPs") - mode := loadDNSMode() - runtimeEnabled := smartDNSRuntimeEnabled() - wildcardSource := wildcardFillSource(runtimeEnabled) - logp("resolver mode=%s smartdns_addr=%s wildcards=%d", mode.Mode, mode.SmartDNSAddr, len(wildcards)) - logp("wildcard source baseline: %s (runtime_nftset=%t)", wildcardSource, runtimeEnabled) - - resolveOpts := ResolverOpts{ - DomainsPath: domTmp.Name(), - MetaPath: domainDir + "/meta-special.txt", - StaticPath: staticIPsFile, - CachePath: stateDir + "/domain-cache.json", - PtrCachePath: stateDir + "/ptr-cache.json", - TraceLog: traceLogPath, - TTL: envInt("RESOLVE_TTL", 24*3600), - Workers: envInt("RESOLVE_JOBS", 40), - DNSConfigPath: dnsUpstreamsConf, - ViaSmartDNS: mode.ViaSmartDNS, // legacy fallback for older clients/state - Mode: mode.Mode, - SmartDNSAddr: mode.SmartDNSAddr, - SmartDNSWildcards: wildcards, - } - - resJob, err := runResolverJob(resolveOpts, logp) + skip, message, err := routesUpdateStagePreflight(iface, logp, heartbeat) if err != nil { - logp("Go resolver FAILED: %v", err) - res.Message = fmt.Sprintf("resolver failed: %v", err) + res.Message = message + return res + } + if skip { + res.OK = true + res.Message = message return res } - if err := writeLines(ipTmp.Name(), resJob.IPs); err != nil { - logp("write ips failed: %v", err) - res.Message = fmt.Sprintf("write ips failed: %v", err) + if err := routesUpdateStagePolicy(iface, logp); err != nil { + res.Message = err.Error() return res } - if err := writeMapPairs(ipMapTmp.Name(), resJob.IPMap); err != nil { - logp("write ip_map failed: %v", err) - res.Message = fmt.Sprintf("write ip_map failed: %v", err) - return res - } - if err := writeLines(ipDirectTmp.Name(), resJob.DirectIPs); err != nil { - logp("write direct ips failed: %v", err) - res.Message = fmt.Sprintf("write direct ips failed: %v", err) - return res - } - if err := writeLines(ipDynTmp.Name(), resJob.WildcardIPs); err != nil { - logp("write wildcard ips failed: %v", err) - res.Message = fmt.Sprintf("write wildcard ips failed: %v", err) - return res - } - if err := writeMapPairs(ipMapDirectTmp.Name(), resJob.DirectIPMap); err != nil { - logp("write direct ip_map failed: %v", err) - res.Message = fmt.Sprintf("write direct ip_map failed: %v", err) - return res - } - if err := writeMapPairs(ipMapDynTmp.Name(), resJob.WildcardIPMap); err != nil { - logp("write wildcard ip_map failed: %v", err) - res.Message = fmt.Sprintf("write wildcard ip_map failed: %v", err) - return res - } - saveJSON(resJob.DomainCache, resolveOpts.CachePath) - saveJSON(resJob.PtrCache, resolveOpts.PtrCachePath) - heartbeat() - ipCount := len(resJob.IPs) - directIPCount := len(resJob.DirectIPs) - wildcardIPCount := len(resJob.WildcardIPs) - domainCount := countDomainsFromPairs(resJob.IPMap) - - // ----------------------------------------------------------------- - // nft population - // ----------------------------------------------------------------- - - // nft load через умный апдейтер - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - progressCb := func(percent int, msg string) { - logp("NFT progress: %d%% - %s", percent, msg) - heartbeat() - events.push("routes_nft_progress", map[string]any{ - "percent": percent, - "message": msg, - }) - } - - progressRange := func(start, end int, prefix string) ProgressCallback { - if progressCb == nil { - return nil - } - if end < start { - end = start - } - return func(percent int, msg string) { - if percent < 0 { - percent = 0 - } - if percent > 100 { - percent = 100 - } - scaled := start + (end-start)*percent/100 - if strings.TrimSpace(msg) == "" { - msg = "updating" - } - progressCb(scaled, prefix+": "+msg) - } - } - - if err := nftUpdateSetIPsSmart(ctx, "agvpn4", resJob.DirectIPs, progressRange(0, 50, "agvpn4")); err != nil { - logp("nft set update failed for agvpn4: %v", err) - res.Message = fmt.Sprintf("nft update failed for agvpn4: %v", err) - return res - } - if err := nftUpdateSetIPsSmart(ctx, "agvpn_dyn4", resJob.WildcardIPs, progressRange(50, 100, "agvpn_dyn4")); err != nil { - logp("nft set update failed for agvpn_dyn4: %v", err) - res.Message = fmt.Sprintf("nft update failed for agvpn_dyn4: %v", err) - return res - } - - logp("summary: domains=%d, unique_ips=%d direct_ips=%d wildcard_ips=%d", len(domains), ipCount, directIPCount, wildcardIPCount) - logp("updated agvpn4 with %d IPs (direct + static)", directIPCount) - logp("updated agvpn_dyn4 with %d IPs (wildcard, source=%s)", wildcardIPCount, wildcardSource) - logWildcardSmartDNSTrace(mode, wildcardSource, resJob.WildcardIPMap, wildcardIPCount) - - // ----------------------------------------------------------------- - // artifacts + status - // ----------------------------------------------------------------- - - // copy artifacts - _ = copyFile(ipTmp.Name(), lastIPsPath) - _ = copyFile(ipMapTmp.Name(), lastIPsMapPath) - _ = copyFile(ipDirectTmp.Name(), lastIPsDirect) - _ = copyFile(ipDynTmp.Name(), lastIPsDyn) - _ = copyFile(ipMapDirectTmp.Name(), lastIPsMapDirect) - _ = copyFile(ipMapDynTmp.Name(), lastIPsMapDyn) - - now := time.Now().Format(time.RFC3339) - status := Status{ - Timestamp: now, - IPCount: ipCount, - DomainCount: domainCount, - Iface: iface, - Table: routesTableName(), - Mark: MARK, - } - statusData, _ := json.MarshalIndent(status, "", " ") - _ = os.WriteFile(statusFilePath, statusData, 0o644) - - chownDev( - traceLogPath, - ipTmp.Name(), ipMapTmp.Name(), - ipDirectTmp.Name(), ipDynTmp.Name(), ipMapDirectTmp.Name(), ipMapDynTmp.Name(), - lastIPsPath, lastIPsMapPath, lastIPsDirect, lastIPsDyn, lastIPsMapDirect, lastIPsMapDyn, - statusFilePath, - heartbeatFile, - ) - chmodPaths( - 0o644, - ipTmp.Name(), ipMapTmp.Name(), - ipDirectTmp.Name(), ipDynTmp.Name(), ipMapDirectTmp.Name(), ipMapDynTmp.Name(), - lastIPsPath, lastIPsMapPath, lastIPsDirect, lastIPsDyn, lastIPsMapDirect, lastIPsMapDyn, - statusFilePath, - heartbeatFile, - ) - _ = os.Chmod(traceLogPath, 0o666) - _ = os.Chmod(stateDir, 0o755) - + routesUpdateStageNftBase(iface) heartbeat() + resolveStage, err := routesUpdateStageResolve(logp, heartbeat) + if err != nil { + res.Message = err.Error() + return res + } + defer resolveStage.cleanup() + + if err := routesUpdateStagePopulateNFT(resolveStage, logp, heartbeat); err != nil { + res.Message = err.Error() + return res + } + + if err := routesUpdateStageArtifacts(iface, resolveStage, heartbeat); err != nil { + res.Message = err.Error() + return res + } + res.OK = true - res.Message = fmt.Sprintf("update done: domains=%d unique_ips=%d direct_ips=%d wildcard_ips=%d", len(domains), ipCount, directIPCount, wildcardIPCount) - res.ExitCode = ipCount + res.Message = fmt.Sprintf( + "update done: domains=%d unique_ips=%d direct_ips=%d wildcard_ips=%d", + len(resolveStage.domains), + resolveStage.ipCount, + resolveStage.directIPCount, + resolveStage.wildcardIPCount, + ) + res.ExitCode = resolveStage.ipCount return res } - -// --------------------------------------------------------------------- -// routesUpdate helpers: table / list / counters -// --------------------------------------------------------------------- - -func routesTableName() string { return "agvpn" } - -// --------------------------------------------------------------------- -// EN: `routesTableNum` contains core logic for routes table num. -// RU: `routesTableNum` - содержит основную логику для routes table num. -// --------------------------------------------------------------------- -func routesTableNum() string { return "666" } - -// --------------------------------------------------------------------- -// EN: `loadList` loads list from storage or config. -// RU: `loadList` - загружает list из хранилища или конфига. -// --------------------------------------------------------------------- -func loadList(path string) []string { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - var out []string - for _, ln := range strings.Split(string(data), "\n") { - ln = strings.TrimSpace(strings.SplitN(ln, "#", 2)[0]) - if ln == "" { - continue - } - out = append(out, ln) - } - return out -} - -// --------------------------------------------------------------------- -// EN: `loadSmartDNSWildcardDomains` loads SmartDNS wildcard domains from canonical API state. -// RU: `loadSmartDNSWildcardDomains` - загружает wildcard-домены SmartDNS из каноничного API-состояния. -// --------------------------------------------------------------------- -func loadSmartDNSWildcardDomains(logf func(string, ...any)) []string { - out, source := loadSmartDNSWildcardDomainsState(logf) - sort.Strings(out) - if logf != nil { - logf("smartdns wildcards loaded: source=%s count=%d", source, len(out)) - } - return out -} - -// --------------------------------------------------------------------- -// EN: `isGoogleLike` checks whether google like is true. -// RU: `isGoogleLike` - проверяет, является ли google like истинным условием. -// --------------------------------------------------------------------- -func isGoogleLike(d string) bool { - low := strings.ToLower(d) - for _, base := range googleLikeDomains { - if low == base || strings.HasSuffix(low, "."+base) { - return true - } - } - return false -} - -// --------------------------------------------------------------------- -// EN: `readNonEmptyLines` reads non empty lines from input data. -// RU: `readNonEmptyLines` - читает non empty lines из входных данных. -// --------------------------------------------------------------------- -func readNonEmptyLines(path string) []string { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - var out []string - for _, ln := range strings.Split(string(data), "\n") { - ln = strings.TrimSpace(ln) - if ln != "" { - out = append(out, ln) - } - } - return out -} - -func writeLines(path string, lines []string) error { - if len(lines) == 0 { - return os.WriteFile(path, []byte{}, 0o644) - } - return os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644) -} - -func writeMapPairs(path string, pairs [][2]string) error { - if len(pairs) == 0 { - return os.WriteFile(path, []byte{}, 0o644) - } - lines := make([]string, 0, len(pairs)) - for _, p := range pairs { - lines = append(lines, p[0]+"\t"+p[1]) - } - return os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644) -} - -func countDomainsFromPairs(pairs [][2]string) int { - seen := make(map[string]struct{}) - for _, p := range pairs { - if len(p) < 2 { - continue - } - d := strings.TrimSpace(p[1]) - if d == "" || strings.HasPrefix(d, "[") { - continue - } - seen[d] = struct{}{} - } - return len(seen) -} - -func wildcardHostIPMap(pairs [][2]string) map[string][]string { - hostToIPs := make(map[string]map[string]struct{}) - for _, p := range pairs { - if len(p) < 2 { - continue - } - ip := strings.TrimSpace(p[0]) - host := strings.TrimSpace(p[1]) - if ip == "" || host == "" || strings.HasPrefix(host, "[") { - continue - } - ips := hostToIPs[host] - if ips == nil { - ips = map[string]struct{}{} - hostToIPs[host] = ips - } - ips[ip] = struct{}{} - } - - out := make(map[string][]string, len(hostToIPs)) - for host, ipset := range hostToIPs { - ips := make([]string, 0, len(ipset)) - for ip := range ipset { - ips = append(ips, ip) - } - sort.Strings(ips) - out[host] = ips - } - return out -} - -func logWildcardSmartDNSTrace(mode DNSMode, source string, pairs [][2]string, wildcardIPCount int) { - lowMode := strings.ToLower(strings.TrimSpace(string(mode.Mode))) - if lowMode != string(DNSModeHybridWildcard) && lowMode != string(DNSModeSmartDNS) { - return - } - - hostMap := wildcardHostIPMap(pairs) - hosts := make([]string, 0, len(hostMap)) - for host := range hostMap { - hosts = append(hosts, host) - } - sort.Strings(hosts) - - const maxHostsLog = 200 - omitted := 0 - if len(hosts) > maxHostsLog { - omitted = len(hosts) - maxHostsLog - } - - appendTraceLineTo( - smartdnsLogPath, - "smartdns", - fmt.Sprintf( - "wildcard sync: mode=%s source=%s domains=%d ips=%d logged=%d omitted=%d map=%s", - mode.Mode, source, len(hosts), wildcardIPCount, len(hosts)-omitted, omitted, lastIPsMapDyn, - ), - ) - - for i, host := range hosts { - if i >= maxHostsLog { - appendTraceLineTo( - smartdnsLogPath, - "smartdns", - fmt.Sprintf("wildcard sync: trace truncated, %d domains not shown (see %s)", omitted, lastIPsMapDyn), - ) - return - } - appendTraceLineTo( - smartdnsLogPath, - "smartdns", - fmt.Sprintf("wildcard add: %s -> %s", host, strings.Join(hostMap[host], ", ")), - ) - } -} - -// --------------------------------------------------------------------- -// EN: `countDomainsFromMap` counts items for domains from map. -// RU: `countDomainsFromMap` - считает элементы для domains from map. -// --------------------------------------------------------------------- -func countDomainsFromMap(path string) int { - data, err := os.ReadFile(path) - if err != nil { - return 0 - } - seen := make(map[string]struct{}) - for _, ln := range strings.Split(string(data), "\n") { - ln = strings.TrimSpace(ln) - if ln == "" { - continue - } - fields := strings.Fields(ln) - if len(fields) < 2 { - continue - } - d := fields[1] - if strings.HasPrefix(d, "[") { - continue - } - seen[d] = struct{}{} - } - return len(seen) -} - -// --------------------------------------------------------------------- -// filesystem helpers -// --------------------------------------------------------------------- - -func copyFile(src, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - return err - } - return os.WriteFile(dst, data, 0o644) -} - -// --------------------------------------------------------------------- -// EN: `chownDev` contains core logic for chown dev. -// RU: `chownDev` - содержит основную логику для chown dev. -// --------------------------------------------------------------------- -func chownDev(paths ...string) { - usr, err := user.Lookup("dev") - if err != nil { - return - } - uid, _ := strconv.Atoi(usr.Uid) - gid, _ := strconv.Atoi(usr.Gid) - for _, p := range paths { - _ = os.Chown(p, uid, gid) - } -} - -// --------------------------------------------------------------------- -// EN: `chmodPaths` contains core logic for chmod paths. -// RU: `chmodPaths` - содержит основную логику для chmod paths. -// --------------------------------------------------------------------- -func chmodPaths(mode fs.FileMode, paths ...string) { - for _, p := range paths { - _ = os.Chmod(p, mode) - } -} - -// --------------------------------------------------------------------- -// readiness helpers -// --------------------------------------------------------------------- - -func waitDNS(attempts int, delay time.Duration) error { - target := "openai.com" - for i := 0; i < attempts; i++ { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - _, err := net.DefaultResolver.LookupHost(ctx, target) - cancel() - if err == nil { - return nil - } - time.Sleep(delay) - } - return fmt.Errorf("dns lookup failed after %d attempts", attempts) -} diff --git a/selective-vpn-api/app/routes_update_helpers.go b/selective-vpn-api/app/routes_update_helpers.go new file mode 100644 index 0000000..5c1bed1 --- /dev/null +++ b/selective-vpn-api/app/routes_update_helpers.go @@ -0,0 +1,13 @@ +package app + +// --------------------------------------------------------------------- +// routesUpdate helpers: table identifiers +// --------------------------------------------------------------------- + +func routesTableName() string { return "agvpn" } + +// --------------------------------------------------------------------- +// EN: `routesTableNum` contains core logic for routes table num. +// RU: `routesTableNum` - содержит основную логику для routes table num. +// --------------------------------------------------------------------- +func routesTableNum() string { return "666" } diff --git a/selective-vpn-api/app/routes_update_helpers_fs.go b/selective-vpn-api/app/routes_update_helpers_fs.go new file mode 100644 index 0000000..a924051 --- /dev/null +++ b/selective-vpn-api/app/routes_update_helpers_fs.go @@ -0,0 +1,68 @@ +package app + +import ( + "context" + "fmt" + "io/fs" + "net" + "os" + "os/user" + "strconv" + "time" +) + +// --------------------------------------------------------------------- +// filesystem helpers +// --------------------------------------------------------------------- + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0o644) +} + +// --------------------------------------------------------------------- +// EN: `chownDev` contains core logic for chown dev. +// RU: `chownDev` - содержит основную логику для chown dev. +// --------------------------------------------------------------------- +func chownDev(paths ...string) { + usr, err := user.Lookup("dev") + if err != nil { + return + } + uid, _ := strconv.Atoi(usr.Uid) + gid, _ := strconv.Atoi(usr.Gid) + for _, p := range paths { + _ = os.Chown(p, uid, gid) + } +} + +// --------------------------------------------------------------------- +// EN: `chmodPaths` contains core logic for chmod paths. +// RU: `chmodPaths` - содержит основную логику для chmod paths. +// --------------------------------------------------------------------- +func chmodPaths(mode fs.FileMode, paths ...string) { + for _, p := range paths { + _ = os.Chmod(p, mode) + } +} + +// --------------------------------------------------------------------- +// readiness helpers +// --------------------------------------------------------------------- + +func waitDNS(attempts int, delay time.Duration) error { + target := "openai.com" + for i := 0; i < attempts; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _, err := net.DefaultResolver.LookupHost(ctx, target) + cancel() + if err == nil { + return nil + } + time.Sleep(delay) + } + return fmt.Errorf("dns lookup failed after %d attempts", attempts) +} diff --git a/selective-vpn-api/app/routes_update_helpers_lists.go b/selective-vpn-api/app/routes_update_helpers_lists.go new file mode 100644 index 0000000..1801fee --- /dev/null +++ b/selective-vpn-api/app/routes_update_helpers_lists.go @@ -0,0 +1,165 @@ +package app + +import ( + "os" + "sort" + "strings" +) + +// --------------------------------------------------------------------- +// EN: `loadList` loads list from storage or config. +// RU: `loadList` - загружает list из хранилища или конфига. +// --------------------------------------------------------------------- +func loadList(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var out []string + for _, ln := range strings.Split(string(data), "\n") { + ln = strings.TrimSpace(strings.SplitN(ln, "#", 2)[0]) + if ln == "" { + continue + } + out = append(out, ln) + } + return out +} + +// --------------------------------------------------------------------- +// EN: `loadSmartDNSWildcardDomains` loads SmartDNS wildcard domains from canonical API state. +// RU: `loadSmartDNSWildcardDomains` - загружает wildcard-домены SmartDNS из каноничного API-состояния. +// --------------------------------------------------------------------- +func loadSmartDNSWildcardDomains(logf func(string, ...any)) []string { + out, source := loadSmartDNSWildcardDomainsState(logf) + sort.Strings(out) + if logf != nil { + logf("smartdns wildcards loaded: source=%s count=%d", source, len(out)) + } + return out +} + +// --------------------------------------------------------------------- +// EN: `isGoogleLike` checks whether google like is true. +// RU: `isGoogleLike` - проверяет, является ли google like истинным условием. +// --------------------------------------------------------------------- +func isGoogleLike(d string) bool { + low := strings.ToLower(d) + for _, base := range googleLikeDomains { + if low == base || strings.HasSuffix(low, "."+base) { + return true + } + } + return false +} + +// --------------------------------------------------------------------- +// EN: `readNonEmptyLines` reads non empty lines from input data. +// RU: `readNonEmptyLines` - читает non empty lines из входных данных. +// --------------------------------------------------------------------- +func readNonEmptyLines(path string) []string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var out []string + for _, ln := range strings.Split(string(data), "\n") { + ln = strings.TrimSpace(ln) + if ln != "" { + out = append(out, ln) + } + } + return out +} + +func writeLines(path string, lines []string) error { + if len(lines) == 0 { + return os.WriteFile(path, []byte{}, 0o644) + } + return os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + +func writeMapPairs(path string, pairs [][2]string) error { + if len(pairs) == 0 { + return os.WriteFile(path, []byte{}, 0o644) + } + lines := make([]string, 0, len(pairs)) + for _, p := range pairs { + lines = append(lines, p[0]+"\t"+p[1]) + } + return os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + +func countDomainsFromPairs(pairs [][2]string) int { + seen := make(map[string]struct{}) + for _, p := range pairs { + if len(p) < 2 { + continue + } + d := strings.TrimSpace(p[1]) + if d == "" || strings.HasPrefix(d, "[") { + continue + } + seen[d] = struct{}{} + } + return len(seen) +} + +func wildcardHostIPMap(pairs [][2]string) map[string][]string { + hostToIPs := make(map[string]map[string]struct{}) + for _, p := range pairs { + if len(p) < 2 { + continue + } + ip := strings.TrimSpace(p[0]) + host := strings.TrimSpace(p[1]) + if ip == "" || host == "" || strings.HasPrefix(host, "[") { + continue + } + ips := hostToIPs[host] + if ips == nil { + ips = map[string]struct{}{} + hostToIPs[host] = ips + } + ips[ip] = struct{}{} + } + + out := make(map[string][]string, len(hostToIPs)) + for host, ipset := range hostToIPs { + ips := make([]string, 0, len(ipset)) + for ip := range ipset { + ips = append(ips, ip) + } + sort.Strings(ips) + out[host] = ips + } + return out +} + +// --------------------------------------------------------------------- +// EN: `countDomainsFromMap` counts items for domains from map. +// RU: `countDomainsFromMap` - считает элементы для domains from map. +// --------------------------------------------------------------------- +func countDomainsFromMap(path string) int { + data, err := os.ReadFile(path) + if err != nil { + return 0 + } + seen := make(map[string]struct{}) + for _, ln := range strings.Split(string(data), "\n") { + ln = strings.TrimSpace(ln) + if ln == "" { + continue + } + fields := strings.Fields(ln) + if len(fields) < 2 { + continue + } + d := fields[1] + if strings.HasPrefix(d, "[") { + continue + } + seen[d] = struct{}{} + } + return len(seen) +} diff --git a/selective-vpn-api/app/routes_update_helpers_trace.go b/selective-vpn-api/app/routes_update_helpers_trace.go new file mode 100644 index 0000000..0e9a413 --- /dev/null +++ b/selective-vpn-api/app/routes_update_helpers_trace.go @@ -0,0 +1,52 @@ +package app + +import ( + "fmt" + "sort" + "strings" +) + +func logWildcardSmartDNSTrace(mode DNSMode, source string, pairs [][2]string, wildcardIPCount int) { + lowMode := strings.ToLower(strings.TrimSpace(string(mode.Mode))) + if lowMode != string(DNSModeHybridWildcard) && lowMode != string(DNSModeSmartDNS) { + return + } + + hostMap := wildcardHostIPMap(pairs) + hosts := make([]string, 0, len(hostMap)) + for host := range hostMap { + hosts = append(hosts, host) + } + sort.Strings(hosts) + + const maxHostsLog = 200 + omitted := 0 + if len(hosts) > maxHostsLog { + omitted = len(hosts) - maxHostsLog + } + + appendTraceLineTo( + smartdnsLogPath, + "smartdns", + fmt.Sprintf( + "wildcard sync: mode=%s source=%s domains=%d ips=%d logged=%d omitted=%d map=%s", + mode.Mode, source, len(hosts), wildcardIPCount, len(hosts)-omitted, omitted, lastIPsMapDyn, + ), + ) + + for i, host := range hosts { + if i >= maxHostsLog { + appendTraceLineTo( + smartdnsLogPath, + "smartdns", + fmt.Sprintf("wildcard sync: trace truncated, %d domains not shown (see %s)", omitted, lastIPsMapDyn), + ) + return + } + appendTraceLineTo( + smartdnsLogPath, + "smartdns", + fmt.Sprintf("wildcard add: %s -> %s", host, strings.Join(hostMap[host], ", ")), + ) + } +} diff --git a/selective-vpn-api/app/routes_update_stage_artifacts.go b/selective-vpn-api/app/routes_update_stage_artifacts.go new file mode 100644 index 0000000..44f7c9c --- /dev/null +++ b/selective-vpn-api/app/routes_update_stage_artifacts.go @@ -0,0 +1,85 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +func routesUpdateStageArtifacts( + iface string, + stage routesUpdateResolveStage, + heartbeat func(), +) error { + if err := copyFile(stage.ipTmpPath, lastIPsPath); err != nil { + return fmt.Errorf("copy ips failed: %v", err) + } + if err := copyFile(stage.ipMapTmpPath, lastIPsMapPath); err != nil { + return fmt.Errorf("copy ip_map failed: %v", err) + } + if err := copyFile(stage.ipDirectTmpPath, lastIPsDirect); err != nil { + return fmt.Errorf("copy direct ips failed: %v", err) + } + if err := copyFile(stage.ipDynTmpPath, lastIPsDyn); err != nil { + return fmt.Errorf("copy wildcard ips failed: %v", err) + } + if err := copyFile(stage.ipMapDirectPath, lastIPsMapDirect); err != nil { + return fmt.Errorf("copy direct ip_map failed: %v", err) + } + if err := copyFile(stage.ipMapDynTmpPath, lastIPsMapDyn); err != nil { + return fmt.Errorf("copy wildcard ip_map failed: %v", err) + } + + now := time.Now().Format(time.RFC3339) + status := Status{ + Timestamp: now, + IPCount: stage.ipCount, + DomainCount: stage.domainCount, + Iface: iface, + Table: routesTableName(), + Mark: MARK, + } + statusData, _ := json.MarshalIndent(status, "", " ") + _ = os.WriteFile(statusFilePath, statusData, 0o644) + + chownDev( + traceLogPath, + stage.ipTmpPath, + stage.ipMapTmpPath, + stage.ipDirectTmpPath, + stage.ipDynTmpPath, + stage.ipMapDirectPath, + stage.ipMapDynTmpPath, + lastIPsPath, + lastIPsMapPath, + lastIPsDirect, + lastIPsDyn, + lastIPsMapDirect, + lastIPsMapDyn, + statusFilePath, + heartbeatFile, + ) + chmodPaths( + 0o644, + stage.ipTmpPath, + stage.ipMapTmpPath, + stage.ipDirectTmpPath, + stage.ipDynTmpPath, + stage.ipMapDirectPath, + stage.ipMapDynTmpPath, + lastIPsPath, + lastIPsMapPath, + lastIPsDirect, + lastIPsDyn, + lastIPsMapDirect, + lastIPsMapDyn, + statusFilePath, + heartbeatFile, + ) + _ = os.Chmod(traceLogPath, 0o666) + _ = os.Chmod(stateDir, 0o755) + + heartbeat() + return nil +} diff --git a/selective-vpn-api/app/routes_update_stage_nft.go b/selective-vpn-api/app/routes_update_stage_nft.go new file mode 100644 index 0000000..3d3e537 --- /dev/null +++ b/selective-vpn-api/app/routes_update_stage_nft.go @@ -0,0 +1,92 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" +) + +func routesUpdateStageNftBase(iface string) { + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", "agvpn") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn_dyn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") + + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output_apps") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output_ips") + + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "jump", "output_apps") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "jump", "output_ips") + + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output_ips") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_ips", "ip", "daddr", "@agvpn4", "meta", "mark", "set", MARK) + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_ips", "ip", "daddr", "@agvpn_dyn4", "meta", "mark", "set", MARK) + + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "prerouting", "{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "prerouting") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "prerouting", "iifname", "!=", iface, "ip", "daddr", "@agvpn4", "meta", "mark", "set", MARK) + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "prerouting", "iifname", "!=", iface, "ip", "daddr", "@agvpn_dyn4", "meta", "mark", "set", MARK) +} + +func routesUpdateStagePopulateNFT( + stage routesUpdateResolveStage, + logp func(string, ...any), + heartbeat func(), +) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + progressCb := func(percent int, msg string) { + logp("NFT progress: %d%% - %s", percent, msg) + heartbeat() + events.push("routes_nft_progress", map[string]any{ + "percent": percent, + "message": msg, + }) + } + + progressRange := func(start, end int, prefix string) ProgressCallback { + if progressCb == nil { + return nil + } + if end < start { + end = start + } + return func(percent int, msg string) { + if percent < 0 { + percent = 0 + } + if percent > 100 { + percent = 100 + } + scaled := start + (end-start)*percent/100 + if strings.TrimSpace(msg) == "" { + msg = "updating" + } + progressCb(scaled, prefix+": "+msg) + } + } + + if err := nftUpdateSetIPsSmart(ctx, "agvpn4", stage.resJob.DirectIPs, progressRange(0, 50, "agvpn4")); err != nil { + logp("nft set update failed for agvpn4: %v", err) + return fmt.Errorf("nft update failed for agvpn4: %v", err) + } + if err := nftUpdateSetIPsSmart(ctx, "agvpn_dyn4", stage.wildcardIPsApplied, progressRange(50, 100, "agvpn_dyn4")); err != nil { + logp("nft set update failed for agvpn_dyn4: %v", err) + return fmt.Errorf("nft update failed for agvpn_dyn4: %v", err) + } + + logp( + "summary: domains=%d, unique_ips=%d direct_ips=%d wildcard_ips=%d", + len(stage.domains), + stage.ipCount, + stage.directIPCount, + stage.wildcardIPCount, + ) + logp("updated agvpn4 with %d IPs (direct + static)", stage.directIPCount) + logp("updated agvpn_dyn4 with %d IPs (wildcard, source=%s)", stage.wildcardIPCount, stage.wildcardSource) + logWildcardSmartDNSTrace(stage.mode, stage.wildcardSource, stage.resJob.WildcardIPMap, stage.wildcardIPCount) + return nil +} diff --git a/selective-vpn-api/app/routes_update_stage_policy.go b/selective-vpn-api/app/routes_update_stage_policy.go new file mode 100644 index 0000000..20edb92 --- /dev/null +++ b/selective-vpn-api/app/routes_update_stage_policy.go @@ -0,0 +1,63 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +func routesUpdateStagePolicy(iface string, logp func(string, ...any)) error { + ensureRoutesTableEntry() + + if out, _, _, _ := runCommandTimeout(5*time.Second, "ip", "rule", "show"); out != "" { + for _, line := range strings.Split(out, "\n") { + if !strings.Contains(line, "lookup "+routesTableName()) { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + pref := strings.TrimSuffix(fields[0], ":") + if pref == "" { + continue + } + _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "rule", "del", "pref", pref) + } + } + + _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "route", "flush", "table", routesTableName()) + _, _, _, _ = runCommandTimeout( + 5*time.Second, + "ip", "-4", "route", "replace", "default", "dev", iface, "table", routesTableName(), "mtu", policyRouteMTU, + ) + + trafficState := loadTrafficModeState() + trafficIface, trafficIfaceReason := resolveTrafficIface(trafficState.PreferredIface) + if trafficIface == "" { + trafficIface = iface + trafficIfaceReason = "routes-update-iface" + } + if err := applyTrafficMode(trafficState, trafficIface); err != nil { + logp("traffic mode apply failed: mode=%s iface=%s err=%v", trafficState.Mode, iface, err) + return fmt.Errorf("traffic mode apply failed: %v", err) + } + trafficEval := evaluateTrafficMode(trafficState) + logp( + "traffic mode: desired=%s applied=%s healthy=%t iface=%s reason=%s", + trafficEval.DesiredMode, + trafficEval.AppliedMode, + trafficEval.Healthy, + trafficEval.ActiveIface, + trafficEval.Message+" (apply_iface_source="+trafficIfaceReason+")", + ) + + if out, _, _, _ := runCommandTimeout(5*time.Second, "ip", "route", "show", "table", routesTableName()); !strings.Contains(out, "default dev "+iface) { + _, _, _, _ = runCommandTimeout( + 5*time.Second, + "ip", "-4", "route", "replace", "default", "dev", iface, "table", routesTableName(), "mtu", policyRouteMTU, + ) + } + + return nil +} diff --git a/selective-vpn-api/app/routes_update_stage_preflight.go b/selective-vpn-api/app/routes_update_stage_preflight.go new file mode 100644 index 0000000..d8a7922 --- /dev/null +++ b/selective-vpn-api/app/routes_update_stage_preflight.go @@ -0,0 +1,39 @@ +package app + +import ( + "os" + "time" +) + +func routesUpdateStagePreflight( + iface string, + logp func(string, ...any), + heartbeat func(), +) (skip bool, message string, err error) { + _ = os.MkdirAll(stateDir, 0o755) + _ = os.MkdirAll(domainDir, 0o755) + _ = os.MkdirAll("/etc/selective-vpn", 0o755) + + heartbeat() + + up := false + for i := 0; i < 30; i++ { + if _, _, code, _ := runCommandTimeout(3*time.Second, "ip", "link", "show", iface); code == 0 { + up = true + break + } + time.Sleep(1 * time.Second) + heartbeat() + } + if !up { + logp("no %s, exit 0", iface) + return true, "interface not found, skipped", nil + } + + if err = waitDNS(15, 1*time.Second); err != nil { + logp("dns not ready: %v", err) + return false, "dns not ready", err + } + + return false, "", nil +} diff --git a/selective-vpn-api/app/routes_update_stage_resolve.go b/selective-vpn-api/app/routes_update_stage_resolve.go new file mode 100644 index 0000000..1e4c37d --- /dev/null +++ b/selective-vpn-api/app/routes_update_stage_resolve.go @@ -0,0 +1,115 @@ +package app + +import ( + "fmt" + "os" + "strings" +) + +type routesUpdateResolveStage struct { + domains []string + mode DNSMode + wildcardSource string + resJob resolverResult + wildcardIPsApplied []string + + domTmpPath string + ipTmpPath string + ipMapTmpPath string + ipDirectTmpPath string + ipDynTmpPath string + ipMapDirectPath string + ipMapDynTmpPath string + ipCount int + directIPCount int + wildcardIPCount int + domainCount int +} + +func (s routesUpdateResolveStage) cleanup() { + for _, p := range []string{ + s.domTmpPath, + s.ipTmpPath, + s.ipMapTmpPath, + s.ipDirectTmpPath, + s.ipDynTmpPath, + s.ipMapDirectPath, + s.ipMapDynTmpPath, + } { + if strings.TrimSpace(p) != "" { + _ = os.Remove(p) + } + } +} + +func createRoutesUpdateTempFile(pattern string) (string, error) { + tmp, err := os.CreateTemp(stateDir, pattern) + if err != nil { + return "", err + } + name := tmp.Name() + if closeErr := tmp.Close(); closeErr != nil { + return "", closeErr + } + return name, nil +} + +func routesUpdateStageResolve( + logp func(string, ...any), + heartbeat func(), +) (routesUpdateResolveStage, error) { + stage := routesUpdateResolveStage{} + domains, wildcards := buildRoutesUpdateDomains(logp) + stage.domains = domains + + if err := initRoutesUpdateResolveTempFiles(&stage); err != nil { + return stage, err + } + + heartbeat() + logp("using Go resolver for domains -> IPs") + mode := loadDNSMode() + runtimeEnabled := smartDNSRuntimeEnabled() + wildcardSource := wildcardFillSource(runtimeEnabled) + logp("resolver mode=%s smartdns_addr=%s wildcards=%d", mode.Mode, mode.SmartDNSAddr, len(wildcards)) + logp("wildcard source baseline: %s (runtime_nftset=%t)", wildcardSource, runtimeEnabled) + + resolveOpts := ResolverOpts{ + DomainsPath: stage.domTmpPath, + MetaPath: domainDir + "/meta-special.txt", + StaticPath: staticIPsFile, + CachePath: stateDir + "/domain-cache.json", + PtrCachePath: stateDir + "/ptr-cache.json", + TraceLog: traceLogPath, + TTL: envInt("RESOLVE_TTL", 24*3600), + Workers: envInt("RESOLVE_JOBS", 40), + DNSConfigPath: dnsUpstreamsConf, + ViaSmartDNS: mode.ViaSmartDNS, + Mode: mode.Mode, + SmartDNSAddr: mode.SmartDNSAddr, + SmartDNSWildcards: wildcards, + } + + resJob, err := runResolverJob(resolveOpts, logp) + if err != nil { + logp("Go resolver FAILED: %v", err) + return stage, fmt.Errorf("resolver failed: %v", err) + } + + wildcardIPsApplied := mergeRoutesUpdateWildcardIPs(logp, resJob, runtimeEnabled) + if err := writeRoutesUpdateResolveArtifacts(stage, resJob, wildcardIPsApplied, resolveOpts, logp); err != nil { + return stage, err + } + + heartbeat() + + stage.mode = mode + stage.wildcardSource = wildcardSource + stage.resJob = resJob + stage.wildcardIPsApplied = wildcardIPsApplied + stage.ipCount = len(resJob.IPs) + stage.directIPCount = len(resJob.DirectIPs) + stage.wildcardIPCount = len(wildcardIPsApplied) + stage.domainCount = countDomainsFromPairs(resJob.IPMap) + return stage, nil +} diff --git a/selective-vpn-api/app/routes_update_stage_resolve_artifacts.go b/selective-vpn-api/app/routes_update_stage_resolve_artifacts.go new file mode 100644 index 0000000..3a6f4b9 --- /dev/null +++ b/selective-vpn-api/app/routes_update_stage_resolve_artifacts.go @@ -0,0 +1,39 @@ +package app + +import "fmt" + +func writeRoutesUpdateResolveArtifacts( + stage routesUpdateResolveStage, + resJob resolverResult, + wildcardIPsApplied []string, + resolveOpts ResolverOpts, + logp func(string, ...any), +) error { + if err := writeLines(stage.ipTmpPath, resJob.IPs); err != nil { + logp("write ips failed: %v", err) + return fmt.Errorf("write ips failed: %v", err) + } + if err := writeMapPairs(stage.ipMapTmpPath, resJob.IPMap); err != nil { + logp("write ip_map failed: %v", err) + return fmt.Errorf("write ip_map failed: %v", err) + } + if err := writeLines(stage.ipDirectTmpPath, resJob.DirectIPs); err != nil { + logp("write direct ips failed: %v", err) + return fmt.Errorf("write direct ips failed: %v", err) + } + if err := writeLines(stage.ipDynTmpPath, wildcardIPsApplied); err != nil { + logp("write wildcard ips failed: %v", err) + return fmt.Errorf("write wildcard ips failed: %v", err) + } + if err := writeMapPairs(stage.ipMapDirectPath, resJob.DirectIPMap); err != nil { + logp("write direct ip_map failed: %v", err) + return fmt.Errorf("write direct ip_map failed: %v", err) + } + if err := writeMapPairs(stage.ipMapDynTmpPath, resJob.WildcardIPMap); err != nil { + logp("write wildcard ip_map failed: %v", err) + return fmt.Errorf("write wildcard ip_map failed: %v", err) + } + saveJSON(resJob.DomainCache, resolveOpts.CachePath) + saveJSON(resJob.PtrCache, resolveOpts.PtrCachePath) + return nil +} diff --git a/selective-vpn-api/app/routes_update_stage_resolve_domains.go b/selective-vpn-api/app/routes_update_stage_resolve_domains.go new file mode 100644 index 0000000..a4f5b92 --- /dev/null +++ b/selective-vpn-api/app/routes_update_stage_resolve_domains.go @@ -0,0 +1,101 @@ +package app + +import ( + "fmt" + "sort" + "strings" +) + +func buildRoutesUpdateDomains(logp func(string, ...any)) ([]string, []string) { + bases := loadList(domainDir + "/bases.txt") + subs := loadList(domainDir + "/subs.txt") + wildcards := loadSmartDNSWildcardDomains(logp) + wildcardBaseSet := make(map[string]struct{}, len(wildcards)) + for _, d := range wildcards { + d = strings.TrimSpace(d) + if d != "" { + wildcardBaseSet[d] = struct{}{} + } + } + wildcardBasesAdded := 0 + for _, d := range wildcards { + d = strings.TrimSpace(d) + if d == "" { + continue + } + bases = append(bases, d) + wildcardBasesAdded++ + } + subsPerBaseLimit := envInt("RESOLVE_SUBS_PER_BASE_LIMIT", 0) + if subsPerBaseLimit < 0 { + subsPerBaseLimit = 0 + } + hardCap := envInt("RESOLVE_DOMAINS_HARD_CAP", 0) + if hardCap < 0 { + hardCap = 0 + } + + domainSet := make(map[string]struct{}) + expandedAdded := 0 + twitterAdded := 0 + for _, d := range bases { + domainSet[d] = struct{}{} + _, wildcardBase := wildcardBaseSet[d] + if !wildcardBase && !isGoogleLike(d) { + limit := len(subs) + if subsPerBaseLimit > 0 && subsPerBaseLimit < limit { + limit = subsPerBaseLimit + } + for i := 0; i < limit; i++ { + fqdn := subs[i] + "." + d + if _, ok := domainSet[fqdn]; !ok { + expandedAdded++ + } + domainSet[fqdn] = struct{}{} + } + } + } + for _, spec := range twitterSpecial { + fqdn := spec + ".twitter.com" + if _, ok := domainSet[fqdn]; !ok { + twitterAdded++ + } + domainSet[fqdn] = struct{}{} + } + + domains := make([]string, 0, len(domainSet)) + for d := range domainSet { + if d != "" { + domains = append(domains, d) + } + } + sort.Strings(domains) + totalBeforeCap := len(domains) + if hardCap > 0 && len(domains) > hardCap { + domains = domains[:hardCap] + logp("domain cap applied: before=%d after=%d hard_cap=%d", totalBeforeCap, len(domains), hardCap) + } + logp( + "domains expanded: bases=%d subs_total=%d subs_per_base_limit=%d expanded_added=%d twitter_added=%d total_before_cap=%d total_used=%d", + len(bases), + len(subs), + subsPerBaseLimit, + expandedAdded, + twitterAdded, + totalBeforeCap, + len(domains), + ) + if wildcardBasesAdded > 0 { + logp("domains wildcard seed added: %d base domains from smartdns.conf state", wildcardBasesAdded) + appendTraceLineTo( + smartdnsLogPath, + "smartdns", + fmt.Sprintf( + "wildcard plan: base_domains=%d sub_expanded=0 (routes update uses pure wildcard bases; subs fan-out only in aggressive prewarm)", + wildcardBasesAdded, + ), + ) + } + + return domains, wildcards +} diff --git a/selective-vpn-api/app/routes_update_stage_resolve_tempfiles.go b/selective-vpn-api/app/routes_update_stage_resolve_tempfiles.go new file mode 100644 index 0000000..4b2b06d --- /dev/null +++ b/selective-vpn-api/app/routes_update_stage_resolve_tempfiles.go @@ -0,0 +1,56 @@ +package app + +import "fmt" + +func initRoutesUpdateResolveTempFiles(stage *routesUpdateResolveStage) error { + if stage == nil { + return fmt.Errorf("resolve stage temp init failed: nil stage") + } + + domTmpPath, err := createRoutesUpdateTempFile("domains-*.txt") + if err != nil { + return fmt.Errorf("create domains temp failed: %v", err) + } + if err := writeLines(domTmpPath, stage.domains); err != nil { + return fmt.Errorf("write domains failed: %v", err) + } + stage.domTmpPath = domTmpPath + + ipTmpPath, err := createRoutesUpdateTempFile("ips-*.txt") + if err != nil { + return fmt.Errorf("create ips temp failed: %v", err) + } + stage.ipTmpPath = ipTmpPath + + ipMapTmpPath, err := createRoutesUpdateTempFile("ipmap-*.txt") + if err != nil { + return fmt.Errorf("create ipmap temp failed: %v", err) + } + stage.ipMapTmpPath = ipMapTmpPath + + ipDirectTmpPath, err := createRoutesUpdateTempFile("ips-direct-*.txt") + if err != nil { + return fmt.Errorf("create direct ips temp failed: %v", err) + } + stage.ipDirectTmpPath = ipDirectTmpPath + + ipDynTmpPath, err := createRoutesUpdateTempFile("ips-dyn-*.txt") + if err != nil { + return fmt.Errorf("create wildcard ips temp failed: %v", err) + } + stage.ipDynTmpPath = ipDynTmpPath + + ipMapDirectPath, err := createRoutesUpdateTempFile("ipmap-direct-*.txt") + if err != nil { + return fmt.Errorf("create direct ipmap temp failed: %v", err) + } + stage.ipMapDirectPath = ipMapDirectPath + + ipMapDynTmpPath, err := createRoutesUpdateTempFile("ipmap-dyn-*.txt") + if err != nil { + return fmt.Errorf("create wildcard ipmap temp failed: %v", err) + } + stage.ipMapDynTmpPath = ipMapDynTmpPath + + return nil +} diff --git a/selective-vpn-api/app/routes_update_stage_resolve_wildcard_runtime.go b/selective-vpn-api/app/routes_update_stage_resolve_wildcard_runtime.go new file mode 100644 index 0000000..e3de3f4 --- /dev/null +++ b/selective-vpn-api/app/routes_update_stage_resolve_wildcard_runtime.go @@ -0,0 +1,54 @@ +package app + +import "sort" + +func mergeRoutesUpdateWildcardIPs( + logp func(string, ...any), + resJob resolverResult, + runtimeEnabled bool, +) []string { + wildcardIPsApplied := append([]string(nil), resJob.WildcardIPs...) + if !runtimeEnabled { + return wildcardIPsApplied + } + + existingDyn, readErr := readNftSetElements("agvpn_dyn4") + if readErr != nil { + logp("wildcard runtime merge: read agvpn_dyn4 failed: %v", readErr) + return wildcardIPsApplied + } + if len(existingDyn) == 0 { + return wildcardIPsApplied + } + + mergedSet := make(map[string]struct{}, len(wildcardIPsApplied)+len(existingDyn)) + merged := make([]string, 0, len(wildcardIPsApplied)+len(existingDyn)) + for _, ip := range wildcardIPsApplied { + if _, ok := mergedSet[ip]; ok { + continue + } + mergedSet[ip] = struct{}{} + merged = append(merged, ip) + } + addedFromRuntime := 0 + for _, ip := range existingDyn { + if _, ok := mergedSet[ip]; ok { + continue + } + mergedSet[ip] = struct{}{} + merged = append(merged, ip) + addedFromRuntime++ + } + if addedFromRuntime > 0 { + sort.Strings(merged) + wildcardIPsApplied = merged + } + logp( + "wildcard runtime merge: resolver=%d existing_dyn=%d added_from_runtime=%d total=%d", + len(resJob.WildcardIPs), + len(existingDyn), + addedFromRuntime, + len(wildcardIPsApplied), + ) + return wildcardIPsApplied +} diff --git a/selective-vpn-api/app/server.go b/selective-vpn-api/app/server.go deleted file mode 100644 index c671da2..0000000 --- a/selective-vpn-api/app/server.go +++ /dev/null @@ -1,221 +0,0 @@ -package app - -import ( - "context" - "errors" - "flag" - "fmt" - "log" - "net/http" - "os" - "syscall" - "time" -) - -// --------------------------------------------------------------------- -// main + общие хелперы -// --------------------------------------------------------------------- - -// EN: Application entrypoint and process bootstrap. -// EN: This file wires CLI modes, registers all HTTP routes, and starts background -// EN: watchers plus the localhost API server. -// RU: Точка входа приложения и bootstrap процесса. -// RU: Этот файл связывает CLI-режимы, регистрирует все HTTP-маршруты и запускает -// RU: фоновые вотчеры вместе с локальным API-сервером. -func Run() { - // --------------------------------------------------------------------- - // CLI modes - // --------------------------------------------------------------------- - - // CLI mode: routes-update - if len(os.Args) > 1 && (os.Args[1] == "routes-update" || os.Args[1] == "-routes-update") { - fs := flag.NewFlagSet("routes-update", flag.ExitOnError) - iface := fs.String("iface", "", "VPN interface (empty/auto = detect active)") - _ = fs.Parse(os.Args[2:]) - lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) - if err != nil { - fmt.Fprintf(os.Stderr, "lock open error: %v\n", err) - os.Exit(1) - } - if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - fmt.Println("routes update already running") - lock.Close() - return - } - res := routesUpdate(*iface) - _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) - _ = lock.Close() - if res.OK { - fmt.Println(res.Message) - return - } - fmt.Fprintln(os.Stderr, res.Message) - os.Exit(1) - } - - // CLI mode: routes-clear - if len(os.Args) > 1 && os.Args[1] == "routes-clear" { - res := routesClear() - if res.OK { - fmt.Println(res.Message) - return - } - fmt.Fprintln(os.Stderr, res.Message) - os.Exit(1) - } - - // CLI mode: autoloop - if len(os.Args) > 1 && os.Args[1] == "autoloop" { - fs := flag.NewFlagSet("autoloop", flag.ExitOnError) - 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") - stateDirFlag := fs.String("state-dir", stateDir, "state directory") - defaultLoc := fs.String("default-location", "Austria", "default location") - _ = fs.Parse(os.Args[2:]) - resolvedIface := normalizePreferredIface(*iface) - if resolvedIface == "" { - resolvedIface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface) - } - if resolvedIface == "" { - fmt.Fprintln(os.Stderr, "autoloop: cannot resolve VPN interface (set --iface or preferred iface)") - os.Exit(1) - } - runAutoloop(resolvedIface, *table, *mtu, *stateDirFlag, *defaultLoc) - return - } - - // --------------------------------------------------------------------- - // API server bootstrap - // --------------------------------------------------------------------- - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - 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) - } - - mux := http.NewServeMux() - - // --------------------------------------------------------------------- - // route registration - // --------------------------------------------------------------------- - - // health - mux.HandleFunc("/healthz", handleHealthz) - // event stream (SSE) - mux.HandleFunc("/api/v1/events/stream", handleEventsStream) - - // статус selective-routes - mux.HandleFunc("/api/v1/status", handleGetStatus) - mux.HandleFunc("/api/v1/routes/status", handleGetStatus) - - // login state - mux.HandleFunc("/api/v1/vpn/login-state", handleVPNLoginState) - - // systemd state - mux.HandleFunc("/api/v1/systemd/state", handleSystemdState) - - // сервис selective-routes - mux.HandleFunc("/api/v1/routes/service/start", - makeRoutesServiceActionHandler("start")) - mux.HandleFunc("/api/v1/routes/service/stop", - makeRoutesServiceActionHandler("stop")) - mux.HandleFunc("/api/v1/routes/service/restart", - makeRoutesServiceActionHandler("restart")) - // универсальный: {"action":"start|stop|restart"} - mux.HandleFunc("/api/v1/routes/service", handleRoutesService) - // ручной апдейт маршрутов (Go-реализация вместо bash) - mux.HandleFunc("/api/v1/routes/update", handleRoutesUpdate) - - // таймер маршрутов (новый API) - mux.HandleFunc("/api/v1/routes/timer", handleRoutesTimer) - // старый toggle для совместимости - mux.HandleFunc("/api/v1/routes/timer/toggle", handleRoutesTimerToggle) - - // rollback / clear (Go implementation) - mux.HandleFunc("/api/v1/routes/rollback", handleRoutesClear) - // alias: /routes/clear - mux.HandleFunc("/api/v1/routes/clear", handleRoutesClear) - // fast restore from clear-cache - mux.HandleFunc("/api/v1/routes/cache/restore", handleRoutesCacheRestore) - - // фиксим policy route - mux.HandleFunc("/api/v1/routes/fix-policy-route", handleFixPolicyRoute) - mux.HandleFunc("/api/v1/routes/fix-policy", handleFixPolicyRoute) - mux.HandleFunc("/api/v1/traffic/mode", handleTrafficMode) - mux.HandleFunc("/api/v1/traffic/mode/test", handleTrafficModeTest) - mux.HandleFunc("/api/v1/traffic/advanced/reset", handleTrafficAdvancedReset) - mux.HandleFunc("/api/v1/traffic/interfaces", handleTrafficInterfaces) - mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates) - // per-app runtime marks (systemd scope / cgroup -> fwmark) - mux.HandleFunc("/api/v1/traffic/appmarks", handleTrafficAppMarks) - // list runtime marks items (for UI) - mux.HandleFunc("/api/v1/traffic/appmarks/items", handleTrafficAppMarksItems) - // persistent app profiles (saved launch configs) - mux.HandleFunc("/api/v1/traffic/app-profiles", handleTrafficAppProfiles) - // traffic audit (sanity checks / duplicates / nft consistency) - mux.HandleFunc("/api/v1/traffic/audit", handleTrafficAudit) - - // trace: хвост + JSON + append для GUI - mux.HandleFunc("/api/v1/trace", handleTraceTailPlain) - mux.HandleFunc("/api/v1/trace-json", handleTraceJSON) - mux.HandleFunc("/api/v1/trace/append", handleTraceAppend) - - // DNS upstreams - mux.HandleFunc("/api/v1/dns-upstreams", handleDNSUpstreams) - mux.HandleFunc("/api/v1/dns/upstream-pool", handleDNSUpstreamPool) - mux.HandleFunc("/api/v1/dns/status", handleDNSStatus) - mux.HandleFunc("/api/v1/dns/mode", handleDNSModeSet) - mux.HandleFunc("/api/v1/dns/benchmark", handleDNSBenchmark) - mux.HandleFunc("/api/v1/dns/smartdns-service", handleDNSSmartdnsService) - - // SmartDNS service - mux.HandleFunc("/api/v1/smartdns/service", handleSmartdnsService) - mux.HandleFunc("/api/v1/smartdns/runtime", handleSmartdnsRuntime) - mux.HandleFunc("/api/v1/smartdns/prewarm", handleSmartdnsPrewarm) - - // domains editor - mux.HandleFunc("/api/v1/domains/table", handleDomainsTable) - mux.HandleFunc("/api/v1/domains/file", handleDomainsFile) - - // SmartDNS wildcards - mux.HandleFunc("/api/v1/smartdns/wildcards", handleSmartdnsWildcards) - - // AdGuard VPN: status + autoloop + autoconnect + locations - mux.HandleFunc("/api/v1/vpn/autoloop-status", handleVPNAutoloopStatus) - mux.HandleFunc("/api/v1/vpn/status", handleVPNStatus) - mux.HandleFunc("/api/v1/vpn/autoconnect", handleVPNAutoconnect) - mux.HandleFunc("/api/v1/vpn/locations", handleVPNListLocations) - mux.HandleFunc("/api/v1/vpn/location", handleVPNSetLocation) - - // AdGuard VPN: interactive login session (PTY) - mux.HandleFunc("/api/v1/vpn/login/session/start", handleVPNLoginSessionStart) - mux.HandleFunc("/api/v1/vpn/login/session/state", handleVPNLoginSessionState) - mux.HandleFunc("/api/v1/vpn/login/session/action", handleVPNLoginSessionAction) - mux.HandleFunc("/api/v1/vpn/login/session/stop", handleVPNLoginSessionStop) - // logout - mux.HandleFunc("/api/v1/vpn/logout", handleVPNLogout) - - // --------------------------------------------------------------------- - // HTTP server - // --------------------------------------------------------------------- - - srv := &http.Server{ - Addr: "127.0.0.1:8080", - Handler: logRequests(mux), - ReadHeaderTimeout: 5 * time.Second, - } - - go startWatchers(ctx) - - log.Printf("selective-vpn API listening on %s", srv.Addr) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("server error: %v", err) - } -} diff --git a/selective-vpn-api/app/shell.go b/selective-vpn-api/app/shell.go index d446388..5d04476 100644 --- a/selective-vpn-api/app/shell.go +++ b/selective-vpn-api/app/shell.go @@ -1,72 +1,18 @@ package app import ( - "context" - "errors" - "fmt" - "os/exec" - "strings" + syscmdpkg "selective-vpn-api/app/syscmd" "time" ) -// --------------------------------------------------------------------- -// низкоуровневые helpers -// --------------------------------------------------------------------- - -// EN: Low-level command execution adapters with timeout handling and small -// EN: policy-route verification helper used by higher-level handlers. -// RU: Низкоуровневые адаптеры запуска команд с таймаутами и вспомогательной -// RU: проверкой policy-route, используемой верхнеуровневыми обработчиками. - func runCommand(name string, args ...string) (string, string, int, error) { - return runCommandTimeout(60*time.Second, name, args...) + return syscmdpkg.RunCommand(name, args...) } -// --------------------------------------------------------------------- -// policy route check -// --------------------------------------------------------------------- - func checkPolicyRoute(iface, table string) (bool, error) { - stdout, _, exitCode, err := runCommand("ip", "route", "show", "table", table) - if exitCode != 0 { - if err == nil { - err = fmt.Errorf("ip route show exited with %d", exitCode) - } - return false, err - } - want := fmt.Sprintf("default dev %s", iface) - for _, line := range strings.Split(stdout, "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, want) { - return true, nil - } - } - return false, nil + return syscmdpkg.CheckPolicyRoute(iface, table) } -// --------------------------------------------------------------------- -// command timeout helper -// --------------------------------------------------------------------- - func runCommandTimeout(timeout time.Duration, name string, args ...string) (string, string, int, error) { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, name, args...) - out, err := cmd.CombinedOutput() - stdout := string(out) - stderr := stdout - - exitCode := 0 - if err != nil { - if ee, ok := err.(*exec.ExitError); ok { - exitCode = ee.ExitCode() - } else if errors.Is(err, context.DeadlineExceeded) { - exitCode = -1 - err = fmt.Errorf("command timeout: %w", err) - } else { - exitCode = -1 - } - } - return stdout, stderr, exitCode, err + return syscmdpkg.RunCommandTimeout(timeout, name, args...) } diff --git a/selective-vpn-api/app/smartdns_runtime.go b/selective-vpn-api/app/smartdns_runtime.go index 8b636b1..5e0e0c8 100644 --- a/selective-vpn-api/app/smartdns_runtime.go +++ b/selective-vpn-api/app/smartdns_runtime.go @@ -1,12 +1,7 @@ package app import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "strings" - "time" ) // --------------------------------------------------------------------- @@ -39,173 +34,6 @@ func wildcardFillSource(runtimeEnabled bool) string { return "resolver" } -func normalizeSmartDNSRuntimeState(st smartDNSRuntimeState) smartDNSRuntimeState { - if st.Version <= 0 { - st.Version = smartdnsRuntimeStateVersion - } - return st -} - -func smartDNSRuntimeEnabledFromConfig() (bool, error) { - data, err := os.ReadFile(smartdnsMainConfig) - if err != nil { - return false, err - } - for _, raw := range strings.Split(string(data), "\n") { - trimmed := strings.TrimSpace(raw) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - if strings.Contains(trimmed, "nftset") && - strings.Contains(trimmed, "domain-set:agvpn_wild") && - strings.Contains(trimmed, "agvpn_dyn4") { - return true, nil - } - } - return false, nil -} - -func inferSmartDNSRuntimeEnabled() bool { - enabled, err := smartDNSRuntimeEnabledFromConfig() - if err != nil { - // Keep historical behavior on first run when config is unavailable. - return true - } - return enabled -} - -func loadSmartDNSRuntimeState(logf func(string, ...any)) smartDNSRuntimeState { - if data, err := os.ReadFile(smartdnsRTPath); err == nil { - var st smartDNSRuntimeState - if json.Unmarshal(data, &st) == nil { - return normalizeSmartDNSRuntimeState(st) - } - if logf != nil { - logf("smartdns runtime: invalid state json at %s, rebuilding", smartdnsRTPath) - } - } - - st := smartDNSRuntimeState{ - Version: smartdnsRuntimeStateVersion, - Enabled: inferSmartDNSRuntimeEnabled(), - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - } - _ = saveSmartDNSRuntimeState(st) - return st -} - -func saveSmartDNSRuntimeState(st smartDNSRuntimeState) error { - st = normalizeSmartDNSRuntimeState(st) - st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - data, err := json.MarshalIndent(st, "", " ") - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(smartdnsRTPath), 0o755); err != nil { - return err - } - tmp := smartdnsRTPath + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, smartdnsRTPath) -} - -func smartDNSRuntimeEnabled() bool { - return loadSmartDNSRuntimeState(nil).Enabled -} - -func normalizeSmartDNSMainConfig(content string, enabled bool) string { - normalized := strings.ReplaceAll(content, "\r\n", "\n") - lines := strings.Split(normalized, "\n") - out := make([]string, 0, len(lines)+4) - - seenDomain := false - seenNftset := false - - isDomainLine := func(raw string) bool { - t := strings.TrimSpace(raw) - if strings.HasPrefix(t, "#") { - t = strings.TrimSpace(strings.TrimPrefix(t, "#")) - } - return strings.HasPrefix(t, "domain-set ") && - strings.Contains(t, "-name agvpn_wild") && - strings.Contains(t, "/etc/selective-vpn/smartdns.conf") - } - isNftsetLine := func(raw string) bool { - t := strings.TrimSpace(raw) - if strings.HasPrefix(t, "#") { - t = strings.TrimSpace(strings.TrimPrefix(t, "#")) - } - return strings.HasPrefix(t, "nftset ") && - strings.Contains(t, "domain-set:agvpn_wild") && - strings.Contains(t, "agvpn_dyn4") - } - - for _, raw := range lines { - switch { - case isDomainLine(raw): - if !seenDomain { - if enabled { - out = append(out, smartdnsRuntimeDomainSetLine) - } else { - out = append(out, "# "+smartdnsRuntimeDomainSetLine) - } - seenDomain = true - } - case isNftsetLine(raw): - if !seenNftset { - if enabled { - out = append(out, smartdnsRuntimeNftsetLine) - } else { - out = append(out, "# "+smartdnsRuntimeNftsetLine) - } - seenNftset = true - } - default: - out = append(out, raw) - } - } - - if enabled && (!seenDomain || !seenNftset) { - if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { - out = append(out, "") - } - if !seenDomain { - out = append(out, smartdnsRuntimeDomainSetLine) - } - if !seenNftset { - out = append(out, smartdnsRuntimeNftsetLine) - } - } - - rendered := strings.Join(out, "\n") - if !strings.HasSuffix(rendered, "\n") { - rendered += "\n" - } - return rendered -} - -func applySmartDNSRuntimeConfig(enabled bool) (bool, error) { - data, err := os.ReadFile(smartdnsMainConfig) - if err != nil { - return false, err - } - current := strings.ReplaceAll(string(data), "\r\n", "\n") - next := normalizeSmartDNSMainConfig(current, enabled) - if next == current { - return false, nil - } - tmp := smartdnsMainConfig + ".tmp" - if err := os.WriteFile(tmp, []byte(next), 0o644); err != nil { - return false, err - } - if err := os.Rename(tmp, smartdnsMainConfig); err != nil { - return false, err - } - return true, nil -} - func smartDNSRuntimeSnapshot() SmartDNSRuntimeStatusResponse { st := loadSmartDNSRuntimeState(nil) appliedEnabled, err := smartDNSRuntimeEnabledFromConfig() diff --git a/selective-vpn-api/app/smartdns_runtime_config.go b/selective-vpn-api/app/smartdns_runtime_config.go new file mode 100644 index 0000000..d7d4102 --- /dev/null +++ b/selective-vpn-api/app/smartdns_runtime_config.go @@ -0,0 +1,116 @@ +package app + +import ( + "os" + "strings" +) + +func smartDNSRuntimeEnabledFromConfig() (bool, error) { + data, err := os.ReadFile(smartdnsMainConfig) + if err != nil { + return false, err + } + for _, raw := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, "nftset") && + strings.Contains(trimmed, "domain-set:agvpn_wild") && + strings.Contains(trimmed, "agvpn_dyn4") { + return true, nil + } + } + return false, nil +} + +func normalizeSmartDNSMainConfig(content string, enabled bool) string { + normalized := strings.ReplaceAll(content, "\r\n", "\n") + lines := strings.Split(normalized, "\n") + out := make([]string, 0, len(lines)+4) + + seenDomain := false + seenNftset := false + + isDomainLine := func(raw string) bool { + t := strings.TrimSpace(raw) + if strings.HasPrefix(t, "#") { + t = strings.TrimSpace(strings.TrimPrefix(t, "#")) + } + return strings.HasPrefix(t, "domain-set ") && + strings.Contains(t, "-name agvpn_wild") && + strings.Contains(t, "/etc/selective-vpn/smartdns.conf") + } + isNftsetLine := func(raw string) bool { + t := strings.TrimSpace(raw) + if strings.HasPrefix(t, "#") { + t = strings.TrimSpace(strings.TrimPrefix(t, "#")) + } + return strings.HasPrefix(t, "nftset ") && + strings.Contains(t, "domain-set:agvpn_wild") && + strings.Contains(t, "agvpn_dyn4") + } + + for _, raw := range lines { + switch { + case isDomainLine(raw): + if !seenDomain { + if enabled { + out = append(out, smartdnsRuntimeDomainSetLine) + } else { + out = append(out, "# "+smartdnsRuntimeDomainSetLine) + } + seenDomain = true + } + case isNftsetLine(raw): + if !seenNftset { + if enabled { + out = append(out, smartdnsRuntimeNftsetLine) + } else { + out = append(out, "# "+smartdnsRuntimeNftsetLine) + } + seenNftset = true + } + default: + out = append(out, raw) + } + } + + if enabled && (!seenDomain || !seenNftset) { + if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { + out = append(out, "") + } + if !seenDomain { + out = append(out, smartdnsRuntimeDomainSetLine) + } + if !seenNftset { + out = append(out, smartdnsRuntimeNftsetLine) + } + } + + rendered := strings.Join(out, "\n") + if !strings.HasSuffix(rendered, "\n") { + rendered += "\n" + } + return rendered +} + +func applySmartDNSRuntimeConfig(enabled bool) (bool, error) { + data, err := os.ReadFile(smartdnsMainConfig) + if err != nil { + return false, err + } + current := strings.ReplaceAll(string(data), "\r\n", "\n") + next := normalizeSmartDNSMainConfig(current, enabled) + if next == current { + return false, nil + } + tmp := smartdnsMainConfig + ".tmp" + if err := os.WriteFile(tmp, []byte(next), 0o644); err != nil { + return false, err + } + if err := os.Rename(tmp, smartdnsMainConfig); err != nil { + return false, err + } + return true, nil +} diff --git a/selective-vpn-api/app/smartdns_runtime_state.go b/selective-vpn-api/app/smartdns_runtime_state.go new file mode 100644 index 0000000..607b248 --- /dev/null +++ b/selective-vpn-api/app/smartdns_runtime_state.go @@ -0,0 +1,65 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +func normalizeSmartDNSRuntimeState(st smartDNSRuntimeState) smartDNSRuntimeState { + if st.Version <= 0 { + st.Version = smartdnsRuntimeStateVersion + } + return st +} + +func inferSmartDNSRuntimeEnabled() bool { + enabled, err := smartDNSRuntimeEnabledFromConfig() + if err != nil { + // Keep historical behavior on first run when config is unavailable. + return true + } + return enabled +} + +func loadSmartDNSRuntimeState(logf func(string, ...any)) smartDNSRuntimeState { + if data, err := os.ReadFile(smartdnsRTPath); err == nil { + var st smartDNSRuntimeState + if json.Unmarshal(data, &st) == nil { + return normalizeSmartDNSRuntimeState(st) + } + if logf != nil { + logf("smartdns runtime: invalid state json at %s, rebuilding", smartdnsRTPath) + } + } + + st := smartDNSRuntimeState{ + Version: smartdnsRuntimeStateVersion, + Enabled: inferSmartDNSRuntimeEnabled(), + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + _ = saveSmartDNSRuntimeState(st) + return st +} + +func saveSmartDNSRuntimeState(st smartDNSRuntimeState) error { + st = normalizeSmartDNSRuntimeState(st) + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(smartdnsRTPath), 0o755); err != nil { + return err + } + tmp := smartdnsRTPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, smartdnsRTPath) +} + +func smartDNSRuntimeEnabled() bool { + return loadSmartDNSRuntimeState(nil).Enabled +} diff --git a/selective-vpn-api/app/syscmd/command.go b/selective-vpn-api/app/syscmd/command.go new file mode 100644 index 0000000..7623a91 --- /dev/null +++ b/selective-vpn-api/app/syscmd/command.go @@ -0,0 +1,55 @@ +package syscmd + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +func RunCommand(name string, args ...string) (string, string, int, error) { + return RunCommandTimeout(60*time.Second, name, args...) +} + +func CheckPolicyRoute(iface, table string) (bool, error) { + stdout, _, exitCode, err := RunCommand("ip", "route", "show", "table", table) + if exitCode != 0 { + if err == nil { + err = fmt.Errorf("ip route show exited with %d", exitCode) + } + return false, err + } + want := fmt.Sprintf("default dev %s", iface) + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, want) { + return true, nil + } + } + return false, nil +} + +func RunCommandTimeout(timeout time.Duration, name string, args ...string) (string, string, int, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + stdout := string(out) + stderr := stdout + + exitCode := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + exitCode = ee.ExitCode() + } else if errors.Is(err, context.DeadlineExceeded) { + exitCode = -1 + err = fmt.Errorf("command timeout: %w", err) + } else { + exitCode = -1 + } + } + return stdout, stderr, exitCode, err +} diff --git a/selective-vpn-api/app/trace_handlers.go b/selective-vpn-api/app/trace_handlers.go index 48bc117..1da7c72 100644 --- a/selective-vpn-api/app/trace_handlers.go +++ b/selective-vpn-api/app/trace_handlers.go @@ -1,261 +1,10 @@ package app -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strconv" - "strings" - "time" -) - // --------------------------------------------------------------------- // trace: чтение + запись // --------------------------------------------------------------------- - +// // EN: Trace log endpoints and helpers for GUI/operator visibility. // EN: Includes plain tail, filtered JSON views, append API, and bounded tail reader. // RU: Эндпоинты и хелперы trace-логов для GUI/оператора. // RU: Включает plain tail, фильтрованные JSON-режимы, append API и безопасный tail-reader. - -func handleTraceTailPlain(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - lines := tailFile(traceLogPath, defaultTraceTailMax) - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, _ = io.WriteString(w, strings.Join(lines, "\n")) -} - -// --------------------------------------------------------------------- -// trace-json -// --------------------------------------------------------------------- - -// GET /api/v1/trace-json?mode=full|gui|events|smartdns -func handleTraceJSON(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - mode := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("mode"))) - if mode == "" { - mode = "full" - } - if mode == "events" { - mode = "gui" - } - - var lines []string - - switch mode { - case "smartdns": - // чисто SmartDNS-лог - lines = tailFile(smartdnsLogPath, defaultTraceTailMax) - - case "gui": - // Events: только человеко-читабельные события/ошибки/команды. - full := tailFile(traceLogPath, defaultTraceTailMax) - allow := []string{ - "[gui]", "[info]", "[login]", "[vpn]", "[event]", "[error]", - } - for _, l := range full { - ll := strings.ToLower(l) - - // берём только наши "человеческие" префиксы - ok := false - for _, a := range allow { - if strings.Contains(ll, strings.ToLower(a)) { - ok = true - break - } - } - if !ok { - // если префикса нет, но это похоже на ошибку — тоже включаем - if strings.Contains(ll, "error") || strings.Contains(ll, "failed") || strings.Contains(ll, "timeout") { - ok = true - } - } - if !ok { - continue - } - - // режем шум от резолвера/маршрутов/массовых вставок - if strings.Contains(ll, "smartdns") || - strings.Contains(ll, "resolver") || - strings.Contains(ll, "dnstt") || - strings.Contains(ll, "routes") || - strings.Contains(ll, "nft add element") || - strings.Contains(ll, "cache hit:") { - continue - } - - lines = append(lines, l) - } - - default: // full - // полный хвост trace.log без фильтрации - lines = tailFile(traceLogPath, defaultTraceTailMax) - } - - writeJSON(w, http.StatusOK, map[string]any{ - "lines": lines, - }) -} - -// --------------------------------------------------------------------- -// trace append -// --------------------------------------------------------------------- - -// POST /api/v1/trace/append { "kind": "gui|smartdns|info", "line": "..." } -func handleTraceAppend(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var body struct { - Kind string `json:"kind"` - Line string `json:"line"` - } - if r.Body != nil { - defer r.Body.Close() - _ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body) - } - kind := strings.ToLower(strings.TrimSpace(body.Kind)) - line := strings.TrimRight(body.Line, "\r\n") - - if line == "" { - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - return - } - - _ = os.MkdirAll(stateDir, 0o755) - - switch kind { - case "smartdns": - appendTraceLineTo(smartdnsLogPath, "smartdns", line) - case "gui": - appendTraceLineTo(traceLogPath, "gui", line) - default: - appendTraceLineTo(traceLogPath, "info", line) - } - - events.push("trace_append", map[string]any{ - "kind": kind, - }) - - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -// --------------------------------------------------------------------- -// trace write helpers -// --------------------------------------------------------------------- - -func appendTraceLineTo(path, prefix, line string) { - line = strings.TrimRight(line, "\r\n") - if line == "" { - return - } - ts := time.Now().UTC().Format(time.RFC3339) - _ = os.MkdirAll(stateDir, 0o755) - - // простейший "ручной логротейт" - const maxSize = 10 * 1024 * 1024 // 10 МБ - if fi, err := os.Stat(path); err == nil && fi.Size() > maxSize { - // можно просто truncate - _ = os.Truncate(path, 0) - // или переименовать в *.1 и начать новый - // _ = os.Rename(path, path+".1") - } - - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return - } - defer f.Close() - _, _ = fmt.Fprintf(f, "[%s] %s %s\n", prefix, ts, line) -} - -// --------------------------------------------------------------------- -// EN: `appendTraceLine` appends or adds trace line to an existing state. -// RU: `appendTraceLine` - добавляет trace line в текущее состояние. -// --------------------------------------------------------------------- -func appendTraceLine(prefix, line string) { - appendTraceLineTo(traceLogPath, prefix, line) -} - -// --------------------------------------------------------------------- -// tail helper -// --------------------------------------------------------------------- - -const defaultTailMaxBytes = 512 * 1024 - -func tailFile(path string, maxLines int) []string { - if maxLines <= 0 { - return nil - } - - // читаем лимит из env, если задан - maxBytes := defaultTailMaxBytes - if env := os.Getenv("SVPN_TAIL_MAX_BYTES"); env != "" { - if n, err := strconv.Atoi(env); err == nil && n > 0 { - maxBytes = n - } - } - - f, err := os.Open(path) - if err != nil { - // файла нет или нет прав — просто ничего не отдаём - return nil - } - defer f.Close() - - fi, err := f.Stat() - if err != nil { - return nil - } - size := fi.Size() - if size <= 0 { - return nil - } - - // с какого смещения читаем хвост - start := int64(0) - if size > int64(maxBytes) { - start = size - int64(maxBytes) - } - - // двигаем указатель в файле - if _, err := f.Seek(start, io.SeekStart); err != nil { - return nil - } - - // читаем хвост - data, err := io.ReadAll(f) - if err != nil { - return nil - } - - // режем по строкам - lines := strings.Split(string(data), "\n") - - // если мы начали читать с середины файла (start > 0), - // первая строка почти наверняка обрезана — выбрасываем её. - if start > 0 && len(lines) > 0 { - lines = lines[1:] - } - - // убираем финальную пустую строку, если есть - if n := len(lines); n > 0 && lines[n-1] == "" { - lines = lines[:n-1] - } - - // берём только последние maxLines - if len(lines) > maxLines { - lines = lines[len(lines)-maxLines:] - } - - return lines -} diff --git a/selective-vpn-api/app/trace_handlers_read.go b/selective-vpn-api/app/trace_handlers_read.go new file mode 100644 index 0000000..3c10cb9 --- /dev/null +++ b/selective-vpn-api/app/trace_handlers_read.go @@ -0,0 +1,92 @@ +package app + +import ( + "io" + "net/http" + "strings" +) + +func handleTraceTailPlain(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + lines := tailFile(traceLogPath, defaultTraceTailMax) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = io.WriteString(w, strings.Join(lines, "\n")) +} + +// --------------------------------------------------------------------- +// trace-json +// --------------------------------------------------------------------- + +// GET /api/v1/trace-json?mode=full|gui|events|smartdns +func handleTraceJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + mode := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("mode"))) + if mode == "" { + mode = "full" + } + if mode == "events" { + mode = "gui" + } + + var lines []string + + switch mode { + case "smartdns": + // чисто SmartDNS-лог + lines = tailFile(smartdnsLogPath, defaultTraceTailMax) + + case "gui": + // Events: только человеко-читабельные события/ошибки/команды. + full := tailFile(traceLogPath, defaultTraceTailMax) + allow := []string{ + "[gui]", "[info]", "[login]", "[vpn]", "[event]", "[error]", + } + for _, l := range full { + ll := strings.ToLower(l) + + // берём только наши "человеческие" префиксы + ok := false + for _, a := range allow { + if strings.Contains(ll, strings.ToLower(a)) { + ok = true + break + } + } + if !ok { + // если префикса нет, но это похоже на ошибку — тоже включаем + if strings.Contains(ll, "error") || strings.Contains(ll, "failed") || strings.Contains(ll, "timeout") { + ok = true + } + } + if !ok { + continue + } + + // режем шум от резолвера/маршрутов/массовых вставок + if strings.Contains(ll, "smartdns") || + strings.Contains(ll, "resolver") || + strings.Contains(ll, "dnstt") || + strings.Contains(ll, "routes") || + strings.Contains(ll, "nft add element") || + strings.Contains(ll, "cache hit:") { + continue + } + + lines = append(lines, l) + } + + default: // full + // полный хвост trace.log без фильтрации + lines = tailFile(traceLogPath, defaultTraceTailMax) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "lines": lines, + }) +} diff --git a/selective-vpn-api/app/trace_handlers_write.go b/selective-vpn-api/app/trace_handlers_write.go new file mode 100644 index 0000000..f393994 --- /dev/null +++ b/selective-vpn-api/app/trace_handlers_write.go @@ -0,0 +1,173 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" +) + +// --------------------------------------------------------------------- +// trace append +// --------------------------------------------------------------------- + +// POST /api/v1/trace/append { "kind": "gui|smartdns|info", "line": "..." } +func handleTraceAppend(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Kind string `json:"kind"` + Line string `json:"line"` + } + if r.Body != nil { + defer r.Body.Close() + _ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body) + } + kind := strings.ToLower(strings.TrimSpace(body.Kind)) + line := strings.TrimRight(body.Line, "\r\n") + + if line == "" { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + + _ = os.MkdirAll(stateDir, 0o755) + + switch kind { + case "smartdns": + appendTraceLineTo(smartdnsLogPath, "smartdns", line) + case "gui": + appendTraceLineTo(traceLogPath, "gui", line) + default: + appendTraceLineTo(traceLogPath, "info", line) + } + + events.push("trace_append", map[string]any{ + "kind": kind, + }) + + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// --------------------------------------------------------------------- +// trace write helpers +// --------------------------------------------------------------------- + +func appendTraceLineTo(path, prefix, line string) { + line = strings.TrimRight(line, "\r\n") + if line == "" { + return + } + ts := time.Now().UTC().Format(time.RFC3339) + _ = os.MkdirAll(stateDir, 0o755) + + // простейший "ручной логротейт" + const maxSize = 10 * 1024 * 1024 // 10 МБ + if fi, err := os.Stat(path); err == nil && fi.Size() > maxSize { + // можно просто truncate + _ = os.Truncate(path, 0) + // или переименовать в *.1 и начать новый + // _ = os.Rename(path, path+".1") + } + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + _, _ = fmt.Fprintf(f, "[%s] %s %s\n", prefix, ts, line) +} + +// --------------------------------------------------------------------- +// EN: `appendTraceLine` appends or adds trace line to an existing state. +// RU: `appendTraceLine` - добавляет trace line в текущее состояние. +// --------------------------------------------------------------------- +func appendTraceLine(prefix, line string) { + appendTraceLineTo(traceLogPath, prefix, line) +} + +const ( + traceRateLimitDefaultWindow = 20 * time.Second + traceRateLimitMaxEntries = 1024 +) + +type traceRateLimitState struct { + lastAt time.Time + suppressed int +} + +var ( + traceRateLimitMu sync.Mutex + traceRateLimitCache = map[string]traceRateLimitState{} +) + +// appendTraceLineRateLimited keeps first line in the window and suppresses exact duplicates. +// When window is over, it writes one compact summary for suppressed duplicates. +func appendTraceLineRateLimited(prefix, line string, window time.Duration) { + line = strings.TrimSpace(strings.TrimRight(line, "\r\n")) + if line == "" { + return + } + if window <= 0 { + window = traceRateLimitDefaultWindow + } + + now := time.Now().UTC() + key := strings.TrimSpace(prefix) + "\n" + line + + var summary string + writeCurrent := true + + traceRateLimitMu.Lock() + st, ok := traceRateLimitCache[key] + if ok && !st.lastAt.IsZero() && now.Sub(st.lastAt) < window { + st.suppressed++ + traceRateLimitCache[key] = st + writeCurrent = false + } else { + if ok && st.suppressed > 0 { + summary = fmt.Sprintf("trace dedup: suppressed=%d within=%s line=%q", st.suppressed, window.String(), line) + } + st.lastAt = now + st.suppressed = 0 + traceRateLimitCache[key] = st + } + traceRateLimitShrinkLocked(now) + traceRateLimitMu.Unlock() + + if summary != "" { + appendTraceLineTo(traceLogPath, prefix, summary) + } + if writeCurrent { + appendTraceLineTo(traceLogPath, prefix, line) + } +} + +func traceRateLimitShrinkLocked(now time.Time) { + if len(traceRateLimitCache) <= traceRateLimitMaxEntries { + return + } + cutoff := now.Add(-10 * time.Minute) + for key, st := range traceRateLimitCache { + if st.lastAt.Before(cutoff) && st.suppressed == 0 { + delete(traceRateLimitCache, key) + } + } + if len(traceRateLimitCache) <= traceRateLimitMaxEntries { + return + } + overflow := len(traceRateLimitCache) - traceRateLimitMaxEntries + for key := range traceRateLimitCache { + delete(traceRateLimitCache, key) + overflow-- + if overflow <= 0 { + break + } + } +} diff --git a/selective-vpn-api/app/trace_tail.go b/selective-vpn-api/app/trace_tail.go new file mode 100644 index 0000000..165fbd4 --- /dev/null +++ b/selective-vpn-api/app/trace_tail.go @@ -0,0 +1,82 @@ +package app + +import ( + "io" + "os" + "strconv" + "strings" +) + +// --------------------------------------------------------------------- +// tail helper +// --------------------------------------------------------------------- + +const defaultTailMaxBytes = 512 * 1024 + +func tailFile(path string, maxLines int) []string { + if maxLines <= 0 { + return nil + } + + // читаем лимит из env, если задан + maxBytes := defaultTailMaxBytes + if env := os.Getenv("SVPN_TAIL_MAX_BYTES"); env != "" { + if n, err := strconv.Atoi(env); err == nil && n > 0 { + maxBytes = n + } + } + + f, err := os.Open(path) + if err != nil { + // файла нет или нет прав — просто ничего не отдаём + return nil + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return nil + } + size := fi.Size() + if size <= 0 { + return nil + } + + // с какого смещения читаем хвост + start := int64(0) + if size > int64(maxBytes) { + start = size - int64(maxBytes) + } + + // двигаем указатель в файле + if _, err := f.Seek(start, io.SeekStart); err != nil { + return nil + } + + // читаем хвост + data, err := io.ReadAll(f) + if err != nil { + return nil + } + + // режем по строкам + lines := strings.Split(string(data), "\n") + + // если мы начали читать с середины файла (start > 0), + // первая строка почти наверняка обрезана — выбрасываем её. + if start > 0 && len(lines) > 0 { + lines = lines[1:] + } + + // убираем финальную пустую строку, если есть + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + + // берём только последние maxLines + if len(lines) > maxLines { + lines = lines[len(lines)-maxLines:] + } + + return lines +} diff --git a/selective-vpn-api/app/traffic_app_profiles.go b/selective-vpn-api/app/traffic_app_profiles.go index f51b51f..44202a9 100644 --- a/selective-vpn-api/app/traffic_app_profiles.go +++ b/selective-vpn-api/app/traffic_app_profiles.go @@ -2,39 +2,24 @@ package app import ( "encoding/json" - "fmt" "io" "net/http" - "os" - "path/filepath" - "sort" + trafficprofilespkg "selective-vpn-api/app/trafficprofiles" "strings" - "sync" - "time" ) -// --------------------------------------------------------------------- -// traffic app profiles (persistent app configs) -// --------------------------------------------------------------------- -// -// EN: App profiles are persistent configs that describe *what* to launch and -// EN: how to route it. They are separate from runtime marks, because runtime -// EN: marks are tied to a конкретный systemd unit/cgroup. -// RU: App profiles - это постоянные конфиги, которые описывают *что* запускать -// RU: и как маршрутизировать. Они отдельно от runtime marks, потому что marks -// RU: привязаны к конкретному systemd unit/cgroup. - const ( trafficAppProfilesDefaultTTLSec = 0 // 0 = persistent runtime mark policy ) -var trafficAppProfilesMu sync.Mutex - -type trafficAppProfilesState struct { - Version int `json:"version"` - UpdatedAt string `json:"updated_at"` - Profiles []TrafficAppProfile `json:"profiles,omitempty"` -} +var trafficAppProfilesStore = trafficprofilespkg.NewStore( + trafficAppProfilesPath, + trafficprofilespkg.Deps{ + CanonicalizeAppKey: canonicalizeAppKey, + SanitizeID: sanitizeID, + DefaultTTLSec: trafficAppProfilesDefaultTTLSec, + }, +) func handleTrafficAppProfiles(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -73,337 +58,3 @@ func handleTrafficAppProfiles(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } - -func listTrafficAppProfiles() []TrafficAppProfile { - trafficAppProfilesMu.Lock() - defer trafficAppProfilesMu.Unlock() - - st := loadTrafficAppProfilesState() - out := append([]TrafficAppProfile(nil), st.Profiles...) - sort.Slice(out, func(i, j int) bool { - // Newest first. - return out[i].UpdatedAt > out[j].UpdatedAt - }) - return out -} - -func upsertTrafficAppProfile(req TrafficAppProfileUpsertRequest) (TrafficAppProfile, error) { - trafficAppProfilesMu.Lock() - defer trafficAppProfilesMu.Unlock() - - st := loadTrafficAppProfilesState() - - target := strings.ToLower(strings.TrimSpace(req.Target)) - if target == "" { - target = "vpn" - } - if target != "vpn" && target != "direct" { - return TrafficAppProfile{}, fmt.Errorf("target must be vpn|direct") - } - - cmd := strings.TrimSpace(req.Command) - if cmd == "" { - return TrafficAppProfile{}, fmt.Errorf("missing command") - } - - appKey := strings.TrimSpace(req.AppKey) - if appKey == "" { - fields := strings.Fields(cmd) - if len(fields) > 0 { - appKey = strings.TrimSpace(fields[0]) - } - } - appKey = canonicalizeAppKey(appKey, cmd) - if appKey == "" { - return TrafficAppProfile{}, fmt.Errorf("cannot infer app_key") - } - - id := strings.TrimSpace(req.ID) - if id == "" { - // If profile for same app_key+target exists, update it. - for _, p := range st.Profiles { - if strings.TrimSpace(p.AppKey) == appKey && strings.ToLower(strings.TrimSpace(p.Target)) == target { - id = strings.TrimSpace(p.ID) - break - } - } - } - if id == "" { - id = deriveTrafficAppProfileID(appKey, target, st.Profiles) - } - if id == "" { - return TrafficAppProfile{}, fmt.Errorf("cannot derive profile id") - } - - name := strings.TrimSpace(req.Name) - if name == "" { - name = filepath.Base(appKey) - if name == "" || name == "/" || name == "." { - name = id - } - } - - ttl := req.TTLSec - if ttl <= 0 { - ttl = trafficAppProfilesDefaultTTLSec - } - - vpnProfile := strings.TrimSpace(req.VPNProfile) - - now := time.Now().UTC().Format(time.RFC3339) - prof := TrafficAppProfile{ - ID: id, - Name: name, - AppKey: appKey, - Command: cmd, - Target: target, - TTLSec: ttl, - VPNProfile: vpnProfile, - UpdatedAt: now, - } - - // Upsert. - updated := false - for i := range st.Profiles { - if strings.TrimSpace(st.Profiles[i].ID) != id { - continue - } - // Keep created_at stable. - prof.CreatedAt = strings.TrimSpace(st.Profiles[i].CreatedAt) - if prof.CreatedAt == "" { - prof.CreatedAt = now - } - st.Profiles[i] = prof - updated = true - break - } - if !updated { - prof.CreatedAt = now - st.Profiles = append(st.Profiles, prof) - } - - if err := saveTrafficAppProfilesState(st); err != nil { - return TrafficAppProfile{}, err - } - return prof, nil -} - -func deleteTrafficAppProfile(id string) (bool, string) { - trafficAppProfilesMu.Lock() - defer trafficAppProfilesMu.Unlock() - - id = strings.TrimSpace(id) - if id == "" { - return false, "empty id" - } - - st := loadTrafficAppProfilesState() - kept := st.Profiles[:0] - found := false - for _, p := range st.Profiles { - if strings.TrimSpace(p.ID) == id { - found = true - continue - } - kept = append(kept, p) - } - st.Profiles = kept - - if !found { - return true, "not found" - } - if err := saveTrafficAppProfilesState(st); err != nil { - return false, err.Error() - } - return true, "deleted" -} - -func deriveTrafficAppProfileID(appKey string, target string, existing []TrafficAppProfile) string { - base := filepath.Base(strings.TrimSpace(appKey)) - if base == "" || base == "/" || base == "." { - base = "app" - } - base = sanitizeID(base) - if base == "" { - base = "app" - } - - idBase := base + "-" + strings.ToLower(strings.TrimSpace(target)) - id := idBase - - used := map[string]struct{}{} - for _, p := range existing { - used[strings.TrimSpace(p.ID)] = struct{}{} - } - if _, ok := used[id]; !ok { - return id - } - for i := 2; i < 1000; i++ { - cand := fmt.Sprintf("%s-%d", idBase, i) - if _, ok := used[cand]; !ok { - return cand - } - } - return "" -} - -func sanitizeID(s string) string { - in := strings.ToLower(strings.TrimSpace(s)) - var b strings.Builder - b.Grow(len(in)) - lastDash := false - for i := 0; i < len(in); i++ { - ch := in[i] - isAZ := ch >= 'a' && ch <= 'z' - is09 := ch >= '0' && ch <= '9' - if isAZ || is09 { - b.WriteByte(ch) - lastDash = false - continue - } - if !lastDash { - b.WriteByte('-') - lastDash = true - } - } - out := strings.Trim(b.String(), "-") - return out -} - -func loadTrafficAppProfilesState() trafficAppProfilesState { - st := trafficAppProfilesState{Version: 1} - data, err := os.ReadFile(trafficAppProfilesPath) - if err != nil { - return st - } - if err := json.Unmarshal(data, &st); err != nil { - return trafficAppProfilesState{Version: 1} - } - if st.Version == 0 { - st.Version = 1 - } - if st.Profiles == nil { - st.Profiles = nil - } - - // EN: Best-effort migration: normalize app keys to canonical form. - // RU: Best-effort миграция: нормализуем app_key в канонический вид. - changed := false - for i := range st.Profiles { - canon := canonicalizeAppKey(st.Profiles[i].AppKey, st.Profiles[i].Command) - if canon != "" && strings.TrimSpace(st.Profiles[i].AppKey) != canon { - st.Profiles[i].AppKey = canon - changed = true - } - st.Profiles[i].Target = strings.ToLower(strings.TrimSpace(st.Profiles[i].Target)) - } - if deduped, dedupChanged := dedupeTrafficAppProfiles(st.Profiles); dedupChanged { - st.Profiles = deduped - changed = true - } - if changed { - _ = saveTrafficAppProfilesState(st) - } - return st -} - -func dedupeTrafficAppProfiles(in []TrafficAppProfile) ([]TrafficAppProfile, bool) { - if len(in) <= 1 { - return in, false - } - - out := make([]TrafficAppProfile, 0, len(in)) - byID := map[string]int{} - byAppTarget := map[string]int{} - changed := false - - for _, raw := range in { - p := raw - p.ID = strings.TrimSpace(p.ID) - p.Target = strings.ToLower(strings.TrimSpace(p.Target)) - p.AppKey = canonicalizeAppKey(p.AppKey, p.Command) - - if p.ID == "" { - changed = true - continue - } - if p.Target != "vpn" && p.Target != "direct" { - p.Target = "vpn" - changed = true - } - - if idx, ok := byID[p.ID]; ok { - if preferTrafficProfile(p, out[idx]) { - out[idx] = p - } - changed = true - continue - } - - if p.AppKey != "" { - key := p.Target + "|" + p.AppKey - if idx, ok := byAppTarget[key]; ok { - if preferTrafficProfile(p, out[idx]) { - byID[p.ID] = idx - out[idx] = p - } - changed = true - continue - } - byAppTarget[key] = len(out) - } - - byID[p.ID] = len(out) - out = append(out, p) - } - return out, changed -} - -func preferTrafficProfile(cand, cur TrafficAppProfile) bool { - cu := strings.TrimSpace(cand.UpdatedAt) - ou := strings.TrimSpace(cur.UpdatedAt) - if cu != ou { - if cu == "" { - return false - } - if ou == "" { - return true - } - return cu > ou - } - - cc := strings.TrimSpace(cand.CreatedAt) - oc := strings.TrimSpace(cur.CreatedAt) - if cc != oc { - if cc == "" { - return false - } - if oc == "" { - return true - } - return cc > oc - } - - if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" { - return true - } - return false -} - -func saveTrafficAppProfilesState(st trafficAppProfilesState) error { - st.Version = 1 - st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - - data, err := json.MarshalIndent(st, "", " ") - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(trafficAppProfilesPath), 0o755); err != nil { - return err - } - tmp := trafficAppProfilesPath + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, trafficAppProfilesPath) -} diff --git a/selective-vpn-api/app/traffic_app_profiles_store.go b/selective-vpn-api/app/traffic_app_profiles_store.go new file mode 100644 index 0000000..3b0e968 --- /dev/null +++ b/selective-vpn-api/app/traffic_app_profiles_store.go @@ -0,0 +1,119 @@ +package app + +import ( + trafficprofilespkg "selective-vpn-api/app/trafficprofiles" + "strings" +) + +func listTrafficAppProfiles() []TrafficAppProfile { + raw := trafficAppProfilesStore.List() + if len(raw) == 0 { + return nil + } + out := make([]TrafficAppProfile, 0, len(raw)) + for _, p := range raw { + out = append(out, fromTrafficProfilesPackage(p)) + } + return out +} + +func upsertTrafficAppProfile(req TrafficAppProfileUpsertRequest) (TrafficAppProfile, error) { + p, err := trafficAppProfilesStore.Upsert(toTrafficProfilesPackageReq(req)) + if err != nil { + return TrafficAppProfile{}, err + } + return fromTrafficProfilesPackage(p), nil +} + +func deleteTrafficAppProfile(id string) (bool, string) { + return trafficAppProfilesStore.Delete(id) +} + +func dedupeTrafficAppProfiles(in []TrafficAppProfile) ([]TrafficAppProfile, bool) { + if len(in) == 0 { + return in, false + } + raw := make([]trafficprofilespkg.Profile, 0, len(in)) + for _, p := range in { + raw = append(raw, toTrafficProfilesPackage(p)) + } + deduped, changed := trafficprofilespkg.Dedupe(raw, canonicalizeAppKey) + if len(deduped) == 0 { + return []TrafficAppProfile{}, changed + } + out := make([]TrafficAppProfile, 0, len(deduped)) + for _, p := range deduped { + out = append(out, fromTrafficProfilesPackage(p)) + } + return out, changed +} + +func toTrafficProfilesPackageReq(in TrafficAppProfileUpsertRequest) trafficprofilespkg.UpsertRequest { + return trafficprofilespkg.UpsertRequest{ + ID: in.ID, + Name: in.Name, + AppKey: in.AppKey, + Command: in.Command, + Target: in.Target, + TTLSec: in.TTLSec, + VPNProfile: in.VPNProfile, + } +} + +func toTrafficProfilesPackage(in TrafficAppProfile) trafficprofilespkg.Profile { + return trafficprofilespkg.Profile{ + ID: in.ID, + Name: in.Name, + AppKey: in.AppKey, + Command: in.Command, + Target: in.Target, + TTLSec: in.TTLSec, + VPNProfile: in.VPNProfile, + CreatedAt: in.CreatedAt, + UpdatedAt: in.UpdatedAt, + } +} + +func fromTrafficProfilesPackage(in trafficprofilespkg.Profile) TrafficAppProfile { + return TrafficAppProfile{ + ID: in.ID, + Name: in.Name, + AppKey: in.AppKey, + Command: in.Command, + Target: in.Target, + TTLSec: in.TTLSec, + VPNProfile: in.VPNProfile, + CreatedAt: in.CreatedAt, + UpdatedAt: in.UpdatedAt, + } +} + +func sanitizeID(s string) string { + in := strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + b.Grow(len(in)) + lastDash := false + for i := 0; i < len(in); i++ { + ch := in[i] + isAZ := ch >= 'a' && ch <= 'z' + is09 := ch >= '0' && ch <= '9' + if isAZ || is09 { + b.WriteByte(ch) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + return strings.Trim(b.String(), "-") +} + +func canonicalizeAppKey(appKey string, command string) string { + return trafficprofilespkg.CanonicalizeAppKey(appKey, command) +} + +func splitCommandTokens(raw string) []string { + return trafficprofilespkg.SplitCommandTokens(raw) +} diff --git a/selective-vpn-api/app/traffic_appmarks.go b/selective-vpn-api/app/traffic_appmarks.go index 86d5e0c..a5beaa3 100644 --- a/selective-vpn-api/app/traffic_appmarks.go +++ b/selective-vpn-api/app/traffic_appmarks.go @@ -1,19 +1,8 @@ package app import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/netip" - "os" - "path/filepath" - "sort" - "strconv" - "strings" + trafficappmarkspkg "selective-vpn-api/app/trafficappmarks" "sync" - "syscall" - "time" ) // --------------------------------------------------------------------- @@ -41,1100 +30,5 @@ const ( var appMarksMu sync.Mutex -type appMarksState struct { - Version int `json:"version"` - UpdatedAt string `json:"updated_at"` - Items []appMarkItem `json:"items,omitempty"` -} - -type appMarkItem struct { - ID uint64 `json:"id"` - Target string `json:"target"` // vpn|direct - Cgroup string `json:"cgroup"` // absolute path ("/user.slice/..."), informational - CgroupRel string `json:"cgroup_rel"` - Level int `json:"level"` - Unit string `json:"unit,omitempty"` - Command string `json:"command,omitempty"` - AppKey string `json:"app_key,omitempty"` - AddedAt string `json:"added_at"` - ExpiresAt string `json:"expires_at"` -} - -func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - vpnCount, directCount := appMarksGetStatus() - writeJSON(w, http.StatusOK, TrafficAppMarksStatusResponse{ - VPNCount: vpnCount, - DirectCount: directCount, - Message: "ok", - }) - case http.MethodPost: - var body TrafficAppMarksRequest - 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 - } - } - - op := TrafficAppMarksOp(strings.ToLower(strings.TrimSpace(string(body.Op)))) - target := strings.ToLower(strings.TrimSpace(body.Target)) - cgroup := strings.TrimSpace(body.Cgroup) - unit := strings.TrimSpace(body.Unit) - command := strings.TrimSpace(body.Command) - appKey := strings.TrimSpace(body.AppKey) - timeoutSec := body.TimeoutSec - - if op == "" { - http.Error(w, "missing op", http.StatusBadRequest) - return - } - if target == "" { - http.Error(w, "missing target", http.StatusBadRequest) - return - } - if target != "vpn" && target != "direct" { - http.Error(w, "target must be vpn|direct", http.StatusBadRequest) - return - } - if (op == TrafficAppMarksAdd || op == TrafficAppMarksDel) && cgroup == "" { - http.Error(w, "missing cgroup", http.StatusBadRequest) - return - } - if timeoutSec < 0 { - http.Error(w, "timeout_sec must be >= 0", http.StatusBadRequest) - return - } - - if err := ensureAppMarksNft(); err != nil { - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: false, - Op: string(op), - Target: target, - Cgroup: cgroup, - Message: "nft init failed: " + err.Error(), - }) - return - } - - switch op { - case TrafficAppMarksAdd: - if isAllDigits(cgroup) { - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: false, - Op: string(op), - Target: target, - Cgroup: cgroup, - Message: "cgroup must be a cgroupv2 path (ControlGroup), not a numeric id", - }) - return - } - - ttl := timeoutSec - - rel, level, inodeID, cgAbs, err := resolveCgroupV2PathForNft(cgroup) - if err != nil { - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: false, - Op: string(op), - Target: target, - Cgroup: body.Cgroup, - Message: err.Error(), - }) - return - } - - vpnIface := "" - if target == "vpn" { - traffic := loadTrafficModeState() - iface, _ := resolveTrafficIface(traffic.PreferredIface) - if strings.TrimSpace(iface) == "" { - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: false, - Op: string(op), - Target: target, - Cgroup: cgAbs, - CgroupID: inodeID, - Message: "vpn interface not found (set preferred iface or bring VPN up)", - }) - return - } - vpnIface = strings.TrimSpace(iface) - if err := ensureTrafficRouteBase(iface, traffic.AutoLocalBypass); err != nil { - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: false, - Op: string(op), - Target: target, - Cgroup: cgAbs, - CgroupID: inodeID, - Message: "ensure vpn route base failed: " + err.Error(), - }) - return - } - } - - if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl, vpnIface); err != nil { - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: false, - Op: string(op), - Target: target, - Cgroup: cgAbs, - CgroupID: inodeID, - TimeoutSec: ttl, - Message: err.Error(), - }) - return - } - - appendTraceLine("traffic", fmt.Sprintf("appmarks add target=%s cgroup=%s id=%d ttl=%ds", target, cgAbs, inodeID, ttl)) - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: true, - Op: string(op), - Target: target, - Cgroup: cgAbs, - CgroupID: inodeID, - TimeoutSec: ttl, - Message: "added", - }) - case TrafficAppMarksDel: - if err := appMarksDel(target, cgroup); err != nil { - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: false, - Op: string(op), - Target: target, - Cgroup: cgroup, - Message: err.Error(), - }) - return - } - appendTraceLine("traffic", fmt.Sprintf("appmarks del target=%s cgroup=%s", target, cgroup)) - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: true, - Op: string(op), - Target: target, - Cgroup: cgroup, - Message: "deleted", - }) - case TrafficAppMarksClear: - if err := appMarksClear(target); err != nil { - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: false, - Op: string(op), - Target: target, - Message: err.Error(), - }) - return - } - appendTraceLine("traffic", fmt.Sprintf("appmarks clear target=%s", target)) - writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ - OK: true, - Op: string(op), - Target: target, - Message: "cleared", - }) - default: - http.Error(w, "unknown op", http.StatusBadRequest) - } - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func handleTrafficAppMarksItems(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - _ = pruneExpiredAppMarks() - - appMarksMu.Lock() - st := loadAppMarksState() - appMarksMu.Unlock() - - now := time.Now().UTC() - items := make([]TrafficAppMarkItemView, 0, len(st.Items)) - for _, it := range st.Items { - rem := -1 // persistent by default - expRaw := strings.TrimSpace(it.ExpiresAt) - if expRaw != "" { - exp, err := time.Parse(time.RFC3339, expRaw) - if err == nil { - rem = int(exp.Sub(now).Seconds()) - if rem < 0 { - rem = 0 - } - } else { - rem = 0 - } - } - items = append(items, TrafficAppMarkItemView{ - ID: it.ID, - Target: it.Target, - Cgroup: it.Cgroup, - CgroupRel: it.CgroupRel, - Level: it.Level, - Unit: it.Unit, - Command: it.Command, - AppKey: it.AppKey, - AddedAt: it.AddedAt, - ExpiresAt: it.ExpiresAt, - RemainingSec: rem, - }) - } - - // Sort: target -> app_key -> remaining desc. - sort.Slice(items, func(i, j int) bool { - if items[i].Target != items[j].Target { - return items[i].Target < items[j].Target - } - if items[i].AppKey != items[j].AppKey { - return items[i].AppKey < items[j].AppKey - } - return items[i].RemainingSec > items[j].RemainingSec - }) - - writeJSON(w, http.StatusOK, TrafficAppMarksItemsResponse{Items: items, Message: "ok"}) -} - -func appMarksGetStatus() (vpnCount int, directCount int) { - _ = pruneExpiredAppMarks() - - appMarksMu.Lock() - defer appMarksMu.Unlock() - - st := loadAppMarksState() - for _, it := range st.Items { - switch strings.ToLower(strings.TrimSpace(it.Target)) { - case "vpn": - vpnCount++ - case "direct": - directCount++ - } - } - return vpnCount, directCount -} - -func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int, vpnIface string) error { - target = strings.ToLower(strings.TrimSpace(target)) - if target != "vpn" && target != "direct" { - return fmt.Errorf("invalid target") - } - if id == 0 { - return fmt.Errorf("invalid cgroup id") - } - if strings.TrimSpace(rel) == "" || level <= 0 { - return fmt.Errorf("invalid cgroup path") - } - if ttlSec <= 0 { - ttlSec = defaultAppMarkTTLSeconds - } - - appMarksMu.Lock() - defer appMarksMu.Unlock() - - st := loadAppMarksState() - changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) - - unit = strings.TrimSpace(unit) - command = strings.TrimSpace(command) - appKey = canonicalizeAppKey(appKey, command) - - // EN: Keep only one effective mark per app and avoid cross-target conflicts. - // EN: If the same app_key is re-marked with another target, old mark is removed first. - // RU: Держим только одну эффективную метку на приложение и убираем конфликты между target. - // RU: Если тот же app_key перемечается на другой target — старая метка удаляется. - kept := st.Items[:0] - for _, it := range st.Items { - itTarget := strings.ToLower(strings.TrimSpace(it.Target)) - itKey := strings.TrimSpace(it.AppKey) - remove := false - - // Same cgroup id but different target => conflicting rules (mark+guard). - if it.ID == id && it.ID != 0 && itTarget != target { - remove = true - } - // Same app_key (if known) should not keep multiple active runtime routes. - if !remove && appKey != "" && itKey != "" && itKey == appKey { - if it.ID != id || itTarget != target { - remove = true - } - } - - if remove { - _ = nftDeleteAppMarkRule(itTarget, it.ID) - changed = true - continue - } - kept = append(kept, it) - } - st.Items = kept - - // Replace any existing rule/state for this (target,id). - _ = nftDeleteAppMarkRule(target, id) - if err := nftInsertAppMarkRule(target, rel, level, id, vpnIface); err != nil { - return err - } - if !nftHasAppMarkRule(target, id) { - _ = nftDeleteAppMarkRule(target, id) - return fmt.Errorf("appmark rule not active after insert (target=%s id=%d)", target, id) - } - - now := time.Now().UTC() - expiresAt := "" - if ttlSec > 0 { - expiresAt = now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339) - } - item := appMarkItem{ - ID: id, - Target: target, - Cgroup: cgAbs, - CgroupRel: rel, - Level: level, - Unit: unit, - Command: command, - AppKey: appKey, - AddedAt: now.Format(time.RFC3339), - ExpiresAt: expiresAt, - } - st.Items = upsertAppMarkItem(st.Items, item) - changed = true - - if changed { - if err := saveAppMarksState(st); err != nil { - // Keep runtime state and nft in sync on disk write errors. - _ = nftDeleteAppMarkRule(target, id) - return err - } - } - return nil -} - -func appMarksDel(target string, cgroup string) error { - target = strings.ToLower(strings.TrimSpace(target)) - if target != "vpn" && target != "direct" { - return fmt.Errorf("invalid target") - } - cgroup = strings.TrimSpace(cgroup) - if cgroup == "" { - return fmt.Errorf("empty cgroup") - } - - appMarksMu.Lock() - defer appMarksMu.Unlock() - - st := loadAppMarksState() - changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) - - var id uint64 - var cgAbs string - - if isAllDigits(cgroup) { - v, err := strconv.ParseUint(cgroup, 10, 64) - if err == nil { - id = v - } - } else { - rel := normalizeCgroupRelOnly(cgroup) - if rel != "" { - cgAbs = "/" + rel - // Try to resolve inode id if directory still exists. - if inode, err := cgroupDirInode(rel); err == nil { - id = inode - } - } - } - - // Fallback to state lookup by cgroup string. - idx := -1 - for i, it := range st.Items { - if strings.ToLower(strings.TrimSpace(it.Target)) != target { - continue - } - if id != 0 && it.ID == id { - idx = i - break - } - if id == 0 && cgAbs != "" && strings.TrimSpace(it.Cgroup) == cgAbs { - id = it.ID - idx = i - break - } - } - - if id != 0 { - _ = nftDeleteAppMarkRule(target, id) - } - if idx >= 0 { - st.Items = append(st.Items[:idx], st.Items[idx+1:]...) - changed = true - } - - if changed { - return saveAppMarksState(st) - } - return nil -} - -func appMarksClear(target string) error { - target = strings.ToLower(strings.TrimSpace(target)) - if target != "vpn" && target != "direct" { - return fmt.Errorf("invalid target") - } - - appMarksMu.Lock() - defer appMarksMu.Unlock() - - st := loadAppMarksState() - changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) - - kept := st.Items[:0] - for _, it := range st.Items { - if strings.ToLower(strings.TrimSpace(it.Target)) == target { - _ = nftDeleteAppMarkRule(target, it.ID) - changed = true - continue - } - kept = append(kept, it) - } - st.Items = kept - - if changed { - return saveAppMarksState(st) - } - return nil -} - -func ensureAppMarksNft() error { - // Best-effort "ensure": ignore "exists" errors and proceed. - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", appMarksTable) - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, appMarksGuardChain, "{", "type", "filter", "hook", "output", "priority", "filter;", "policy", "accept;", "}") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, appMarksChain) - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", appMarksTable, appMarksLocalBypassSet, "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") - - out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "list", "chain", "inet", appMarksTable, "output") - if !strings.Contains(out, "jump "+appMarksChain) { - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "insert", "rule", "inet", appMarksTable, "output", "jump", appMarksChain) - } - - // Remove legacy rules that relied on `meta cgroup @svpn_cg_*` (broken on some kernels). - _ = cleanupLegacyAppMarksRules() - return nil -} - -func cleanupLegacyAppMarksRules() error { - out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain) - for _, line := range strings.Split(out, "\n") { - l := strings.ToLower(line) - if !strings.Contains(l, "meta cgroup") { - continue - } - if !strings.Contains(l, "svpn_cg_") { - continue - } - h := parseNftHandle(line) - if h <= 0 { - continue - } - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, appMarksChain, "handle", strconv.Itoa(h)) - } - return nil -} - -func appMarkComment(target string, id uint64) string { - return fmt.Sprintf("%s:%s:%d", appMarkCommentPrefix, target, id) -} - -func appGuardComment(target string, id uint64) string { - return fmt.Sprintf("%s:%s:%d", appGuardCommentPrefix, target, id) -} - -func appGuardEnabled() bool { - v := strings.ToLower(strings.TrimSpace(os.Getenv("SVPN_APP_GUARD"))) - return v == "1" || v == "true" || v == "yes" || v == "on" -} - -func updateAppMarkLocalBypassSet(vpnIface string) error { - // EN: Keep a small allowlist for local/LAN/container destinations so VPN app kill-switch - // EN: does not break host-local access. - // RU: Держим небольшой allowlist локальных/LAN/container направлений, чтобы VPN kill-switch - // RU: не ломал локальный доступ хоста. - vpnIface = strings.TrimSpace(vpnIface) - _ = ensureAppMarksNft() - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", appMarksTable, appMarksLocalBypassSet) - - elems := []string{"127.0.0.0/8"} - for _, rt := range detectAutoLocalBypassRoutes(vpnIface) { - dst := strings.TrimSpace(rt.Dst) - if dst == "" || dst == "default" { - continue - } - elems = append(elems, dst) - } - elems = compactIPv4IntervalElements(elems) - for _, e := range elems { - _, out, code, err := runCommandTimeout( - 5*time.Second, - "nft", "add", "element", "inet", appMarksTable, appMarksLocalBypassSet, - "{", e, "}", - ) - if err != nil || code != 0 { - if err == nil { - err = fmt.Errorf("nft add element exited with %d", code) - } - return fmt.Errorf("failed to update %s: %w (%s)", appMarksLocalBypassSet, err, strings.TrimSpace(out)) - } - } - return nil -} - -func compactIPv4IntervalElements(raw []string) []string { - pfxs := make([]netip.Prefix, 0, len(raw)) - for _, v := range raw { - s := strings.TrimSpace(v) - if s == "" { - continue - } - if strings.Contains(s, "/") { - p, err := netip.ParsePrefix(s) - if err != nil || !p.Addr().Is4() { - continue - } - pfxs = append(pfxs, p.Masked()) - continue - } - a, err := netip.ParseAddr(s) - if err != nil || !a.Is4() { - continue - } - pfxs = append(pfxs, netip.PrefixFrom(a, 32)) - } - - sort.Slice(pfxs, func(i, j int) bool { - ib, jb := pfxs[i].Bits(), pfxs[j].Bits() - if ib != jb { - return ib < jb // broader first - } - return pfxs[i].Addr().Less(pfxs[j].Addr()) - }) - - out := make([]netip.Prefix, 0, len(pfxs)) - for _, p := range pfxs { - covered := false - for _, ex := range out { - if ex.Contains(p.Addr()) { - covered = true - break - } - } - if covered { - continue - } - out = append(out, p) - } - - res := make([]string, 0, len(out)) - for _, p := range out { - res = append(res, p.String()) - } - return res -} - -func nftInsertAppMarkRule(target string, rel string, level int, id uint64, vpnIface string) error { - mark := MARK_DIRECT - if target == "vpn" { - mark = MARK_APP - } - comment := appMarkComment(target, id) - // EN: nft requires a *string literal* for cgroupv2 path; paths with '@' (user@1000.service) - // EN: break tokenization unless we pass quotes as part of nft language input. - // RU: nft ожидает *строку* для cgroupv2 пути; пути с '@' (user@1000.service) - // RU: ломают токенизацию, поэтому передаем кавычки как часть nft-выражения. - pathLit := fmt.Sprintf("\"%s\"", rel) - commentLit := fmt.Sprintf("\"%s\"", comment) - - if target == "vpn" { - if !appGuardEnabled() { - goto insertMark - } - iface := strings.TrimSpace(vpnIface) - if iface == "" { - return fmt.Errorf("vpn interface required for app guard") - } - if err := updateAppMarkLocalBypassSet(iface); err != nil { - return err - } - - guardComment := appGuardComment(target, id) - guardCommentLit := fmt.Sprintf("\"%s\"", guardComment) - // IPv4: drop non-tun egress except local bypass ranges. - _, out, code, err := runCommandTimeout( - 5*time.Second, - "nft", "insert", "rule", "inet", appMarksTable, appMarksGuardChain, - "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, - "meta", "mark", MARK_APP, - "oifname", "!=", iface, - "ip", "daddr", "!=", "@"+appMarksLocalBypassSet, - "drop", - "comment", guardCommentLit, - ) - if err != nil || code != 0 { - if err == nil { - err = fmt.Errorf("nft insert guard(v4) exited with %d", code) - } - return fmt.Errorf("nft insert app guard(v4) failed: %w (%s)", err, strings.TrimSpace(out)) - } - - // IPv6: default deny outside VPN iface to prevent WebRTC/STUN leaks on dual-stack hosts. - _, out, code, err = runCommandTimeout( - 5*time.Second, - "nft", "insert", "rule", "inet", appMarksTable, appMarksGuardChain, - "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, - "meta", "mark", MARK_APP, - "oifname", "!=", iface, - "meta", "nfproto", "ipv6", - "drop", - "comment", guardCommentLit, - ) - if err != nil || code != 0 { - if err == nil { - err = fmt.Errorf("nft insert guard(v6) exited with %d", code) - } - return fmt.Errorf("nft insert app guard(v6) failed: %w (%s)", err, strings.TrimSpace(out)) - } - } - -insertMark: - _, out, code, err := runCommandTimeout( - 5*time.Second, - "nft", "insert", "rule", "inet", appMarksTable, appMarksChain, - "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, - "meta", "mark", "set", mark, - "accept", - "comment", commentLit, - ) - if err != nil || code != 0 { - if err == nil { - err = fmt.Errorf("nft insert rule exited with %d", code) - } - _ = nftDeleteAppMarkRule(target, id) - return fmt.Errorf("nft insert appmark rule failed: %w (%s)", err, strings.TrimSpace(out)) - } - return nil -} - -func nftDeleteAppMarkRule(target string, id uint64) error { - comments := []string{ - appMarkComment(target, id), - appGuardComment(target, id), - } - chains := []string{appMarksChain, appMarksGuardChain} - for _, chain := range chains { - out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, chain) - for _, line := range strings.Split(out, "\n") { - match := false - for _, comment := range comments { - if strings.Contains(line, comment) { - match = true - break - } - } - if !match { - continue - } - h := parseNftHandle(line) - if h <= 0 { - continue - } - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, chain, "handle", strconv.Itoa(h)) - } - } - return nil -} - -func nftHasAppMarkRule(target string, id uint64) bool { - markComment := appMarkComment(target, id) - guardComment := appGuardComment(target, id) - - hasMark := false - out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain) - for _, line := range strings.Split(out, "\n") { - if strings.Contains(line, markComment) { - hasMark = true - break - } - } - if !hasMark { - return false - } - if strings.EqualFold(strings.TrimSpace(target), "vpn") { - if !appGuardEnabled() { - return true - } - out, _, _, _ = runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksGuardChain) - for _, line := range strings.Split(out, "\n") { - if strings.Contains(line, guardComment) { - return true - } - } - return false - } - return true -} - -func parseNftHandle(line string) int { - fields := strings.Fields(line) - for i := 0; i < len(fields)-1; i++ { - if fields[i] == "handle" { - n, _ := strconv.Atoi(fields[i+1]) - return n - } - } - return 0 -} - -func resolveCgroupV2PathForNft(input string) (rel string, level int, inodeID uint64, abs string, err error) { - raw := strings.TrimSpace(input) - if raw == "" { - return "", 0, 0, "", fmt.Errorf("empty cgroup") - } - - rel = normalizeCgroupRelOnly(raw) - if rel == "" { - return "", 0, 0, raw, fmt.Errorf("invalid cgroup path: %s", raw) - } - - inodeID, err = cgroupDirInode(rel) - if err != nil { - return "", 0, 0, raw, err - } - - level = strings.Count(rel, "/") + 1 - abs = "/" + rel - return rel, level, inodeID, abs, nil -} - -func normalizeCgroupRelOnly(raw string) string { - rel := strings.TrimSpace(raw) - rel = strings.TrimPrefix(rel, "/") - rel = filepath.Clean(rel) - if rel == "." || rel == "" { - return "" - } - if strings.HasPrefix(rel, "..") || strings.Contains(rel, "../") { - return "" - } - return rel -} - -func cgroupDirInode(rel string) (uint64, error) { - full := filepath.Join(cgroupRootPath, strings.TrimPrefix(rel, "/")) - fi, err := os.Stat(full) - if err != nil || fi == nil || !fi.IsDir() { - return 0, fmt.Errorf("cgroup not found: %s", "/"+strings.TrimPrefix(rel, "/")) - } - st, ok := fi.Sys().(*syscall.Stat_t) - if !ok || st == nil { - return 0, fmt.Errorf("cannot stat cgroup: %s", "/"+strings.TrimPrefix(rel, "/")) - } - if st.Ino == 0 { - return 0, fmt.Errorf("invalid cgroup inode id: %s", "/"+strings.TrimPrefix(rel, "/")) - } - return st.Ino, nil -} - -func pruneExpiredAppMarks() error { - appMarksMu.Lock() - defer appMarksMu.Unlock() - - st := loadAppMarksState() - if pruneExpiredAppMarksLocked(&st, time.Now().UTC()) { - return saveAppMarksState(st) - } - return nil -} - -func pruneExpiredAppMarksLocked(st *appMarksState, now time.Time) (changed bool) { - if st == nil { - return false - } - kept := st.Items[:0] - for _, it := range st.Items { - expRaw := strings.TrimSpace(it.ExpiresAt) - if expRaw == "" { - kept = append(kept, it) - continue - } - exp, err := time.Parse(time.RFC3339, expRaw) - if err != nil { - // Corrupted timestamp: keep mark as persistent to avoid accidental route leak. - it.ExpiresAt = "" - kept = append(kept, it) - changed = true - continue - } - if !exp.After(now) { - _ = nftDeleteAppMarkRule(strings.ToLower(strings.TrimSpace(it.Target)), it.ID) - changed = true - continue - } - kept = append(kept, it) - } - st.Items = kept - return changed -} - -func upsertAppMarkItem(items []appMarkItem, next appMarkItem) []appMarkItem { - out := items[:0] - for _, it := range items { - if strings.ToLower(strings.TrimSpace(it.Target)) == strings.ToLower(strings.TrimSpace(next.Target)) && it.ID == next.ID { - continue - } - out = append(out, it) - } - out = append(out, next) - return out -} - -func clearManagedAppMarkRules(chain string) { - out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, chain) - for _, line := range strings.Split(out, "\n") { - l := strings.ToLower(line) - if !strings.Contains(l, strings.ToLower(appMarkCommentPrefix)) && - !strings.Contains(l, strings.ToLower(appGuardCommentPrefix)) { - continue - } - h := parseNftHandle(line) - if h <= 0 { - continue - } - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, chain, "handle", strconv.Itoa(h)) - } -} - -func restoreAppMarksFromState() error { - appMarksMu.Lock() - defer appMarksMu.Unlock() - - if err := ensureAppMarksNft(); err != nil { - return err - } - - st := loadAppMarksState() - now := time.Now().UTC() - changed := pruneExpiredAppMarksLocked(&st, now) - - clearManagedAppMarkRules(appMarksChain) - clearManagedAppMarkRules(appMarksGuardChain) - - traffic := loadTrafficModeState() - vpnIface, _ := resolveTrafficIface(traffic.PreferredIface) - vpnIface = strings.TrimSpace(vpnIface) - - kept := make([]appMarkItem, 0, len(st.Items)) - for _, it := range st.Items { - target := strings.ToLower(strings.TrimSpace(it.Target)) - if target != "vpn" && target != "direct" { - changed = true - continue - } - - rel := normalizeCgroupRelOnly(it.CgroupRel) - if rel == "" { - rel = normalizeCgroupRelOnly(it.Cgroup) - } - if rel == "" { - changed = true - continue - } - - id := it.ID - if id == 0 { - inode, err := cgroupDirInode(rel) - if err != nil { - changed = true - continue - } - id = inode - it.ID = inode - changed = true - } - - level := it.Level - if level <= 0 { - level = strings.Count(strings.Trim(rel, "/"), "/") + 1 - it.Level = level - changed = true - } - - abs := "/" + strings.TrimPrefix(rel, "/") - it.CgroupRel = rel - it.Cgroup = abs - - if _, err := cgroupDirInode(rel); err != nil { - changed = true - continue - } - - iface := "" - if target == "vpn" { - if vpnIface == "" { - // Keep state for later retry when VPN interface appears. - kept = append(kept, it) - continue - } - iface = vpnIface - } - - if err := nftInsertAppMarkRule(target, rel, level, id, iface); err != nil { - appendTraceLine("traffic", fmt.Sprintf("appmarks restore failed target=%s id=%d err=%v", target, id, err)) - kept = append(kept, it) - continue - } - if !nftHasAppMarkRule(target, id) { - appendTraceLine("traffic", fmt.Sprintf("appmarks restore post-check failed target=%s id=%d", target, id)) - kept = append(kept, it) - continue - } - kept = append(kept, it) - } - st.Items = kept - - if changed { - return saveAppMarksState(st) - } - return nil -} - -func loadAppMarksState() appMarksState { - st := appMarksState{Version: 1} - data, err := os.ReadFile(trafficAppMarksPath) - if err != nil { - return st - } - if err := json.Unmarshal(data, &st); err != nil { - return appMarksState{Version: 1} - } - if st.Version == 0 { - st.Version = 1 - } - - // EN: Best-effort migration: normalize app keys to canonical form. - // RU: Best-effort миграция: нормализуем app_key в канонический вид. - changed := false - for i := range st.Items { - st.Items[i].Target = strings.ToLower(strings.TrimSpace(st.Items[i].Target)) - canon := canonicalizeAppKey(st.Items[i].AppKey, st.Items[i].Command) - if canon != "" && strings.TrimSpace(st.Items[i].AppKey) != canon { - st.Items[i].AppKey = canon - changed = true - } - } - if deduped, dedupChanged := dedupeAppMarkItems(st.Items); dedupChanged { - st.Items = deduped - changed = true - } - if changed { - _ = saveAppMarksState(st) - } - return st -} - -func dedupeAppMarkItems(in []appMarkItem) ([]appMarkItem, bool) { - if len(in) <= 1 { - return in, false - } - out := make([]appMarkItem, 0, len(in)) - byTargetID := map[string]int{} - byTargetApp := map[string]int{} - changed := false - - for _, raw := range in { - it := raw - it.Target = strings.ToLower(strings.TrimSpace(it.Target)) - if it.Target != "vpn" && it.Target != "direct" { - changed = true - continue - } - it.AppKey = canonicalizeAppKey(it.AppKey, it.Command) - - if it.ID > 0 { - idKey := fmt.Sprintf("%s:%d", it.Target, it.ID) - if idx, ok := byTargetID[idKey]; ok { - if preferAppMarkItem(it, out[idx]) { - out[idx] = it - } - changed = true - continue - } - byTargetID[idKey] = len(out) - } - - if it.AppKey != "" { - appKey := it.Target + "|" + it.AppKey - if idx, ok := byTargetApp[appKey]; ok { - if preferAppMarkItem(it, out[idx]) { - out[idx] = it - } - changed = true - continue - } - byTargetApp[appKey] = len(out) - } - - out = append(out, it) - } - return out, changed -} - -func preferAppMarkItem(cand, cur appMarkItem) bool { - ca := strings.TrimSpace(cand.AddedAt) - oa := strings.TrimSpace(cur.AddedAt) - if ca != oa { - if ca == "" { - return false - } - if oa == "" { - return true - } - return ca > oa - } - if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" { - return true - } - return false -} - -func saveAppMarksState(st appMarksState) error { - st.Version = 1 - st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - - data, err := json.MarshalIndent(st, "", " ") - if err != nil { - return err - } - if err := os.MkdirAll(stateDir, 0o755); err != nil { - return err - } - tmp := trafficAppMarksPath + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, trafficAppMarksPath) -} - -func isAllDigits(s string) bool { - s = strings.TrimSpace(s) - if s == "" { - return false - } - for i := 0; i < len(s); i++ { - ch := s[i] - if ch < '0' || ch > '9' { - return false - } - } - return true -} +type appMarksState = trafficappmarkspkg.State +type appMarkItem = trafficappmarkspkg.Item diff --git a/selective-vpn-api/app/traffic_appmarks_handlers.go b/selective-vpn-api/app/traffic_appmarks_handlers.go new file mode 100644 index 0000000..f9047b6 --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_handlers.go @@ -0,0 +1,21 @@ +package app + +import ( + "net/http" +) + +func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + vpnCount, directCount := appMarksGetStatus() + writeJSON(w, http.StatusOK, TrafficAppMarksStatusResponse{ + VPNCount: vpnCount, + DirectCount: directCount, + Message: "ok", + }) + case http.MethodPost: + handleTrafficAppMarksPost(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/selective-vpn-api/app/traffic_appmarks_handlers_items.go b/selective-vpn-api/app/traffic_appmarks_handlers_items.go new file mode 100644 index 0000000..e7a20fb --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_handlers_items.go @@ -0,0 +1,64 @@ +package app + +import ( + "net/http" + "sort" + "strings" + "time" +) + +func handleTrafficAppMarksItems(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + _ = pruneExpiredAppMarks() + + appMarksMu.Lock() + st := loadAppMarksState() + appMarksMu.Unlock() + + now := time.Now().UTC() + items := make([]TrafficAppMarkItemView, 0, len(st.Items)) + for _, it := range st.Items { + rem := -1 // persistent by default + expRaw := strings.TrimSpace(it.ExpiresAt) + if expRaw != "" { + exp, err := time.Parse(time.RFC3339, expRaw) + if err == nil { + rem = int(exp.Sub(now).Seconds()) + if rem < 0 { + rem = 0 + } + } else { + rem = 0 + } + } + items = append(items, TrafficAppMarkItemView{ + ID: it.ID, + Target: it.Target, + Cgroup: it.Cgroup, + CgroupRel: it.CgroupRel, + Level: it.Level, + Unit: it.Unit, + Command: it.Command, + AppKey: it.AppKey, + AddedAt: it.AddedAt, + ExpiresAt: it.ExpiresAt, + RemainingSec: rem, + }) + } + + // Sort: target -> app_key -> remaining desc. + sort.Slice(items, func(i, j int) bool { + if items[i].Target != items[j].Target { + return items[i].Target < items[j].Target + } + if items[i].AppKey != items[j].AppKey { + return items[i].AppKey < items[j].AppKey + } + return items[i].RemainingSec > items[j].RemainingSec + }) + + writeJSON(w, http.StatusOK, TrafficAppMarksItemsResponse{Items: items, Message: "ok"}) +} diff --git a/selective-vpn-api/app/traffic_appmarks_handlers_post.go b/selective-vpn-api/app/traffic_appmarks_handlers_post.go new file mode 100644 index 0000000..9feca3d --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_handlers_post.go @@ -0,0 +1,35 @@ +package app + +import ( + "net/http" +) + +func handleTrafficAppMarksPost(w http.ResponseWriter, r *http.Request) { + in, badReqMessage := decodeTrafficAppMarksPostInput(r) + if badReqMessage != "" { + http.Error(w, badReqMessage, http.StatusBadRequest) + return + } + + if err := ensureAppMarksNft(); err != nil { + writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ + OK: false, + Op: string(in.Op), + Target: in.Target, + Cgroup: in.Cgroup, + Message: "nft init failed: " + err.Error(), + }) + return + } + + switch in.Op { + case TrafficAppMarksAdd: + writeJSON(w, http.StatusOK, executeTrafficAppMarksAdd(in)) + case TrafficAppMarksDel: + writeJSON(w, http.StatusOK, executeTrafficAppMarksDelete(in)) + case TrafficAppMarksClear: + writeJSON(w, http.StatusOK, executeTrafficAppMarksClear(in)) + default: + http.Error(w, "unknown op", http.StatusBadRequest) + } +} diff --git a/selective-vpn-api/app/traffic_appmarks_handlers_post_ops.go b/selective-vpn-api/app/traffic_appmarks_handlers_post_ops.go new file mode 100644 index 0000000..8e481ae --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_handlers_post_ops.go @@ -0,0 +1,118 @@ +package app + +import ( + "fmt" + "strings" +) + +func executeTrafficAppMarksAdd(in trafficAppMarksPostInput) TrafficAppMarksResponse { + if isAllDigits(in.Cgroup) { + return TrafficAppMarksResponse{ + OK: false, + Op: string(in.Op), + Target: in.Target, + Cgroup: in.Cgroup, + Message: "cgroup must be a cgroupv2 path (ControlGroup), not a numeric id", + } + } + + ttl := in.TimeoutSec + rel, level, inodeID, cgAbs, err := resolveCgroupV2PathForNft(in.Cgroup) + if err != nil { + return TrafficAppMarksResponse{ + OK: false, + Op: string(in.Op), + Target: in.Target, + Cgroup: in.CgroupRaw, + Message: err.Error(), + } + } + + vpnIface := "" + if in.Target == "vpn" { + traffic := loadTrafficModeState() + iface, _ := resolveTrafficIface(traffic.PreferredIface) + if strings.TrimSpace(iface) == "" { + return TrafficAppMarksResponse{ + OK: false, + Op: string(in.Op), + Target: in.Target, + Cgroup: cgAbs, + CgroupID: inodeID, + Message: "vpn interface not found (set preferred iface or bring VPN up)", + } + } + vpnIface = strings.TrimSpace(iface) + if err := ensureTrafficRouteBase(iface, traffic.AutoLocalBypass); err != nil { + return TrafficAppMarksResponse{ + OK: false, + Op: string(in.Op), + Target: in.Target, + Cgroup: cgAbs, + CgroupID: inodeID, + Message: "ensure vpn route base failed: " + err.Error(), + } + } + } + + if err := appMarksAdd(in.Target, inodeID, cgAbs, rel, level, in.Unit, in.Command, in.AppKey, ttl, vpnIface); err != nil { + return TrafficAppMarksResponse{ + OK: false, + Op: string(in.Op), + Target: in.Target, + Cgroup: cgAbs, + CgroupID: inodeID, + TimeoutSec: ttl, + Message: err.Error(), + } + } + + appendTraceLine("traffic", fmt.Sprintf("appmarks add target=%s cgroup=%s id=%d ttl=%ds", in.Target, cgAbs, inodeID, ttl)) + return TrafficAppMarksResponse{ + OK: true, + Op: string(in.Op), + Target: in.Target, + Cgroup: cgAbs, + CgroupID: inodeID, + TimeoutSec: ttl, + Message: "added", + } +} + +func executeTrafficAppMarksDelete(in trafficAppMarksPostInput) TrafficAppMarksResponse { + if err := appMarksDel(in.Target, in.Cgroup); err != nil { + return TrafficAppMarksResponse{ + OK: false, + Op: string(in.Op), + Target: in.Target, + Cgroup: in.Cgroup, + Message: err.Error(), + } + } + appendTraceLine("traffic", fmt.Sprintf("appmarks del target=%s cgroup=%s", in.Target, in.Cgroup)) + return TrafficAppMarksResponse{ + OK: true, + Op: string(in.Op), + Target: in.Target, + Cgroup: in.Cgroup, + Message: "deleted", + } +} + +func executeTrafficAppMarksClear(in trafficAppMarksPostInput) TrafficAppMarksResponse { + if err := appMarksClear(in.Target); err != nil { + return TrafficAppMarksResponse{ + OK: false, + Op: string(in.Op), + Target: in.Target, + Message: err.Error(), + } + } + appendTraceLine("traffic", fmt.Sprintf("appmarks clear target=%s", in.Target)) + return TrafficAppMarksResponse{ + OK: true, + Op: string(in.Op), + Target: in.Target, + Message: "cleared", + } +} diff --git a/selective-vpn-api/app/traffic_appmarks_handlers_post_parse.go b/selective-vpn-api/app/traffic_appmarks_handlers_post_parse.go new file mode 100644 index 0000000..8d56703 --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_handlers_post_parse.go @@ -0,0 +1,56 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" +) + +type trafficAppMarksPostInput struct { + Op TrafficAppMarksOp + Target string + Cgroup string + CgroupRaw string + Unit string + Command string + AppKey string + TimeoutSec int +} + +func decodeTrafficAppMarksPostInput(r *http.Request) (trafficAppMarksPostInput, string) { + var body TrafficAppMarksRequest + 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 { + return trafficAppMarksPostInput{}, "bad json" + } + } + + in := trafficAppMarksPostInput{ + Op: TrafficAppMarksOp(strings.ToLower(strings.TrimSpace(string(body.Op)))), + Target: strings.ToLower(strings.TrimSpace(body.Target)), + Cgroup: strings.TrimSpace(body.Cgroup), + CgroupRaw: body.Cgroup, + Unit: strings.TrimSpace(body.Unit), + Command: strings.TrimSpace(body.Command), + AppKey: strings.TrimSpace(body.AppKey), + TimeoutSec: body.TimeoutSec, + } + if in.Op == "" { + return trafficAppMarksPostInput{}, "missing op" + } + if in.Target == "" { + return trafficAppMarksPostInput{}, "missing target" + } + if in.Target != "vpn" && in.Target != "direct" { + return trafficAppMarksPostInput{}, "target must be vpn|direct" + } + if (in.Op == TrafficAppMarksAdd || in.Op == TrafficAppMarksDel) && in.Cgroup == "" { + return trafficAppMarksPostInput{}, "missing cgroup" + } + if in.TimeoutSec < 0 { + return trafficAppMarksPostInput{}, "timeout_sec must be >= 0" + } + return in, "" +} diff --git a/selective-vpn-api/app/traffic_appmarks_ops.go b/selective-vpn-api/app/traffic_appmarks_ops.go new file mode 100644 index 0000000..7e53d23 --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_ops.go @@ -0,0 +1,23 @@ +package app + +import ( + "strings" +) + +func appMarksGetStatus() (vpnCount int, directCount int) { + _ = pruneExpiredAppMarks() + + appMarksMu.Lock() + defer appMarksMu.Unlock() + + st := loadAppMarksState() + for _, it := range st.Items { + switch strings.ToLower(strings.TrimSpace(it.Target)) { + case "vpn": + vpnCount++ + case "direct": + directCount++ + } + } + return vpnCount, directCount +} diff --git a/selective-vpn-api/app/traffic_appmarks_ops_mutations.go b/selective-vpn-api/app/traffic_appmarks_ops_mutations.go new file mode 100644 index 0000000..4879f7a --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_ops_mutations.go @@ -0,0 +1 @@ +package app diff --git a/selective-vpn-api/app/traffic_appmarks_ops_mutations_add.go b/selective-vpn-api/app/traffic_appmarks_ops_mutations_add.go new file mode 100644 index 0000000..67ce561 --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_ops_mutations_add.go @@ -0,0 +1,102 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int, vpnIface string) error { + target = strings.ToLower(strings.TrimSpace(target)) + if target != "vpn" && target != "direct" { + return fmt.Errorf("invalid target") + } + if id == 0 { + return fmt.Errorf("invalid cgroup id") + } + if strings.TrimSpace(rel) == "" || level <= 0 { + return fmt.Errorf("invalid cgroup path") + } + if ttlSec <= 0 { + ttlSec = defaultAppMarkTTLSeconds + } + + appMarksMu.Lock() + defer appMarksMu.Unlock() + + st := loadAppMarksState() + changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) + + unit = strings.TrimSpace(unit) + command = strings.TrimSpace(command) + appKey = canonicalizeAppKey(appKey, command) + + // EN: Keep only one effective mark per app and avoid cross-target conflicts. + // EN: If the same app_key is re-marked with another target, old mark is removed first. + // RU: Держим только одну эффективную метку на приложение и убираем конфликты между target. + // RU: Если тот же app_key перемечается на другой target — старая метка удаляется. + kept := st.Items[:0] + for _, it := range st.Items { + itTarget := strings.ToLower(strings.TrimSpace(it.Target)) + itKey := strings.TrimSpace(it.AppKey) + remove := false + + // Same cgroup id but different target => conflicting rules (mark+guard). + if it.ID == id && it.ID != 0 && itTarget != target { + remove = true + } + // Same app_key (if known) should not keep multiple active runtime routes. + if !remove && appKey != "" && itKey != "" && itKey == appKey { + if it.ID != id || itTarget != target { + remove = true + } + } + + if remove { + _ = nftDeleteAppMarkRule(itTarget, it.ID) + changed = true + continue + } + kept = append(kept, it) + } + st.Items = kept + + // Replace any existing rule/state for this (target,id). + _ = nftDeleteAppMarkRule(target, id) + if err := nftInsertAppMarkRule(target, rel, level, id, vpnIface); err != nil { + return err + } + if !nftHasAppMarkRule(target, id) { + _ = nftDeleteAppMarkRule(target, id) + return fmt.Errorf("appmark rule not active after insert (target=%s id=%d)", target, id) + } + + now := time.Now().UTC() + expiresAt := "" + if ttlSec > 0 { + expiresAt = now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339) + } + item := appMarkItem{ + ID: id, + Target: target, + Cgroup: cgAbs, + CgroupRel: rel, + Level: level, + Unit: unit, + Command: command, + AppKey: appKey, + AddedAt: now.Format(time.RFC3339), + ExpiresAt: expiresAt, + } + st.Items = upsertAppMarkItem(st.Items, item) + changed = true + + if changed { + if err := saveAppMarksState(st); err != nil { + // Keep runtime state and nft in sync on disk write errors. + _ = nftDeleteAppMarkRule(target, id) + return err + } + } + return nil +} diff --git a/selective-vpn-api/app/traffic_appmarks_ops_mutations_clear.go b/selective-vpn-api/app/traffic_appmarks_ops_mutations_clear.go new file mode 100644 index 0000000..94a93ec --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_ops_mutations_clear.go @@ -0,0 +1,36 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +func appMarksClear(target string) error { + target = strings.ToLower(strings.TrimSpace(target)) + if target != "vpn" && target != "direct" { + return fmt.Errorf("invalid target") + } + + appMarksMu.Lock() + defer appMarksMu.Unlock() + + st := loadAppMarksState() + changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) + + kept := st.Items[:0] + for _, it := range st.Items { + if strings.ToLower(strings.TrimSpace(it.Target)) == target { + _ = nftDeleteAppMarkRule(target, it.ID) + changed = true + continue + } + kept = append(kept, it) + } + st.Items = kept + + if changed { + return saveAppMarksState(st) + } + return nil +} diff --git a/selective-vpn-api/app/traffic_appmarks_ops_mutations_delete.go b/selective-vpn-api/app/traffic_appmarks_ops_mutations_delete.go new file mode 100644 index 0000000..74d8265 --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_ops_mutations_delete.go @@ -0,0 +1,74 @@ +package app + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +func appMarksDel(target string, cgroup string) error { + target = strings.ToLower(strings.TrimSpace(target)) + if target != "vpn" && target != "direct" { + return fmt.Errorf("invalid target") + } + cgroup = strings.TrimSpace(cgroup) + if cgroup == "" { + return fmt.Errorf("empty cgroup") + } + + appMarksMu.Lock() + defer appMarksMu.Unlock() + + st := loadAppMarksState() + changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) + + var id uint64 + var cgAbs string + + if isAllDigits(cgroup) { + v, err := strconv.ParseUint(cgroup, 10, 64) + if err == nil { + id = v + } + } else { + rel := normalizeCgroupRelOnly(cgroup) + if rel != "" { + cgAbs = "/" + rel + // Try to resolve inode id if directory still exists. + if inode, err := cgroupDirInode(rel); err == nil { + id = inode + } + } + } + + // Fallback to state lookup by cgroup string. + idx := -1 + for i, it := range st.Items { + if strings.ToLower(strings.TrimSpace(it.Target)) != target { + continue + } + if id != 0 && it.ID == id { + idx = i + break + } + if id == 0 && cgAbs != "" && strings.TrimSpace(it.Cgroup) == cgAbs { + id = it.ID + idx = i + break + } + } + + if id != 0 { + _ = nftDeleteAppMarkRule(target, id) + } + if idx >= 0 { + st.Items = append(st.Items[:idx], st.Items[idx+1:]...) + changed = true + } + + if changed { + return saveAppMarksState(st) + } + return nil +} diff --git a/selective-vpn-api/app/traffic_appmarks_runtime.go b/selective-vpn-api/app/traffic_appmarks_runtime.go new file mode 100644 index 0000000..d014803 --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_runtime.go @@ -0,0 +1,118 @@ +package app + +import ( + "os" + trafficappmarkspkg "selective-vpn-api/app/trafficappmarks" + "strings" +) + +func ensureAppMarksNft() error { + _ = trafficappmarkspkg.EnsureBase(appMarksNFTConfig(), runCommandTimeout) + + // Remove legacy rules that relied on `meta cgroup @svpn_cg_*` (broken on some kernels). + _ = cleanupLegacyAppMarksRules() + return nil +} + +func cleanupLegacyAppMarksRules() error { + return trafficappmarkspkg.CleanupLegacyRules(appMarksNFTConfig(), runCommandTimeout) +} + +func appMarkComment(target string, id uint64) string { + return trafficappmarkspkg.AppMarkComment(appMarkCommentPrefix, target, id) +} + +func appGuardComment(target string, id uint64) string { + return trafficappmarkspkg.AppGuardComment(appGuardCommentPrefix, target, id) +} + +func appGuardEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv("SVPN_APP_GUARD"))) + return v == "1" || v == "true" || v == "yes" || v == "on" +} + +func appMarksNFTConfig() trafficappmarkspkg.NFTConfig { + return trafficappmarkspkg.NFTConfig{ + Table: appMarksTable, + Chain: appMarksChain, + GuardChain: appMarksGuardChain, + LocalBypassSet: appMarksLocalBypassSet, + MarkApp: MARK_APP, + MarkDirect: MARK_DIRECT, + MarkCommentPrefix: appMarkCommentPrefix, + GuardCommentPrefix: appGuardCommentPrefix, + GuardEnabled: appGuardEnabled(), + } +} + +func appMarkAutoBypassCIDRs(vpnIface string) []string { + routes := detectAutoLocalBypassRoutes(vpnIface) + out := make([]string, 0, len(routes)) + for _, rt := range routes { + dst := strings.TrimSpace(rt.Dst) + if dst == "" || dst == "default" { + continue + } + out = append(out, dst) + } + return out +} + +func updateAppMarkLocalBypassSet(vpnIface string) error { + _ = ensureAppMarksNft() + return trafficappmarkspkg.UpdateLocalBypassSet( + appMarksNFTConfig(), + strings.TrimSpace(vpnIface), + appMarkAutoBypassCIDRs(vpnIface), + runCommandTimeout, + ) +} + +func nftInsertAppMarkRule(target string, rel string, level int, id uint64, vpnIface string) error { + return trafficappmarkspkg.InsertAppMarkRule( + appMarksNFTConfig(), + target, + rel, + level, + id, + strings.TrimSpace(vpnIface), + appMarkAutoBypassCIDRs(vpnIface), + runCommandTimeout, + ) +} + +func nftDeleteAppMarkRule(target string, id uint64) error { + return trafficappmarkspkg.DeleteAppMarkRule(appMarksNFTConfig(), target, id, runCommandTimeout) +} + +func nftHasAppMarkRule(target string, id uint64) bool { + return trafficappmarkspkg.HasAppMarkRule(appMarksNFTConfig(), target, id, runCommandTimeout) +} + +func parseNftHandle(line string) int { + return trafficappmarkspkg.ParseNftHandle(line) +} + +func resolveCgroupV2PathForNft(input string) (rel string, level int, inodeID uint64, abs string, err error) { + return trafficappmarkspkg.ResolveCgroupV2PathForNft(input, cgroupRootPath) +} + +func normalizeCgroupRelOnly(raw string) string { + return trafficappmarkspkg.NormalizeCgroupRelOnly(raw) +} + +func cgroupDirInode(rel string) (uint64, error) { + return trafficappmarkspkg.CgroupDirInode(cgroupRootPath, rel) +} + +func upsertAppMarkItem(items []appMarkItem, next appMarkItem) []appMarkItem { + return trafficappmarkspkg.UpsertItem(items, next) +} + +func clearManagedAppMarkRules(chain string) { + trafficappmarkspkg.ClearManagedRules(appMarksNFTConfig(), chain, runCommandTimeout) +} + +func isAllDigits(s string) bool { + return trafficappmarkspkg.IsAllDigits(s) +} diff --git a/selective-vpn-api/app/traffic_appmarks_runtime_restore.go b/selective-vpn-api/app/traffic_appmarks_runtime_restore.go new file mode 100644 index 0000000..c5e2501 --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_runtime_restore.go @@ -0,0 +1,101 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +func restoreAppMarksFromState() error { + appMarksMu.Lock() + defer appMarksMu.Unlock() + + if err := ensureAppMarksNft(); err != nil { + return err + } + + st := loadAppMarksState() + now := time.Now().UTC() + changed := pruneExpiredAppMarksLocked(&st, now) + + clearManagedAppMarkRules(appMarksChain) + clearManagedAppMarkRules(appMarksGuardChain) + + traffic := loadTrafficModeState() + vpnIface, _ := resolveTrafficIface(traffic.PreferredIface) + vpnIface = strings.TrimSpace(vpnIface) + + kept := make([]appMarkItem, 0, len(st.Items)) + for _, it := range st.Items { + target := strings.ToLower(strings.TrimSpace(it.Target)) + if target != "vpn" && target != "direct" { + changed = true + continue + } + + rel := normalizeCgroupRelOnly(it.CgroupRel) + if rel == "" { + rel = normalizeCgroupRelOnly(it.Cgroup) + } + if rel == "" { + changed = true + continue + } + + id := it.ID + if id == 0 { + inode, err := cgroupDirInode(rel) + if err != nil { + changed = true + continue + } + id = inode + it.ID = inode + changed = true + } + + level := it.Level + if level <= 0 { + level = strings.Count(strings.Trim(rel, "/"), "/") + 1 + it.Level = level + changed = true + } + + abs := "/" + strings.TrimPrefix(rel, "/") + it.CgroupRel = rel + it.Cgroup = abs + + if _, err := cgroupDirInode(rel); err != nil { + changed = true + continue + } + + iface := "" + if target == "vpn" { + if vpnIface == "" { + // Keep state for later retry when VPN interface appears. + kept = append(kept, it) + continue + } + iface = vpnIface + } + + if err := nftInsertAppMarkRule(target, rel, level, id, iface); err != nil { + appendTraceLine("traffic", fmt.Sprintf("appmarks restore failed target=%s id=%d err=%v", target, id, err)) + kept = append(kept, it) + continue + } + if !nftHasAppMarkRule(target, id) { + appendTraceLine("traffic", fmt.Sprintf("appmarks restore post-check failed target=%s id=%d", target, id)) + kept = append(kept, it) + continue + } + kept = append(kept, it) + } + st.Items = kept + + if changed { + return saveAppMarksState(st) + } + return nil +} diff --git a/selective-vpn-api/app/traffic_appmarks_runtime_state.go b/selective-vpn-api/app/traffic_appmarks_runtime_state.go new file mode 100644 index 0000000..e9267c4 --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks_runtime_state.go @@ -0,0 +1,35 @@ +package app + +import ( + trafficappmarkspkg "selective-vpn-api/app/trafficappmarks" + "time" +) + +func pruneExpiredAppMarks() error { + appMarksMu.Lock() + defer appMarksMu.Unlock() + + st := loadAppMarksState() + if pruneExpiredAppMarksLocked(&st, time.Now().UTC()) { + return saveAppMarksState(st) + } + return nil +} + +func pruneExpiredAppMarksLocked(st *appMarksState, now time.Time) (changed bool) { + return trafficappmarkspkg.PruneExpired(st, now, func(target string, id uint64) { + _ = nftDeleteAppMarkRule(target, id) + }) +} + +func loadAppMarksState() appMarksState { + return trafficappmarkspkg.LoadState(trafficAppMarksPath, canonicalizeAppKey) +} + +func dedupeAppMarkItems(in []appMarkItem) ([]appMarkItem, bool) { + return trafficappmarkspkg.DedupeItems(in, canonicalizeAppKey) +} + +func saveAppMarksState(st appMarksState) error { + return trafficappmarkspkg.SaveState(trafficAppMarksPath, st) +} diff --git a/selective-vpn-api/app/traffic_audit.go b/selective-vpn-api/app/traffic_audit.go index d26a189..39b0124 100644 --- a/selective-vpn-api/app/traffic_audit.go +++ b/selective-vpn-api/app/traffic_audit.go @@ -1,10 +1,7 @@ package app import ( - "fmt" "net/http" - "sort" - "strconv" "strings" "time" ) @@ -88,201 +85,3 @@ func handleTrafficAudit(w http.ResponseWriter, r *http.Request) { "nft": nftSummary, }) } - -func findProfileDuplicates(profiles []TrafficAppProfile) []string { - seen := map[string]int{} - for _, p := range profiles { - tgt := strings.ToLower(strings.TrimSpace(p.Target)) - key := strings.TrimSpace(p.AppKey) - if tgt == "" || key == "" { - continue - } - seen[tgt+"|"+key]++ - } - var out []string - for k, n := range seen { - if n > 1 { - out = append(out, fmt.Sprintf("%s x%d", k, n)) - } - } - sort.Strings(out) - return out -} - -func findMarkDuplicates(items []appMarkItem) []string { - seen := map[string]int{} - for _, it := range items { - tgt := strings.ToLower(strings.TrimSpace(it.Target)) - key := strings.TrimSpace(it.AppKey) - if tgt == "" || key == "" { - continue - } - seen[tgt+"|"+key]++ - } - var out []string - for k, n := range seen { - if n > 1 { - out = append(out, fmt.Sprintf("%s x%d", k, n)) - } - } - sort.Strings(out) - return out -} - -func auditNftAppMarks(state []appMarkItem) (issues []string, summary map[string]any) { - summary = map[string]any{ - "output_jump_ok": false, - "output_apps_ok": false, - "state_items": len(state), - "nft_rules": 0, - "missing_rules": 0, - "orphan_rules": 0, - "missing_rule_ids": []string{}, - "orphan_rule_ids": []string{}, - } - - // Check output -> jump output_apps. - outOutput, _, codeOut, errOut := runCommandTimeout(3*time.Second, "nft", "list", "chain", "inet", appMarksTable, "output") - if errOut != nil || codeOut != 0 { - issues = append(issues, "nft_error: failed to read chain output") - } else { - ok := strings.Contains(outOutput, "jump "+appMarksChain) - summary["output_jump_ok"] = ok - if !ok { - issues = append(issues, "nft_missing_jump: output -> output_apps") - } - } - - outApps, _, codeApps, errApps := runCommandTimeout(3*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain) - if errApps != nil || codeApps != 0 { - issues = append(issues, "nft_error: failed to read chain output_apps") - return issues, summary - } - summary["output_apps_ok"] = true - - rules := parseAppMarkRules(outApps) - summary["nft_rules"] = len(rules) - - stateIDs := map[string]struct{}{} - for _, it := range state { - tgt := strings.ToLower(strings.TrimSpace(it.Target)) - if tgt != "vpn" && tgt != "direct" { - continue - } - if it.ID == 0 { - continue - } - stateIDs[fmt.Sprintf("%s:%d", tgt, it.ID)] = struct{}{} - } - - ruleIDs := map[string]struct{}{} - for _, k := range rules { - ruleIDs[k] = struct{}{} - } - - missing := []string{} - for k := range stateIDs { - if _, ok := ruleIDs[k]; !ok { - missing = append(missing, k) - } - } - orphan := []string{} - for k := range ruleIDs { - if _, ok := stateIDs[k]; !ok { - orphan = append(orphan, k) - } - } - sort.Strings(missing) - sort.Strings(orphan) - - summary["missing_rules"] = len(missing) - summary["orphan_rules"] = len(orphan) - summary["missing_rule_ids"] = missing - summary["orphan_rule_ids"] = orphan - - for _, k := range missing { - issues = append(issues, "nft_missing_rule: "+k) - } - for _, k := range orphan { - issues = append(issues, "nft_orphan_rule: "+k) - } - - return issues, summary -} - -// parseAppMarkRules extracts "target:id" keys from output_apps chain dump. -func parseAppMarkRules(out string) []string { - var keys []string - for _, line := range strings.Split(out, "\n") { - // comment "svpn_appmark:vpn:123" - i := strings.Index(line, appMarkCommentPrefix+":") - if i < 0 { - continue - } - rest := line[i:] - end := len(rest) - for j := 0; j < len(rest); j++ { - ch := rest[j] - if ch == '"' || ch == ' ' || ch == '\t' { - end = j - break - } - } - tag := rest[:end] - parts := strings.Split(tag, ":") - if len(parts) != 3 { - continue - } - tgt := strings.ToLower(strings.TrimSpace(parts[1])) - idRaw := strings.TrimSpace(parts[2]) - if tgt != "vpn" && tgt != "direct" { - continue - } - id, err := strconv.ParseUint(idRaw, 10, 64) - if err != nil || id == 0 { - continue - } - keys = append(keys, fmt.Sprintf("%s:%d", tgt, id)) - } - sort.Strings(keys) - // Dedup. - outKeys := keys[:0] - var last string - for _, k := range keys { - if k == last { - continue - } - outKeys = append(outKeys, k) - last = k - } - return outKeys -} - -func buildTrafficAuditPretty(now string, traffic TrafficModeStatusResponse, profiles []TrafficAppProfile, marks []appMarkItem, issues []string, nft map[string]any) string { - var b strings.Builder - b.WriteString("Traffic audit\n") - b.WriteString("now=" + now + "\n\n") - - b.WriteString("traffic: desired=" + string(traffic.DesiredMode) + " applied=" + string(traffic.AppliedMode) + " iface=" + strings.TrimSpace(traffic.ActiveIface) + " healthy=" + strconv.FormatBool(traffic.Healthy) + "\n") - if strings.TrimSpace(traffic.Message) != "" { - b.WriteString("traffic_message: " + strings.TrimSpace(traffic.Message) + "\n") - } - b.WriteString("\n") - - b.WriteString(fmt.Sprintf("profiles=%d marks=%d\n", len(profiles), len(marks))) - if nft != nil { - b.WriteString(fmt.Sprintf("nft: rules=%v missing=%v orphan=%v jump_ok=%v\n", - nft["nft_rules"], nft["missing_rules"], nft["orphan_rules"], nft["output_jump_ok"])) - } - b.WriteString("\n") - - if len(issues) == 0 { - b.WriteString("issues: none\n") - return b.String() - } - b.WriteString("issues:\n") - for _, it := range issues { - b.WriteString("- " + it + "\n") - } - return b.String() -} diff --git a/selective-vpn-api/app/traffic_audit_checks.go b/selective-vpn-api/app/traffic_audit_checks.go new file mode 100644 index 0000000..cfb6e94 --- /dev/null +++ b/selective-vpn-api/app/traffic_audit_checks.go @@ -0,0 +1,89 @@ +package app + +import ( + "fmt" + "sort" + "strings" + "time" +) + +func auditNftAppMarks(state []appMarkItem) (issues []string, summary map[string]any) { + summary = map[string]any{ + "output_jump_ok": false, + "output_apps_ok": false, + "state_items": len(state), + "nft_rules": 0, + "missing_rules": 0, + "orphan_rules": 0, + "missing_rule_ids": []string{}, + "orphan_rule_ids": []string{}, + } + + // Check output -> jump output_apps. + outOutput, _, codeOut, errOut := runCommandTimeout(3*time.Second, "nft", "list", "chain", "inet", appMarksTable, "output") + if errOut != nil || codeOut != 0 { + issues = append(issues, "nft_error: failed to read chain output") + } else { + ok := strings.Contains(outOutput, "jump "+appMarksChain) + summary["output_jump_ok"] = ok + if !ok { + issues = append(issues, "nft_missing_jump: output -> output_apps") + } + } + + outApps, _, codeApps, errApps := runCommandTimeout(3*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain) + if errApps != nil || codeApps != 0 { + issues = append(issues, "nft_error: failed to read chain output_apps") + return issues, summary + } + summary["output_apps_ok"] = true + + rules := parseAppMarkRules(outApps) + summary["nft_rules"] = len(rules) + + stateIDs := map[string]struct{}{} + for _, it := range state { + tgt := strings.ToLower(strings.TrimSpace(it.Target)) + if tgt != "vpn" && tgt != "direct" { + continue + } + if it.ID == 0 { + continue + } + stateIDs[fmt.Sprintf("%s:%d", tgt, it.ID)] = struct{}{} + } + + ruleIDs := map[string]struct{}{} + for _, k := range rules { + ruleIDs[k] = struct{}{} + } + + missing := []string{} + for k := range stateIDs { + if _, ok := ruleIDs[k]; !ok { + missing = append(missing, k) + } + } + orphan := []string{} + for k := range ruleIDs { + if _, ok := stateIDs[k]; !ok { + orphan = append(orphan, k) + } + } + sort.Strings(missing) + sort.Strings(orphan) + + summary["missing_rules"] = len(missing) + summary["orphan_rules"] = len(orphan) + summary["missing_rule_ids"] = missing + summary["orphan_rule_ids"] = orphan + + for _, k := range missing { + issues = append(issues, "nft_missing_rule: "+k) + } + for _, k := range orphan { + issues = append(issues, "nft_orphan_rule: "+k) + } + + return issues, summary +} diff --git a/selective-vpn-api/app/traffic_audit_duplicates.go b/selective-vpn-api/app/traffic_audit_duplicates.go new file mode 100644 index 0000000..eb0985b --- /dev/null +++ b/selective-vpn-api/app/traffic_audit_duplicates.go @@ -0,0 +1,44 @@ +package app + +import ( + "fmt" + "sort" + "strings" +) + +func findProfileDuplicates(profiles []TrafficAppProfile) []string { + return collectTargetKeyDuplicates(func(add func(target, key string)) { + for _, p := range profiles { + add(p.Target, p.AppKey) + } + }) +} + +func findMarkDuplicates(items []appMarkItem) []string { + return collectTargetKeyDuplicates(func(add func(target, key string)) { + for _, it := range items { + add(it.Target, it.AppKey) + } + }) +} + +func collectTargetKeyDuplicates(feed func(add func(target, key string))) []string { + seen := map[string]int{} + feed(func(target, key string) { + tgt := strings.ToLower(strings.TrimSpace(target)) + k := strings.TrimSpace(key) + if tgt == "" || k == "" { + return + } + seen[tgt+"|"+k]++ + }) + + var out []string + for k, n := range seen { + if n > 1 { + out = append(out, fmt.Sprintf("%s x%d", k, n)) + } + } + sort.Strings(out) + return out +} diff --git a/selective-vpn-api/app/traffic_audit_nft_parse.go b/selective-vpn-api/app/traffic_audit_nft_parse.go new file mode 100644 index 0000000..8ef425e --- /dev/null +++ b/selective-vpn-api/app/traffic_audit_nft_parse.go @@ -0,0 +1,68 @@ +package app + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +// parseAppMarkRules extracts "target:id" keys from output_apps chain dump. +func parseAppMarkRules(out string) []string { + var keys []string + for _, line := range strings.Split(out, "\n") { + if key, ok := parseTrafficAppMarkRuleLine(line); ok { + keys = append(keys, key) + } + } + sort.Strings(keys) + return dedupeSortedStrings(keys) +} + +func parseTrafficAppMarkRuleLine(line string) (string, bool) { + // comment "svpn_appmark:vpn:123" + i := strings.Index(line, appMarkCommentPrefix+":") + if i < 0 { + return "", false + } + rest := line[i:] + end := len(rest) + for j := 0; j < len(rest); j++ { + ch := rest[j] + if ch == '"' || ch == ' ' || ch == '\t' { + end = j + break + } + } + tag := rest[:end] + parts := strings.Split(tag, ":") + if len(parts) != 3 { + return "", false + } + tgt := strings.ToLower(strings.TrimSpace(parts[1])) + idRaw := strings.TrimSpace(parts[2]) + if tgt != "vpn" && tgt != "direct" { + return "", false + } + id, err := strconv.ParseUint(idRaw, 10, 64) + if err != nil || id == 0 { + return "", false + } + return fmt.Sprintf("%s:%d", tgt, id), true +} + +func dedupeSortedStrings(in []string) []string { + if len(in) == 0 { + return nil + } + out := in[:0] + last := "" + for _, it := range in { + if it == last { + continue + } + out = append(out, it) + last = it + } + return out +} diff --git a/selective-vpn-api/app/traffic_audit_render.go b/selective-vpn-api/app/traffic_audit_render.go new file mode 100644 index 0000000..d35a9a1 --- /dev/null +++ b/selective-vpn-api/app/traffic_audit_render.go @@ -0,0 +1,36 @@ +package app + +import ( + "fmt" + "strconv" + "strings" +) + +func buildTrafficAuditPretty(now string, traffic TrafficModeStatusResponse, profiles []TrafficAppProfile, marks []appMarkItem, issues []string, nft map[string]any) string { + var b strings.Builder + b.WriteString("Traffic audit\n") + b.WriteString("now=" + now + "\n\n") + + b.WriteString("traffic: desired=" + string(traffic.DesiredMode) + " applied=" + string(traffic.AppliedMode) + " iface=" + strings.TrimSpace(traffic.ActiveIface) + " healthy=" + strconv.FormatBool(traffic.Healthy) + "\n") + if strings.TrimSpace(traffic.Message) != "" { + b.WriteString("traffic_message: " + strings.TrimSpace(traffic.Message) + "\n") + } + b.WriteString("\n") + + b.WriteString(fmt.Sprintf("profiles=%d marks=%d\n", len(profiles), len(marks))) + if nft != nil { + b.WriteString(fmt.Sprintf("nft: rules=%v missing=%v orphan=%v jump_ok=%v\n", + nft["nft_rules"], nft["missing_rules"], nft["orphan_rules"], nft["output_jump_ok"])) + } + b.WriteString("\n") + + if len(issues) == 0 { + b.WriteString("issues: none\n") + return b.String() + } + b.WriteString("issues:\n") + for _, it := range issues { + b.WriteString("- " + it + "\n") + } + return b.String() +} diff --git a/selective-vpn-api/app/traffic_candidates.go b/selective-vpn-api/app/traffic_candidates.go index 9edb0fb..6f3f541 100644 --- a/selective-vpn-api/app/traffic_candidates.go +++ b/selective-vpn-api/app/traffic_candidates.go @@ -2,224 +2,55 @@ package app import ( "net/http" - "sort" - "strconv" - "strings" + trafficcandidatespkg "selective-vpn-api/app/trafficcandidates" "time" ) -// --------------------------------------------------------------------- -// traffic candidates (subnets / systemd units / UIDs) -// --------------------------------------------------------------------- - -// EN: Provides best-effort suggestions for traffic overrides UI. -// EN: This endpoint must never apply anything automatically. -// RU: Отдаёт подсказки для UI overrides. -// RU: Этот эндпоинт никогда не должен применять что-либо автоматически. - func handleTrafficCandidates(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + payload := trafficcandidatespkg.Collect( + time.Now().UTC(), + trafficcandidatespkg.Deps{ + RunCommand: runCommand, + ParseRouteDevice: parseRouteDevice, + IsVPNLikeIface: isVPNLikeIface, + IsContainerIface: isContainerIface, + IsAutoBypassDestination: isAutoBypassDestination, + }, + ) + resp := TrafficCandidatesResponse{ - GeneratedAt: time.Now().UTC().Format(time.RFC3339), - Subnets: trafficCandidateSubnets(), - Units: trafficCandidateUnits(), - UIDs: trafficCandidateUIDs(), + GeneratedAt: payload.GeneratedAt, + Subnets: make([]TrafficCandidateSubnet, 0, len(payload.Subnets)), + Units: make([]TrafficCandidateUnit, 0, len(payload.Units)), + UIDs: make([]TrafficCandidateUID, 0, len(payload.UIDs)), } + for _, it := range payload.Subnets { + resp.Subnets = append(resp.Subnets, TrafficCandidateSubnet{ + CIDR: it.CIDR, + Dev: it.Dev, + Kind: it.Kind, + LinkDown: it.LinkDown, + }) + } + for _, it := range payload.Units { + resp.Units = append(resp.Units, TrafficCandidateUnit{ + Unit: it.Unit, + Description: it.Description, + Cgroup: it.Cgroup, + }) + } + for _, it := range payload.UIDs { + resp.UIDs = append(resp.UIDs, TrafficCandidateUID{ + UID: it.UID, + User: it.User, + Examples: it.Examples, + }) + } + writeJSON(w, http.StatusOK, resp) } - -func trafficCandidateSubnets() []TrafficCandidateSubnet { - out, _, code, _ := runCommand("ip", "-4", "route", "show", "table", "main") - if code != 0 { - return nil - } - - seen := map[string]struct{}{} - items := make([]TrafficCandidateSubnet, 0, 24) - - for _, raw := range strings.Split(out, "\n") { - line := strings.TrimSpace(raw) - if line == "" { - continue - } - fields := strings.Fields(line) - if len(fields) == 0 { - continue - } - - dst := strings.TrimSpace(fields[0]) - if dst == "" || dst == "default" { - continue - } - dev := parseRouteDevice(fields) - if dev == "" || dev == "lo" { - continue - } - if isVPNLikeIface(dev) { - continue - } - - isDocker := isContainerIface(dev) - isLocal := isAutoBypassDestination(dst) - if !isDocker && !isLocal { - // keep suggestions intentionally small: only local/LAN + container subnets - continue - } - - kind := "lan" - if isDocker { - kind = "docker" - } else if strings.Contains(" "+strings.ToLower(line)+" ", " scope link ") { - kind = "link" - } - - key := kind + "|" + dst + "|" + dev - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - - items = append(items, TrafficCandidateSubnet{ - CIDR: dst, - Dev: dev, - Kind: kind, - LinkDown: strings.Contains(strings.ToLower(line), " linkdown"), - }) - } - - sort.Slice(items, func(i, j int) bool { - if items[i].Kind != items[j].Kind { - return items[i].Kind < items[j].Kind - } - if items[i].Dev != items[j].Dev { - return items[i].Dev < items[j].Dev - } - return items[i].CIDR < items[j].CIDR - }) - return items -} - -func trafficCandidateUnits() []TrafficCandidateUnit { - stdout, _, code, _ := runCommand( - "systemctl", - "list-units", - "--type=service", - "--state=running", - "--no-legend", - "--no-pager", - "--plain", - ) - if code != 0 { - return nil - } - - seen := map[string]struct{}{} - items := make([]TrafficCandidateUnit, 0, 32) - for _, raw := range strings.Split(stdout, "\n") { - line := strings.TrimSpace(raw) - if line == "" { - continue - } - fields := strings.Fields(line) - if len(fields) < 1 { - continue - } - unit := strings.TrimSpace(fields[0]) - if unit == "" { - continue - } - if _, ok := seen[unit]; ok { - continue - } - seen[unit] = struct{}{} - - desc := "" - // UNIT LOAD ACTIVE SUB DESCRIPTION - if len(fields) >= 5 { - desc = strings.Join(fields[4:], " ") - } - - items = append(items, TrafficCandidateUnit{ - Unit: unit, - Description: strings.TrimSpace(desc), - Cgroup: "system.slice/" + unit, - }) - } - - sort.Slice(items, func(i, j int) bool { - return items[i].Unit < items[j].Unit - }) - return items -} - -func trafficCandidateUIDs() []TrafficCandidateUID { - stdout, _, code, _ := runCommand("ps", "-eo", "uid,user,comm", "--no-headers") - if code != 0 { - return nil - } - - type agg struct { - uid int - user string - comms map[string]struct{} - } - - aggs := map[int]*agg{} - for _, raw := range strings.Split(stdout, "\n") { - line := strings.TrimSpace(raw) - if line == "" { - continue - } - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - uidN, err := strconv.Atoi(strings.TrimSpace(fields[0])) - if err != nil || uidN < 0 { - continue - } - user := strings.TrimSpace(fields[1]) - comm := "" - if len(fields) >= 3 { - comm = strings.TrimSpace(fields[2]) - } - - a := aggs[uidN] - if a == nil { - a = &agg{uid: uidN, user: user, comms: map[string]struct{}{}} - aggs[uidN] = a - } - if a.user == "" && user != "" { - a.user = user - } - if comm != "" { - a.comms[comm] = struct{}{} - } - } - - items := make([]TrafficCandidateUID, 0, len(aggs)) - for _, a := range aggs { - examples := make([]string, 0, len(a.comms)) - for c := range a.comms { - examples = append(examples, c) - } - sort.Strings(examples) - if len(examples) > 3 { - examples = examples[:3] - } - items = append(items, TrafficCandidateUID{ - UID: a.uid, - User: a.user, - Examples: examples, - }) - } - - sort.Slice(items, func(i, j int) bool { - return items[i].UID < items[j].UID - }) - return items -} diff --git a/selective-vpn-api/app/traffic_mode.go b/selective-vpn-api/app/traffic_mode.go index febca75..fa1cb36 100644 --- a/selective-vpn-api/app/traffic_mode.go +++ b/selective-vpn-api/app/traffic_mode.go @@ -1,20 +1,5 @@ package app -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/netip" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "syscall" - "time" -) - const ( trafficRulePrefMarkDirect = 11500 trafficRulePrefMarkIngressReply = 11505 @@ -38,1402 +23,13 @@ const ( trafficIngressRestoreComment = "svpn_ingress_reply_restore" ) -var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10") - const cgroupRootPath = "/sys/fs/cgroup" // --------------------------------------------------------------------- // traffic mode (selective / full_tunnel / direct) // --------------------------------------------------------------------- - +// // EN: Controls route-policy behavior independently from DNS mode. // EN: Uses a persisted desired state with runtime verification and rollback. // RU: Управляет policy routing независимо от DNS-режима. // RU: Использует сохраненное desired-state, runtime-проверку и откат. - -func normalizeTrafficMode(raw TrafficMode) TrafficMode { - switch strings.ToLower(strings.TrimSpace(string(raw))) { - case string(TrafficModeFullTunnel): - return TrafficModeFullTunnel - case string(TrafficModeDirect): - return TrafficModeDirect - case string(TrafficModeSelective): - return TrafficModeSelective - default: - return TrafficModeSelective - } -} - -func normalizePreferredIface(raw string) string { - v := strings.TrimSpace(raw) - l := strings.ToLower(v) - if l == "" || l == "auto" || l == "-" || l == "default" { - return "" - } - return v -} - -func tokenizeList(raw []string) []string { - repl := strings.NewReplacer(",", " ", ";", " ", "\n", " ", "\t", " ") - out := make([]string, 0, len(raw)) - for _, line := range raw { - for _, tok := range strings.Fields(repl.Replace(line)) { - val := strings.TrimSpace(tok) - if val != "" { - out = append(out, val) - } - } - } - return out -} - -func normalizeSubnetList(raw []string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(raw)) - for _, tok := range tokenizeList(raw) { - var cidr string - if strings.Contains(tok, "/") { - pfx, err := netip.ParsePrefix(tok) - if err != nil || !pfx.Addr().Is4() { - continue - } - cidr = pfx.Masked().String() - } else { - ip, err := netip.ParseAddr(tok) - if err != nil || !ip.Is4() { - continue - } - cidr = netip.PrefixFrom(ip, 32).String() - } - if _, ok := seen[cidr]; ok { - continue - } - seen[cidr] = struct{}{} - out = append(out, cidr) - } - sort.Strings(out) - return out -} - -func normalizeUIDToken(tok string) (string, bool) { - t := strings.TrimSpace(tok) - if t == "" { - return "", false - } - parseOne := func(s string) (uint64, bool) { - n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 32) - if err != nil { - return 0, false - } - return n, true - } - if strings.Contains(t, "-") { - parts := strings.SplitN(t, "-", 2) - if len(parts) != 2 { - return "", false - } - start, okA := parseOne(parts[0]) - end, okB := parseOne(parts[1]) - if !okA || !okB || end < start { - return "", false - } - return fmt.Sprintf("%d-%d", start, end), true - } - n, ok := parseOne(t) - if !ok { - return "", false - } - return fmt.Sprintf("%d-%d", n, n), true -} - -func normalizeUIDList(raw []string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(raw)) - for _, tok := range tokenizeList(raw) { - v, ok := normalizeUIDToken(tok) - if !ok { - continue - } - if _, exists := seen[v]; exists { - continue - } - seen[v] = struct{}{} - out = append(out, v) - } - sort.Strings(out) - return out -} - -func normalizeCgroupList(raw []string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(raw)) - for _, tok := range tokenizeList(raw) { - v := strings.TrimSpace(tok) - if v == "" { - continue - } - v = strings.TrimSuffix(v, "/") - if v == "" { - v = "/" - } - if _, exists := seen[v]; exists { - continue - } - seen[v] = struct{}{} - out = append(out, v) - } - sort.Strings(out) - return out -} - -func normalizeTrafficModeState(st TrafficModeState) TrafficModeState { - st.Mode = normalizeTrafficMode(st.Mode) - st.PreferredIface = normalizePreferredIface(st.PreferredIface) - st.ForceVPNSubnets = normalizeSubnetList(st.ForceVPNSubnets) - st.ForceVPNUIDs = normalizeUIDList(st.ForceVPNUIDs) - st.ForceVPNCGroups = normalizeCgroupList(st.ForceVPNCGroups) - st.ForceDirectSubnets = normalizeSubnetList(st.ForceDirectSubnets) - st.ForceDirectUIDs = normalizeUIDList(st.ForceDirectUIDs) - st.ForceDirectCGroups = normalizeCgroupList(st.ForceDirectCGroups) - return st -} - -func loadTrafficModeState() TrafficModeState { - data, err := os.ReadFile(trafficModePath) - if err != nil { - return inferTrafficModeState() - } - - type diskState struct { - Mode TrafficMode `json:"mode"` - PreferredIface string `json:"preferred_iface,omitempty"` - AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"` - IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"` - ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"` - ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"` - ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"` - ForceDirectSubnets []string `json:"force_direct_subnets,omitempty"` - ForceDirectUIDs []string `json:"force_direct_uids,omitempty"` - ForceDirectCGroups []string `json:"force_direct_cgroups,omitempty"` - } - var raw diskState - if err := json.Unmarshal(data, &raw); err != nil { - return inferTrafficModeState() - } - st := TrafficModeState{ - Mode: raw.Mode, - PreferredIface: raw.PreferredIface, - AutoLocalBypass: trafficAutoLocalDefault, - IngressReplyBypass: trafficIngressReplyDefault, - ForceVPNSubnets: append([]string(nil), raw.ForceVPNSubnets...), - ForceVPNUIDs: append([]string(nil), raw.ForceVPNUIDs...), - ForceVPNCGroups: append([]string(nil), raw.ForceVPNCGroups...), - ForceDirectSubnets: append([]string(nil), raw.ForceDirectSubnets...), - ForceDirectUIDs: append([]string(nil), raw.ForceDirectUIDs...), - ForceDirectCGroups: append([]string(nil), raw.ForceDirectCGroups...), - } - if raw.AutoLocalBypass != nil { - st.AutoLocalBypass = *raw.AutoLocalBypass - } - if raw.IngressReplyBypass != nil { - st.IngressReplyBypass = *raw.IngressReplyBypass - } - return normalizeTrafficModeState(st) -} - -func saveTrafficModeState(st TrafficModeState) error { - st = normalizeTrafficModeState(st) - st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - - data, err := json.MarshalIndent(st, "", " ") - if err != nil { - return err - } - if err := os.MkdirAll(stateDir, 0o755); err != nil { - return err - } - tmp := trafficModePath + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, trafficModePath) -} - -func inferTrafficModeState() TrafficModeState { - rules := readTrafficRules() - mode := detectAppliedTrafficMode(rules) - iface, _ := resolveTrafficIface("") - return normalizeTrafficModeState(TrafficModeState{ - Mode: mode, - PreferredIface: iface, - AutoLocalBypass: trafficAutoLocalDefault, - IngressReplyBypass: trafficIngressReplyDefault, - ForceVPNSubnets: nil, - ForceVPNUIDs: nil, - ForceVPNCGroups: nil, - ForceDirectSubnets: nil, - ForceDirectUIDs: nil, - ForceDirectCGroups: nil, - }) -} - -func ensureRoutesTableEntry() { - data, _ := os.ReadFile("/etc/iproute2/rt_tables") - want := fmt.Sprintf("%s %s", routesTableNum(), routesTableName()) - if strings.Contains(string(data), "\n"+want) || strings.HasPrefix(string(data), want) { - return - } - f, err := os.OpenFile("/etc/iproute2/rt_tables", os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - return - } - defer f.Close() - _, _ = fmt.Fprintf(f, "%s\n", want) -} - -func ifaceExists(iface string) bool { - iface = strings.TrimSpace(iface) - if iface == "" { - return false - } - _, _, code, _ := runCommand("ip", "link", "show", iface) - return code == 0 -} - -func statusIfaceFromFile() string { - data, err := os.ReadFile(statusFilePath) - if err != nil { - return "" - } - var st Status - if json.Unmarshal(data, &st) != nil { - return "" - } - return strings.TrimSpace(st.Iface) -} - -func listUpIfaces() []string { - out, _, code, _ := runCommand("ip", "-o", "link", "show", "up") - if code != 0 { - return nil - } - seen := map[string]struct{}{} - var outIfaces []string - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - parts := strings.SplitN(line, ":", 3) - if len(parts) < 3 { - continue - } - name := strings.TrimSpace(parts[1]) - name = strings.SplitN(name, "@", 2)[0] - name = strings.TrimSpace(name) - if name == "" || name == "lo" { - continue - } - if _, ok := seen[name]; ok { - continue - } - seen[name] = struct{}{} - outIfaces = append(outIfaces, name) - } - return outIfaces -} - -func listSelectableIfaces(preferred string) []string { - up := listUpIfaces() - seen := map[string]struct{}{} - var vpnLike []string - var other []string - - add := func(dst *[]string, iface string) { - iface = strings.TrimSpace(iface) - if iface == "" { - return - } - if _, ok := seen[iface]; ok { - return - } - seen[iface] = struct{}{} - *dst = append(*dst, iface) - } - - for _, iface := range up { - if isVPNLikeIface(iface) { - add(&vpnLike, iface) - } - } - for _, iface := range up { - if !isVPNLikeIface(iface) { - add(&other, iface) - } - } - sort.Strings(vpnLike) - sort.Strings(other) - - selected := make([]string, 0, len(vpnLike)+len(other)+1) - selected = append(selected, vpnLike...) - selected = append(selected, other...) - - pref := normalizePreferredIface(preferred) - if pref != "" { - if _, ok := seen[pref]; !ok { - selected = append([]string{pref}, selected...) - } - } - return selected -} - -func isVPNLikeIface(iface string) bool { - l := strings.ToLower(strings.TrimSpace(iface)) - return strings.HasPrefix(l, "tun") || - strings.HasPrefix(l, "wg") || - strings.HasPrefix(l, "ppp") || - strings.HasPrefix(l, "tap") || - strings.HasPrefix(l, "utun") || - strings.HasPrefix(l, "vpn") -} - -func resolveTrafficIface(preferred string) (string, string) { - pref := normalizePreferredIface(preferred) - if pref != "" && ifaceExists(pref) { - return pref, "preferred" - } - - statusIface := statusIfaceFromFile() - if statusIface != "" && ifaceExists(statusIface) { - return statusIface, "status" - } - - for _, iface := range listUpIfaces() { - if isVPNLikeIface(iface) { - return iface, "auto-vpn-like" - } - } - - if pref != "" { - return "", "preferred-not-found" - } - return "", "iface-not-found" -} - -type autoLocalRoute struct { - Dst string - Dev string -} - -func parseRouteDevice(fields []string) string { - for i := 0; i+1 < len(fields); i++ { - if fields[i] == "dev" { - return strings.TrimSpace(fields[i+1]) - } - } - return "" -} - -func isContainerIface(iface string) bool { - l := strings.ToLower(strings.TrimSpace(iface)) - return strings.HasPrefix(l, "docker") || - strings.HasPrefix(l, "br-") || - strings.HasPrefix(l, "veth") || - strings.HasPrefix(l, "cni") -} - -func isPrivateLikeAddr(a netip.Addr) bool { - if !a.Is4() { - return false - } - if a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() { - return true - } - // Carrier-grade NAT block. - return cgnatPrefix.Contains(a) -} - -func isAutoBypassDestination(dst string) bool { - dst = strings.TrimSpace(dst) - if dst == "" || dst == "default" { - return false - } - if strings.Contains(dst, "/") { - pfx, err := netip.ParsePrefix(dst) - if err != nil { - return false - } - return isPrivateLikeAddr(pfx.Addr()) - } - addr, err := netip.ParseAddr(dst) - if err != nil { - return false - } - return isPrivateLikeAddr(addr) -} - -func detectAutoLocalBypassRoutes(vpnIface string) []autoLocalRoute { - vpnIface = strings.TrimSpace(vpnIface) - out, _, code, _ := runCommand("ip", "-4", "route", "show", "table", "main") - if code != 0 { - return nil - } - - seen := map[string]struct{}{} - routes := make([]autoLocalRoute, 0, 8) - - add := func(dst, dev string) { - dst = strings.TrimSpace(dst) - dev = strings.TrimSpace(dev) - if dst == "" || dev == "" { - return - } - key := dst + "|" + dev - if _, ok := seen[key]; ok { - return - } - seen[key] = struct{}{} - routes = append(routes, autoLocalRoute{Dst: dst, Dev: dev}) - } - - for _, raw := range strings.Split(out, "\n") { - line := strings.TrimSpace(raw) - if line == "" { - continue - } - fields := strings.Fields(line) - if len(fields) == 0 { - continue - } - dst := strings.TrimSpace(fields[0]) - if dst == "" || dst == "default" { - continue - } - dev := parseRouteDevice(fields) - if dev == "" || dev == "lo" { - continue - } - if vpnIface != "" && dev == vpnIface { - continue - } - if isVPNLikeIface(dev) { - continue - } - - isScopeLink := strings.Contains(" "+line+" ", " scope link ") - if isScopeLink || isContainerIface(dev) || isAutoBypassDestination(dst) { - add(dst, dev) - } - } - - sort.Slice(routes, func(i, j int) bool { - if routes[i].Dev == routes[j].Dev { - return routes[i].Dst < routes[j].Dst - } - return routes[i].Dev < routes[j].Dev - }) - return routes -} - -func applyAutoLocalBypass(vpnIface string) { - for _, rt := range detectAutoLocalBypassRoutes(vpnIface) { - _, _, _, _ = runCommand( - "ip", "-4", "route", "replace", - rt.Dst, "dev", rt.Dev, "table", routesTableName(), - ) - } -} - -func nftObjectMissing(stdout, stderr string) bool { - text := strings.ToLower(strings.TrimSpace(stdout + " " + stderr)) - return strings.Contains(text, "no such file") || strings.Contains(text, "not found") -} - -func ensureIngressReplyBypassChains() { - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", routesTableName()) - _, _, _, _ = runCommandTimeout( - 5*time.Second, - "nft", "add", "chain", "inet", routesTableName(), trafficIngressPreroutingChain, - "{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}", - ) - _, _, _, _ = runCommandTimeout( - 5*time.Second, - "nft", "add", "chain", "inet", routesTableName(), trafficIngressOutputChain, - "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}", - ) -} - -func flushIngressReplyBypassChains() error { - for _, chain := range []string{trafficIngressPreroutingChain, trafficIngressOutputChain} { - out, errOut, code, err := runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", routesTableName(), chain) - if err == nil && code == 0 { - continue - } - if nftObjectMissing(out, errOut) { - continue - } - if err == nil { - err = fmt.Errorf("nft flush chain exited with %d", code) - } - return fmt.Errorf("flush %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut)) - } - return nil -} - -func enableIngressReplyBypass(vpnIface string) error { - vpnIface = strings.TrimSpace(vpnIface) - if vpnIface == "" { - return fmt.Errorf("empty vpn iface for ingress bypass") - } - - ensureIngressReplyBypassChains() - if err := flushIngressReplyBypassChains(); err != nil { - return err - } - - addRule := func(chain string, args ...string) error { - out, errOut, code, err := runCommandTimeout(5*time.Second, "nft", append([]string{"add", "rule", "inet", routesTableName(), chain}, args...)...) - if err != nil || code != 0 { - if err == nil { - err = fmt.Errorf("nft add rule exited with %d", code) - } - return fmt.Errorf("nft add rule %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut)) - } - return nil - } - - // EN: Mark inbound NEW connections (except loopback/VPN iface) so reply path can stay direct in full tunnel. - // RU: Помечаем входящие NEW-соединения (кроме loopback/VPN iface), чтобы ответ шел напрямую в full tunnel. - if err := addRule( - trafficIngressPreroutingChain, - "iifname", "!=", "lo", - "iifname", "!=", vpnIface, - "fib", "daddr", "type", "local", - "ct", "state", "new", - "ct", "mark", "set", MARK_INGRESS, - "comment", trafficIngressCaptureComment, - ); err != nil { - return err - } - // EN: Restore fwmark from ct mark in prerouting for forwarded reply traffic. - // RU: Восстанавливаем fwmark из ct mark в prerouting для forwarded-ответов. - if err := addRule( - trafficIngressPreroutingChain, - "ct", "mark", MARK_INGRESS, - "meta", "mark", "set", MARK_INGRESS, - "comment", trafficIngressRestoreComment, - ); err != nil { - return err - } - // EN: Restore fwmark from ct mark in output for local-process replies. - // RU: Восстанавливаем fwmark из ct mark в output для ответов локальных процессов. - if err := addRule( - trafficIngressOutputChain, - "ct", "mark", MARK_INGRESS, - "meta", "mark", "set", MARK_INGRESS, - "comment", trafficIngressRestoreComment, - ); err != nil { - return err - } - return nil -} - -func disableIngressReplyBypass() error { - ensureIngressReplyBypassChains() - return flushIngressReplyBypassChains() -} - -func ingressReplyNftActive() bool { - outPre, _, codePre, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", routesTableName(), trafficIngressPreroutingChain) - outOut, _, codeOut, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", routesTableName(), trafficIngressOutputChain) - if codePre != 0 || codeOut != 0 { - return false - } - return strings.Contains(outPre, trafficIngressCaptureComment) && - strings.Contains(outPre, trafficIngressRestoreComment) && - strings.Contains(outOut, trafficIngressRestoreComment) -} - -func prefStr(v int) string { - return strconv.Itoa(v) -} - -func removeTrafficRulesForTable() { - out, _, _, _ := runCommand("ip", "rule", "show") - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - fields := strings.Fields(line) - if len(fields) == 0 { - continue - } - pref := strings.TrimSuffix(fields[0], ":") - if pref == "" { - continue - } - prefNum, _ := strconv.Atoi(pref) - low := strings.ToLower(line) - managed := prefNum >= trafficRulePrefManagedMin && prefNum <= trafficRulePrefManagedMax - legacy := strings.Contains(low, "lookup "+routesTableName()) - if !managed && !legacy { - continue - } - _, _, _, _ = runCommand("ip", "rule", "del", "pref", pref) - } -} - -func cgroupCandidates(entry string) []string { - v := strings.TrimSpace(entry) - if v == "" { - return nil - } - vc := filepath.Clean(v) - vals := []string{} - if filepath.IsAbs(vc) { - if strings.HasPrefix(vc, cgroupRootPath) { - vals = append(vals, vc) - } else { - vals = append(vals, filepath.Join(cgroupRootPath, strings.TrimPrefix(vc, "/"))) - } - } else { - vals = append(vals, - filepath.Join(cgroupRootPath, strings.TrimPrefix(vc, "/")), - filepath.Join(cgroupRootPath, "system.slice", strings.TrimPrefix(vc, "/")), - filepath.Join(cgroupRootPath, "user.slice", strings.TrimPrefix(vc, "/")), - ) - } - seen := map[string]struct{}{} - out := make([]string, 0, len(vals)) - for _, p := range vals { - cp := filepath.Clean(p) - if cp == "." || cp == "" { - continue - } - if _, ok := seen[cp]; ok { - continue - } - seen[cp] = struct{}{} - out = append(out, cp) - } - return out -} - -func resolveCgroupPath(entry string) (string, string) { - for _, cand := range cgroupCandidates(entry) { - fi, err := os.Stat(cand) - if err != nil || !fi.IsDir() { - continue - } - return cand, "" - } - return "", "cgroup not found: " + strings.TrimSpace(entry) -} - -func collectPIDsFromCgroup(root string) (map[int]struct{}, string) { - const ( - maxDirs = 5000 - maxPIDs = 50000 - ) - - pids := map[int]struct{}{} - dirs := 0 - warn := "" - - _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil || d == nil || !d.IsDir() { - return nil - } - dirs++ - if dirs > maxDirs { - warn = "cgroup scan truncated by directory limit" - return filepath.SkipDir - } - data, err := os.ReadFile(filepath.Join(path, "cgroup.procs")) - if err != nil { - return nil - } - for _, ln := range strings.Split(string(data), "\n") { - ln = strings.TrimSpace(ln) - if ln == "" { - continue - } - pid, err := strconv.Atoi(ln) - if err != nil || pid <= 0 { - continue - } - pids[pid] = struct{}{} - if len(pids) > maxPIDs { - warn = "cgroup scan truncated by pid limit" - return filepath.SkipDir - } - } - return nil - }) - return pids, warn -} - -func uidRangeForPID(pid int) (string, bool) { - data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) - if err != nil { - return "", false - } - for _, ln := range strings.Split(string(data), "\n") { - ln = strings.TrimSpace(ln) - if !strings.HasPrefix(ln, "Uid:") { - continue - } - fields := strings.Fields(ln) - if len(fields) < 2 { - return "", false - } - v, ok := normalizeUIDToken(fields[1]) - return v, ok - } - return "", false -} - -func resolveCgroupUIDRanges(entries []string) ([]string, string) { - var uids []string - var warnings []string - - for _, entry := range normalizeCgroupList(entries) { - root, warn := resolveCgroupPath(entry) - if root == "" { - if warn != "" { - warnings = append(warnings, warn) - } - continue - } - pids, scanWarn := collectPIDsFromCgroup(root) - if scanWarn != "" { - warnings = append(warnings, scanWarn) - } - if len(pids) == 0 { - warnings = append(warnings, "cgroup has no processes: "+entry) - continue - } - for pid := range pids { - uidRange, ok := uidRangeForPID(pid) - if !ok || uidRange == "" { - continue - } - uids = append(uids, uidRange) - } - } - seenWarn := map[string]struct{}{} - uniqWarn := make([]string, 0, len(warnings)) - for _, w := range warnings { - ww := strings.TrimSpace(w) - if ww == "" { - continue - } - if _, ok := seenWarn[ww]; ok { - continue - } - seenWarn[ww] = struct{}{} - uniqWarn = append(uniqWarn, ww) - } - return normalizeUIDList(uids), strings.Join(uniqWarn, "; ") -} - -type effectiveTrafficOverrides struct { - VPNSubnets []string - VPNUIDs []string - DirectSubnets []string - DirectUIDs []string - CgroupResolvedUIDs int - CgroupWarning string -} - -func buildEffectiveOverrides(st TrafficModeState) effectiveTrafficOverrides { - st = normalizeTrafficModeState(st) - e := effectiveTrafficOverrides{ - VPNSubnets: append([]string(nil), st.ForceVPNSubnets...), - VPNUIDs: append([]string(nil), st.ForceVPNUIDs...), - DirectSubnets: append([]string(nil), st.ForceDirectSubnets...), - DirectUIDs: append([]string(nil), st.ForceDirectUIDs...), - } - - vpnUIDsFromCG, warnVPN := resolveCgroupUIDRanges(st.ForceVPNCGroups) - directUIDsFromCG, warnDirect := resolveCgroupUIDRanges(st.ForceDirectCGroups) - e.CgroupResolvedUIDs = len(vpnUIDsFromCG) + len(directUIDsFromCG) - e.VPNUIDs = normalizeUIDList(append(e.VPNUIDs, vpnUIDsFromCG...)) - e.DirectUIDs = normalizeUIDList(append(e.DirectUIDs, directUIDsFromCG...)) - warns := make([]string, 0, 2) - if strings.TrimSpace(warnVPN) != "" { - warns = append(warns, strings.TrimSpace(warnVPN)) - } - if strings.TrimSpace(warnDirect) != "" { - warns = append(warns, strings.TrimSpace(warnDirect)) - } - e.CgroupWarning = strings.Join(warns, "; ") - return e -} - -func applyRule(pref int, args ...string) error { - if pref <= 0 { - return fmt.Errorf("invalid pref: %d", pref) - } - cmd := []string{"rule", "add"} - cmd = append(cmd, args...) - cmd = append(cmd, "pref", prefStr(pref)) - _, _, code, err := runCommand("ip", cmd...) - if err != nil || code != 0 { - if err == nil { - err = fmt.Errorf("ip %s exited with %d", strings.Join(cmd, " "), code) - } - return err - } - return nil -} - -func applyTrafficOverrides(e effectiveTrafficOverrides) (int, error) { - applied := 0 - if len(e.DirectSubnets) > trafficRulePerKindLimit || - len(e.DirectUIDs) > trafficRulePerKindLimit || - len(e.VPNSubnets) > trafficRulePerKindLimit || - len(e.VPNUIDs) > trafficRulePerKindLimit { - return 0, fmt.Errorf("override list too large (max %d entries per kind)", trafficRulePerKindLimit) - } - - for i, cidr := range e.DirectSubnets { - if err := applyRule(trafficRulePrefDirectSubnetStart+i, "from", cidr, "lookup", "main"); err != nil { - return applied, err - } - applied++ - } - for i, uidr := range e.DirectUIDs { - if err := applyRule(trafficRulePrefDirectUIDStart+i, "uidrange", uidr, "lookup", "main"); err != nil { - return applied, err - } - applied++ - } - for i, cidr := range e.VPNSubnets { - if err := applyRule(trafficRulePrefVPNSubnetStart+i, "from", cidr, "lookup", routesTableName()); err != nil { - return applied, err - } - applied++ - } - for i, uidr := range e.VPNUIDs { - if err := applyRule(trafficRulePrefVPNUIDStart+i, "uidrange", uidr, "lookup", routesTableName()); err != nil { - return applied, err - } - applied++ - } - return applied, nil -} - -func ensureTrafficRouteBase(iface string, autoLocalBypass bool) error { - iface = strings.TrimSpace(iface) - if iface == "" { - return fmt.Errorf("empty interface") - } - if !ifaceExists(iface) { - return fmt.Errorf("interface not found: %s", iface) - } - - ensureRoutesTableEntry() - - if _, _, code, err := runCommand("ip", "-4", "route", "replace", "default", "dev", iface, "table", routesTableName(), "mtu", policyRouteMTU); err != nil || code != 0 { - if err == nil { - err = fmt.Errorf("ip route replace default exited with %d", code) - } - return err - } - - if autoLocalBypass { - applyAutoLocalBypass(iface) - } - return nil -} - -func applyTrafficMode(st TrafficModeState, iface string) error { - st = normalizeTrafficModeState(st) - eff := buildEffectiveOverrides(st) - advancedActive := st.Mode == TrafficModeFullTunnel - autoLocalActive := advancedActive && st.AutoLocalBypass - ingressReplyActive := advancedActive && st.IngressReplyBypass - - removeTrafficRulesForTable() - - // EN: Ensure the policy table name exists even in direct mode so mark-based rules can be installed. - // RU: Гарантируем наличие имени policy-table даже в direct режиме, чтобы можно было ставить mark-правила. - ensureRoutesTableEntry() - if err := disableIngressReplyBypass(); err != nil { - return err - } - - needVPNTable := st.Mode != TrafficModeDirect || len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0 - if needVPNTable { - if err := ensureTrafficRouteBase(iface, autoLocalActive); err != nil { - return err - } - } - - if _, err := applyTrafficOverrides(eff); err != nil { - return err - } - - // EN: Mark-based per-app routing support (cgroup-based marking in nftables). - // EN: These rules are safe even when no packets are marked with MARK_APP/MARK_DIRECT. - // RU: Поддержка per-app маршрутизации по mark (cgroup-based marking в nftables). - // RU: Эти правила безопасны, если пакеты не помечаются MARK_APP/MARK_DIRECT. - if err := applyRule(trafficRulePrefMarkDirect, "fwmark", MARK_DIRECT, "lookup", "main"); err != nil { - return err - } - if ingressReplyActive { - if err := applyRule(trafficRulePrefMarkIngressReply, "fwmark", MARK_INGRESS, "lookup", "main"); err != nil { - return err - } - } - if err := applyRule(trafficRulePrefMarkAppVPN, "fwmark", MARK_APP, "lookup", routesTableName()); err != nil { - return err - } - - switch st.Mode { - case TrafficModeFullTunnel: - if err := applyRule(trafficRulePrefFull, "lookup", routesTableName()); err != nil { - return err - } - case TrafficModeSelective: - if err := applyRule(trafficRulePrefSelective, "fwmark", MARK, "lookup", routesTableName()); err != nil { - return err - } - case TrafficModeDirect: - // direct mode relies only on optional direct/vpn overrides. - default: - return fmt.Errorf("unknown traffic mode: %s", st.Mode) - } - if ingressReplyActive { - if err := enableIngressReplyBypass(iface); err != nil { - return err - } - } - - if err := restoreAppMarksFromState(); err != nil { - appendTraceLine("traffic", fmt.Sprintf("appmarks restore warning: %v", err)) - } - - return nil -} - -type trafficRulesState struct { - Mark bool - Full bool - IngressReply bool -} - -func readTrafficRules() trafficRulesState { - out, _, _, _ := runCommand("ip", "rule", "show") - var st trafficRulesState - for _, line := range strings.Split(out, "\n") { - l := strings.ToLower(strings.TrimSpace(line)) - if l == "" { - continue - } - fields := strings.Fields(l) - if len(fields) == 0 { - continue - } - prefRaw := strings.TrimSuffix(fields[0], ":") - pref, _ := strconv.Atoi(prefRaw) - switch pref { - case trafficRulePrefSelective: - if strings.Contains(l, "lookup "+routesTableName()) { - st.Mark = true - } - case trafficRulePrefFull: - if strings.Contains(l, "lookup "+routesTableName()) { - st.Full = true - } - case trafficRulePrefMarkIngressReply: - if strings.Contains(l, "fwmark "+strings.ToLower(MARK_INGRESS)) && strings.Contains(l, "lookup main") { - st.IngressReply = true - } - } - } - return st -} - -func detectAppliedTrafficMode(rules trafficRulesState) TrafficMode { - if rules.Full { - return TrafficModeFullTunnel - } - if rules.Mark { - return TrafficModeSelective - } - return TrafficModeDirect -} - -func probeTrafficMode(mode TrafficMode, iface string) (bool, string) { - mode = normalizeTrafficMode(mode) - iface = strings.TrimSpace(iface) - - args := []string{"-4", "route", "get", "1.1.1.1"} - if mode == TrafficModeSelective { - args = append(args, "mark", MARK) - } - - out, _, code, err := runCommand("ip", args...) - if err != nil || code != 0 { - if err == nil { - err = fmt.Errorf("ip route get exited with %d", code) - } - return false, err.Error() - } - - text := strings.ToLower(out) - switch mode { - case TrafficModeDirect: - // direct mode must not be forced through agvpn rule table. - if strings.Contains(text, " table "+strings.ToLower(routesTableName())) { - return false, "route probe still uses agvpn table" - } - return true, "route probe direct path ok" - case TrafficModeFullTunnel, TrafficModeSelective: - if iface == "" { - return false, "route probe has empty iface" - } - if !strings.Contains(text, "dev "+strings.ToLower(iface)) { - return false, fmt.Sprintf("route probe mismatch: expected dev %s", iface) - } - return true, "route probe vpn path ok" - default: - return false, "route probe unknown mode" - } -} - -func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse { - st = normalizeTrafficModeState(st) - eff := buildEffectiveOverrides(st) - advancedActive := st.Mode == TrafficModeFullTunnel - autoLocalActive := advancedActive && st.AutoLocalBypass - ingressDesired := st.IngressReplyBypass - ingressExpected := advancedActive && ingressDesired - hasVPN := len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0 - iface, reason := resolveTrafficIface(st.PreferredIface) - rules := readTrafficRules() - applied := detectAppliedTrafficMode(rules) - ingressNft := false - if rules.IngressReply || st.Mode == TrafficModeFullTunnel || st.IngressReplyBypass { - ingressNft = ingressReplyNftActive() - } - bypassCandidates := 0 - if autoLocalActive && (st.Mode != TrafficModeDirect || hasVPN) { - bypassCandidates = len(detectAutoLocalBypassRoutes(iface)) - } - - overridesApplied := len(eff.VPNSubnets) + len(eff.VPNUIDs) + len(eff.DirectSubnets) + len(eff.DirectUIDs) - - tableDefault := false - if iface != "" && (st.Mode != TrafficModeDirect || hasVPN) { - ok, _ := checkPolicyRoute(iface, routesTableName()) - tableDefault = ok - } - - res := TrafficModeStatusResponse{ - Mode: st.Mode, - DesiredMode: st.Mode, - AppliedMode: applied, - PreferredIface: st.PreferredIface, - AdvancedActive: advancedActive, - AutoLocalBypass: st.AutoLocalBypass, - AutoLocalActive: autoLocalActive, - IngressReplyBypass: ingressDesired, - IngressReplyActive: rules.IngressReply && ingressNft, - BypassCandidates: bypassCandidates, - ForceVPNSubnets: append([]string(nil), st.ForceVPNSubnets...), - ForceVPNUIDs: append([]string(nil), st.ForceVPNUIDs...), - ForceVPNCGroups: append([]string(nil), st.ForceVPNCGroups...), - ForceDirectSubnets: append([]string(nil), st.ForceDirectSubnets...), - ForceDirectUIDs: append([]string(nil), st.ForceDirectUIDs...), - ForceDirectCGroups: append([]string(nil), st.ForceDirectCGroups...), - OverridesApplied: overridesApplied, - CgroupResolvedUIDs: eff.CgroupResolvedUIDs, - CgroupWarning: eff.CgroupWarning, - ActiveIface: iface, - IfaceReason: reason, - RuleMark: rules.Mark, - RuleFull: rules.Full, - IngressRulePresent: rules.IngressReply, - IngressNftActive: ingressNft, - TableDefault: tableDefault, - } - - res.ProbeOK, res.ProbeMessage = probeTrafficMode(st.Mode, iface) - - switch st.Mode { - case TrafficModeDirect: - // direct mode can still be healthy when vpn overrides exist - // (base full/selective rules must be absent). - if hasVPN { - res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK - } else { - res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && res.ProbeOK - } - case TrafficModeFullTunnel: - if ingressExpected { - res.Healthy = rules.Full && !rules.Mark && rules.IngressReply && ingressNft && tableDefault && iface != "" && res.ProbeOK - } else { - res.Healthy = rules.Full && !rules.Mark && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK - } - case TrafficModeSelective: - res.Healthy = rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK - default: - res.Healthy = false - } - - if res.Healthy { - res.Message = "traffic mode applied" - return res - } - if iface == "" && (st.Mode != TrafficModeDirect || hasVPN) { - res.Message = "vpn interface not found" - return res - } - if st.Mode != applied { - res.Message = fmt.Sprintf("desired=%s applied=%s mismatch", st.Mode, applied) - return res - } - if (st.Mode != TrafficModeDirect || hasVPN) && !tableDefault { - res.Message = "policy table default route is missing" - return res - } - if !res.ProbeOK { - res.Message = res.ProbeMessage - return res - } - if rules.Mark && rules.Full { - res.Message = "conflicting traffic rules detected" - return res - } - if ingressExpected && (!rules.IngressReply || !ingressNft) { - res.Message = "ingress-reply bypass rule is not active" - return res - } - if !ingressExpected && (rules.IngressReply || ingressNft) { - res.Message = "stale ingress-reply bypass rule is active" - return res - } - res.Message = "traffic mode check failed" - return res -} - -func handleTrafficInterfaces(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - st := loadTrafficModeState() - active, reason := resolveTrafficIface(st.PreferredIface) - resp := TrafficInterfacesResponse{ - Interfaces: listSelectableIfaces(st.PreferredIface), - PreferredIface: normalizePreferredIface(st.PreferredIface), - ActiveIface: active, - IfaceReason: reason, - } - writeJSON(w, http.StatusOK, resp) -} - -func handleTrafficModeTest(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - st := loadTrafficModeState() - writeJSON(w, http.StatusOK, evaluateTrafficMode(st)) -} - -func acquireTrafficApplyLock() (*os.File, *TrafficModeStatusResponse) { - lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) - if err != nil { - msg := evaluateTrafficMode(loadTrafficModeState()) - msg.Message = "traffic lock open failed: " + err.Error() - return nil, &msg - } - if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - _ = lock.Close() - msg := evaluateTrafficMode(loadTrafficModeState()) - msg.Message = "traffic apply skipped: routes operation already running" - return nil, &msg - } - return lock, nil -} - -func handleTrafficAdvancedReset(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - lock, lockMsg := acquireTrafficApplyLock() - if lockMsg != nil { - writeJSON(w, http.StatusOK, *lockMsg) - return - } - defer func() { - _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) - _ = lock.Close() - }() - - prev := normalizeTrafficModeState(loadTrafficModeState()) - next := prev - next.AutoLocalBypass = false - next.IngressReplyBypass = false - - nextIface, _ := resolveTrafficIface(next.PreferredIface) - if err := applyTrafficMode(next, nextIface); err != nil { - prevIface, _ := resolveTrafficIface(prev.PreferredIface) - _ = applyTrafficMode(prev, prevIface) - msg := evaluateTrafficMode(prev) - msg.Message = "advanced reset failed, rolled back: " + err.Error() - writeJSON(w, http.StatusOK, msg) - return - } - - if err := saveTrafficModeState(next); err != nil { - prevIface, _ := resolveTrafficIface(prev.PreferredIface) - _ = applyTrafficMode(prev, prevIface) - _ = saveTrafficModeState(prev) - msg := evaluateTrafficMode(prev) - msg.Message = "advanced reset save failed, rolled back: " + err.Error() - writeJSON(w, http.StatusOK, msg) - return - } - - res := evaluateTrafficMode(next) - if !res.Healthy { - prevIface, _ := resolveTrafficIface(prev.PreferredIface) - _ = applyTrafficMode(prev, prevIface) - _ = saveTrafficModeState(prev) - rolled := evaluateTrafficMode(prev) - rolled.Message = "advanced reset verification failed, rolled back: " + res.Message - writeJSON(w, http.StatusOK, rolled) - return - } - - events.push("traffic_advanced_reset", map[string]any{ - "mode": res.Mode, - "applied": res.AppliedMode, - "active_iface": res.ActiveIface, - "healthy": res.Healthy, - "auto_local": res.AutoLocalBypass, - "ingress_reply": res.IngressReplyBypass, - "advanced_active": res.AdvancedActive, - }) - res.Message = "advanced bypass reset" - writeJSON(w, http.StatusOK, res) -} - -func handleTrafficMode(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - st := loadTrafficModeState() - writeJSON(w, http.StatusOK, evaluateTrafficMode(st)) - case http.MethodPost: - lock, lockMsg := acquireTrafficApplyLock() - if lockMsg != nil { - writeJSON(w, http.StatusOK, *lockMsg) - return - } - defer func() { - _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) - _ = lock.Close() - }() - - prev := loadTrafficModeState() - next := prev - - var body TrafficModeRequest - 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(string(body.Mode)) != "" { - next.Mode = normalizeTrafficMode(body.Mode) - } - if body.PreferredIface != nil { - next.PreferredIface = normalizePreferredIface(*body.PreferredIface) - } - if body.AutoLocalBypass != nil { - next.AutoLocalBypass = *body.AutoLocalBypass - } - if body.IngressReplyBypass != nil { - next.IngressReplyBypass = *body.IngressReplyBypass - } - if body.ForceVPNSubnets != nil { - next.ForceVPNSubnets = append([]string(nil), (*body.ForceVPNSubnets)...) - } - if body.ForceVPNUIDs != nil { - next.ForceVPNUIDs = append([]string(nil), (*body.ForceVPNUIDs)...) - } - if body.ForceVPNCGroups != nil { - next.ForceVPNCGroups = append([]string(nil), (*body.ForceVPNCGroups)...) - } - if body.ForceDirectSubnets != nil { - next.ForceDirectSubnets = append([]string(nil), (*body.ForceDirectSubnets)...) - } - if body.ForceDirectUIDs != nil { - next.ForceDirectUIDs = append([]string(nil), (*body.ForceDirectUIDs)...) - } - if body.ForceDirectCGroups != nil { - next.ForceDirectCGroups = append([]string(nil), (*body.ForceDirectCGroups)...) - } - - next = normalizeTrafficModeState(next) - prev = normalizeTrafficModeState(prev) - - nextIface, _ := resolveTrafficIface(next.PreferredIface) - if err := applyTrafficMode(next, nextIface); err != nil { - prevIface, _ := resolveTrafficIface(prev.PreferredIface) - _ = applyTrafficMode(prev, prevIface) - msg := evaluateTrafficMode(prev) - msg.Message = "apply failed, rolled back: " + err.Error() - writeJSON(w, http.StatusOK, msg) - return - } - - if err := saveTrafficModeState(next); err != nil { - prevIface, _ := resolveTrafficIface(prev.PreferredIface) - _ = applyTrafficMode(prev, prevIface) - _ = saveTrafficModeState(prev) - rolled := evaluateTrafficMode(prev) - rolled.Message = "state save failed, rolled back: " + err.Error() - writeJSON(w, http.StatusOK, rolled) - return - } - - res := evaluateTrafficMode(next) - if !res.Healthy { - prevIface, _ := resolveTrafficIface(prev.PreferredIface) - _ = applyTrafficMode(prev, prevIface) - _ = saveTrafficModeState(prev) - rolled := evaluateTrafficMode(prev) - rolled.Message = "verification failed, rolled back: " + res.Message - writeJSON(w, http.StatusOK, rolled) - return - } - - events.push("traffic_mode_changed", map[string]any{ - "mode": res.Mode, - "applied": res.AppliedMode, - "active_iface": res.ActiveIface, - "healthy": res.Healthy, - "advanced_active": res.AdvancedActive, - "auto_local_bypass": res.AutoLocalBypass, - "auto_local_active": res.AutoLocalActive, - "ingress_reply": res.IngressReplyBypass, - "ingress_active": res.IngressReplyActive, - "overrides_applied": res.OverridesApplied, - }) - writeJSON(w, http.StatusOK, res) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} diff --git a/selective-vpn-api/app/traffic_mode_apply.go b/selective-vpn-api/app/traffic_mode_apply.go new file mode 100644 index 0000000..ddc2c8e --- /dev/null +++ b/selective-vpn-api/app/traffic_mode_apply.go @@ -0,0 +1,152 @@ +package app + +import ( + "fmt" + trafficmodepkg "selective-vpn-api/app/trafficmode" + "strings" +) + +type effectiveTrafficOverrides struct { + VPNSubnets []string + VPNUIDs []string + DirectSubnets []string + DirectUIDs []string + CgroupResolvedUIDs int + CgroupWarning string +} + +func buildEffectiveOverrides(st TrafficModeState) effectiveTrafficOverrides { + st = normalizeTrafficModeState(st) + e := effectiveTrafficOverrides{ + VPNSubnets: append([]string(nil), st.ForceVPNSubnets...), + VPNUIDs: append([]string(nil), st.ForceVPNUIDs...), + DirectSubnets: append([]string(nil), st.ForceDirectSubnets...), + DirectUIDs: append([]string(nil), st.ForceDirectUIDs...), + } + + vpnUIDsFromCG, warnVPN := trafficmodepkg.ResolveCgroupUIDRanges(st.ForceVPNCGroups, cgroupRootPath) + directUIDsFromCG, warnDirect := trafficmodepkg.ResolveCgroupUIDRanges(st.ForceDirectCGroups, cgroupRootPath) + e.CgroupResolvedUIDs = len(vpnUIDsFromCG) + len(directUIDsFromCG) + e.VPNUIDs = normalizeUIDList(append(e.VPNUIDs, vpnUIDsFromCG...)) + e.DirectUIDs = normalizeUIDList(append(e.DirectUIDs, directUIDsFromCG...)) + warns := make([]string, 0, 2) + if strings.TrimSpace(warnVPN) != "" { + warns = append(warns, strings.TrimSpace(warnVPN)) + } + if strings.TrimSpace(warnDirect) != "" { + warns = append(warns, strings.TrimSpace(warnDirect)) + } + e.CgroupWarning = strings.Join(warns, "; ") + return e +} + +func applyRule(pref int, args ...string) error { + return trafficmodepkg.ApplyRule(pref, runCommand, args...) +} + +func applyTrafficOverrides(e effectiveTrafficOverrides) (int, error) { + return trafficmodepkg.ApplyOverrides( + trafficModeOverrideConfig(), + trafficmodepkg.EffectiveOverrides{ + VPNSubnets: append([]string(nil), e.VPNSubnets...), + VPNUIDs: append([]string(nil), e.VPNUIDs...), + DirectSubnets: append([]string(nil), e.DirectSubnets...), + DirectUIDs: append([]string(nil), e.DirectUIDs...), + }, + applyRule, + ) +} + +func ensureTrafficRouteBase(iface string, autoLocalBypass bool) error { + iface = strings.TrimSpace(iface) + if iface == "" { + return fmt.Errorf("empty interface") + } + if !ifaceExists(iface) { + return fmt.Errorf("interface not found: %s", iface) + } + + ensureRoutesTableEntry() + + if _, _, code, err := runCommand("ip", "-4", "route", "replace", "default", "dev", iface, "table", routesTableName(), "mtu", policyRouteMTU); err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("ip route replace default exited with %d", code) + } + return err + } + + if autoLocalBypass { + applyAutoLocalBypass(iface) + } + return nil +} + +func applyTrafficMode(st TrafficModeState, iface string) error { + st = normalizeTrafficModeState(st) + eff := buildEffectiveOverrides(st) + advancedActive := st.Mode == TrafficModeFullTunnel + autoLocalActive := advancedActive && st.AutoLocalBypass + ingressReplyActive := advancedActive && st.IngressReplyBypass + + removeTrafficRulesForTable() + + // EN: Ensure the policy table name exists even in direct mode so mark-based rules can be installed. + // RU: Гарантируем наличие имени policy-table даже в direct режиме, чтобы можно было ставить mark-правила. + ensureRoutesTableEntry() + if err := disableIngressReplyBypass(); err != nil { + return err + } + + needVPNTable := st.Mode != TrafficModeDirect || len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0 + if needVPNTable { + if err := ensureTrafficRouteBase(iface, autoLocalActive); err != nil { + return err + } + } + + if _, err := applyTrafficOverrides(eff); err != nil { + return err + } + + // EN: Mark-based per-app routing support (cgroup-based marking in nftables). + // EN: These rules are safe even when no packets are marked with MARK_APP/MARK_DIRECT. + // RU: Поддержка per-app маршрутизации по mark (cgroup-based marking в nftables). + // RU: Эти правила безопасны, если пакеты не помечаются MARK_APP/MARK_DIRECT. + if err := applyRule(trafficRulePrefMarkDirect, "fwmark", MARK_DIRECT, "lookup", "main"); err != nil { + return err + } + if ingressReplyActive { + if err := applyRule(trafficRulePrefMarkIngressReply, "fwmark", MARK_INGRESS, "lookup", "main"); err != nil { + return err + } + } + if err := applyRule(trafficRulePrefMarkAppVPN, "fwmark", MARK_APP, "lookup", routesTableName()); err != nil { + return err + } + + switch st.Mode { + case TrafficModeFullTunnel: + if err := applyRule(trafficRulePrefFull, "lookup", routesTableName()); err != nil { + return err + } + case TrafficModeSelective: + if err := applyRule(trafficRulePrefSelective, "fwmark", MARK, "lookup", routesTableName()); err != nil { + return err + } + case TrafficModeDirect: + // direct mode relies only on optional direct/vpn overrides. + default: + return fmt.Errorf("unknown traffic mode: %s", st.Mode) + } + if ingressReplyActive { + if err := enableIngressReplyBypass(iface); err != nil { + return err + } + } + + if err := restoreAppMarksFromState(); err != nil { + appendTraceLine("traffic", fmt.Sprintf("appmarks restore warning: %v", err)) + } + + return nil +} diff --git a/selective-vpn-api/app/traffic_mode_evaluate.go b/selective-vpn-api/app/traffic_mode_evaluate.go new file mode 100644 index 0000000..629da89 --- /dev/null +++ b/selective-vpn-api/app/traffic_mode_evaluate.go @@ -0,0 +1,142 @@ +package app + +import ( + "fmt" + trafficmodepkg "selective-vpn-api/app/trafficmode" + "strings" +) + +type trafficRulesState = trafficmodepkg.RulesState + +func readTrafficRules() trafficRulesState { + return trafficmodepkg.ReadRules(trafficModeRulesConfig(), runCommand) +} + +func detectAppliedTrafficMode(rules trafficRulesState) TrafficMode { + return TrafficMode(trafficmodepkg.DetectAppliedMode(trafficModeRulesConfig(), rules)) +} + +func probeTrafficMode(mode TrafficMode, iface string) (bool, string) { + return trafficmodepkg.ProbeMode( + trafficModeRulesConfig(), + string(normalizeTrafficMode(mode)), + strings.TrimSpace(iface), + runCommand, + ) +} + +func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse { + st = normalizeTrafficModeState(st) + eff := buildEffectiveOverrides(st) + advancedActive := st.Mode == TrafficModeFullTunnel + autoLocalActive := advancedActive && st.AutoLocalBypass + ingressDesired := st.IngressReplyBypass + ingressExpected := advancedActive && ingressDesired + hasVPN := len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0 + iface, reason := resolveTrafficIface(st.PreferredIface) + rules := readTrafficRules() + applied := detectAppliedTrafficMode(rules) + ingressNft := false + if rules.IngressReply || st.Mode == TrafficModeFullTunnel || st.IngressReplyBypass { + ingressNft = ingressReplyNftActive() + } + bypassCandidates := 0 + if autoLocalActive && (st.Mode != TrafficModeDirect || hasVPN) { + bypassCandidates = len(detectAutoLocalBypassRoutes(iface)) + } + + overridesApplied := len(eff.VPNSubnets) + len(eff.VPNUIDs) + len(eff.DirectSubnets) + len(eff.DirectUIDs) + + tableDefault := false + if iface != "" && (st.Mode != TrafficModeDirect || hasVPN) { + ok, _ := checkPolicyRoute(iface, routesTableName()) + tableDefault = ok + } + + res := TrafficModeStatusResponse{ + Mode: st.Mode, + DesiredMode: st.Mode, + AppliedMode: applied, + PreferredIface: st.PreferredIface, + AdvancedActive: advancedActive, + AutoLocalBypass: st.AutoLocalBypass, + AutoLocalActive: autoLocalActive, + IngressReplyBypass: ingressDesired, + IngressReplyActive: rules.IngressReply && ingressNft, + BypassCandidates: bypassCandidates, + ForceVPNSubnets: append([]string(nil), st.ForceVPNSubnets...), + ForceVPNUIDs: append([]string(nil), st.ForceVPNUIDs...), + ForceVPNCGroups: append([]string(nil), st.ForceVPNCGroups...), + ForceDirectSubnets: append([]string(nil), st.ForceDirectSubnets...), + ForceDirectUIDs: append([]string(nil), st.ForceDirectUIDs...), + ForceDirectCGroups: append([]string(nil), st.ForceDirectCGroups...), + OverridesApplied: overridesApplied, + CgroupResolvedUIDs: eff.CgroupResolvedUIDs, + CgroupWarning: eff.CgroupWarning, + ActiveIface: iface, + IfaceReason: reason, + RuleMark: rules.Mark, + RuleFull: rules.Full, + IngressRulePresent: rules.IngressReply, + IngressNftActive: ingressNft, + TableDefault: tableDefault, + } + + res.ProbeOK, res.ProbeMessage = probeTrafficMode(st.Mode, iface) + + switch st.Mode { + case TrafficModeDirect: + // direct mode can still be healthy when vpn overrides exist + // (base full/selective rules must be absent). + if hasVPN { + res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK + } else { + res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && res.ProbeOK + } + case TrafficModeFullTunnel: + if ingressExpected { + res.Healthy = rules.Full && !rules.Mark && rules.IngressReply && ingressNft && tableDefault && iface != "" && res.ProbeOK + } else { + res.Healthy = rules.Full && !rules.Mark && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK + } + case TrafficModeSelective: + res.Healthy = rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK + default: + res.Healthy = false + } + + if res.Healthy { + res.Message = "traffic mode applied" + return res + } + if iface == "" && (st.Mode != TrafficModeDirect || hasVPN) { + res.Message = "vpn interface not found" + return res + } + if st.Mode != applied { + res.Message = fmt.Sprintf("desired=%s applied=%s mismatch", st.Mode, applied) + return res + } + if (st.Mode != TrafficModeDirect || hasVPN) && !tableDefault { + res.Message = "policy table default route is missing" + return res + } + if !res.ProbeOK { + res.Message = res.ProbeMessage + return res + } + if rules.Mark && rules.Full { + res.Message = "conflicting traffic rules detected" + return res + } + if ingressExpected && (!rules.IngressReply || !ingressNft) { + res.Message = "ingress-reply bypass rule is not active" + return res + } + if !ingressExpected && (rules.IngressReply || ingressNft) { + res.Message = "stale ingress-reply bypass rule is active" + return res + } + res.Message = "traffic mode check failed" + return res +} diff --git a/selective-vpn-api/app/traffic_mode_handlers.go b/selective-vpn-api/app/traffic_mode_handlers.go new file mode 100644 index 0000000..96b0f42 --- /dev/null +++ b/selective-vpn-api/app/traffic_mode_handlers.go @@ -0,0 +1,61 @@ +package app + +import ( + "net/http" + "os" + "syscall" +) + +func handleTrafficInterfaces(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + st := loadTrafficModeState() + active, reason := resolveTrafficIface(st.PreferredIface) + resp := TrafficInterfacesResponse{ + Interfaces: listSelectableIfaces(st.PreferredIface), + PreferredIface: normalizePreferredIface(st.PreferredIface), + ActiveIface: active, + IfaceReason: reason, + } + writeJSON(w, http.StatusOK, resp) +} + +func handleTrafficModeTest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + st := loadTrafficModeState() + writeJSON(w, http.StatusOK, evaluateTrafficMode(st)) +} + +func acquireTrafficApplyLock() (*os.File, *TrafficModeStatusResponse) { + lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + msg := evaluateTrafficMode(loadTrafficModeState()) + msg.Message = "traffic lock open failed: " + err.Error() + return nil, &msg + } + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = lock.Close() + msg := evaluateTrafficMode(loadTrafficModeState()) + msg.Message = "traffic apply skipped: routes operation already running" + return nil, &msg + } + return lock, nil +} + +func handleTrafficMode(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + st := loadTrafficModeState() + writeJSON(w, http.StatusOK, evaluateTrafficMode(st)) + case http.MethodPost: + handleTrafficModePost(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/selective-vpn-api/app/traffic_mode_handlers_advanced.go b/selective-vpn-api/app/traffic_mode_handlers_advanced.go new file mode 100644 index 0000000..c887757 --- /dev/null +++ b/selective-vpn-api/app/traffic_mode_handlers_advanced.go @@ -0,0 +1,70 @@ +package app + +import ( + "net/http" + "syscall" +) + +func handleTrafficAdvancedReset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + lock, lockMsg := acquireTrafficApplyLock() + if lockMsg != nil { + writeJSON(w, http.StatusOK, *lockMsg) + return + } + defer func() { + _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + _ = lock.Close() + }() + + prev := normalizeTrafficModeState(loadTrafficModeState()) + next := prev + next.AutoLocalBypass = false + next.IngressReplyBypass = false + + nextIface, _ := resolveTrafficIface(next.PreferredIface) + if err := applyTrafficMode(next, nextIface); err != nil { + prevIface, _ := resolveTrafficIface(prev.PreferredIface) + _ = applyTrafficMode(prev, prevIface) + msg := evaluateTrafficMode(prev) + msg.Message = "advanced reset failed, rolled back: " + err.Error() + writeJSON(w, http.StatusOK, msg) + return + } + + if err := saveTrafficModeState(next); err != nil { + prevIface, _ := resolveTrafficIface(prev.PreferredIface) + _ = applyTrafficMode(prev, prevIface) + _ = saveTrafficModeState(prev) + msg := evaluateTrafficMode(prev) + msg.Message = "advanced reset save failed, rolled back: " + err.Error() + writeJSON(w, http.StatusOK, msg) + return + } + + res := evaluateTrafficMode(next) + if !res.Healthy { + prevIface, _ := resolveTrafficIface(prev.PreferredIface) + _ = applyTrafficMode(prev, prevIface) + _ = saveTrafficModeState(prev) + rolled := evaluateTrafficMode(prev) + rolled.Message = "advanced reset verification failed, rolled back: " + res.Message + writeJSON(w, http.StatusOK, rolled) + return + } + + events.push("traffic_advanced_reset", map[string]any{ + "mode": res.Mode, + "applied": res.AppliedMode, + "active_iface": res.ActiveIface, + "healthy": res.Healthy, + "auto_local": res.AutoLocalBypass, + "ingress_reply": res.IngressReplyBypass, + "advanced_active": res.AdvancedActive, + }) + res.Message = "advanced bypass reset" + writeJSON(w, http.StatusOK, res) +} diff --git a/selective-vpn-api/app/traffic_mode_handlers_apply.go b/selective-vpn-api/app/traffic_mode_handlers_apply.go new file mode 100644 index 0000000..64d315e --- /dev/null +++ b/selective-vpn-api/app/traffic_mode_handlers_apply.go @@ -0,0 +1,112 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "syscall" +) + +func handleTrafficModePost(w http.ResponseWriter, r *http.Request) { + lock, lockMsg := acquireTrafficApplyLock() + if lockMsg != nil { + writeJSON(w, http.StatusOK, *lockMsg) + return + } + defer func() { + _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + _ = lock.Close() + }() + + prev := loadTrafficModeState() + next := prev + + var body TrafficModeRequest + 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(string(body.Mode)) != "" { + next.Mode = normalizeTrafficMode(body.Mode) + } + if body.PreferredIface != nil { + next.PreferredIface = normalizePreferredIface(*body.PreferredIface) + } + if body.AutoLocalBypass != nil { + next.AutoLocalBypass = *body.AutoLocalBypass + } + if body.IngressReplyBypass != nil { + next.IngressReplyBypass = *body.IngressReplyBypass + } + if body.ForceVPNSubnets != nil { + next.ForceVPNSubnets = append([]string(nil), (*body.ForceVPNSubnets)...) + } + if body.ForceVPNUIDs != nil { + next.ForceVPNUIDs = append([]string(nil), (*body.ForceVPNUIDs)...) + } + if body.ForceVPNCGroups != nil { + next.ForceVPNCGroups = append([]string(nil), (*body.ForceVPNCGroups)...) + } + if body.ForceDirectSubnets != nil { + next.ForceDirectSubnets = append([]string(nil), (*body.ForceDirectSubnets)...) + } + if body.ForceDirectUIDs != nil { + next.ForceDirectUIDs = append([]string(nil), (*body.ForceDirectUIDs)...) + } + if body.ForceDirectCGroups != nil { + next.ForceDirectCGroups = append([]string(nil), (*body.ForceDirectCGroups)...) + } + + next = normalizeTrafficModeState(next) + prev = normalizeTrafficModeState(prev) + + nextIface, _ := resolveTrafficIface(next.PreferredIface) + if err := applyTrafficMode(next, nextIface); err != nil { + prevIface, _ := resolveTrafficIface(prev.PreferredIface) + _ = applyTrafficMode(prev, prevIface) + msg := evaluateTrafficMode(prev) + msg.Message = "apply failed, rolled back: " + err.Error() + writeJSON(w, http.StatusOK, msg) + return + } + + if err := saveTrafficModeState(next); err != nil { + prevIface, _ := resolveTrafficIface(prev.PreferredIface) + _ = applyTrafficMode(prev, prevIface) + _ = saveTrafficModeState(prev) + rolled := evaluateTrafficMode(prev) + rolled.Message = "state save failed, rolled back: " + err.Error() + writeJSON(w, http.StatusOK, rolled) + return + } + + res := evaluateTrafficMode(next) + if !res.Healthy { + prevIface, _ := resolveTrafficIface(prev.PreferredIface) + _ = applyTrafficMode(prev, prevIface) + _ = saveTrafficModeState(prev) + rolled := evaluateTrafficMode(prev) + rolled.Message = "verification failed, rolled back: " + res.Message + writeJSON(w, http.StatusOK, rolled) + return + } + + events.push("traffic_mode_changed", map[string]any{ + "mode": res.Mode, + "applied": res.AppliedMode, + "active_iface": res.ActiveIface, + "healthy": res.Healthy, + "advanced_active": res.AdvancedActive, + "auto_local_bypass": res.AutoLocalBypass, + "auto_local_active": res.AutoLocalActive, + "ingress_reply": res.IngressReplyBypass, + "ingress_active": res.IngressReplyActive, + "overrides_applied": res.OverridesApplied, + }) + writeJSON(w, http.StatusOK, res) +} diff --git a/selective-vpn-api/app/traffic_mode_helpers_test.go b/selective-vpn-api/app/traffic_mode_helpers_test.go new file mode 100644 index 0000000..a672d59 --- /dev/null +++ b/selective-vpn-api/app/traffic_mode_helpers_test.go @@ -0,0 +1,30 @@ +package app + +import "testing" + +func TestIsContainerIfaceIncludesNetnsVeth(t *testing.T) { + cases := map[string]bool{ + "docker0": true, + "br-abcdef123": true, + "veth123": true, + "svh6198e294": true, + "svn6198e294": true, + "eth0": false, + "tun0": false, + } + for iface, want := range cases { + if got := isContainerIface(iface); got != want { + t.Fatalf("isContainerIface(%q)=%v want=%v", iface, got, want) + } + } +} + +func TestRouteLineIsLinkDown(t *testing.T) { + if !routeLineIsLinkDown("10.240.20.0/30 dev svh6a59db31 proto kernel scope link src 10.240.20.1 linkdown") { + t.Fatalf("expected linkdown route to be detected") + } + if routeLineIsLinkDown("10.240.20.0/30 dev svh6198e294 proto kernel scope link src 10.240.20.1") { + t.Fatalf("unexpected linkdown detection for active route") + } +} + diff --git a/selective-vpn-api/app/traffic_mode_iface.go b/selective-vpn-api/app/traffic_mode_iface.go new file mode 100644 index 0000000..dffadc0 --- /dev/null +++ b/selective-vpn-api/app/traffic_mode_iface.go @@ -0,0 +1,157 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + trafficmodepkg "selective-vpn-api/app/trafficmode" + "strings" +) + +func ensureRoutesTableEntry() { + data, _ := os.ReadFile("/etc/iproute2/rt_tables") + want := fmt.Sprintf("%s %s", routesTableNum(), routesTableName()) + if strings.Contains(string(data), "\n"+want) || strings.HasPrefix(string(data), want) { + return + } + f, err := os.OpenFile("/etc/iproute2/rt_tables", os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + _, _ = fmt.Fprintf(f, "%s\n", want) +} + +func ifaceExists(iface string) bool { + return trafficmodepkg.IfaceExists(iface, runCommand) +} + +func statusIfaceFromFile() string { + data, err := os.ReadFile(statusFilePath) + if err != nil { + return "" + } + var st Status + if json.Unmarshal(data, &st) != nil { + return "" + } + return strings.TrimSpace(st.Iface) +} + +func listUpIfaces() []string { + return trafficmodepkg.ListUpIfaces(runCommand) +} + +func listSelectableIfaces(preferred string) []string { + return trafficmodepkg.ListSelectableIfaces(listUpIfaces(), preferred) +} + +func isVPNLikeIface(iface string) bool { + return trafficmodepkg.IsVPNLikeIface(iface) +} + +func resolveTrafficIface(preferred string) (string, string) { + return trafficmodepkg.ResolveTrafficIface(preferred, ifaceExists, statusIfaceFromFile, listUpIfaces) +} + +type autoLocalRoute = trafficmodepkg.AutoLocalRoute + +func parseRouteDevice(fields []string) string { + return trafficmodepkg.ParseRouteDevice(fields) +} + +func isContainerIface(iface string) bool { + return trafficmodepkg.IsContainerIface(iface) +} + +func routeLineIsLinkDown(line string) bool { + return trafficmodepkg.RouteLineIsLinkDown(line) +} + +func isAutoBypassDestination(dst string) bool { + return trafficmodepkg.IsAutoBypassDestination(dst) +} + +func detectAutoLocalBypassRoutes(vpnIface string) []autoLocalRoute { + vpnIface = strings.TrimSpace(vpnIface) + out, _, code, _ := runCommand("ip", "-4", "route", "show", "table", "main") + if code != 0 { + return nil + } + return trafficmodepkg.ParseAutoBypassRoutes(out, vpnIface, isVPNLikeIface) +} + +func applyAutoLocalBypass(vpnIface string) { + for _, rt := range detectAutoLocalBypassRoutes(vpnIface) { + _, _, _, _ = runCommand( + "ip", "-4", "route", "replace", + rt.Dst, "dev", rt.Dev, "table", routesTableName(), + ) + } +} + +func ingressBypassConfig() trafficmodepkg.IngressBypassConfig { + return trafficmodepkg.IngressBypassConfig{ + TableName: routesTableName(), + PreroutingChain: trafficIngressPreroutingChain, + OutputChain: trafficIngressOutputChain, + MarkIngress: MARK_INGRESS, + CaptureComment: trafficIngressCaptureComment, + RestoreComment: trafficIngressRestoreComment, + } +} + +func ensureIngressReplyBypassChains() { + trafficmodepkg.EnsureIngressReplyBypassChains(ingressBypassConfig(), runCommandTimeout) +} + +func flushIngressReplyBypassChains() error { + return trafficmodepkg.FlushIngressReplyBypassChains(ingressBypassConfig(), runCommandTimeout) +} + +func enableIngressReplyBypass(vpnIface string) error { + return trafficmodepkg.EnableIngressReplyBypass(ingressBypassConfig(), strings.TrimSpace(vpnIface), runCommandTimeout) +} + +func disableIngressReplyBypass() error { + return trafficmodepkg.DisableIngressReplyBypass(ingressBypassConfig(), runCommandTimeout) +} + +func ingressReplyNftActive() bool { + return trafficmodepkg.IngressReplyNftActive(ingressBypassConfig(), runCommandTimeout) +} + +func prefStr(v int) string { + return trafficmodepkg.PrefStr(v) +} + +func trafficModeRulesConfig() trafficmodepkg.RulesConfig { + return trafficmodepkg.RulesConfig{ + RoutesTableName: routesTableName(), + Mark: MARK, + MarkIngress: MARK_INGRESS, + PrefSelective: trafficRulePrefSelective, + PrefFull: trafficRulePrefFull, + PrefMarkIngressReply: trafficRulePrefMarkIngressReply, + ModeFull: string(TrafficModeFullTunnel), + ModeSelective: string(TrafficModeSelective), + ModeDirect: string(TrafficModeDirect), + } +} + +func trafficModeOverrideConfig() trafficmodepkg.OverrideConfig { + return trafficmodepkg.OverrideConfig{ + RoutesTableName: routesTableName(), + RulePerKindLimit: trafficRulePerKindLimit, + PrefManagedMin: trafficRulePrefManagedMin, + PrefManagedMax: trafficRulePrefManagedMax, + PrefDirectSubnetBase: trafficRulePrefDirectSubnetStart, + PrefDirectUIDBase: trafficRulePrefDirectUIDStart, + PrefVPNSubnetBase: trafficRulePrefVPNSubnetStart, + PrefVPNUIDBase: trafficRulePrefVPNUIDStart, + } +} + +func removeTrafficRulesForTable() { + trafficmodepkg.RemoveRulesForTable(trafficModeOverrideConfig(), runCommand) +} diff --git a/selective-vpn-api/app/traffic_mode_state.go b/selective-vpn-api/app/traffic_mode_state.go new file mode 100644 index 0000000..fcbf66a --- /dev/null +++ b/selective-vpn-api/app/traffic_mode_state.go @@ -0,0 +1,137 @@ +package app + +import ( + "encoding/json" + "os" + trafficmodepkg "selective-vpn-api/app/trafficmode" + "strings" + "time" +) + +func normalizeTrafficMode(raw TrafficMode) TrafficMode { + switch strings.ToLower(strings.TrimSpace(string(raw))) { + case string(TrafficModeFullTunnel): + return TrafficModeFullTunnel + case string(TrafficModeDirect): + return TrafficModeDirect + case string(TrafficModeSelective): + return TrafficModeSelective + default: + return TrafficModeSelective + } +} + +func normalizePreferredIface(raw string) string { + return trafficmodepkg.NormalizePreferredIface(raw) +} + +func tokenizeList(raw []string) []string { + return trafficmodepkg.TokenizeList(raw) +} + +func normalizeSubnetList(raw []string) []string { + return trafficmodepkg.NormalizeSubnetList(raw) +} + +func normalizeUIDToken(tok string) (string, bool) { + return trafficmodepkg.NormalizeUIDToken(tok) +} + +func normalizeUIDList(raw []string) []string { + return trafficmodepkg.NormalizeUIDList(raw) +} + +func normalizeCgroupList(raw []string) []string { + return trafficmodepkg.NormalizeCgroupList(raw) +} + +func normalizeTrafficModeState(st TrafficModeState) TrafficModeState { + st.Mode = normalizeTrafficMode(st.Mode) + st.PreferredIface = normalizePreferredIface(st.PreferredIface) + st.ForceVPNSubnets = normalizeSubnetList(st.ForceVPNSubnets) + st.ForceVPNUIDs = normalizeUIDList(st.ForceVPNUIDs) + st.ForceVPNCGroups = normalizeCgroupList(st.ForceVPNCGroups) + st.ForceDirectSubnets = normalizeSubnetList(st.ForceDirectSubnets) + st.ForceDirectUIDs = normalizeUIDList(st.ForceDirectUIDs) + st.ForceDirectCGroups = normalizeCgroupList(st.ForceDirectCGroups) + return st +} + +func loadTrafficModeState() TrafficModeState { + data, err := os.ReadFile(trafficModePath) + if err != nil { + return inferTrafficModeState() + } + + type diskState struct { + Mode TrafficMode `json:"mode"` + PreferredIface string `json:"preferred_iface,omitempty"` + AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"` + IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"` + ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"` + ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"` + ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"` + ForceDirectSubnets []string `json:"force_direct_subnets,omitempty"` + ForceDirectUIDs []string `json:"force_direct_uids,omitempty"` + ForceDirectCGroups []string `json:"force_direct_cgroups,omitempty"` + } + var raw diskState + if err := json.Unmarshal(data, &raw); err != nil { + return inferTrafficModeState() + } + st := TrafficModeState{ + Mode: raw.Mode, + PreferredIface: raw.PreferredIface, + AutoLocalBypass: trafficAutoLocalDefault, + IngressReplyBypass: trafficIngressReplyDefault, + ForceVPNSubnets: append([]string(nil), raw.ForceVPNSubnets...), + ForceVPNUIDs: append([]string(nil), raw.ForceVPNUIDs...), + ForceVPNCGroups: append([]string(nil), raw.ForceVPNCGroups...), + ForceDirectSubnets: append([]string(nil), raw.ForceDirectSubnets...), + ForceDirectUIDs: append([]string(nil), raw.ForceDirectUIDs...), + ForceDirectCGroups: append([]string(nil), raw.ForceDirectCGroups...), + } + if raw.AutoLocalBypass != nil { + st.AutoLocalBypass = *raw.AutoLocalBypass + } + if raw.IngressReplyBypass != nil { + st.IngressReplyBypass = *raw.IngressReplyBypass + } + return normalizeTrafficModeState(st) +} + +func saveTrafficModeState(st TrafficModeState) error { + st = normalizeTrafficModeState(st) + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return err + } + tmp := trafficModePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, trafficModePath) +} + +func inferTrafficModeState() TrafficModeState { + rules := readTrafficRules() + mode := detectAppliedTrafficMode(rules) + iface, _ := resolveTrafficIface("") + return normalizeTrafficModeState(TrafficModeState{ + Mode: mode, + PreferredIface: iface, + AutoLocalBypass: trafficAutoLocalDefault, + IngressReplyBypass: trafficIngressReplyDefault, + ForceVPNSubnets: nil, + ForceVPNUIDs: nil, + ForceVPNCGroups: nil, + ForceDirectSubnets: nil, + ForceDirectUIDs: nil, + ForceDirectCGroups: nil, + }) +} diff --git a/selective-vpn-api/app/trafficappmarks/cgroup.go b/selective-vpn-api/app/trafficappmarks/cgroup.go new file mode 100644 index 0000000..78d8489 --- /dev/null +++ b/selective-vpn-api/app/trafficappmarks/cgroup.go @@ -0,0 +1,59 @@ +package trafficappmarks + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" +) + +func ResolveCgroupV2PathForNft(input string, cgroupRootPath string) (rel string, level int, inodeID uint64, abs string, err error) { + raw := strings.TrimSpace(input) + if raw == "" { + return "", 0, 0, "", fmt.Errorf("empty cgroup") + } + + rel = NormalizeCgroupRelOnly(raw) + if rel == "" { + return "", 0, 0, raw, fmt.Errorf("invalid cgroup path: %s", raw) + } + + inodeID, err = CgroupDirInode(cgroupRootPath, rel) + if err != nil { + return "", 0, 0, raw, err + } + + level = strings.Count(rel, "/") + 1 + abs = "/" + rel + return rel, level, inodeID, abs, nil +} + +func NormalizeCgroupRelOnly(raw string) string { + rel := strings.TrimSpace(raw) + rel = strings.TrimPrefix(rel, "/") + rel = filepath.Clean(rel) + if rel == "." || rel == "" { + return "" + } + if strings.HasPrefix(rel, "..") || strings.Contains(rel, "../") { + return "" + } + return rel +} + +func CgroupDirInode(cgroupRootPath, rel string) (uint64, error) { + full := filepath.Join(cgroupRootPath, strings.TrimPrefix(rel, "/")) + fi, err := os.Stat(full) + if err != nil || fi == nil || !fi.IsDir() { + return 0, fmt.Errorf("cgroup not found: %s", "/"+strings.TrimPrefix(rel, "/")) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok || st == nil { + return 0, fmt.Errorf("cannot stat cgroup: %s", "/"+strings.TrimPrefix(rel, "/")) + } + if st.Ino == 0 { + return 0, fmt.Errorf("invalid cgroup inode id: %s", "/"+strings.TrimPrefix(rel, "/")) + } + return st.Ino, nil +} diff --git a/selective-vpn-api/app/trafficappmarks/nft.go b/selective-vpn-api/app/trafficappmarks/nft.go new file mode 100644 index 0000000..a9ef764 --- /dev/null +++ b/selective-vpn-api/app/trafficappmarks/nft.go @@ -0,0 +1,337 @@ +package trafficappmarks + +import ( + "fmt" + "net/netip" + "sort" + "strconv" + "strings" + "time" +) + +type RunCommandFunc func(timeout time.Duration, name string, args ...string) (stdout string, stderr string, code int, err error) + +type NFTConfig struct { + Table string + Chain string + GuardChain string + LocalBypassSet string + MarkApp string + MarkDirect string + MarkCommentPrefix string + GuardCommentPrefix string + GuardEnabled bool +} + +func EnsureBase(cfg NFTConfig, run RunCommandFunc) error { + if run == nil { + return fmt.Errorf("run command func is nil") + } + _, _, _, _ = run(5*time.Second, "nft", "add", "table", "inet", cfg.Table) + _, _, _, _ = run(5*time.Second, "nft", "add", "chain", "inet", cfg.Table, "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}") + _, _, _, _ = run(5*time.Second, "nft", "add", "chain", "inet", cfg.Table, cfg.GuardChain, "{", "type", "filter", "hook", "output", "priority", "filter;", "policy", "accept;", "}") + _, _, _, _ = run(5*time.Second, "nft", "add", "chain", "inet", cfg.Table, cfg.Chain) + _, _, _, _ = run(5*time.Second, "nft", "add", "set", "inet", cfg.Table, cfg.LocalBypassSet, "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") + + out, _, _, _ := run(5*time.Second, "nft", "list", "chain", "inet", cfg.Table, "output") + if !strings.Contains(out, "jump "+cfg.Chain) { + _, _, _, _ = run(5*time.Second, "nft", "insert", "rule", "inet", cfg.Table, "output", "jump", cfg.Chain) + } + return nil +} + +func AppMarkComment(prefix string, target string, id uint64) string { + return fmt.Sprintf("%s:%s:%d", prefix, target, id) +} + +func AppGuardComment(prefix string, target string, id uint64) string { + return fmt.Sprintf("%s:%s:%d", prefix, target, id) +} + +func UpdateLocalBypassSet(cfg NFTConfig, vpnIface string, bypassCIDRs []string, run RunCommandFunc) error { + if run == nil { + return fmt.Errorf("run command func is nil") + } + if strings.TrimSpace(cfg.Table) == "" || strings.TrimSpace(cfg.LocalBypassSet) == "" { + return fmt.Errorf("invalid nft config for local bypass set") + } + + _, _, _, _ = run(5*time.Second, "nft", "flush", "set", "inet", cfg.Table, cfg.LocalBypassSet) + + elems := []string{"127.0.0.0/8"} + for _, dst := range bypassCIDRs { + val := strings.TrimSpace(dst) + if val == "" || val == "default" { + continue + } + elems = append(elems, val) + } + elems = CompactIPv4IntervalElements(elems) + + for _, e := range elems { + _, out, code, err := run( + 5*time.Second, + "nft", "add", "element", "inet", cfg.Table, cfg.LocalBypassSet, + "{", e, "}", + ) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("nft add element exited with %d", code) + } + return fmt.Errorf("failed to update %s: %w (%s)", cfg.LocalBypassSet, err, strings.TrimSpace(out)) + } + } + return nil +} + +func InsertAppMarkRule(cfg NFTConfig, target string, rel string, level int, id uint64, vpnIface string, bypassCIDRs []string, run RunCommandFunc) error { + if run == nil { + return fmt.Errorf("run command func is nil") + } + + target = strings.ToLower(strings.TrimSpace(target)) + mark := cfg.MarkDirect + if target == "vpn" { + mark = cfg.MarkApp + } + + comment := AppMarkComment(cfg.MarkCommentPrefix, target, id) + pathLit := fmt.Sprintf("\"%s\"", rel) + commentLit := fmt.Sprintf("\"%s\"", comment) + + if target == "vpn" && cfg.GuardEnabled { + iface := strings.TrimSpace(vpnIface) + if iface == "" { + return fmt.Errorf("vpn interface required for app guard") + } + if err := UpdateLocalBypassSet(cfg, iface, bypassCIDRs, run); err != nil { + return err + } + + guardComment := AppGuardComment(cfg.GuardCommentPrefix, target, id) + guardCommentLit := fmt.Sprintf("\"%s\"", guardComment) + + _, out, code, err := run( + 5*time.Second, + "nft", "insert", "rule", "inet", cfg.Table, cfg.GuardChain, + "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, + "meta", "mark", cfg.MarkApp, + "oifname", "!=", iface, + "ip", "daddr", "!=", "@"+cfg.LocalBypassSet, + "drop", + "comment", guardCommentLit, + ) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("nft insert guard(v4) exited with %d", code) + } + return fmt.Errorf("nft insert app guard(v4) failed: %w (%s)", err, strings.TrimSpace(out)) + } + + _, out, code, err = run( + 5*time.Second, + "nft", "insert", "rule", "inet", cfg.Table, cfg.GuardChain, + "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, + "meta", "mark", cfg.MarkApp, + "oifname", "!=", iface, + "meta", "nfproto", "ipv6", + "drop", + "comment", guardCommentLit, + ) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("nft insert guard(v6) exited with %d", code) + } + return fmt.Errorf("nft insert app guard(v6) failed: %w (%s)", err, strings.TrimSpace(out)) + } + } + + _, out, code, err := run( + 5*time.Second, + "nft", "insert", "rule", "inet", cfg.Table, cfg.Chain, + "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, + "meta", "mark", "set", mark, + "accept", + "comment", commentLit, + ) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("nft insert rule exited with %d", code) + } + _ = DeleteAppMarkRule(cfg, target, id, run) + return fmt.Errorf("nft insert appmark rule failed: %w (%s)", err, strings.TrimSpace(out)) + } + return nil +} + +func DeleteAppMarkRule(cfg NFTConfig, target string, id uint64, run RunCommandFunc) error { + if run == nil { + return fmt.Errorf("run command func is nil") + } + comments := []string{ + AppMarkComment(cfg.MarkCommentPrefix, target, id), + AppGuardComment(cfg.GuardCommentPrefix, target, id), + } + chains := []string{cfg.Chain, cfg.GuardChain} + for _, chain := range chains { + if strings.TrimSpace(chain) == "" { + continue + } + out, _, _, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.Table, chain) + for _, line := range strings.Split(out, "\n") { + match := false + for _, comment := range comments { + if strings.Contains(line, comment) { + match = true + break + } + } + if !match { + continue + } + h := ParseNftHandle(line) + if h <= 0 { + continue + } + _, _, _, _ = run(5*time.Second, "nft", "delete", "rule", "inet", cfg.Table, chain, "handle", strconv.Itoa(h)) + } + } + return nil +} + +func HasAppMarkRule(cfg NFTConfig, target string, id uint64, run RunCommandFunc) bool { + if run == nil { + return false + } + markComment := AppMarkComment(cfg.MarkCommentPrefix, target, id) + guardComment := AppGuardComment(cfg.GuardCommentPrefix, target, id) + + hasMark := false + out, _, _, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.Table, cfg.Chain) + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, markComment) { + hasMark = true + break + } + } + if !hasMark { + return false + } + if strings.EqualFold(strings.TrimSpace(target), "vpn") { + if !cfg.GuardEnabled { + return true + } + out, _, _, _ = run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.Table, cfg.GuardChain) + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, guardComment) { + return true + } + } + return false + } + return true +} + +func CleanupLegacyRules(cfg NFTConfig, run RunCommandFunc) error { + if run == nil { + return fmt.Errorf("run command func is nil") + } + out, _, _, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.Table, cfg.Chain) + for _, line := range strings.Split(out, "\n") { + l := strings.ToLower(line) + if !strings.Contains(l, "meta cgroup") { + continue + } + if !strings.Contains(l, "svpn_cg_") { + continue + } + h := ParseNftHandle(line) + if h <= 0 { + continue + } + _, _, _, _ = run(5*time.Second, "nft", "delete", "rule", "inet", cfg.Table, cfg.Chain, "handle", strconv.Itoa(h)) + } + return nil +} + +func ClearManagedRules(cfg NFTConfig, chain string, run RunCommandFunc) { + if run == nil { + return + } + out, _, _, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.Table, chain) + for _, line := range strings.Split(out, "\n") { + l := strings.ToLower(line) + if !strings.Contains(l, strings.ToLower(cfg.MarkCommentPrefix)) && + !strings.Contains(l, strings.ToLower(cfg.GuardCommentPrefix)) { + continue + } + h := ParseNftHandle(line) + if h <= 0 { + continue + } + _, _, _, _ = run(5*time.Second, "nft", "delete", "rule", "inet", cfg.Table, chain, "handle", strconv.Itoa(h)) + } +} + +func ParseNftHandle(line string) int { + fields := strings.Fields(line) + for i := 0; i < len(fields)-1; i++ { + if fields[i] == "handle" { + n, _ := strconv.Atoi(fields[i+1]) + return n + } + } + return 0 +} + +func CompactIPv4IntervalElements(raw []string) []string { + pfxs := make([]netip.Prefix, 0, len(raw)) + for _, v := range raw { + s := strings.TrimSpace(v) + if s == "" { + continue + } + if strings.Contains(s, "/") { + p, err := netip.ParsePrefix(s) + if err != nil || !p.Addr().Is4() { + continue + } + pfxs = append(pfxs, p.Masked()) + continue + } + a, err := netip.ParseAddr(s) + if err != nil || !a.Is4() { + continue + } + pfxs = append(pfxs, netip.PrefixFrom(a, 32)) + } + + sort.Slice(pfxs, func(i, j int) bool { + ib, jb := pfxs[i].Bits(), pfxs[j].Bits() + if ib != jb { + return ib < jb + } + return pfxs[i].Addr().Less(pfxs[j].Addr()) + }) + + out := make([]netip.Prefix, 0, len(pfxs)) + for _, p := range pfxs { + covered := false + for _, ex := range out { + if ex.Contains(p.Addr()) { + covered = true + break + } + } + if covered { + continue + } + out = append(out, p) + } + + res := make([]string, 0, len(out)) + for _, p := range out { + res = append(res, p.String()) + } + return res +} diff --git a/selective-vpn-api/app/trafficappmarks/store.go b/selective-vpn-api/app/trafficappmarks/store.go new file mode 100644 index 0000000..3f6bdf0 --- /dev/null +++ b/selective-vpn-api/app/trafficappmarks/store.go @@ -0,0 +1,205 @@ +package trafficappmarks + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type State struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at"` + Items []Item `json:"items,omitempty"` +} + +type Item struct { + ID uint64 `json:"id"` + Target string `json:"target"` // vpn|direct + Cgroup string `json:"cgroup"` // absolute path ("/user.slice/..."), informational + CgroupRel string `json:"cgroup_rel"` + Level int `json:"level"` + Unit string `json:"unit,omitempty"` + Command string `json:"command,omitempty"` + AppKey string `json:"app_key,omitempty"` + AddedAt string `json:"added_at"` + ExpiresAt string `json:"expires_at"` +} + +func LoadState(statePath string, canonicalizeAppKey func(appKey, command string) string) State { + st := State{Version: 1} + data, err := os.ReadFile(statePath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return State{Version: 1} + } + if st.Version == 0 { + st.Version = 1 + } + + changed := false + for i := range st.Items { + st.Items[i].Target = strings.ToLower(strings.TrimSpace(st.Items[i].Target)) + if canonicalizeAppKey != nil { + canon := canonicalizeAppKey(st.Items[i].AppKey, st.Items[i].Command) + if canon != "" && strings.TrimSpace(st.Items[i].AppKey) != canon { + st.Items[i].AppKey = canon + changed = true + } + } + } + if deduped, dedupChanged := DedupeItems(st.Items, canonicalizeAppKey); dedupChanged { + st.Items = deduped + changed = true + } + if changed { + _ = SaveState(statePath, st) + } + return st +} + +func SaveState(statePath string, st State) error { + st.Version = 1 + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil { + return err + } + tmp := statePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, statePath) +} + +func DedupeItems(in []Item, canonicalizeAppKey func(appKey, command string) string) ([]Item, bool) { + if len(in) <= 1 { + return in, false + } + out := make([]Item, 0, len(in)) + byTargetID := map[string]int{} + byTargetApp := map[string]int{} + changed := false + + for _, raw := range in { + it := raw + it.Target = strings.ToLower(strings.TrimSpace(it.Target)) + if it.Target != "vpn" && it.Target != "direct" { + changed = true + continue + } + if canonicalizeAppKey != nil { + it.AppKey = canonicalizeAppKey(it.AppKey, it.Command) + } + + if it.ID > 0 { + idKey := fmt.Sprintf("%s:%d", it.Target, it.ID) + if idx, ok := byTargetID[idKey]; ok { + if preferItem(it, out[idx]) { + out[idx] = it + } + changed = true + continue + } + byTargetID[idKey] = len(out) + } + + if it.AppKey != "" { + appKey := it.Target + "|" + it.AppKey + if idx, ok := byTargetApp[appKey]; ok { + if preferItem(it, out[idx]) { + out[idx] = it + } + changed = true + continue + } + byTargetApp[appKey] = len(out) + } + + out = append(out, it) + } + return out, changed +} + +func UpsertItem(items []Item, next Item) []Item { + out := items[:0] + for _, it := range items { + if strings.ToLower(strings.TrimSpace(it.Target)) == strings.ToLower(strings.TrimSpace(next.Target)) && it.ID == next.ID { + continue + } + out = append(out, it) + } + out = append(out, next) + return out +} + +func IsAllDigits(s string) bool { + s = strings.TrimSpace(s) + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + ch := s[i] + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func PruneExpired(st *State, now time.Time, deleteRule func(target string, id uint64)) (changed bool) { + if st == nil { + return false + } + kept := st.Items[:0] + for _, it := range st.Items { + expRaw := strings.TrimSpace(it.ExpiresAt) + if expRaw == "" { + kept = append(kept, it) + continue + } + exp, err := time.Parse(time.RFC3339, expRaw) + if err != nil { + it.ExpiresAt = "" + kept = append(kept, it) + changed = true + continue + } + if !exp.After(now) { + if deleteRule != nil { + deleteRule(strings.ToLower(strings.TrimSpace(it.Target)), it.ID) + } + changed = true + continue + } + kept = append(kept, it) + } + st.Items = kept + return changed +} + +func preferItem(cand, cur Item) bool { + ca := strings.TrimSpace(cand.AddedAt) + oa := strings.TrimSpace(cur.AddedAt) + if ca != oa { + if ca == "" { + return false + } + if oa == "" { + return true + } + return ca > oa + } + if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" { + return true + } + return false +} diff --git a/selective-vpn-api/app/trafficcandidates/candidates.go b/selective-vpn-api/app/trafficcandidates/candidates.go new file mode 100644 index 0000000..98f92d7 --- /dev/null +++ b/selective-vpn-api/app/trafficcandidates/candidates.go @@ -0,0 +1,256 @@ +package trafficcandidates + +import ( + "sort" + "strconv" + "strings" + "time" +) + +type Subnet struct { + CIDR string + Dev string + Kind string + LinkDown bool +} + +type Unit struct { + Unit string + Description string + Cgroup string +} + +type UID struct { + UID int + User string + Examples []string +} + +type Response struct { + GeneratedAt string + Subnets []Subnet + Units []Unit + UIDs []UID +} + +type Deps struct { + RunCommand func(name string, args ...string) (stdout, stderr string, exitCode int, err error) + ParseRouteDevice func(fields []string) string + IsVPNLikeIface func(iface string) bool + IsContainerIface func(iface string) bool + IsAutoBypassDestination func(dst string) bool +} + +func Collect(now time.Time, deps Deps) Response { + if now.IsZero() { + now = time.Now().UTC() + } + return Response{ + GeneratedAt: now.UTC().Format(time.RFC3339), + Subnets: candidateSubnets(deps), + Units: candidateUnits(deps), + UIDs: candidateUIDs(deps), + } +} + +func candidateSubnets(deps Deps) []Subnet { + if deps.RunCommand == nil { + return nil + } + out, _, code, _ := deps.RunCommand("ip", "-4", "route", "show", "table", "main") + if code != 0 { + return nil + } + + seen := map[string]struct{}{} + items := make([]Subnet, 0, 24) + + for _, raw := range strings.Split(out, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + + dst := strings.TrimSpace(fields[0]) + if dst == "" || dst == "default" { + continue + } + dev := "" + if deps.ParseRouteDevice != nil { + dev = deps.ParseRouteDevice(fields) + } + if dev == "" || dev == "lo" { + continue + } + if deps.IsVPNLikeIface != nil && deps.IsVPNLikeIface(dev) { + continue + } + + isDocker := deps.IsContainerIface != nil && deps.IsContainerIface(dev) + isLocal := deps.IsAutoBypassDestination != nil && deps.IsAutoBypassDestination(dst) + if !isDocker && !isLocal { + continue + } + + kind := "lan" + if isDocker { + kind = "docker" + } else if strings.Contains(" "+strings.ToLower(line)+" ", " scope link ") { + kind = "link" + } + + key := kind + "|" + dst + "|" + dev + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + items = append(items, Subnet{ + CIDR: dst, + Dev: dev, + Kind: kind, + LinkDown: strings.Contains(strings.ToLower(line), " linkdown"), + }) + } + + sort.Slice(items, func(i, j int) bool { + if items[i].Kind != items[j].Kind { + return items[i].Kind < items[j].Kind + } + if items[i].Dev != items[j].Dev { + return items[i].Dev < items[j].Dev + } + return items[i].CIDR < items[j].CIDR + }) + return items +} + +func candidateUnits(deps Deps) []Unit { + if deps.RunCommand == nil { + return nil + } + stdout, _, code, _ := deps.RunCommand( + "systemctl", + "list-units", + "--type=service", + "--state=running", + "--no-legend", + "--no-pager", + "--plain", + ) + if code != 0 { + return nil + } + + seen := map[string]struct{}{} + items := make([]Unit, 0, 32) + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 1 { + continue + } + unit := strings.TrimSpace(fields[0]) + if unit == "" { + continue + } + if _, ok := seen[unit]; ok { + continue + } + seen[unit] = struct{}{} + + desc := "" + if len(fields) >= 5 { + desc = strings.Join(fields[4:], " ") + } + + items = append(items, Unit{ + Unit: unit, + Description: strings.TrimSpace(desc), + Cgroup: "system.slice/" + unit, + }) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].Unit < items[j].Unit + }) + return items +} + +func candidateUIDs(deps Deps) []UID { + if deps.RunCommand == nil { + return nil + } + stdout, _, code, _ := deps.RunCommand("ps", "-eo", "uid,user,comm", "--no-headers") + if code != 0 { + return nil + } + + type agg struct { + uid int + user string + comms map[string]struct{} + } + + aggs := map[int]*agg{} + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + uidN, err := strconv.Atoi(strings.TrimSpace(fields[0])) + if err != nil || uidN < 0 { + continue + } + user := strings.TrimSpace(fields[1]) + comm := "" + if len(fields) >= 3 { + comm = strings.TrimSpace(fields[2]) + } + + a := aggs[uidN] + if a == nil { + a = &agg{uid: uidN, user: user, comms: map[string]struct{}{}} + aggs[uidN] = a + } + if a.user == "" && user != "" { + a.user = user + } + if comm != "" { + a.comms[comm] = struct{}{} + } + } + + items := make([]UID, 0, len(aggs)) + for _, a := range aggs { + examples := make([]string, 0, len(a.comms)) + for c := range a.comms { + examples = append(examples, c) + } + sort.Strings(examples) + if len(examples) > 3 { + examples = examples[:3] + } + items = append(items, UID{ + UID: a.uid, + User: a.user, + Examples: examples, + }) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].UID < items[j].UID + }) + return items +} diff --git a/selective-vpn-api/app/trafficmode/apply.go b/selective-vpn-api/app/trafficmode/apply.go new file mode 100644 index 0000000..3acf71e --- /dev/null +++ b/selective-vpn-api/app/trafficmode/apply.go @@ -0,0 +1,115 @@ +package trafficmode + +import ( + "fmt" + "strconv" + "strings" +) + +type RunCommandSimpleFunc func(name string, args ...string) (stdout string, stderr string, code int, err error) + +type OverrideConfig struct { + RoutesTableName string + RulePerKindLimit int + PrefManagedMin int + PrefManagedMax int + PrefDirectSubnetBase int + PrefDirectUIDBase int + PrefVPNSubnetBase int + PrefVPNUIDBase int +} + +type EffectiveOverrides struct { + VPNSubnets []string + VPNUIDs []string + DirectSubnets []string + DirectUIDs []string +} + +func RemoveRulesForTable(cfg OverrideConfig, run RunCommandSimpleFunc) { + if run == nil { + return + } + out, _, _, _ := run("ip", "rule", "show") + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + pref := strings.TrimSuffix(fields[0], ":") + if pref == "" { + continue + } + prefNum, _ := strconv.Atoi(pref) + low := strings.ToLower(line) + managed := prefNum >= cfg.PrefManagedMin && prefNum <= cfg.PrefManagedMax + legacy := strings.Contains(low, "lookup "+cfg.RoutesTableName) + if !managed && !legacy { + continue + } + _, _, _, _ = run("ip", "rule", "del", "pref", pref) + } +} + +func ApplyRule(pref int, run RunCommandSimpleFunc, args ...string) error { + if run == nil { + return fmt.Errorf("run command func is nil") + } + if pref <= 0 { + return fmt.Errorf("invalid pref: %d", pref) + } + cmd := []string{"rule", "add"} + cmd = append(cmd, args...) + cmd = append(cmd, "pref", PrefStr(pref)) + _, _, code, err := run("ip", cmd...) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("ip %s exited with %d", strings.Join(cmd, " "), code) + } + return err + } + return nil +} + +func ApplyOverrides(cfg OverrideConfig, e EffectiveOverrides, applyRule func(pref int, args ...string) error) (int, error) { + applied := 0 + if applyRule == nil { + return 0, fmt.Errorf("applyRule callback is nil") + } + if len(e.DirectSubnets) > cfg.RulePerKindLimit || + len(e.DirectUIDs) > cfg.RulePerKindLimit || + len(e.VPNSubnets) > cfg.RulePerKindLimit || + len(e.VPNUIDs) > cfg.RulePerKindLimit { + return 0, fmt.Errorf("override list too large (max %d entries per kind)", cfg.RulePerKindLimit) + } + + for i, cidr := range e.DirectSubnets { + if err := applyRule(cfg.PrefDirectSubnetBase+i, "from", cidr, "lookup", "main"); err != nil { + return applied, err + } + applied++ + } + for i, uidr := range e.DirectUIDs { + if err := applyRule(cfg.PrefDirectUIDBase+i, "uidrange", uidr, "lookup", "main"); err != nil { + return applied, err + } + applied++ + } + for i, cidr := range e.VPNSubnets { + if err := applyRule(cfg.PrefVPNSubnetBase+i, "from", cidr, "lookup", cfg.RoutesTableName); err != nil { + return applied, err + } + applied++ + } + for i, uidr := range e.VPNUIDs { + if err := applyRule(cfg.PrefVPNUIDBase+i, "uidrange", uidr, "lookup", cfg.RoutesTableName); err != nil { + return applied, err + } + applied++ + } + return applied, nil +} diff --git a/selective-vpn-api/app/trafficmode/autolocal.go b/selective-vpn-api/app/trafficmode/autolocal.go new file mode 100644 index 0000000..23ea72f --- /dev/null +++ b/selective-vpn-api/app/trafficmode/autolocal.go @@ -0,0 +1,128 @@ +package trafficmode + +import ( + "net/netip" + "sort" + "strings" +) + +var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10") + +type AutoLocalRoute struct { + Dst string + Dev string +} + +func ParseAutoBypassRoutes(mainRoutes string, vpnIface string, isVPNLikeIface func(string) bool) []AutoLocalRoute { + vpnIface = strings.TrimSpace(vpnIface) + seen := map[string]struct{}{} + routes := make([]AutoLocalRoute, 0, 8) + + add := func(dst, dev string) { + dst = strings.TrimSpace(dst) + dev = strings.TrimSpace(dev) + if dst == "" || dev == "" { + return + } + key := dst + "|" + dev + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + routes = append(routes, AutoLocalRoute{Dst: dst, Dev: dev}) + } + + for _, raw := range strings.Split(mainRoutes, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + if RouteLineIsLinkDown(line) { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + dst := strings.TrimSpace(fields[0]) + if dst == "" || dst == "default" { + continue + } + dev := ParseRouteDevice(fields) + if dev == "" || dev == "lo" { + continue + } + if vpnIface != "" && dev == vpnIface { + continue + } + if isVPNLikeIface != nil && isVPNLikeIface(dev) { + continue + } + + isScopeLink := strings.Contains(" "+line+" ", " scope link ") + if isScopeLink || IsContainerIface(dev) || IsAutoBypassDestination(dst) { + add(dst, dev) + } + } + + sort.Slice(routes, func(i, j int) bool { + if routes[i].Dev == routes[j].Dev { + return routes[i].Dst < routes[j].Dst + } + return routes[i].Dev < routes[j].Dev + }) + return routes +} + +func ParseRouteDevice(fields []string) string { + for i := 0; i+1 < len(fields); i++ { + if fields[i] == "dev" { + return strings.TrimSpace(fields[i+1]) + } + } + return "" +} + +func IsContainerIface(iface string) bool { + l := strings.ToLower(strings.TrimSpace(iface)) + return strings.HasPrefix(l, "docker") || + strings.HasPrefix(l, "br-") || + strings.HasPrefix(l, "veth") || + strings.HasPrefix(l, "svh") || + strings.HasPrefix(l, "svn") || + strings.HasPrefix(l, "cni") +} + +func RouteLineIsLinkDown(line string) bool { + l := " " + strings.ToLower(strings.TrimSpace(line)) + " " + return strings.Contains(l, " linkdown ") +} + +func IsPrivateLikeAddr(a netip.Addr) bool { + if !a.Is4() { + return false + } + if a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() { + return true + } + return cgnatPrefix.Contains(a) +} + +func IsAutoBypassDestination(dst string) bool { + dst = strings.TrimSpace(dst) + if dst == "" || dst == "default" { + return false + } + if strings.Contains(dst, "/") { + pfx, err := netip.ParsePrefix(dst) + if err != nil { + return false + } + return IsPrivateLikeAddr(pfx.Addr()) + } + addr, err := netip.ParseAddr(dst) + if err != nil { + return false + } + return IsPrivateLikeAddr(addr) +} diff --git a/selective-vpn-api/app/trafficmode/cgroup.go b/selective-vpn-api/app/trafficmode/cgroup.go new file mode 100644 index 0000000..2065706 --- /dev/null +++ b/selective-vpn-api/app/trafficmode/cgroup.go @@ -0,0 +1,163 @@ +package trafficmode + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +func CgroupCandidates(entry string, cgroupRootPath string) []string { + v := strings.TrimSpace(entry) + if v == "" { + return nil + } + vc := filepath.Clean(v) + vals := []string{} + if filepath.IsAbs(vc) { + if strings.HasPrefix(vc, cgroupRootPath) { + vals = append(vals, vc) + } else { + vals = append(vals, filepath.Join(cgroupRootPath, strings.TrimPrefix(vc, "/"))) + } + } else { + vals = append(vals, + filepath.Join(cgroupRootPath, strings.TrimPrefix(vc, "/")), + filepath.Join(cgroupRootPath, "system.slice", strings.TrimPrefix(vc, "/")), + filepath.Join(cgroupRootPath, "user.slice", strings.TrimPrefix(vc, "/")), + ) + } + seen := map[string]struct{}{} + out := make([]string, 0, len(vals)) + for _, p := range vals { + cp := filepath.Clean(p) + if cp == "." || cp == "" { + continue + } + if _, ok := seen[cp]; ok { + continue + } + seen[cp] = struct{}{} + out = append(out, cp) + } + return out +} + +func ResolveCgroupPath(entry string, cgroupRootPath string) (string, string) { + for _, cand := range CgroupCandidates(entry, cgroupRootPath) { + fi, err := os.Stat(cand) + if err != nil || !fi.IsDir() { + continue + } + return cand, "" + } + return "", "cgroup not found: " + strings.TrimSpace(entry) +} + +func CollectPIDsFromCgroup(root string) (map[int]struct{}, string) { + const ( + maxDirs = 5000 + maxPIDs = 50000 + ) + + pids := map[int]struct{}{} + dirs := 0 + warn := "" + + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d == nil || !d.IsDir() { + return nil + } + dirs++ + if dirs > maxDirs { + warn = "cgroup scan truncated by directory limit" + return filepath.SkipDir + } + data, err := os.ReadFile(filepath.Join(path, "cgroup.procs")) + if err != nil { + return nil + } + for _, ln := range strings.Split(string(data), "\n") { + ln = strings.TrimSpace(ln) + if ln == "" { + continue + } + pid, err := strconv.Atoi(ln) + if err != nil || pid <= 0 { + continue + } + pids[pid] = struct{}{} + if len(pids) > maxPIDs { + warn = "cgroup scan truncated by pid limit" + return filepath.SkipDir + } + } + return nil + }) + return pids, warn +} + +func UIDRangeForPID(pid int) (string, bool) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + return "", false + } + for _, ln := range strings.Split(string(data), "\n") { + ln = strings.TrimSpace(ln) + if !strings.HasPrefix(ln, "Uid:") { + continue + } + fields := strings.Fields(ln) + if len(fields) < 2 { + return "", false + } + v, ok := NormalizeUIDToken(fields[1]) + return v, ok + } + return "", false +} + +func ResolveCgroupUIDRanges(entries []string, cgroupRootPath string) ([]string, string) { + var uids []string + var warnings []string + + for _, entry := range NormalizeCgroupList(entries) { + root, warn := ResolveCgroupPath(entry, cgroupRootPath) + if root == "" { + if warn != "" { + warnings = append(warnings, warn) + } + continue + } + pids, scanWarn := CollectPIDsFromCgroup(root) + if scanWarn != "" { + warnings = append(warnings, scanWarn) + } + if len(pids) == 0 { + warnings = append(warnings, "cgroup has no processes: "+entry) + continue + } + for pid := range pids { + uidRange, ok := UIDRangeForPID(pid) + if !ok || uidRange == "" { + continue + } + uids = append(uids, uidRange) + } + } + seenWarn := map[string]struct{}{} + uniqWarn := make([]string, 0, len(warnings)) + for _, w := range warnings { + ww := strings.TrimSpace(w) + if ww == "" { + continue + } + if _, ok := seenWarn[ww]; ok { + continue + } + seenWarn[ww] = struct{}{} + uniqWarn = append(uniqWarn, ww) + } + return NormalizeUIDList(uids), strings.Join(uniqWarn, "; ") +} diff --git a/selective-vpn-api/app/trafficmode/ingress.go b/selective-vpn-api/app/trafficmode/ingress.go new file mode 100644 index 0000000..100e145 --- /dev/null +++ b/selective-vpn-api/app/trafficmode/ingress.go @@ -0,0 +1,134 @@ +package trafficmode + +import ( + "fmt" + "strings" + "time" +) + +type RunCommandTimeoutFunc func(timeout time.Duration, name string, args ...string) (stdout string, stderr string, code int, err error) + +type IngressBypassConfig struct { + TableName string + PreroutingChain string + OutputChain string + MarkIngress string + CaptureComment string + RestoreComment string +} + +func NftObjectMissing(stdout, stderr string) bool { + text := strings.ToLower(strings.TrimSpace(stdout + " " + stderr)) + return strings.Contains(text, "no such file") || strings.Contains(text, "not found") +} + +func EnsureIngressReplyBypassChains(cfg IngressBypassConfig, run RunCommandTimeoutFunc) { + if run == nil { + return + } + _, _, _, _ = run(5*time.Second, "nft", "add", "table", "inet", cfg.TableName) + _, _, _, _ = run( + 5*time.Second, + "nft", "add", "chain", "inet", cfg.TableName, cfg.PreroutingChain, + "{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}", + ) + _, _, _, _ = run( + 5*time.Second, + "nft", "add", "chain", "inet", cfg.TableName, cfg.OutputChain, + "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}", + ) +} + +func FlushIngressReplyBypassChains(cfg IngressBypassConfig, run RunCommandTimeoutFunc) error { + if run == nil { + return fmt.Errorf("run command func is nil") + } + for _, chain := range []string{cfg.PreroutingChain, cfg.OutputChain} { + out, errOut, code, err := run(5*time.Second, "nft", "flush", "chain", "inet", cfg.TableName, chain) + if err == nil && code == 0 { + continue + } + if NftObjectMissing(out, errOut) { + continue + } + if err == nil { + err = fmt.Errorf("nft flush chain exited with %d", code) + } + return fmt.Errorf("flush %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut)) + } + return nil +} + +func EnableIngressReplyBypass(cfg IngressBypassConfig, vpnIface string, run RunCommandTimeoutFunc) error { + if run == nil { + return fmt.Errorf("run command func is nil") + } + vpnIface = strings.TrimSpace(vpnIface) + if vpnIface == "" { + return fmt.Errorf("empty vpn iface for ingress bypass") + } + + EnsureIngressReplyBypassChains(cfg, run) + if err := FlushIngressReplyBypassChains(cfg, run); err != nil { + return err + } + + addRule := func(chain string, args ...string) error { + out, errOut, code, err := run(5*time.Second, "nft", append([]string{"add", "rule", "inet", cfg.TableName, chain}, args...)...) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("nft add rule exited with %d", code) + } + return fmt.Errorf("nft add rule %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut)) + } + return nil + } + + if err := addRule( + cfg.PreroutingChain, + "iifname", "!=", "lo", + "iifname", "!=", vpnIface, + "fib", "daddr", "type", "local", + "ct", "state", "new", + "ct", "mark", "set", cfg.MarkIngress, + "comment", cfg.CaptureComment, + ); err != nil { + return err + } + if err := addRule( + cfg.PreroutingChain, + "ct", "mark", cfg.MarkIngress, + "meta", "mark", "set", cfg.MarkIngress, + "comment", cfg.RestoreComment, + ); err != nil { + return err + } + if err := addRule( + cfg.OutputChain, + "ct", "mark", cfg.MarkIngress, + "meta", "mark", "set", cfg.MarkIngress, + "comment", cfg.RestoreComment, + ); err != nil { + return err + } + return nil +} + +func DisableIngressReplyBypass(cfg IngressBypassConfig, run RunCommandTimeoutFunc) error { + EnsureIngressReplyBypassChains(cfg, run) + return FlushIngressReplyBypassChains(cfg, run) +} + +func IngressReplyNftActive(cfg IngressBypassConfig, run RunCommandTimeoutFunc) bool { + if run == nil { + return false + } + outPre, _, codePre, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.TableName, cfg.PreroutingChain) + outOut, _, codeOut, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.TableName, cfg.OutputChain) + if codePre != 0 || codeOut != 0 { + return false + } + return strings.Contains(outPre, cfg.CaptureComment) && + strings.Contains(outPre, cfg.RestoreComment) && + strings.Contains(outOut, cfg.RestoreComment) +} diff --git a/selective-vpn-api/app/trafficmode/interfaces.go b/selective-vpn-api/app/trafficmode/interfaces.go new file mode 100644 index 0000000..38793cb --- /dev/null +++ b/selective-vpn-api/app/trafficmode/interfaces.go @@ -0,0 +1,156 @@ +package trafficmode + +import ( + "sort" + "strings" +) + +func NormalizePreferredIface(raw string) string { + v := strings.TrimSpace(raw) + l := strings.ToLower(v) + if l == "" || l == "auto" || l == "-" || l == "default" { + return "" + } + return v +} + +func IfaceExists(iface string, run RunCommandSimpleFunc) bool { + iface = strings.TrimSpace(iface) + if iface == "" || run == nil { + return false + } + _, _, code, _ := run("ip", "link", "show", iface) + return code == 0 +} + +func ParseUpIfaces(ipLinkOutput string) []string { + seen := map[string]struct{}{} + outIfaces := make([]string, 0, 8) + for _, line := range strings.Split(ipLinkOutput, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, ":", 3) + if len(parts) < 3 { + continue + } + name := strings.TrimSpace(parts[1]) + name = strings.SplitN(name, "@", 2)[0] + name = strings.TrimSpace(name) + if name == "" || name == "lo" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + outIfaces = append(outIfaces, name) + } + return outIfaces +} + +func ListUpIfaces(run RunCommandSimpleFunc) []string { + if run == nil { + return nil + } + out, _, code, _ := run("ip", "-o", "link", "show", "up") + if code != 0 { + return nil + } + return ParseUpIfaces(out) +} + +func IsVPNLikeIface(iface string) bool { + l := strings.ToLower(strings.TrimSpace(iface)) + return strings.HasPrefix(l, "tun") || + strings.HasPrefix(l, "wg") || + strings.HasPrefix(l, "ppp") || + strings.HasPrefix(l, "tap") || + strings.HasPrefix(l, "utun") || + strings.HasPrefix(l, "vpn") +} + +func ListSelectableIfaces(up []string, preferred string) []string { + seen := map[string]struct{}{} + var vpnLike []string + var other []string + + add := func(dst *[]string, iface string) { + iface = strings.TrimSpace(iface) + if iface == "" { + return + } + if _, ok := seen[iface]; ok { + return + } + seen[iface] = struct{}{} + *dst = append(*dst, iface) + } + + for _, iface := range up { + if IsVPNLikeIface(iface) { + add(&vpnLike, iface) + } + } + for _, iface := range up { + if !IsVPNLikeIface(iface) { + add(&other, iface) + } + } + sort.Strings(vpnLike) + sort.Strings(other) + + selected := make([]string, 0, len(vpnLike)+len(other)+1) + selected = append(selected, vpnLike...) + selected = append(selected, other...) + + pref := NormalizePreferredIface(preferred) + if pref != "" { + if _, ok := seen[pref]; !ok { + selected = append([]string{pref}, selected...) + } + } + return selected +} + +func ResolveTrafficIface( + preferred string, + ifaceExists func(string) bool, + statusIface func() string, + listUp func() []string, +) (string, string) { + exists := ifaceExists + if exists == nil { + exists = func(string) bool { return false } + } + status := statusIface + if status == nil { + status = func() string { return "" } + } + upIfaces := listUp + if upIfaces == nil { + upIfaces = func() []string { return nil } + } + + pref := NormalizePreferredIface(preferred) + if pref != "" && exists(pref) { + return pref, "preferred" + } + + statusResolved := strings.TrimSpace(status()) + if statusResolved != "" && exists(statusResolved) { + return statusResolved, "status" + } + + for _, iface := range upIfaces() { + if IsVPNLikeIface(iface) { + return iface, "auto-vpn-like" + } + } + + if pref != "" { + return "", "preferred-not-found" + } + return "", "iface-not-found" +} diff --git a/selective-vpn-api/app/trafficmode/normalize.go b/selective-vpn-api/app/trafficmode/normalize.go new file mode 100644 index 0000000..cb6c5c1 --- /dev/null +++ b/selective-vpn-api/app/trafficmode/normalize.go @@ -0,0 +1,122 @@ +package trafficmode + +import ( + "fmt" + "net/netip" + "sort" + "strconv" + "strings" +) + +func TokenizeList(raw []string) []string { + repl := strings.NewReplacer(",", " ", ";", " ", "\n", " ", "\t", " ") + out := make([]string, 0, len(raw)) + for _, line := range raw { + for _, tok := range strings.Fields(repl.Replace(line)) { + val := strings.TrimSpace(tok) + if val != "" { + out = append(out, val) + } + } + } + return out +} + +func NormalizeSubnetList(raw []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(raw)) + for _, tok := range TokenizeList(raw) { + var cidr string + if strings.Contains(tok, "/") { + pfx, err := netip.ParsePrefix(tok) + if err != nil || !pfx.Addr().Is4() { + continue + } + cidr = pfx.Masked().String() + } else { + ip, err := netip.ParseAddr(tok) + if err != nil || !ip.Is4() { + continue + } + cidr = netip.PrefixFrom(ip, 32).String() + } + if _, ok := seen[cidr]; ok { + continue + } + seen[cidr] = struct{}{} + out = append(out, cidr) + } + sort.Strings(out) + return out +} + +func NormalizeUIDToken(tok string) (string, bool) { + t := strings.TrimSpace(tok) + if t == "" { + return "", false + } + parseOne := func(s string) (uint64, bool) { + n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 32) + if err != nil { + return 0, false + } + return n, true + } + if strings.Contains(t, "-") { + parts := strings.SplitN(t, "-", 2) + if len(parts) != 2 { + return "", false + } + start, okA := parseOne(parts[0]) + end, okB := parseOne(parts[1]) + if !okA || !okB || end < start { + return "", false + } + return fmt.Sprintf("%d-%d", start, end), true + } + n, ok := parseOne(t) + if !ok { + return "", false + } + return fmt.Sprintf("%d-%d", n, n), true +} + +func NormalizeUIDList(raw []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(raw)) + for _, tok := range TokenizeList(raw) { + v, ok := NormalizeUIDToken(tok) + if !ok { + continue + } + if _, exists := seen[v]; exists { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} + +func NormalizeCgroupList(raw []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(raw)) + for _, tok := range TokenizeList(raw) { + v := strings.TrimSpace(tok) + if v == "" { + continue + } + v = strings.TrimSuffix(v, "/") + if v == "" { + v = "/" + } + if _, exists := seen[v]; exists { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} diff --git a/selective-vpn-api/app/trafficmode/rules.go b/selective-vpn-api/app/trafficmode/rules.go new file mode 100644 index 0000000..f09a132 --- /dev/null +++ b/selective-vpn-api/app/trafficmode/rules.go @@ -0,0 +1,116 @@ +package trafficmode + +import ( + "fmt" + "strconv" + "strings" +) + +type RunCommandFunc func(name string, args ...string) (stdout string, stderr string, code int, err error) + +type RulesConfig struct { + RoutesTableName string + Mark string + MarkIngress string + PrefSelective int + PrefFull int + PrefMarkIngressReply int + ModeFull string + ModeSelective string + ModeDirect string +} + +type RulesState struct { + Mark bool + Full bool + IngressReply bool +} + +func PrefStr(v int) string { + return strconv.Itoa(v) +} + +func ReadRules(cfg RulesConfig, run RunCommandFunc) RulesState { + if run == nil { + return RulesState{} + } + out, _, _, _ := run("ip", "rule", "show") + var st RulesState + for _, line := range strings.Split(out, "\n") { + l := strings.ToLower(strings.TrimSpace(line)) + if l == "" { + continue + } + fields := strings.Fields(l) + if len(fields) == 0 { + continue + } + prefRaw := strings.TrimSuffix(fields[0], ":") + pref, _ := strconv.Atoi(prefRaw) + switch pref { + case cfg.PrefSelective: + if strings.Contains(l, "lookup "+cfg.RoutesTableName) { + st.Mark = true + } + case cfg.PrefFull: + if strings.Contains(l, "lookup "+cfg.RoutesTableName) { + st.Full = true + } + case cfg.PrefMarkIngressReply: + if strings.Contains(l, "fwmark "+strings.ToLower(cfg.MarkIngress)) && strings.Contains(l, "lookup main") { + st.IngressReply = true + } + } + } + return st +} + +func DetectAppliedMode(cfg RulesConfig, rules RulesState) string { + if rules.Full { + return cfg.ModeFull + } + if rules.Mark { + return cfg.ModeSelective + } + return cfg.ModeDirect +} + +func ProbeMode(cfg RulesConfig, mode string, iface string, run RunCommandFunc) (bool, string) { + if run == nil { + return false, "run command func is nil" + } + mode = strings.ToLower(strings.TrimSpace(mode)) + iface = strings.TrimSpace(iface) + + args := []string{"-4", "route", "get", "1.1.1.1"} + if mode == strings.ToLower(cfg.ModeSelective) { + args = append(args, "mark", cfg.Mark) + } + + out, _, code, err := run("ip", args...) + if err != nil || code != 0 { + if err == nil { + err = fmt.Errorf("ip route get exited with %d", code) + } + return false, err.Error() + } + + text := strings.ToLower(out) + switch mode { + case strings.ToLower(cfg.ModeDirect): + if strings.Contains(text, " table "+strings.ToLower(cfg.RoutesTableName)) { + return false, "route probe still uses agvpn table" + } + return true, "route probe direct path ok" + case strings.ToLower(cfg.ModeFull), strings.ToLower(cfg.ModeSelective): + if iface == "" { + return false, "route probe has empty iface" + } + if !strings.Contains(text, "dev "+strings.ToLower(iface)) { + return false, fmt.Sprintf("route probe mismatch: expected dev %s", iface) + } + return true, "route probe vpn path ok" + default: + return false, "route probe unknown mode" + } +} diff --git a/selective-vpn-api/app/traffic_appkey.go b/selective-vpn-api/app/trafficprofiles/appkey.go similarity index 57% rename from selective-vpn-api/app/traffic_appkey.go rename to selective-vpn-api/app/trafficprofiles/appkey.go index 3779790..c3e1dd8 100644 --- a/selective-vpn-api/app/traffic_appkey.go +++ b/selective-vpn-api/app/trafficprofiles/appkey.go @@ -1,38 +1,15 @@ -package app +package trafficprofiles import ( "path/filepath" "strings" ) -// --------------------------------------------------------------------- -// traffic app key normalization -// --------------------------------------------------------------------- -// -// EN: app_key is used as a stable per-app identity for: -// EN: - deduplicating runtime marks (avoid unbounded growth) -// EN: - matching profiles <-> runtime marks in UI -// EN: -// EN: Raw command token[0] is not stable across launch methods: -// EN: - "/usr/bin/google-chrome-stable" vs "google-chrome-stable" -// EN: - "flatpak run org.mozilla.firefox" (token[0]="flatpak") -// EN: -// EN: We normalize app_key into a canonical form. -// RU: app_key используется как стабильный идентификатор приложения для: -// RU: - дедупликации runtime marks (не плодить бесконечно) -// RU: - сопоставления profiles <-> runtime marks в UI -// RU: -// RU: token[0] команды нестабилен для разных способов запуска: -// RU: - "/usr/bin/google-chrome-stable" vs "google-chrome-stable" -// RU: - "flatpak run org.mozilla.firefox" (token[0]="flatpak") -// RU: -// RU: Нормализуем app_key в канонический вид. - -func canonicalizeAppKey(appKey string, command string) string { +func CanonicalizeAppKey(appKey string, command string) string { key := strings.TrimSpace(appKey) cmd := strings.TrimSpace(command) - fields := splitCommandTokens(cmd) + fields := SplitCommandTokens(cmd) if len(fields) == 0 && key != "" { fields = []string{key} } @@ -46,9 +23,7 @@ func canonicalizeAppKey(appKey string, command string) string { return "" } - // Normalize common wrappers into stable identifiers. base := strings.ToLower(filepath.Base(primary)) - // Build a cleaned field list for wrapper parsing. clean := make([]string, 0, len(fields)) for _, f := range fields { f = stripOuterQuotes(strings.TrimSpace(f)) @@ -70,7 +45,6 @@ func canonicalizeAppKey(appKey string, command string) string { } return "snap" case "gtk-launch": - // gtk-launch if len(clean) >= 2 { id := strings.TrimSpace(clean[1]) if id != "" && !strings.HasPrefix(id, "-") { @@ -78,9 +52,6 @@ func canonicalizeAppKey(appKey string, command string) string { } } case "env": - // env VAR=1 /usr/bin/app ... - // EN: Skip env flags and VAR=VAL assignments and re-canonicalize for the real command. - // RU: Пропускаем флаги env и VAR=VAL и канонизируем по реальной команде. for i := 1; i < len(clean); i++ { tok := strings.TrimSpace(clean[i]) if tok == "" { @@ -89,16 +60,14 @@ func canonicalizeAppKey(appKey string, command string) string { if strings.HasPrefix(tok, "-") { continue } - // VAR=VAL assignment if strings.Contains(tok, "=") { continue } - return canonicalizeAppKey(tok, strings.Join(clean[i:], " ")) + return CanonicalizeAppKey(tok, strings.Join(clean[i:], " ")) } return "env" } - // If it looks like a path, canonicalize to basename. if strings.Contains(primary, "/") { b := filepath.Base(primary) if b != "" && b != "." && b != "/" { @@ -119,9 +88,6 @@ func stripOuterQuotes(s string) string { return in } -// extractRunTarget finds the first non-flag token after "run". -// Example: flatpak run --branch=stable org.mozilla.firefox => org.mozilla.firefox -// Example: snap run chromium => chromium func extractRunTarget(fields []string) string { if len(fields) == 0 { return "" @@ -152,10 +118,7 @@ func extractRunTarget(fields []string) string { return "" } -// splitCommandTokens performs lightweight shell-style tokenization. -// It supports single/double quotes and backslash escaping which is enough -// for canonical app key extraction. -func splitCommandTokens(raw string) []string { +func SplitCommandTokens(raw string) []string { s := strings.TrimSpace(raw) if s == "" { return nil diff --git a/selective-vpn-api/app/trafficprofiles/store.go b/selective-vpn-api/app/trafficprofiles/store.go new file mode 100644 index 0000000..a6fd71b --- /dev/null +++ b/selective-vpn-api/app/trafficprofiles/store.go @@ -0,0 +1,411 @@ +package trafficprofiles + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type Profile struct { + ID string + Name string + AppKey string + Command string + Target string + TTLSec int + VPNProfile string + CreatedAt string + UpdatedAt string +} + +type UpsertRequest struct { + ID string + Name string + AppKey string + Command string + Target string + TTLSec int + VPNProfile string +} + +type Deps struct { + CanonicalizeAppKey func(appKey, command string) string + SanitizeID func(string) string + DefaultTTLSec int +} + +type Store struct { + mu sync.Mutex + + statePath string + canonicalizeAppKey func(appKey, command string) string + sanitizeID func(string) string + defaultTTLSec int +} + +type state struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at"` + Profiles []Profile `json:"profiles,omitempty"` +} + +func NewStore(statePath string, deps Deps) *Store { + canon := deps.CanonicalizeAppKey + if canon == nil { + canon = func(appKey, _ string) string { return strings.TrimSpace(appKey) } + } + sanitize := deps.SanitizeID + if sanitize == nil { + sanitize = defaultSanitizeID + } + return &Store{ + statePath: strings.TrimSpace(statePath), + canonicalizeAppKey: canon, + sanitizeID: sanitize, + defaultTTLSec: deps.DefaultTTLSec, + } +} + +func (s *Store) List() []Profile { + s.mu.Lock() + defer s.mu.Unlock() + + st := s.loadStateLocked() + out := append([]Profile(nil), st.Profiles...) + sort.Slice(out, func(i, j int) bool { + return out[i].UpdatedAt > out[j].UpdatedAt + }) + return out +} + +func (s *Store) Upsert(req UpsertRequest) (Profile, error) { + s.mu.Lock() + defer s.mu.Unlock() + + st := s.loadStateLocked() + + target := strings.ToLower(strings.TrimSpace(req.Target)) + if target == "" { + target = "vpn" + } + if target != "vpn" && target != "direct" { + return Profile{}, fmt.Errorf("target must be vpn|direct") + } + + cmd := strings.TrimSpace(req.Command) + if cmd == "" { + return Profile{}, fmt.Errorf("missing command") + } + + appKey := strings.TrimSpace(req.AppKey) + if appKey == "" { + fields := strings.Fields(cmd) + if len(fields) > 0 { + appKey = strings.TrimSpace(fields[0]) + } + } + appKey = s.canonicalizeAppKey(appKey, cmd) + if appKey == "" { + return Profile{}, fmt.Errorf("cannot infer app_key") + } + + id := strings.TrimSpace(req.ID) + if id == "" { + for _, p := range st.Profiles { + if strings.TrimSpace(p.AppKey) == appKey && strings.ToLower(strings.TrimSpace(p.Target)) == target { + id = strings.TrimSpace(p.ID) + break + } + } + } + if id == "" { + id = deriveProfileID(appKey, target, st.Profiles, s.sanitizeID) + } + if id == "" { + return Profile{}, fmt.Errorf("cannot derive profile id") + } + + name := strings.TrimSpace(req.Name) + if name == "" { + name = filepath.Base(appKey) + if name == "" || name == "/" || name == "." { + name = id + } + } + + ttl := req.TTLSec + if ttl <= 0 { + ttl = s.defaultTTLSec + } + + vpnProfile := strings.TrimSpace(req.VPNProfile) + now := time.Now().UTC().Format(time.RFC3339) + + prof := Profile{ + ID: id, + Name: name, + AppKey: appKey, + Command: cmd, + Target: target, + TTLSec: ttl, + VPNProfile: vpnProfile, + UpdatedAt: now, + } + + updated := false + for i := range st.Profiles { + if strings.TrimSpace(st.Profiles[i].ID) != id { + continue + } + prof.CreatedAt = strings.TrimSpace(st.Profiles[i].CreatedAt) + if prof.CreatedAt == "" { + prof.CreatedAt = now + } + st.Profiles[i] = prof + updated = true + break + } + if !updated { + prof.CreatedAt = now + st.Profiles = append(st.Profiles, prof) + } + + if err := s.saveStateLocked(st); err != nil { + return Profile{}, err + } + return prof, nil +} + +func (s *Store) Delete(id string) (bool, string) { + s.mu.Lock() + defer s.mu.Unlock() + + id = strings.TrimSpace(id) + if id == "" { + return false, "empty id" + } + + st := s.loadStateLocked() + kept := st.Profiles[:0] + found := false + for _, p := range st.Profiles { + if strings.TrimSpace(p.ID) == id { + found = true + continue + } + kept = append(kept, p) + } + st.Profiles = kept + + if !found { + return true, "not found" + } + if err := s.saveStateLocked(st); err != nil { + return false, err.Error() + } + return true, "deleted" +} + +func Dedupe(in []Profile, canonicalize func(appKey, command string) string) ([]Profile, bool) { + if canonicalize == nil { + canonicalize = func(appKey, _ string) string { return strings.TrimSpace(appKey) } + } + if len(in) <= 1 { + return in, false + } + + out := make([]Profile, 0, len(in)) + byID := map[string]int{} + byAppTarget := map[string]int{} + changed := false + + for _, raw := range in { + p := raw + p.ID = strings.TrimSpace(p.ID) + p.Target = strings.ToLower(strings.TrimSpace(p.Target)) + p.AppKey = canonicalize(p.AppKey, p.Command) + + if p.ID == "" { + changed = true + continue + } + if p.Target != "vpn" && p.Target != "direct" { + p.Target = "vpn" + changed = true + } + + if idx, ok := byID[p.ID]; ok { + if preferProfile(p, out[idx]) { + out[idx] = p + } + changed = true + continue + } + + if p.AppKey != "" { + key := p.Target + "|" + p.AppKey + if idx, ok := byAppTarget[key]; ok { + if preferProfile(p, out[idx]) { + byID[p.ID] = idx + out[idx] = p + } + changed = true + continue + } + byAppTarget[key] = len(out) + } + + byID[p.ID] = len(out) + out = append(out, p) + } + return out, changed +} + +func (s *Store) loadStateLocked() state { + st := state{Version: 1} + if strings.TrimSpace(s.statePath) == "" { + return st + } + data, err := os.ReadFile(s.statePath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return state{Version: 1} + } + if st.Version == 0 { + st.Version = 1 + } + if st.Profiles == nil { + st.Profiles = nil + } + + changed := false + for i := range st.Profiles { + canon := s.canonicalizeAppKey(st.Profiles[i].AppKey, st.Profiles[i].Command) + if canon != "" && strings.TrimSpace(st.Profiles[i].AppKey) != canon { + st.Profiles[i].AppKey = canon + changed = true + } + st.Profiles[i].Target = strings.ToLower(strings.TrimSpace(st.Profiles[i].Target)) + } + if deduped, dedupChanged := Dedupe(st.Profiles, s.canonicalizeAppKey); dedupChanged { + st.Profiles = deduped + changed = true + } + if changed { + _ = s.saveStateLocked(st) + } + return st +} + +func (s *Store) saveStateLocked(st state) error { + st.Version = 1 + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if strings.TrimSpace(s.statePath) == "" { + return fmt.Errorf("state path is empty") + } + if err := os.MkdirAll(filepath.Dir(s.statePath), 0o755); err != nil { + return err + } + tmp := s.statePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.statePath) +} + +func deriveProfileID(appKey string, target string, existing []Profile, sanitize func(string) string) string { + if sanitize == nil { + sanitize = defaultSanitizeID + } + base := filepath.Base(strings.TrimSpace(appKey)) + if base == "" || base == "/" || base == "." { + base = "app" + } + base = sanitize(base) + if base == "" { + base = "app" + } + + idBase := base + "-" + strings.ToLower(strings.TrimSpace(target)) + id := idBase + + used := map[string]struct{}{} + for _, p := range existing { + used[strings.TrimSpace(p.ID)] = struct{}{} + } + if _, ok := used[id]; !ok { + return id + } + for i := 2; i < 1000; i++ { + cand := fmt.Sprintf("%s-%d", idBase, i) + if _, ok := used[cand]; !ok { + return cand + } + } + return "" +} + +func preferProfile(cand, cur Profile) bool { + cu := strings.TrimSpace(cand.UpdatedAt) + ou := strings.TrimSpace(cur.UpdatedAt) + if cu != ou { + if cu == "" { + return false + } + if ou == "" { + return true + } + return cu > ou + } + + cc := strings.TrimSpace(cand.CreatedAt) + oc := strings.TrimSpace(cur.CreatedAt) + if cc != oc { + if cc == "" { + return false + } + if oc == "" { + return true + } + return cc > oc + } + + if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" { + return true + } + return false +} + +func defaultSanitizeID(s string) string { + in := strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + b.Grow(len(in)) + lastDash := false + for i := 0; i < len(in); i++ { + ch := in[i] + isAZ := ch >= 'a' && ch <= 'z' + is09 := ch >= '0' && ch <= '9' + if isAZ || is09 { + b.WriteByte(ch) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + return strings.Trim(b.String(), "-") +} diff --git a/selective-vpn-api/app/transport_backends.go b/selective-vpn-api/app/transport_backends.go new file mode 100644 index 0000000..30e00fd --- /dev/null +++ b/selective-vpn-api/app/transport_backends.go @@ -0,0 +1,73 @@ +package app + +import ( + "strings" + "time" +) + +const ( + transportBackendActionTimeout = 20 * time.Second + transportBackendHealthTimeout = 5 * time.Second + transportBackendProbeTimeout = 900 * time.Millisecond +) + +var transportSystemdUnitsDir = "/etc/systemd/system" + +type transportCommandRunner func(timeout time.Duration, name string, args ...string) (string, string, int, error) + +var transportRunCommand transportCommandRunner = runCommandTimeout + +type transportBackendActionResult struct { + OK bool + Code string + Message string + ExitCode int + Stdout string + Stderr string + Retryable bool +} + +type transportBackendHealthResult struct { + OK bool + Code string + Message string + Status TransportClientStatus + LatencyMS int + Retryable bool +} + +type transportBackendAdapter interface { + ID() string + Action(client TransportClient, action string) transportBackendActionResult + Health(client TransportClient) transportBackendHealthResult + Provision(client TransportClient) transportBackendActionResult + Cleanup(client TransportClient) transportBackendActionResult +} + +func selectTransportBackend(client TransportClient) transportBackendAdapter { + mode := transportRuntimeMode(client.Config) + switch mode { + case "exec": + // Continue with current runner-based backend selection. + case "embedded", "sidecar": + return transportUnsupportedRuntimeBackend{mode: mode} + default: + return transportUnsupportedRuntimeBackend{mode: mode} + } + + runner := strings.ToLower(strings.TrimSpace(transportConfigString(client.Config, "runner"))) + unit := strings.TrimSpace(transportConfigString(client.Config, "unit")) + switch runner { + case "systemd": + return transportSystemdBackend{} + case "mock": + return transportMockBackend{} + case "": + if unit != "" { + return transportSystemdBackend{} + } + return transportMockBackend{} + default: + return transportMockBackend{} + } +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd.go b/selective-vpn-api/app/transport_backends_adapter_systemd.go new file mode 100644 index 0000000..e399a5c --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd.go @@ -0,0 +1,77 @@ +package app + +import transportcfg "selective-vpn-api/app/transportcfg" + +type transportSystemdBackend struct{} + +type transportSystemdServiceTuning = transportcfg.SystemdServiceTuning + +type transportSystemdHardening = transportcfg.SystemdHardening + +func validSystemdUnitName(unit string) bool { + return transportcfg.ValidSystemdUnitName(unit) +} + +func transportSystemdUnitPath(unit string) string { + return transportcfg.SystemdUnitPath(transportSystemdUnitsDir, unit) +} + +func transportSystemdUnitOwnedByClient(path, clientID string) (bool, error) { + return transportcfg.SystemdUnitOwnedByClient(path, clientID) +} + +func writeTransportSystemdUnitFile(unit string, content string) error { + return transportcfg.WriteSystemdUnitFile(transportSystemdUnitsDir, unit, content) +} + +func renderTransportSystemdUnit( + client TransportClient, + unit string, + execStart string, + requiresSSH bool, + sshUnit string, + tuning transportSystemdServiceTuning, + hardening transportSystemdHardening, +) string { + return transportcfg.RenderSystemdUnit( + transportCfgClient(client), + stateDir, + unit, + execStart, + requiresSSH, + sshUnit, + transportcfg.SystemdServiceTuning(tuning), + transportcfg.SystemdHardening(hardening), + shellQuoteArg, + ) +} + +func renderTransportSSHOverlayUnit( + client TransportClient, + unit string, + execStart string, + tuning transportSystemdServiceTuning, + hardening transportSystemdHardening, +) string { + return transportcfg.RenderSSHOverlayUnit( + transportCfgClient(client), + stateDir, + unit, + execStart, + transportcfg.SystemdServiceTuning(tuning), + transportcfg.SystemdHardening(hardening), + shellQuoteArg, + ) +} + +func transportSystemdServiceTuningFromConfig(cfg map[string]any, prefix string) transportSystemdServiceTuning { + return transportcfg.SystemdServiceTuningFromConfig(cfg, prefix, transportConfigInt) +} + +func transportSystemdHardeningFromConfig(cfg map[string]any, prefix string) transportSystemdHardening { + return transportcfg.SystemdHardeningFromConfig(cfg, prefix) +} + +func transportConfigHasKey(cfg map[string]any, key string) bool { + return transportcfg.ConfigHasKey(cfg, key) +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd_action.go b/selective-vpn-api/app/transport_backends_adapter_systemd_action.go new file mode 100644 index 0000000..af49904 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd_action.go @@ -0,0 +1,73 @@ +package app + +import ( + "fmt" + "strings" +) + +func (transportSystemdBackend) ID() string { return "systemd" } + +func (transportSystemdBackend) Action(client TransportClient, action string) transportBackendActionResult { + action = strings.ToLower(strings.TrimSpace(action)) + if action != "start" && action != "stop" && action != "restart" { + return transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_ACTION_FAILED", + Message: "unsupported action: " + action, + ExitCode: -1, + } + } + units, errCode, errMsg := transportSystemdActionUnits(client, action) + if len(units) == 0 { + return transportBackendActionResult{ + OK: false, + Code: errCode, + Message: errMsg, + ExitCode: -1, + } + } + + aggOut := make([]string, 0, len(units)) + aggErr := make([]string, 0, len(units)) + + netnsEnabled := transportNetnsEnabled(client) + if res := transportSystemdRunPreActionHooks(client, action, units, netnsEnabled, &aggOut, &aggErr); res != nil { + return *res + } + if res := transportSystemdRunActionUnits(client, action, units, &aggOut, &aggErr); res != nil { + return *res + } + if netnsEnabled && action == "stop" && transportNetnsAutoCleanup(client) { + transportSystemdRunPostStopCleanup(client, action, &aggOut, &aggErr) + } + + msg := fmt.Sprintf("systemctl %s ok (%s)", action, strings.Join(units, ", ")) + if len(aggErr) > 0 { + msg += "; with warnings" + } + return transportBackendActionResult{ + OK: true, + ExitCode: 0, + Stdout: strings.Join(aggOut, "\n"), + Stderr: strings.Join(aggErr, "\n"), + Message: msg, + } +} + +func transportSystemdStopMissingUnitNoop(stdout, stderr string, exitCode int, err error) bool { + return transportSystemdUnitMissingError(stdout, stderr, exitCode, err) +} + +func transportSystemdUnitMissingError(stdout, stderr string, exitCode int, err error) bool { + if err == nil && exitCode == 0 { + return false + } + msg := strings.ToLower(strings.TrimSpace(stderr + "\n" + stdout)) + if msg == "" { + return false + } + return strings.Contains(msg, "not loaded") || + strings.Contains(msg, "not found") || + strings.Contains(msg, "could not be found") || + strings.Contains(msg, "unknown unit") +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd_action_exec.go b/selective-vpn-api/app/transport_backends_adapter_systemd_action_exec.go new file mode 100644 index 0000000..3a2f470 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd_action_exec.go @@ -0,0 +1,90 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +func transportSystemdRunActionUnits( + client TransportClient, + action string, + units []string, + aggOut *[]string, + aggErr *[]string, +) *transportBackendActionResult { + for _, unit := range units { + stdout, stderr, exitCode, err := transportRunCommand(transportBackendActionTimeout, "systemctl", action, unit) + if s := strings.TrimSpace(stdout); s != "" { + *aggOut = append(*aggOut, unit+": "+s) + } + if s := strings.TrimSpace(stderr); s != "" { + *aggErr = append(*aggErr, unit+": "+s) + } + if (action == "start" || action == "restart") && + client.Kind == TransportClientSingBox && + transportSystemdUnitMissingError(stdout, stderr, exitCode, err) { + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("systemctl %s unit missing, trying auto-provision: client=%s unit=%s", action, client.ID, unit), + 20*time.Second, + ) + provision := (transportSystemdBackend{}).Provision(client) + if s := strings.TrimSpace(provision.Stdout); s != "" { + *aggOut = append(*aggOut, "provision: "+s) + } + if s := strings.TrimSpace(provision.Stderr); s != "" { + *aggErr = append(*aggErr, "provision: "+s) + } + if !provision.OK { + msg := strings.TrimSpace(provision.Message) + if msg == "" { + msg = "auto-provision failed" + } + return &transportBackendActionResult{ + OK: false, + Code: provision.Code, + Message: msg, + ExitCode: provision.ExitCode, + Stdout: strings.Join(*aggOut, "\n"), + Stderr: strings.Join(*aggErr, "\n"), + Retryable: true, + } + } + stdout, stderr, exitCode, err = transportRunCommand(transportBackendActionTimeout, "systemctl", action, unit) + if s := strings.TrimSpace(stdout); s != "" { + *aggOut = append(*aggOut, unit+": "+s) + } + if s := strings.TrimSpace(stderr); s != "" { + *aggErr = append(*aggErr, unit+": "+s) + } + } + if action == "stop" && transportSystemdStopMissingUnitNoop(stdout, stderr, exitCode, err) { + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("systemctl stop noop: client=%s unit=%s reason=unit-missing", client.ID, unit), + 15*time.Second, + ) + continue + } + if err != nil || exitCode != 0 { + msg := strings.TrimSpace(stderr) + if msg == "" { + msg = strings.TrimSpace(stdout) + } + if msg == "" { + msg = fmt.Sprintf("systemctl %s %s failed", action, unit) + } + return &transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_ACTION_FAILED", + Message: msg, + ExitCode: exitCode, + Stdout: strings.Join(*aggOut, "\n"), + Stderr: strings.Join(*aggErr, "\n"), + Retryable: action != "stop", + } + } + } + return nil +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd_action_hooks.go b/selective-vpn-api/app/transport_backends_adapter_systemd_action_hooks.go new file mode 100644 index 0000000..8f6697d --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd_action_hooks.go @@ -0,0 +1,105 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +func transportSystemdRunPreActionHooks( + client TransportClient, + action string, + units []string, + netnsEnabled bool, + aggOut *[]string, + aggErr *[]string, +) *transportBackendActionResult { + if action == "start" || action == "restart" { + migOut, migErr := transportSystemdMaybeMigrateLegacySingBoxUnits(client, units) + *aggOut = append(*aggOut, migOut...) + *aggErr = append(*aggErr, migErr...) + } + + if netnsEnabled && (action == "start" || action == "restart") { + if msg, err := transportEnsureNetnsForClient(client); strings.TrimSpace(msg) != "" || err != nil { + if s := strings.TrimSpace(msg); s != "" { + *aggOut = append(*aggOut, "netns: "+s) + appendTraceLine("transport", fmt.Sprintf("netns setup: client=%s action=%s %s", client.ID, action, s)) + } + if err != nil { + *aggErr = append(*aggErr, "netns: "+err.Error()) + appendTraceLineRateLimited("transport", fmt.Sprintf("netns setup failed: client=%s action=%s err=%v", client.ID, action, err), 30*time.Second) + if transportNetnsStrict(client) { + return &transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_NETNS_SETUP_FAILED", + Message: err.Error(), + ExitCode: -1, + Stdout: strings.Join(*aggOut, "\n"), + Stderr: strings.Join(*aggErr, "\n"), + Retryable: true, + } + } + } + } + } + + if !netnsEnabled { + if msg, err := transportMaybeSyncBootstrapBypass(client, action); strings.TrimSpace(msg) != "" || err != nil { + if s := strings.TrimSpace(msg); s != "" { + *aggOut = append(*aggOut, "bootstrap: "+s) + appendTraceLine("transport", fmt.Sprintf("bootstrap bypass: client=%s action=%s %s", client.ID, action, s)) + } + if err != nil { + *aggErr = append(*aggErr, "bootstrap: "+err.Error()) + appendTraceLineRateLimited("transport", fmt.Sprintf("bootstrap bypass failed: client=%s action=%s err=%v", client.ID, action, err), 30*time.Second) + if (action == "start" || action == "restart") && transportBootstrapBypassStrict(client) { + return &transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_BOOTSTRAP_BYPASS_FAILED", + Message: err.Error(), + ExitCode: -1, + Stdout: strings.Join(*aggOut, "\n"), + Stderr: strings.Join(*aggErr, "\n"), + Retryable: true, + } + } + } + } + } + + if action == "start" || action == "restart" { + transportSystemdResetFailedUnits(units) + } + return nil +} + +func transportSystemdResetFailedUnits(units []string) { + for _, unit := range units { + stdout, stderr, code, err := transportRunCommand(transportBackendActionTimeout, "systemctl", "reset-failed", unit) + // reset-failed is best-effort: do not block lifecycle on this step. + if err != nil || code != 0 { + appendTraceLineRateLimited("transport", fmt.Sprintf("systemctl reset-failed stdout: unit=%s out=%q", unit, strings.TrimSpace(stdout)), 20*time.Second) + appendTraceLineRateLimited("transport", fmt.Sprintf("systemctl reset-failed stderr: unit=%s err=%q", unit, strings.TrimSpace(stderr)), 20*time.Second) + appendTraceLineRateLimited("transport", fmt.Sprintf("systemctl reset-failed warning: unit=%s code=%d err=%v", unit, code, err), 20*time.Second) + } + } +} + +func transportSystemdRunPostStopCleanup( + client TransportClient, + action string, + aggOut *[]string, + aggErr *[]string, +) { + if msg, err := transportCleanupNetnsForClient(client); strings.TrimSpace(msg) != "" || err != nil { + if s := strings.TrimSpace(msg); s != "" { + *aggOut = append(*aggOut, "netns: "+s) + appendTraceLine("transport", fmt.Sprintf("netns cleanup: client=%s action=%s %s", client.ID, action, s)) + } + if err != nil { + *aggErr = append(*aggErr, "netns: "+err.Error()) + appendTraceLineRateLimited("transport", fmt.Sprintf("netns cleanup warning: client=%s action=%s err=%v", client.ID, action, err), 30*time.Second) + } + } +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd_cleanup.go b/selective-vpn-api/app/transport_backends_adapter_systemd_cleanup.go new file mode 100644 index 0000000..0f591a4 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd_cleanup.go @@ -0,0 +1,173 @@ +package app + +import ( + "fmt" + "os" + "strings" +) + +func (transportSystemdBackend) Cleanup(client TransportClient) transportBackendActionResult { + unit := transportBackendUnit(client) + if !validSystemdUnitName(unit) { + return transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_UNIT_REQUIRED", + Message: "valid systemd unit is required for cleanup", + ExitCode: -1, + } + } + + units := []string{unit} + if transportSystemdSingBoxUsesTemplateInstance(client, unit) { + units = transportSystemdAppendUniqueUnits(units, transportSystemdLegacySingBoxUnitCandidates(client, unit)) + } + if transportDNSTTSSHTunnelEnabled(client) { + sshUnit := transportDNSTTSSHUnit(client) + if !validSystemdUnitName(sshUnit) { + return transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_UNIT_REQUIRED", + Message: "valid config.ssh_unit is required for cleanup", + ExitCode: -1, + } + } + units = append(units, sshUnit) + } + + ownedUnits := make([]string, 0, len(units)) + out := make([]string, 0, len(units)*2) + errOut := make([]string, 0, len(units)*2) + + for _, u := range units { + path := transportSystemdUnitPath(u) + dropInMode := false + dropInDir := "" + if transportSystemdSingBoxUsesTemplateInstance(client, u) { + path = transportSystemdUnitDropInPath(u, transportSingBoxInstanceDropIn) + dropInMode = true + dropInDir = transportSystemdUnitDropInDir(u) + } + owned, err := transportSystemdUnitOwnedByClient(path, client.ID) + if err != nil { + if os.IsNotExist(err) { + continue + } + errOut = append(errOut, fmt.Sprintf("%s ownership check failed: %v", u, err)) + continue + } + if !owned { + // Safety: never touch units we did not provision for this client id. + continue + } + ownedUnits = append(ownedUnits, u) + + stdout, stderr, code, runErr := transportRunCommand(transportBackendActionTimeout, "systemctl", "stop", u) + if s := strings.TrimSpace(stdout); s != "" { + out = append(out, u+" stop: "+s) + } + if s := strings.TrimSpace(stderr); s != "" { + errOut = append(errOut, u+" stop: "+s) + } + if runErr != nil || code != 0 { + errOut = append(errOut, fmt.Sprintf("%s stop failed (exit=%d)", u, code)) + } + + stdout, stderr, code, runErr = transportRunCommand(transportBackendActionTimeout, "systemctl", "disable", u) + if s := strings.TrimSpace(stdout); s != "" { + out = append(out, u+" disable: "+s) + } + if s := strings.TrimSpace(stderr); s != "" { + errOut = append(errOut, u+" disable: "+s) + } + if runErr != nil || code != 0 { + errOut = append(errOut, fmt.Sprintf("%s disable failed (exit=%d)", u, code)) + } + + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + errOut = append(errOut, fmt.Sprintf("%s remove failed: %v", u, err)) + } + if dropInMode && strings.TrimSpace(dropInDir) != "" { + if err := os.Remove(dropInDir); err != nil && !os.IsNotExist(err) { + msg := strings.ToLower(strings.TrimSpace(err.Error())) + if !strings.Contains(msg, "directory not empty") { + errOut = append(errOut, fmt.Sprintf("%s drop-in dir remove failed: %v", u, err)) + } + } + } + } + + if len(ownedUnits) > 0 { + stdout, stderr, code, runErr := transportRunCommand(transportBackendActionTimeout, "systemctl", "daemon-reload") + if s := strings.TrimSpace(stdout); s != "" { + out = append(out, "daemon-reload: "+s) + } + if s := strings.TrimSpace(stderr); s != "" { + errOut = append(errOut, "daemon-reload: "+s) + } + if runErr != nil || code != 0 { + errOut = append(errOut, fmt.Sprintf("daemon-reload failed (exit=%d)", code)) + } + + for _, u := range ownedUnits { + _, stderr, code, runErr := transportRunCommand(transportBackendActionTimeout, "systemctl", "reset-failed", u) + if s := strings.TrimSpace(stderr); s != "" { + errOut = append(errOut, u+" reset-failed: "+s) + } + if runErr != nil || code != 0 { + errOut = append(errOut, fmt.Sprintf("%s reset-failed failed (exit=%d)", u, code)) + } + } + } + + if transportNetnsEnabled(client) { + msg, err := transportCleanupNetnsForClient(client) + if s := strings.TrimSpace(msg); s != "" { + out = append(out, "netns: "+s) + } + if err != nil { + errOut = append(errOut, "netns: "+err.Error()) + } + } + + msg := fmt.Sprintf("cleanup done for %d managed units", len(ownedUnits)) + if len(ownedUnits) == 0 { + msg = "no managed unit artifacts found" + } + if len(errOut) > 0 { + return transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_CLEANUP_FAILED", + Message: msg, + ExitCode: -1, + Stdout: strings.Join(out, "\n"), + Stderr: strings.Join(errOut, "\n"), + Retryable: true, + } + } + return transportBackendActionResult{ + OK: true, + ExitCode: 0, + Message: msg, + Stdout: strings.Join(out, "\n"), + } +} + +func transportSystemdAppendUniqueUnits(dst []string, candidates []string) []string { + for _, candidate := range candidates { + u := strings.TrimSpace(candidate) + if u == "" { + continue + } + already := false + for _, existing := range dst { + if strings.EqualFold(strings.TrimSpace(existing), u) { + already = true + break + } + } + if !already { + dst = append(dst, u) + } + } + return dst +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd_health.go b/selective-vpn-api/app/transport_backends_adapter_systemd_health.go new file mode 100644 index 0000000..c538f16 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd_health.go @@ -0,0 +1,83 @@ +package app + +import ( + "strings" +) + +func (transportSystemdBackend) Health(client TransportClient) transportBackendHealthResult { + units, errCode, errMsg := transportSystemdHealthUnits(client) + if len(units) == 0 { + return transportBackendHealthResult{ + OK: false, + Code: errCode, + Message: errMsg, + Status: TransportClientDown, + } + } + + active := 0 + starting := 0 + inactive := 0 + failed := 0 + unknown := 0 + issues := make([]string, 0, len(units)) + for _, unit := range units { + stdout, stderr, _, _ := transportRunCommand(transportBackendHealthTimeout, "systemctl", "is-active", unit) + state := strings.ToLower(strings.TrimSpace(stdout)) + switch state { + case "active": + active++ + case "activating", "reloading": + starting++ + case "inactive", "deactivating": + inactive++ + case "failed": + failed++ + msg := strings.TrimSpace(stderr) + if msg == "" { + msg = "systemd unit failed" + } + issues = append(issues, unit+": "+msg) + default: + unknown++ + msg := strings.TrimSpace(stderr) + if msg == "" { + msg = strings.TrimSpace(stdout) + } + if msg == "" { + msg = "status unknown" + } + issues = append(issues, unit+": "+msg) + } + } + + total := len(units) + if active == total { + res := transportBackendHealthResult{OK: true, Status: TransportClientUp} + if latency, _ := transportProbeClientLatency(client); latency > 0 { + res.LatencyMS = latency + } + return res + } + if active+starting == total { + return transportBackendHealthResult{OK: true, Status: TransportClientStarting} + } + if inactive == total { + return transportBackendHealthResult{OK: true, Status: TransportClientDown} + } + msg := strings.TrimSpace(strings.Join(issues, "; ")) + if msg == "" { + msg = "backend units are not synchronized" + } + status := TransportClientDegraded + if active == 0 && failed == 0 && unknown == 0 { + status = TransportClientDown + } + return transportBackendHealthResult{ + OK: false, + Code: "TRANSPORT_BACKEND_HEALTH_FAILED", + Message: msg, + Status: status, + Retryable: true, + } +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd_migration.go b/selective-vpn-api/app/transport_backends_adapter_systemd_migration.go new file mode 100644 index 0000000..c784f50 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd_migration.go @@ -0,0 +1,218 @@ +package app + +import ( + "fmt" + "os" + "strings" + "time" +) + +const ( + transportSingBoxLegacyMigrateConfigKey = "singbox_legacy_unit_migrate" + transportSingBoxLegacyMigrateDryRunConfigKey = "singbox_legacy_unit_migrate_dry_run" +) + +func transportSystemdMaybeMigrateLegacySingBoxUnits(client TransportClient, units []string) ([]string, []string) { + out := make([]string, 0, 4) + errOut := make([]string, 0, 4) + + if !transportSystemdLegacySingBoxMigrationEnabled(client) { + return out, errOut + } + target := transportSystemdResolveSingBoxTemplateTargetUnit(client, units) + if target == "" { + return out, errOut + } + + dryRun := transportSystemdLegacySingBoxMigrationDryRun(client) + legacyUnits := transportSystemdLegacySingBoxUnitCandidates(client, target) + if len(legacyUnits) == 0 { + return out, errOut + } + + migrated := make([]string, 0, len(legacyUnits)) + for _, legacyUnit := range legacyUnits { + if strings.EqualFold(strings.TrimSpace(legacyUnit), target) { + continue + } + path := transportSystemdUnitPath(legacyUnit) + owned, err := transportSystemdUnitOwnedByClient(path, client.ID) + if err != nil { + if os.IsNotExist(err) { + continue + } + errOut = append(errOut, fmt.Sprintf("legacy-migrate ownership check failed (%s): %v", legacyUnit, err)) + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("legacy unit migrate warning: client=%s unit=%s ownership-check err=%v", client.ID, legacyUnit, err), + 30*time.Second, + ) + continue + } + if !owned { + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("legacy unit migrate skip: client=%s unit=%s reason=ownership-mismatch", client.ID, legacyUnit), + 30*time.Second, + ) + continue + } + if dryRun { + msg := fmt.Sprintf("legacy-migrate dry-run: %s -> %s", legacyUnit, target) + out = append(out, msg) + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("legacy unit migrate dry-run: client=%s from=%s to=%s", client.ID, legacyUnit, target), + 20*time.Second, + ) + continue + } + + transportSystemdRunLegacyMigrateCommand("stop", legacyUnit, &out, &errOut) + transportSystemdRunLegacyMigrateCommand("disable", legacyUnit, &out, &errOut) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + errOut = append(errOut, fmt.Sprintf("legacy-migrate remove failed (%s): %v", legacyUnit, err)) + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("legacy unit migrate warning: client=%s unit=%s remove err=%v", client.ID, legacyUnit, err), + 30*time.Second, + ) + continue + } + out = append(out, fmt.Sprintf("legacy-migrate: %s -> %s", legacyUnit, target)) + migrated = append(migrated, legacyUnit) + appendTraceLine( + "transport", + fmt.Sprintf("legacy unit migrated: client=%s from=%s to=%s", client.ID, legacyUnit, target), + ) + } + + if dryRun || len(migrated) == 0 { + return out, errOut + } + + stdout, stderr, code, err := transportRunCommand(transportBackendActionTimeout, "systemctl", "daemon-reload") + if s := strings.TrimSpace(stdout); s != "" { + out = append(out, "legacy-migrate daemon-reload: "+s) + } + if s := strings.TrimSpace(stderr); s != "" { + errOut = append(errOut, "legacy-migrate daemon-reload: "+s) + } + if err != nil || code != 0 { + errOut = append(errOut, fmt.Sprintf("legacy-migrate daemon-reload failed (exit=%d)", code)) + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("legacy unit migrate warning: client=%s daemon-reload code=%d err=%v", client.ID, code, err), + 20*time.Second, + ) + } + + for _, legacyUnit := range migrated { + stdout, stderr, code, err = transportRunCommand(transportBackendActionTimeout, "systemctl", "reset-failed", legacyUnit) + if s := strings.TrimSpace(stdout); s != "" { + out = append(out, fmt.Sprintf("legacy-migrate reset-failed %s: %s", legacyUnit, s)) + } + if s := strings.TrimSpace(stderr); s != "" { + errOut = append(errOut, fmt.Sprintf("legacy-migrate reset-failed %s: %s", legacyUnit, s)) + } + if err != nil || code != 0 { + errOut = append(errOut, fmt.Sprintf("legacy-migrate reset-failed %s failed (exit=%d)", legacyUnit, code)) + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("legacy unit migrate warning: client=%s reset-failed unit=%s code=%d err=%v", client.ID, legacyUnit, code, err), + 20*time.Second, + ) + } + } + return out, errOut +} + +func transportSystemdRunLegacyMigrateCommand(action, unit string, out, errOut *[]string) { + stdout, stderr, code, err := transportRunCommand(transportBackendActionTimeout, "systemctl", action, unit) + if s := strings.TrimSpace(stdout); s != "" { + *out = append(*out, fmt.Sprintf("legacy-migrate %s %s: %s", action, unit, s)) + } + if s := strings.TrimSpace(stderr); s != "" { + *errOut = append(*errOut, fmt.Sprintf("legacy-migrate %s %s: %s", action, unit, s)) + } + if err == nil && code == 0 { + return + } + if transportSystemdUnitMissingError(stdout, stderr, code, err) { + return + } + *errOut = append(*errOut, fmt.Sprintf("legacy-migrate %s %s failed (exit=%d)", action, unit, code)) + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("legacy unit migrate warning: action=%s unit=%s code=%d err=%v", action, unit, code, err), + 20*time.Second, + ) +} + +func transportSystemdLegacySingBoxMigrationEnabled(client TransportClient) bool { + if client.Kind != TransportClientSingBox { + return false + } + if transportConfigHasKey(client.Config, transportSingBoxLegacyMigrateConfigKey) { + return transportConfigBool(client.Config, transportSingBoxLegacyMigrateConfigKey) + } + return true +} + +func transportSystemdLegacySingBoxMigrationDryRun(client TransportClient) bool { + return transportConfigBool(client.Config, transportSingBoxLegacyMigrateDryRunConfigKey) +} + +func transportSystemdResolveSingBoxTemplateTargetUnit(client TransportClient, units []string) string { + for _, unit := range units { + u := strings.TrimSpace(unit) + if transportSystemdSingBoxUsesTemplateInstance(client, u) { + return u + } + } + return "" +} + +func transportSystemdLegacySingBoxUnitCandidates(client TransportClient, targetUnit string) []string { + candidates := make([]string, 0, 4) + addCandidate := func(unit string) { + u := strings.TrimSpace(unit) + if !validSystemdUnitName(u) { + return + } + for _, existing := range candidates { + if strings.EqualFold(existing, u) { + return + } + } + candidates = append(candidates, u) + } + + instanceRaw := transportSystemdInstanceIDFromUnit(targetUnit) + if instanceRaw != "" { + addCandidate("singbox-" + strings.ToLower(instanceRaw) + ".service") + if normalized := sanitizeID(instanceRaw); normalized != "" { + addCandidate("singbox-" + normalized + ".service") + } + } + if id := sanitizeID(client.ID); id != "" { + addCandidate("singbox-" + id + ".service") + } + return candidates +} + +func transportSystemdInstanceIDFromUnit(unit string) string { + u := strings.TrimSpace(unit) + if u == "" || !strings.HasSuffix(strings.ToLower(u), ".service") { + return "" + } + at := strings.IndexByte(u, '@') + if at <= 0 || at+1 >= len(u) { + return "" + } + instance := strings.TrimSpace(strings.TrimSuffix(u[at+1:], ".service")) + if instance == "" { + return "" + } + return instance +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd_provision.go b/selective-vpn-api/app/transport_backends_adapter_systemd_provision.go new file mode 100644 index 0000000..653d8c2 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd_provision.go @@ -0,0 +1,13 @@ +package app + +func (transportSystemdBackend) Provision(client TransportClient) transportBackendActionResult { + inputs, preOut, preErr, failure := buildTransportSystemdProvisionInputs(client) + if failure != nil { + return *failure + } + if failure = writeTransportSystemdProvisionUnits(client, inputs); failure != nil { + return *failure + } + res := finalizeTransportSystemdProvision(client, inputs, preOut, preErr) + return res +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd_provision_finalize.go b/selective-vpn-api/app/transport_backends_adapter_systemd_provision_finalize.go new file mode 100644 index 0000000..0409bb3 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd_provision_finalize.go @@ -0,0 +1,125 @@ +package app + +import "strings" + +func writeTransportSystemdProvisionUnits( + client TransportClient, + in transportSystemdProvisionInputs, +) *transportBackendActionResult { + if transportSystemdSingBoxUsesTemplateInstance(client, in.unit) { + if err := writeTransportSingBoxTemplateArtifacts(client, in); err != nil { + res := transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_PROVISION_FAILED", + Message: "write singbox template/drop-in failed: " + err.Error(), + ExitCode: -1, + } + return &res + } + return nil + } + + primaryUnitContent := renderTransportSystemdUnit( + client, + in.unit, + in.primaryCmd, + in.sshOverlay, + in.sshUnit, + in.primaryTuning, + in.primaryHardening, + ) + if err := writeTransportSystemdUnitFile(in.unit, primaryUnitContent); err != nil { + res := transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_PROVISION_FAILED", + Message: "write primary unit failed: " + err.Error(), + ExitCode: -1, + } + return &res + } + if in.sshOverlay { + sshUnitContent := renderTransportSSHOverlayUnit(client, in.sshUnit, in.sshCmd, in.sshTuning, in.sshHardening) + if err := writeTransportSystemdUnitFile(in.sshUnit, sshUnitContent); err != nil { + res := transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_PROVISION_FAILED", + Message: "write ssh unit failed: " + err.Error(), + ExitCode: -1, + } + return &res + } + } + return nil +} + +func finalizeTransportSystemdProvision( + client TransportClient, + in transportSystemdProvisionInputs, + preOut []string, + preErr []string, +) transportBackendActionResult { + out := make([]string, 0, 4+len(preOut)) + errOut := make([]string, 0, 4+len(preErr)) + out = append(out, preOut...) + errOut = append(errOut, preErr...) + + stdout, stderr, code, err := transportRunCommand(transportBackendActionTimeout, "systemctl", "daemon-reload") + if s := strings.TrimSpace(stdout); s != "" { + out = append(out, s) + } + if s := strings.TrimSpace(stderr); s != "" { + errOut = append(errOut, s) + } + if err != nil || code != 0 { + return transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_PROVISION_FAILED", + Message: "systemctl daemon-reload failed", + ExitCode: code, + Stdout: strings.Join(out, "\n"), + Stderr: strings.Join(errOut, "\n"), + } + } + + if transportConfigBool(client.Config, "enable_on_boot") { + units := []string{in.unit} + if in.sshOverlay { + units = []string{in.sshUnit, in.unit} + } + for _, u := range units { + stdout, stderr, code, err = transportRunCommand(transportBackendActionTimeout, "systemctl", "enable", u) + if s := strings.TrimSpace(stdout); s != "" { + out = append(out, s) + } + if s := strings.TrimSpace(stderr); s != "" { + errOut = append(errOut, s) + } + if err != nil || code != 0 { + return transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_PROVISION_FAILED", + Message: "systemctl enable failed for " + u, + ExitCode: code, + Stdout: strings.Join(out, "\n"), + Stderr: strings.Join(errOut, "\n"), + Retryable: true, + } + } + } + } + + msg := "provision done: " + in.unit + if in.sshOverlay { + msg += " + " + in.sshUnit + } + if in.cmdSource != "" { + msg += " [" + in.cmdSource + "]" + } + return transportBackendActionResult{ + OK: true, + ExitCode: 0, + Message: msg, + Stdout: strings.Join(out, "\n"), + Stderr: strings.Join(errOut, "\n"), + } +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd_provision_inputs.go b/selective-vpn-api/app/transport_backends_adapter_systemd_provision_inputs.go new file mode 100644 index 0000000..727f881 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd_provision_inputs.go @@ -0,0 +1,128 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +type transportSystemdProvisionInputs struct { + unit string + primaryCmd string + cmdSource string + sshOverlay bool + sshUnit string + sshCmd string + primaryTuning transportSystemdServiceTuning + sshTuning transportSystemdServiceTuning + primaryHardening transportSystemdHardening + sshHardening transportSystemdHardening +} + +func buildTransportSystemdProvisionInputs( + client TransportClient, +) (transportSystemdProvisionInputs, []string, []string, *transportBackendActionResult) { + unit := transportBackendUnit(client) + if !validSystemdUnitName(unit) { + res := transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_UNIT_REQUIRED", + Message: "valid systemd unit is required", + ExitCode: -1, + } + return transportSystemdProvisionInputs{}, nil, nil, &res + } + + preOut, preErr, failure := runTransportSystemdProvisionPreflight(client) + if failure != nil { + return transportSystemdProvisionInputs{}, preOut, preErr, failure + } + + primaryCmd, cmdSource, err := resolveTransportPrimaryExecStart(client) + if err != nil { + res := transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED", + Message: err.Error(), + ExitCode: -1, + } + return transportSystemdProvisionInputs{}, preOut, preErr, &res + } + if transportNetnsEnabled(client) { + primaryCmd = transportWrapExecWithNetns(client, primaryCmd) + } + + sshOverlay := transportDNSTTSSHTunnelEnabled(client) + sshUnit := strings.TrimSpace(transportDNSTTSSHUnit(client)) + sshCmd := strings.TrimSpace(transportConfigString(client.Config, "ssh_exec_start")) + if sshOverlay && !validSystemdUnitName(sshUnit) { + res := transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED", + Message: "valid config.ssh_unit is required when ssh overlay enabled", + ExitCode: -1, + } + return transportSystemdProvisionInputs{}, preOut, preErr, &res + } + if sshOverlay && sshCmd == "" { + overlayCmd, overlayErr := buildTransportSSHOverlayCommand(client.Config) + if overlayErr != nil { + res := transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED", + Message: overlayErr.Error(), + ExitCode: -1, + } + return transportSystemdProvisionInputs{}, preOut, preErr, &res + } + sshCmd = overlayCmd + } + + in := transportSystemdProvisionInputs{ + unit: unit, + primaryCmd: primaryCmd, + cmdSource: cmdSource, + sshOverlay: sshOverlay, + sshUnit: sshUnit, + sshCmd: sshCmd, + primaryTuning: transportSystemdServiceTuningFromConfig(client.Config, ""), + sshTuning: transportSystemdServiceTuningFromConfig(client.Config, "ssh_"), + primaryHardening: transportSystemdHardeningFromConfig(client.Config, ""), + sshHardening: transportSystemdHardeningFromConfig(client.Config, "ssh_"), + } + return in, preOut, preErr, nil +} + +func runTransportSystemdProvisionPreflight( + client TransportClient, +) ([]string, []string, *transportBackendActionResult) { + preOut := make([]string, 0, 2) + preErr := make([]string, 0, 2) + if client.Kind != TransportClientSingBox { + return preOut, preErr, nil + } + + migMsg, migErr := transportMaybeMigrateSingBoxDNSConfig(client) + if s := strings.TrimSpace(migMsg); s != "" { + preOut = append(preOut, "dns-migrate: "+s) + appendTraceLine("transport", fmt.Sprintf("singbox dns migrate: client=%s %s", client.ID, s)) + } + if migErr == nil { + return preOut, preErr, nil + } + + preErr = append(preErr, "dns-migrate: "+migErr.Error()) + appendTraceLineRateLimited("transport", fmt.Sprintf("singbox dns migrate warning: client=%s err=%v", client.ID, migErr), 60*time.Second) + if transportSingBoxDNSMigrationStrict(client) { + res := transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_SINGBOX_DNS_MIGRATE_FAILED", + Message: migErr.Error(), + ExitCode: -1, + Stdout: strings.Join(preOut, "\n"), + Stderr: strings.Join(preErr, "\n"), + } + return preOut, preErr, &res + } + return preOut, preErr, nil +} diff --git a/selective-vpn-api/app/transport_backends_adapter_systemd_singbox_template.go b/selective-vpn-api/app/transport_backends_adapter_systemd_singbox_template.go new file mode 100644 index 0000000..761460a --- /dev/null +++ b/selective-vpn-api/app/transport_backends_adapter_systemd_singbox_template.go @@ -0,0 +1,168 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + transportcfg "selective-vpn-api/app/transportcfg" +) + +const ( + transportSingBoxTemplateUnitName = "singbox@.service" + transportSingBoxInstanceDropIn = "10-selective-vpn.conf" + transportSingBoxTemplateMarker = "# SVPN_TEMPLATE=singbox-instance" + transportSingBoxInstanceDropInTag = "# SVPN_INSTANCE_DROPIN=singbox" +) + +func transportSystemdSingBoxUsesTemplateInstance(client TransportClient, unit string) bool { + if client.Kind != TransportClientSingBox { + return false + } + u := strings.TrimSpace(unit) + if u == "" || !strings.HasSuffix(strings.ToLower(u), ".service") { + return false + } + at := strings.IndexByte(u, '@') + return at > 0 +} + +func transportSystemdTemplateUnitForInstance(unit string) string { + u := strings.TrimSpace(unit) + if !strings.HasSuffix(strings.ToLower(u), ".service") { + return "" + } + at := strings.IndexByte(u, '@') + if at <= 0 { + return "" + } + return u[:at] + "@.service" +} + +func transportSystemdUnitDropInDir(unit string) string { + path := transportSystemdUnitPath(strings.TrimSpace(unit)) + return path + ".d" +} + +func transportSystemdUnitDropInPath(unit, fileName string) string { + name := strings.TrimSpace(fileName) + if name == "" { + name = transportSingBoxInstanceDropIn + } + return filepath.Join(transportSystemdUnitDropInDir(unit), name) +} + +func writeTransportSystemdDropInFile(unit, fileName, content string) error { + path := transportSystemdUnitDropInPath(unit, fileName) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func writeTransportSingBoxTemplateArtifacts( + client TransportClient, + in transportSystemdProvisionInputs, +) error { + templateUnit := transportSystemdTemplateUnitForInstance(in.unit) + if templateUnit == "" { + templateUnit = transportSingBoxTemplateUnitName + } + if !validSystemdUnitName(templateUnit) { + return fmt.Errorf("invalid template unit: %s", templateUnit) + } + + templateTuning := transportSystemdServiceTuningFromConfig(nil, "") + templateHardening := transportSystemdHardeningFromConfig(nil, "") + templateContent := renderTransportSystemdUnit( + TransportClient{ID: "%i", Kind: TransportClientSingBox}, + templateUnit, + "/bin/true", + false, + "", + templateTuning, + templateHardening, + ) + templateContent = transportSingBoxTemplateMarker + "\n" + templateContent + if err := writeTransportSystemdUnitFile(templateUnit, templateContent); err != nil { + return err + } + + netnsEnabled := transportNetnsEnabled(client) + netnsName := "" + if netnsEnabled { + netnsName = transportNetnsName(client) + } + configPath := transportSingBoxConfigPath(client) + dropIn := renderTransportSingBoxInstanceDropIn(client, in, configPath, netnsEnabled, netnsName) + return writeTransportSystemdDropInFile(in.unit, transportSingBoxInstanceDropIn, dropIn) +} + +func renderTransportSingBoxInstanceDropIn( + client TransportClient, + in transportSystemdProvisionInputs, + configPath string, + netnsEnabled bool, + netnsName string, +) string { + b := strings.Builder{} + b.WriteString(transportSingBoxInstanceDropInTag + "\n") + b.WriteString("[Unit]\n") + b.WriteString("StartLimitIntervalSec=" + fmt.Sprintf("%d", in.primaryTuning.StartLimitIntervalSec) + "\n") + b.WriteString("StartLimitBurst=" + fmt.Sprintf("%d", in.primaryTuning.StartLimitBurst) + "\n") + b.WriteString("\n[Service]\n") + b.WriteString("WorkingDirectory=" + stateDir + "\n") + b.WriteString("Restart=" + in.primaryTuning.RestartPolicy + "\n") + b.WriteString("RestartSec=" + fmt.Sprintf("%d", in.primaryTuning.RestartSec) + "\n") + b.WriteString("Environment=SVPN_TRANSPORT_ID=" + strings.TrimSpace(client.ID) + "\n") + b.WriteString("Environment=SVPN_TRANSPORT_KIND=" + strings.TrimSpace(string(client.Kind)) + "\n") + if strings.TrimSpace(configPath) != "" { + b.WriteString("Environment=SVPN_CONFIG_PATH=" + strings.TrimSpace(configPath) + "\n") + } + if netnsEnabled { + b.WriteString("Environment=SVPN_NETNS_ENABLED=1\n") + if strings.TrimSpace(netnsName) != "" { + b.WriteString("Environment=SVPN_NETNS_NAME=" + strings.TrimSpace(netnsName) + "\n") + } + } else { + b.WriteString("Environment=SVPN_NETNS_ENABLED=0\n") + } + b.WriteString("ExecStart=\n") + b.WriteString("ExecStart=" + transportcfg.SystemdShellExec(in.primaryCmd, shellQuoteArg) + "\n") + b.WriteString("ExecStop=\n") + b.WriteString("ExecStop=/bin/kill -TERM $MAINPID\n") + b.WriteString("TimeoutStartSec=" + fmt.Sprintf("%d", in.primaryTuning.TimeoutStartSec) + "\n") + b.WriteString("TimeoutStopSec=" + fmt.Sprintf("%d", in.primaryTuning.TimeoutStopSec) + "\n") + if in.primaryTuning.WatchdogSec > 0 { + b.WriteString("WatchdogSec=" + fmt.Sprintf("%d", in.primaryTuning.WatchdogSec) + "\n") + b.WriteString("NotifyAccess=main\n") + } + if in.primaryHardening.Enabled { + b.WriteString("NoNewPrivileges=" + transportSystemdBool(in.primaryHardening.NoNewPrivileges) + "\n") + b.WriteString("PrivateTmp=" + transportSystemdBool(in.primaryHardening.PrivateTmp) + "\n") + b.WriteString("ProtectSystem=" + in.primaryHardening.ProtectSystem + "\n") + b.WriteString("ProtectHome=" + in.primaryHardening.ProtectHome + "\n") + b.WriteString("ProtectControlGroups=" + transportSystemdBool(in.primaryHardening.ProtectControlGroups) + "\n") + b.WriteString("ProtectKernelModules=" + transportSystemdBool(in.primaryHardening.ProtectKernelModules) + "\n") + b.WriteString("ProtectKernelTunables=" + transportSystemdBool(in.primaryHardening.ProtectKernelTunables) + "\n") + b.WriteString("RestrictSUIDSGID=" + transportSystemdBool(in.primaryHardening.RestrictSUIDSGID) + "\n") + b.WriteString("LockPersonality=" + transportSystemdBool(in.primaryHardening.LockPersonality) + "\n") + if in.primaryHardening.PrivateDevices { + b.WriteString("PrivateDevices=yes\n") + } + b.WriteString("UMask=" + in.primaryHardening.UMask + "\n") + } + return b.String() +} + +func transportSystemdBool(v bool) string { + if v { + return "yes" + } + return "no" +} diff --git a/selective-vpn-api/app/transport_backends_config_helpers.go b/selective-vpn-api/app/transport_backends_config_helpers.go new file mode 100644 index 0000000..b035314 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_config_helpers.go @@ -0,0 +1,115 @@ +package app + +import transportcfg "selective-vpn-api/app/transportcfg" + +func transportBackendUnit(client TransportClient) string { + return transportcfg.BackendUnit(transportCfgClient(client)) +} + +func defaultTransportBackendUnit(kind TransportClientKind) string { + return transportcfg.DefaultBackendUnit(string(kind)) +} + +func transportDNSTTSSHTunnelEnabled(client TransportClient) bool { + return transportcfg.DNSTTSSHTunnelEnabled(transportCfgClient(client)) +} + +func transportDNSTTSSHUnit(client TransportClient) string { + return transportcfg.DNSTTSSHUnit(transportCfgClient(client)) +} + +func transportRuntimeMode(cfg map[string]any) string { + return transportcfg.RuntimeMode(cfg) +} + +func transportSystemdActionUnits(client TransportClient, action string) ([]string, string, string) { + return transportcfg.SystemdActionUnits(transportCfgClient(client), action) +} + +func transportSystemdHealthUnits(client TransportClient) ([]string, string, string) { + return transportcfg.SystemdHealthUnits(transportCfgClient(client)) +} + +func transportConfigString(cfg map[string]any, key string) string { + return transportcfg.ConfigString(cfg, key) +} + +func transportConfigBool(cfg map[string]any, key string) bool { + return transportcfg.ConfigBool(cfg, key) +} + +func transportCfgClient(client TransportClient) transportcfg.Client { + return transportcfg.Client{ + ID: client.ID, + Kind: string(client.Kind), + Config: client.Config, + } +} + +func resolveTransportPrimaryExecStart(client TransportClient) (string, string, error) { + return transportcfg.ResolvePrimaryExecStart( + transportCfgClient(client), + transportSingBoxConfigPath(client), + defaultTransportConfigPath(client.ID, "client.toml"), + ) +} + +func buildTransportSingBoxCommand(client TransportClient) (string, error) { + return transportcfg.BuildSingBoxCommand(transportCfgClient(client), transportSingBoxConfigPath(client)) +} + +func buildTransportPhoenixClientCommand(client TransportClient) (string, error) { + return transportcfg.BuildPhoenixClientCommand(transportCfgClient(client), defaultTransportConfigPath(client.ID, "client.toml")) +} + +func buildTransportDNSTTClientCommand(client TransportClient) (string, error) { + return transportcfg.BuildDNSTTClientCommand(transportCfgClient(client)) +} + +func defaultTransportConfigPath(clientID, fileName string) string { + return transportcfg.DefaultConfigPath(clientID, fileName, sanitizeID) +} + +func resolveTransportBinary(cfg map[string]any, kind string, systemCandidates ...string) (string, error) { + return transportcfg.ResolveBinary(cfg, kind, systemCandidates...) +} + +func validateRequiredBinary(cfg map[string]any, kind, bin string) error { + return transportcfg.ValidateRequiredBinary(cfg, kind, bin) +} + +func transportPackagingProfile(cfg map[string]any) string { + return transportcfg.PackagingProfile(cfg) +} + +func transportBinaryName(kind string) string { + return transportcfg.BinaryName(kind) +} + +func firstExistingBinaryCandidate(candidates ...string) (string, bool) { + return transportcfg.FirstExistingBinaryCandidate(candidates...) +} + +func findBinaryPath(candidate string) (string, bool) { + return transportcfg.FindBinaryPath(candidate) +} + +func transportBinaryExists(candidate string) bool { + return transportcfg.BinaryExists(candidate) +} + +func shellJoinArgs(args []string) string { + return transportcfg.ShellJoinArgs(args, shellQuoteArg) +} + +func shellQuoteArg(in string) string { + return transportcfg.ShellQuoteArg(in) +} + +func buildTransportSSHOverlayCommand(cfg map[string]any) (string, error) { + return transportcfg.BuildSSHOverlayCommand(cfg, transportConfigInt, shellQuoteArg) +} + +func transportConfigInt(cfg map[string]any, key string, defaultVal int) int { + return transportcfg.ConfigInt(cfg, key, defaultVal) +} diff --git a/selective-vpn-api/app/transport_backends_probe.go b/selective-vpn-api/app/transport_backends_probe.go new file mode 100644 index 0000000..79d3ede --- /dev/null +++ b/selective-vpn-api/app/transport_backends_probe.go @@ -0,0 +1,144 @@ +package app + +import ( + "context" + "net" + "time" + + transportcfg "selective-vpn-api/app/transportcfg" +) + +type transportDialRunner func(ctx context.Context, network, address string) (net.Conn, error) + +var transportDialContext transportDialRunner = func(ctx context.Context, network, address string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, address) +} + +type transportDialEndpoint struct { + Host string + Port int +} + +func (ep transportDialEndpoint) address() string { + return transportcfg.Endpoint{Host: ep.Host, Port: ep.Port}.Address() +} + +func transportProbeClientLatency(client TransportClient) (int, error) { + return transportcfg.ProbeClientLatency(transportCfgClient(client), transportProbeDeps()) +} + +func transportProbeDialEndpoint(client TransportClient, ep transportDialEndpoint, timeout time.Duration) (int, error) { + return transportcfg.ProbeDialEndpoint( + transportCfgClient(client), + transportcfg.Endpoint{Host: ep.Host, Port: ep.Port}, + timeout, + transportProbeDeps(), + ) +} + +func transportProbeDialEndpointHost(ep transportDialEndpoint, timeout time.Duration) (int, error) { + return transportcfg.ProbeDialEndpointHost( + transportcfg.Endpoint{Host: ep.Host, Port: ep.Port}, + timeout, + transportcfg.DialRunner(transportDialContext), + ) +} + +func transportProbeDialEndpointInNetns(client TransportClient, ep transportDialEndpoint, timeout time.Duration) (int, error) { + return transportcfg.ProbeDialEndpointInNetns( + transportCfgClient(client), + transportcfg.Endpoint{Host: ep.Host, Port: ep.Port}, + timeout, + transportProbeDeps(), + ) +} + +func transportCollectProbeEndpoints(client TransportClient) []transportDialEndpoint { + return transportDialEndpointsFromCfg(transportcfg.CollectProbeEndpoints(transportCfgClient(client), nil, transportConfigInt)) +} + +func transportCollectConfigProbeEndpoints(cfg map[string]any) []transportDialEndpoint { + return transportDialEndpointsFromCfg(transportcfg.CollectConfigProbeEndpoints(cfg, transportConfigInt)) +} + +func transportCollectSingBoxConfigProbeEndpoints(client TransportClient) []transportDialEndpoint { + return transportDialEndpointsFromCfg(transportcfg.CollectSingBoxConfigProbeEndpoints(transportCfgClient(client), nil)) +} + +func transportCollectProbeEndpointsRecursive(node any, out *[]transportDialEndpoint) { + if out == nil { + return + } + tmp := make([]transportcfg.Endpoint, 0, len(*out)) + for _, ep := range *out { + tmp = append(tmp, transportcfg.Endpoint{Host: ep.Host, Port: ep.Port}) + } + transportcfg.CollectProbeEndpointsRecursive(node, &tmp) + *out = transportDialEndpointsFromCfg(tmp) +} + +func transportParseDialEndpoint(raw string, fallbackPort int) (transportDialEndpoint, bool) { + ep, ok := transportcfg.ParseDialEndpoint(raw, fallbackPort) + if !ok { + return transportDialEndpoint{}, false + } + return transportDialEndpoint{Host: ep.Host, Port: ep.Port}, true +} + +func transportDedupeProbeEndpoints(in []transportDialEndpoint) []transportDialEndpoint { + cfgIn := make([]transportcfg.Endpoint, 0, len(in)) + for _, ep := range in { + cfgIn = append(cfgIn, transportcfg.Endpoint{Host: ep.Host, Port: ep.Port}) + } + return transportDialEndpointsFromCfg(transportcfg.DedupeProbeEndpoints(cfgIn)) +} + +func transportParseInt(raw any) (int, bool) { + return transportcfg.ParseInt(raw) +} + +func splitCSV(raw string) []string { + return transportcfg.SplitCSV(raw) +} + +func transportProbeDeps() transportcfg.ProbeDeps { + return transportcfg.ProbeDeps{ + Dial: transportcfg.DialRunner(transportDialContext), + HealthTimeout: transportBackendHealthTimeout, + ProbeTimeout: transportBackendProbeTimeout, + NetnsEnabled: func(c transportcfg.Client) bool { + return transportNetnsEnabled(transportClientFromCfg(c)) + }, + NetnsName: func(c transportcfg.Client) string { + return transportNetnsName(transportClientFromCfg(c)) + }, + NetnsExecCommand: func(c transportcfg.Client, ns string, command ...string) (string, []string, error) { + return transportNetnsExecCommand(transportClientFromCfg(c), ns, command...) + }, + RunCommand: func(timeout time.Duration, name string, args ...string) (string, string, int, error) { + return transportRunCommand(timeout, name, args...) + }, + CommandError: transportCommandError, + ShellJoinArgs: func(args []string) string { + return shellJoinArgs(args) + }, + ConfigInt: transportConfigInt, + } +} + +func transportClientFromCfg(client transportcfg.Client) TransportClient { + return TransportClient{ + ID: client.ID, + Kind: TransportClientKind(client.Kind), + Config: cloneMap(client.Config), + } +} + +func transportDialEndpointsFromCfg(in []transportcfg.Endpoint) []transportDialEndpoint { + out := make([]transportDialEndpoint, 0, len(in)) + for _, ep := range in { + out = append(out, transportDialEndpoint{Host: ep.Host, Port: ep.Port}) + } + return out +} diff --git a/selective-vpn-api/app/transport_backends_runtime_modes.go b/selective-vpn-api/app/transport_backends_runtime_modes.go new file mode 100644 index 0000000..e0c4639 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_runtime_modes.go @@ -0,0 +1,49 @@ +package app + +import "strings" + +type transportUnsupportedRuntimeBackend struct { + mode string +} + +func (b transportUnsupportedRuntimeBackend) ID() string { + return "unsupported" +} + +type transportMockBackend struct{} + +func (transportMockBackend) ID() string { return "mock" } + +func (transportMockBackend) Action(_ TransportClient, action string) transportBackendActionResult { + action = strings.ToLower(strings.TrimSpace(action)) + return transportBackendActionResult{ + OK: true, + ExitCode: 0, + Message: "mock backend " + action + " done", + } +} + +func (transportMockBackend) Health(client TransportClient) transportBackendHealthResult { + status := normalizeTransportStatus(client.Status) + return transportBackendHealthResult{ + OK: true, + Status: status, + LatencyMS: client.Health.LatencyMS, + } +} + +func (transportMockBackend) Provision(client TransportClient) transportBackendActionResult { + return transportBackendActionResult{ + OK: true, + ExitCode: 0, + Message: "mock backend provision skipped for " + client.ID, + } +} + +func (transportMockBackend) Cleanup(client TransportClient) transportBackendActionResult { + return transportBackendActionResult{ + OK: true, + ExitCode: 0, + Message: "no cleanup required for backend mock (" + client.ID + ")", + } +} diff --git a/selective-vpn-api/app/transport_backends_runtime_unsupported.go b/selective-vpn-api/app/transport_backends_runtime_unsupported.go new file mode 100644 index 0000000..ea755f7 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_runtime_unsupported.go @@ -0,0 +1,42 @@ +package app + +import ( + "fmt" + "strings" +) + +func (b transportUnsupportedRuntimeBackend) Action(_ TransportClient, action string) transportBackendActionResult { + return transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED", + Message: fmt.Sprintf("runtime_mode %q is not supported yet for action %q", b.mode, strings.TrimSpace(action)), + ExitCode: -1, + } +} + +func (b transportUnsupportedRuntimeBackend) Health(_ TransportClient) transportBackendHealthResult { + return transportBackendHealthResult{ + OK: false, + Code: "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED", + Message: fmt.Sprintf("runtime_mode %q is not supported yet", b.mode), + Status: TransportClientDegraded, + Retryable: false, + } +} + +func (b transportUnsupportedRuntimeBackend) Provision(_ TransportClient) transportBackendActionResult { + return transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED", + Message: fmt.Sprintf("runtime_mode %q is not supported yet for provision", b.mode), + ExitCode: -1, + } +} + +func (b transportUnsupportedRuntimeBackend) Cleanup(_ TransportClient) transportBackendActionResult { + return transportBackendActionResult{ + OK: true, + ExitCode: 0, + Message: fmt.Sprintf("cleanup skipped for unsupported runtime_mode %q", b.mode), + } +} diff --git a/selective-vpn-api/app/transport_backends_test.go b/selective-vpn-api/app/transport_backends_test.go new file mode 100644 index 0000000..0810011 --- /dev/null +++ b/selective-vpn-api/app/transport_backends_test.go @@ -0,0 +1,1185 @@ +package app + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSelectTransportBackendDefaultMock(t *testing.T) { + b := selectTransportBackend(TransportClient{ + Kind: TransportClientSingBox, + Config: nil, + }) + if b.ID() != "mock" { + t.Fatalf("expected mock backend, got %q", b.ID()) + } +} + +func TestSelectTransportBackendUnitInfersSystemd(t *testing.T) { + b := selectTransportBackend(TransportClient{ + Kind: TransportClientPhoenix, + Config: map[string]any{ + "unit": "phoenix.service", + }, + }) + if b.ID() != "systemd" { + t.Fatalf("expected systemd backend, got %q", b.ID()) + } +} + +func TestSelectTransportBackendRuntimeModeEmbeddedUnsupported(t *testing.T) { + b := selectTransportBackend(TransportClient{ + Kind: TransportClientSingBox, + Config: map[string]any{ + "runtime_mode": "embedded", + "runner": "systemd", + }, + }) + if b.ID() != "unsupported" { + t.Fatalf("expected unsupported backend for embedded runtime_mode, got %q", b.ID()) + } +} + +func TestSelectTransportBackendRuntimeModeSidecarUnsupported(t *testing.T) { + b := selectTransportBackend(TransportClient{ + Kind: TransportClientPhoenix, + Config: map[string]any{ + "runtime_mode": "sidecar", + "runner": "systemd", + }, + }) + if b.ID() != "unsupported" { + t.Fatalf("expected unsupported backend for sidecar runtime_mode, got %q", b.ID()) + } +} + +func TestTransportUnsupportedRuntimeBackendReturnsUnifiedError(t *testing.T) { + client := TransportClient{ + ID: "ph-1", + Kind: TransportClientPhoenix, + Config: map[string]any{ + "runtime_mode": "sidecar", + }, + } + backend := selectTransportBackend(client) + act := backend.Action(client, "start") + if act.OK { + t.Fatalf("expected unsupported runtime action to fail") + } + if act.Code != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED" { + t.Fatalf("unexpected action code: %#v", act) + } + prov := backend.Provision(client) + if prov.OK { + t.Fatalf("expected unsupported runtime provision to fail") + } + if prov.Code != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED" { + t.Fatalf("unexpected provision code: %#v", prov) + } + health := backend.Health(client) + if health.OK { + t.Fatalf("expected unsupported runtime health to fail") + } + if health.Code != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED" { + t.Fatalf("unexpected health code: %#v", health) + } + if health.Status != TransportClientDegraded { + t.Fatalf("expected degraded status for unsupported runtime_mode, got %#v", health) + } +} + +func TestTransportSystemdActionUnitsDNSTTSSHTunnel(t *testing.T) { + client := TransportClient{ + Kind: TransportClientDNSTT, + Config: map[string]any{ + "runner": "systemd", + "unit": "dnstt-client.service", + "ssh_tunnel": true, + "ssh_unit": "dnstt-ssh.service", + }, + } + unitsStart, code, msg := transportSystemdActionUnits(client, "start") + if code != "" || msg != "" { + t.Fatalf("unexpected action-unit error: code=%q msg=%q", code, msg) + } + if strings.Join(unitsStart, ",") != "dnstt-ssh.service,dnstt-client.service" { + t.Fatalf("unexpected start units order: %#v", unitsStart) + } + unitsStop, code, msg := transportSystemdActionUnits(client, "stop") + if code != "" || msg != "" { + t.Fatalf("unexpected action-unit error: code=%q msg=%q", code, msg) + } + if strings.Join(unitsStop, ",") != "dnstt-client.service,dnstt-ssh.service" { + t.Fatalf("unexpected stop units order: %#v", unitsStop) + } +} + +func TestTransportSystemdBackendActionAndHealth(t *testing.T) { + orig := transportRunCommand + defer func() { transportRunCommand = orig }() + + calls := make([]string, 0, 8) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + if cmd == "systemctl reset-failed dnstt-client.service" { + return "", "", 0, nil + } + if cmd == "systemctl reset-failed dnstt-ssh.service" { + return "", "", 0, nil + } + if cmd == "systemctl start dnstt-client.service" { + return "", "", 0, nil + } + if cmd == "systemctl start dnstt-ssh.service" { + return "", "", 0, nil + } + if cmd == "systemctl is-active dnstt-client.service" { + return "active\n", "", 0, nil + } + if cmd == "systemctl is-active dnstt-ssh.service" { + return "active\n", "", 0, nil + } + return "", "unexpected command", 1, fmt.Errorf("unexpected command: %s", cmd) + } + + client := TransportClient{ + Kind: TransportClientDNSTT, + Config: map[string]any{ + "runner": "systemd", + "unit": "dnstt-client.service", + "ssh_tunnel": true, + "ssh_unit": "dnstt-ssh.service", + }, + } + backend := selectTransportBackend(client) + res := backend.Action(client, "start") + if !res.OK { + t.Fatalf("expected action success, got %#v", res) + } + if len(calls) < 4 { + t.Fatalf("expected start calls, got %#v", calls) + } + if calls[0] != "systemctl reset-failed dnstt-ssh.service" || + calls[1] != "systemctl reset-failed dnstt-client.service" || + calls[2] != "systemctl start dnstt-ssh.service" || + calls[3] != "systemctl start dnstt-client.service" { + t.Fatalf("unexpected call order: %#v", calls) + } + + health := backend.Health(client) + if !health.OK || health.Status != TransportClientUp { + t.Fatalf("expected healthy up, got %#v", health) + } +} + +func TestTransportSystemdBackendStopMissingUnitIsNoop(t *testing.T) { + orig := transportRunCommand + defer func() { transportRunCommand = orig }() + + calls := make([]string, 0, 4) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + if cmd == "systemctl stop singbox@sg-missing.service" { + return "", "Failed to stop singbox@sg-missing.service: Unit singbox@sg-missing.service not loaded.", 5, fmt.Errorf("exit status 5") + } + return "", "unexpected command", 1, fmt.Errorf("unexpected command: %s", cmd) + } + + client := TransportClient{ + ID: "sg-missing", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + }, + } + backend := selectTransportBackend(client) + res := backend.Action(client, "stop") + if !res.OK { + t.Fatalf("expected stop noop success, got %#v", res) + } + if len(calls) != 1 || calls[0] != "systemctl stop singbox@sg-missing.service" { + t.Fatalf("unexpected calls: %#v", calls) + } + if !strings.Contains(strings.ToLower(res.Message), "systemctl stop ok") { + t.Fatalf("unexpected success message: %#v", res) + } +} + +func TestTransportSystemdBackendStartAutoProvisionOnMissingUnit(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + startCalls := 0 + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + switch cmd { + case "systemctl reset-failed singbox@sg-auto.service": + return "", "", 0, nil + case "systemctl daemon-reload": + return "", "", 0, nil + case "systemctl start singbox@sg-auto.service": + startCalls++ + if startCalls == 1 { + return "", "Failed to start singbox@sg-auto.service: Unit singbox@sg-auto.service not found.", 5, fmt.Errorf("exit status 5") + } + return "", "", 0, nil + default: + return "", "", 0, nil + } + } + + client := TransportClient{ + ID: "sg-auto", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + "exec_start": "/usr/bin/sing-box run -c /tmp/sg-auto.json", + }, + } + backend := selectTransportBackend(client) + res := backend.Action(client, "start") + if !res.OK { + t.Fatalf("expected start success after auto-provision, got %#v", res) + } + if startCalls != 2 { + t.Fatalf("expected start retried after auto-provision, got calls=%d", startCalls) + } + if _, err := os.Stat(filepath.Join(tmpDir, "singbox@.service")); err != nil { + t.Fatalf("expected provisioned template unit file, stat error: %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "singbox@sg-auto.service.d", transportSingBoxInstanceDropIn)); err != nil { + t.Fatalf("expected provisioned template drop-in file, stat error: %v", err) + } +} + +func TestTransportSystemdBackendHealthOverlayMismatch(t *testing.T) { + orig := transportRunCommand + defer func() { transportRunCommand = orig }() + + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + switch cmd { + case "systemctl is-active dnstt-client.service": + return "active\n", "", 0, nil + case "systemctl is-active dnstt-ssh.service": + return "inactive\n", "", 0, nil + default: + return "", "", 0, nil + } + } + + client := TransportClient{ + Kind: TransportClientDNSTT, + Config: map[string]any{ + "runner": "systemd", + "unit": "dnstt-client.service", + "ssh_overlay": true, + "ssh_unit": "dnstt-ssh.service", + }, + } + backend := selectTransportBackend(client) + health := backend.Health(client) + if health.OK { + t.Fatalf("expected unhealthy overlay mismatch, got %#v", health) + } + if health.Code != "TRANSPORT_BACKEND_HEALTH_FAILED" { + t.Fatalf("unexpected health code: %#v", health) + } + if health.Status != TransportClientDegraded { + t.Fatalf("unexpected health status: %#v", health) + } +} + +func TestTransportCollectSingBoxConfigProbeEndpoints(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "singbox.json") + cfg := `{ + "outbounds": [ + { "type": "vless", "server": "n3.elmprod.tech", "server_port": 40903 }, + { "type": "wireguard", "server": "198.51.100.5", "server_port": "51820" } + ] +}` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + client := TransportClient{ + ID: "sg-probe", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + "config_path": cfgPath, + }, + } + endpoints := transportCollectSingBoxConfigProbeEndpoints(client) + got := make([]string, 0, len(endpoints)) + for _, ep := range endpoints { + got = append(got, ep.address()) + } + want := []string{"n3.elmprod.tech:40903", "198.51.100.5:51820"} + for _, w := range want { + found := false + for _, g := range got { + if g == w { + found = true + break + } + } + if !found { + t.Fatalf("missing endpoint %q in %#v", w, got) + } + } +} + +func TestTransportSystemdBackendHealthIncludesLatencyProbe(t *testing.T) { + origRun := transportRunCommand + origDial := transportDialContext + defer func() { + transportRunCommand = origRun + transportDialContext = origDial + }() + + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "singbox.json") + cfg := `{ + "outbounds": [ + { "type": "vless", "server": "n3.elmprod.tech", "server_port": 40903 } + ] +}` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + if cmd == "systemctl is-active singbox@sg-probe.service" { + return "active\n", "", 0, nil + } + return "", "unexpected command", 1, fmt.Errorf("unexpected command: %s", cmd) + } + + calledAddr := "" + transportDialContext = func(_ context.Context, network, address string) (net.Conn, error) { + if network != "tcp4" { + return nil, fmt.Errorf("unexpected network: %s", network) + } + calledAddr = address + c1, c2 := net.Pipe() + _ = c2.Close() + return c1, nil + } + + client := TransportClient{ + ID: "sg-probe", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + "config_path": cfgPath, + }, + } + backend := selectTransportBackend(client) + health := backend.Health(client) + if !health.OK || health.Status != TransportClientUp { + t.Fatalf("expected healthy up, got %#v", health) + } + if health.LatencyMS <= 0 { + t.Fatalf("expected latency sample, got %#v", health) + } + if calledAddr != "n3.elmprod.tech:40903" { + t.Fatalf("unexpected dial addr: %q", calledAddr) + } +} + +func TestTransportSystemdBackendHealthProbeFailureKeepsUpStatus(t *testing.T) { + origRun := transportRunCommand + origDial := transportDialContext + defer func() { + transportRunCommand = origRun + transportDialContext = origDial + }() + + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "singbox.json") + cfg := `{ + "outbounds": [ + { "type": "vless", "server": "n3.elmprod.tech", "server_port": 40903 } + ] +}` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + if cmd == "systemctl is-active singbox@sg-probe.service" { + return "active\n", "", 0, nil + } + return "", "unexpected command", 1, fmt.Errorf("unexpected command: %s", cmd) + } + transportDialContext = func(_ context.Context, _ string, _ string) (net.Conn, error) { + return nil, fmt.Errorf("dial timeout") + } + + client := TransportClient{ + ID: "sg-probe", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + "config_path": cfgPath, + }, + } + backend := selectTransportBackend(client) + health := backend.Health(client) + if !health.OK || health.Status != TransportClientUp { + t.Fatalf("probe failure must not degrade systemd up status, got %#v", health) + } + if health.LatencyMS != 0 { + t.Fatalf("latency must be empty when probe fails, got %#v", health) + } +} + +func TestTransportSystemdBackendHealthNetnsProbePreferred(t *testing.T) { + origRun := transportRunCommand + origDial := transportDialContext + defer func() { + transportRunCommand = origRun + transportDialContext = origDial + }() + + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "singbox.json") + cfg := `{ + "outbounds": [ + { "type": "vless", "server": "n3.elmprod.tech", "server_port": 40903 } + ] +}` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + netnsProbeCalled := false + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + switch cmd { + case "systemctl is-active singbox@sg-probe.service": + return "active\n", "", 0, nil + } + if strings.HasPrefix(cmd, "ip netns exec svpn-sg bash -lc ") { + netnsProbeCalled = true + return "", "", 0, nil + } + return "", "unexpected command", 1, fmt.Errorf("unexpected command: %s", cmd) + } + + hostDialCalled := false + transportDialContext = func(_ context.Context, _ string, _ string) (net.Conn, error) { + hostDialCalled = true + return nil, fmt.Errorf("must not use host dial when netns probe succeeds") + } + + client := TransportClient{ + ID: "sg-probe", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + "config_path": cfgPath, + "netns_enabled": true, + "netns_name": "svpn-sg", + "netns_exec_mode": "ip", + "netns_ip_bin": "ip", + }, + } + backend := selectTransportBackend(client) + health := backend.Health(client) + if !health.OK || health.Status != TransportClientUp { + t.Fatalf("expected healthy up, got %#v", health) + } + if health.LatencyMS <= 0 { + t.Fatalf("expected latency sample from netns probe, got %#v", health) + } + if !netnsProbeCalled { + t.Fatalf("expected netns probe command to be called") + } + if hostDialCalled { + t.Fatalf("host dial must not be called when netns probe succeeds") + } +} + +func TestTransportSystemdBackendProvisionWritesUnits(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + calls := make([]string, 0, 8) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + switch cmd { + case "systemctl daemon-reload", "systemctl enable dnstt-ssh.service", "systemctl enable dnstt-client.service": + return "", "", 0, nil + default: + return "", "", 0, nil + } + } + + client := TransportClient{ + ID: "dnstt-home", + Kind: TransportClientDNSTT, + Config: map[string]any{ + "runner": "systemd", + "unit": "dnstt-client.service", + "doh_url": "https://dns.google/dns-query", + "pubkey": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "domain": "tunnel.example.com", + "local_addr": "127.0.0.1:7001", + "ssh_tunnel": true, + "ssh_unit": "dnstt-ssh.service", + "ssh_exec_start": "/usr/bin/ssh -N -D 127.0.0.1:1080 root@example.com", + "enable_on_boot": true, + }, + } + + backend := selectTransportBackend(client) + res := backend.Provision(client) + if !res.OK { + t.Fatalf("expected provision success, got %#v", res) + } + if !strings.Contains(res.Message, "template:dnstt") { + t.Fatalf("expected template source in provision message, got %#v", res) + } + primaryPath := filepath.Join(tmpDir, "dnstt-client.service") + sshPath := filepath.Join(tmpDir, "dnstt-ssh.service") + primaryData, err := os.ReadFile(primaryPath) + if err != nil { + t.Fatalf("failed to read primary unit: %v", err) + } + sshData, err := os.ReadFile(sshPath) + if err != nil { + t.Fatalf("failed to read ssh unit: %v", err) + } + primaryText := string(primaryData) + if !strings.Contains(primaryText, "Requires=dnstt-ssh.service") { + t.Fatalf("primary unit missing ssh require: %s", primaryText) + } + if !strings.Contains(primaryText, "dnstt-client") { + t.Fatalf("primary unit missing dnstt command: %s", primaryText) + } + if !strings.Contains(primaryText, "ExecStart=/bin/sh -lc ") { + t.Fatalf("primary unit missing shell exec start: %s", primaryText) + } + if !strings.Contains(string(sshData), "ExecStart=/bin/sh -lc ") { + t.Fatalf("ssh unit missing shell exec start") + } + if len(calls) == 0 || calls[0] != "systemctl daemon-reload" { + t.Fatalf("expected daemon-reload call first, got %#v", calls) + } +} + +func TestTransportSystemdBackendProvisionRequiresDNSTTTemplateFields(t *testing.T) { + backend := transportSystemdBackend{} + client := TransportClient{ + ID: "dnstt-home", + Kind: TransportClientDNSTT, + Config: map[string]any{ + "runner": "systemd", + "unit": "dnstt-client.service", + }, + } + res := backend.Provision(client) + if res.OK { + t.Fatalf("expected provision failure without required dnstt template fields") + } + if res.Code != "TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED" { + t.Fatalf("unexpected code: %#v", res) + } + if !strings.Contains(strings.ToLower(res.Message), "dnstt template requires") { + t.Fatalf("unexpected message: %#v", res) + } +} + +func TestTransportSystemdBackendCleanupRemovesOwnedUnits(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + unit := "sg-clean.service" + unitPath := filepath.Join(tmpDir, unit) + unitBody := "[Service]\nEnvironment=SVPN_TRANSPORT_ID=sg-clean\nExecStart=/usr/bin/sleep 60\n" + if err := os.WriteFile(unitPath, []byte(unitBody), 0o644); err != nil { + t.Fatalf("write unit file: %v", err) + } + + calls := make([]string, 0, 8) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-clean", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": unit, + }, + } + backend := selectTransportBackend(client) + res := backend.Cleanup(client) + if !res.OK { + t.Fatalf("expected cleanup success, got %#v", res) + } + if _, err := os.Stat(unitPath); !os.IsNotExist(err) { + t.Fatalf("unit file was not removed: %v", err) + } + + got := strings.Join(calls, " | ") + wantParts := []string{ + "systemctl stop " + unit, + "systemctl disable " + unit, + "systemctl daemon-reload", + "systemctl reset-failed " + unit, + } + for _, part := range wantParts { + if !strings.Contains(got, part) { + t.Fatalf("expected cleanup calls to contain %q, got: %s", part, got) + } + } +} + +func TestTransportSystemdBackendCleanupSkipsForeignUnits(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + unit := "sg-foreign.service" + unitPath := filepath.Join(tmpDir, unit) + unitBody := "[Service]\nEnvironment=SVPN_TRANSPORT_ID=someone-else\nExecStart=/usr/bin/sleep 60\n" + if err := os.WriteFile(unitPath, []byte(unitBody), 0o644); err != nil { + t.Fatalf("write unit file: %v", err) + } + + calls := make([]string, 0, 4) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-clean", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": unit, + }, + } + backend := selectTransportBackend(client) + res := backend.Cleanup(client) + if !res.OK { + t.Fatalf("expected cleanup success for foreign unit skip, got %#v", res) + } + if !strings.Contains(strings.ToLower(res.Message), "no managed unit artifacts") { + t.Fatalf("unexpected cleanup message: %#v", res) + } + if _, err := os.Stat(unitPath); err != nil { + t.Fatalf("foreign unit should stay intact, stat error: %v", err) + } + if len(calls) != 0 { + t.Fatalf("expected no systemctl calls for foreign unit, got %#v", calls) + } +} + +func TestResolveTransportPrimaryExecStartManualOverride(t *testing.T) { + client := TransportClient{ + Kind: TransportClientSingBox, + Config: map[string]any{ + "exec_start": " /custom/bin/sing-box run -c /tmp/custom.json ", + }, + } + cmd, source, err := resolveTransportPrimaryExecStart(client) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if source != "manual" { + t.Fatalf("expected manual source, got %q", source) + } + if cmd != "/custom/bin/sing-box run -c /tmp/custom.json" { + t.Fatalf("unexpected manual command: %q", cmd) + } +} + +func TestResolveTransportPrimaryExecStartSingBoxTemplate(t *testing.T) { + client := TransportClient{ + ID: "sg-eu", + Kind: TransportClientSingBox, + Config: map[string]any{ + "bin": "/usr/bin/sing-box", + "config_path": "/etc/singbox/eu.json", + }, + } + cmd, source, err := resolveTransportPrimaryExecStart(client) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if source != "template:singbox" { + t.Fatalf("unexpected source: %q", source) + } + if !strings.Contains(cmd, "'/usr/bin/sing-box' 'run' '-c' '/etc/singbox/eu.json'") { + t.Fatalf("unexpected template command: %q", cmd) + } +} + +func TestResolveTransportPrimaryExecStartPhoenixTemplate(t *testing.T) { + client := TransportClient{ + ID: "ph-eu", + Kind: TransportClientPhoenix, + Config: map[string]any{ + "phoenix_bin": "/usr/local/bin/phoenix-client", + "config_path": "/etc/phoenix/client.toml", + }, + } + cmd, source, err := resolveTransportPrimaryExecStart(client) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if source != "template:phoenix" { + t.Fatalf("unexpected source: %q", source) + } + if !strings.Contains(cmd, "'/usr/local/bin/phoenix-client' '-config' '/etc/phoenix/client.toml'") { + t.Fatalf("unexpected template command: %q", cmd) + } +} + +func TestBuildTransportDNSTTClientCommandTemplate(t *testing.T) { + client := TransportClient{ + ID: "dn-home", + Kind: TransportClientDNSTT, + Config: map[string]any{ + "dnstt_bin": "/usr/local/bin/dnstt-client", + "doh_url": "https://dns.example.com/dns-query", + "pubkey": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "domain": "tunnel.example.com", + "local_addr": "127.0.0.1:7002", + "utls": "HelloChrome_Auto", + }, + } + cmd, err := buildTransportDNSTTClientCommand(client) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + wantParts := []string{ + "'/usr/local/bin/dnstt-client'", + "'-doh' 'https://dns.example.com/dns-query'", + "'-pubkey' 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'", + "'-utls' 'HelloChrome_Auto'", + "'tunnel.example.com' '127.0.0.1:7002'", + } + for _, part := range wantParts { + if !strings.Contains(cmd, part) { + t.Fatalf("command missing %q in %q", part, cmd) + } + } +} + +func TestBuildTransportDNSTTClientCommandRequiresDomain(t *testing.T) { + client := TransportClient{ + Kind: TransportClientDNSTT, + Config: map[string]any{ + "doh_url": "https://dns.example.com/dns-query", + "pubkey": "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe", + }, + } + _, err := buildTransportDNSTTClientCommand(client) + if err == nil { + t.Fatalf("expected error for missing domain") + } + if !strings.Contains(err.Error(), "config.domain") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildTransportSingBoxCommandBundledProfilePrefersBinRoot(t *testing.T) { + tmpDir := t.TempDir() + binPath := filepath.Join(tmpDir, "sing-box") + if err := os.WriteFile(binPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write bundled binary: %v", err) + } + client := TransportClient{ + ID: "sg-pack", + Kind: TransportClientSingBox, + Config: map[string]any{ + "packaging_profile": "bundled", + "bin_root": tmpDir, + "require_binary": true, + "config_path": "/etc/singbox/eu.json", + }, + } + cmd, err := buildTransportSingBoxCommand(client) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(cmd, shellQuoteArg(binPath)) { + t.Fatalf("expected bundled binary path in command, got %q", cmd) + } +} + +func TestBuildTransportPhoenixClientCommandRequireBinaryMissing(t *testing.T) { + client := TransportClient{ + ID: "ph-pack", + Kind: TransportClientPhoenix, + Config: map[string]any{ + "packaging_profile": "bundled", + "bin_root": t.TempDir(), + "packaging_system_fallback": false, + "require_binary": true, + "config_path": "/etc/phoenix/client.toml", + }, + } + _, err := buildTransportPhoenixClientCommand(client) + if err == nil { + t.Fatalf("expected error when required bundled binary is missing") + } + if !strings.Contains(err.Error(), "required phoenix binary not found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildTransportSingBoxCommandRequireBinaryMissingManualOverride(t *testing.T) { + client := TransportClient{ + Kind: TransportClientSingBox, + Config: map[string]any{ + "singbox_bin": "/tmp/definitely-missing-sing-box-bin", + "require_binary": true, + "singbox_config_path": "/etc/singbox/eu.json", + }, + } + _, err := buildTransportSingBoxCommand(client) + if err == nil { + t.Fatalf("expected manual binary validation error") + } + if !strings.Contains(err.Error(), "required singbox binary not found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTransportSystemdBackendProvisionRendersServiceTuning(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + transportRunCommand = func(_ time.Duration, _ string, _ ...string) (string, string, int, error) { + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-eu", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + "exec_start": "/usr/bin/sing-box run -c /etc/singbox/eu.json", + "restart_policy": "on-failure", + "restart_sec": 7, + "start_limit_interval_sec": 900, + "start_limit_burst": 11, + "timeout_start_sec": 55, + "timeout_stop_sec": 33, + "watchdog_sec": 20, + }, + } + + backend := selectTransportBackend(client) + res := backend.Provision(client) + if !res.OK { + t.Fatalf("expected provision success, got %#v", res) + } + unitPath := filepath.Join(tmpDir, "singbox@sg-eu.service.d", transportSingBoxInstanceDropIn) + data, err := os.ReadFile(unitPath) + if err != nil { + t.Fatalf("failed to read drop-in unit: %v", err) + } + text := string(data) + want := []string{ + "StartLimitIntervalSec=900", + "StartLimitBurst=11", + "Restart=on-failure", + "RestartSec=7", + "TimeoutStartSec=55", + "TimeoutStopSec=33", + "WatchdogSec=20", + "NotifyAccess=main", + } + for _, part := range want { + if !strings.Contains(text, part) { + t.Fatalf("expected unit to contain %q, got: %s", part, text) + } + } +} + +func TestTransportSystemdBackendProvisionRendersSSHServiceTuningOverrides(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + transportRunCommand = func(_ time.Duration, _ string, _ ...string) (string, string, int, error) { + return "", "", 0, nil + } + + client := TransportClient{ + ID: "dn-home", + Kind: TransportClientDNSTT, + Config: map[string]any{ + "runner": "systemd", + "unit": "dnstt-home.service", + "doh_url": "https://dns.google/dns-query", + "pubkey": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "domain": "tunnel.example.com", + "local_addr": "127.0.0.1:7003", + "ssh_tunnel": true, + "ssh_unit": "dnstt-home-ssh.service", + "ssh_exec_start": "/usr/bin/ssh -N -D 127.0.0.1:1080 root@example.com", + "restart_sec": 4, + "ssh_restart_sec": 9, + "watchdog_sec": 0, + "ssh_watchdog_sec": 25, + "ssh_restart_policy": "on-failure", + }, + } + + backend := selectTransportBackend(client) + res := backend.Provision(client) + if !res.OK { + t.Fatalf("expected provision success, got %#v", res) + } + primaryData, err := os.ReadFile(filepath.Join(tmpDir, "dnstt-home.service")) + if err != nil { + t.Fatalf("failed to read primary unit: %v", err) + } + sshData, err := os.ReadFile(filepath.Join(tmpDir, "dnstt-home-ssh.service")) + if err != nil { + t.Fatalf("failed to read ssh unit: %v", err) + } + primaryText := string(primaryData) + sshText := string(sshData) + if !strings.Contains(primaryText, "RestartSec=4") { + t.Fatalf("expected primary restart sec from base config: %s", primaryText) + } + if strings.Contains(primaryText, "WatchdogSec=") { + t.Fatalf("did not expect primary watchdog line: %s", primaryText) + } + if !strings.Contains(sshText, "RestartSec=9") { + t.Fatalf("expected ssh restart sec override: %s", sshText) + } + if !strings.Contains(sshText, "Restart=on-failure") { + t.Fatalf("expected ssh restart policy override: %s", sshText) + } + if !strings.Contains(sshText, "WatchdogSec=25") { + t.Fatalf("expected ssh watchdog override: %s", sshText) + } +} + +func TestTransportSystemdBackendProvisionDefaultHardeningEnabled(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + transportRunCommand = func(_ time.Duration, _ string, _ ...string) (string, string, int, error) { + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-hardened", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "sg-hardened.service", + "exec_start": "/usr/bin/sing-box run -c /etc/singbox/eu.json", + }, + } + backend := selectTransportBackend(client) + res := backend.Provision(client) + if !res.OK { + t.Fatalf("expected provision success, got %#v", res) + } + data, err := os.ReadFile(filepath.Join(tmpDir, "sg-hardened.service")) + if err != nil { + t.Fatalf("failed to read unit: %v", err) + } + text := string(data) + want := []string{ + "NoNewPrivileges=yes", + "PrivateTmp=yes", + "ProtectSystem=full", + "ProtectHome=read-only", + "ProtectControlGroups=yes", + "ProtectKernelModules=yes", + "ProtectKernelTunables=yes", + "RestrictSUIDSGID=yes", + "LockPersonality=yes", + "UMask=0077", + } + for _, part := range want { + if !strings.Contains(text, part) { + t.Fatalf("expected baseline hardening line %q in unit: %s", part, text) + } + } +} + +func TestTransportSystemdBackendProvisionHardeningCanBeDisabled(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + transportRunCommand = func(_ time.Duration, _ string, _ ...string) (string, string, int, error) { + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-soft", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "sg-soft.service", + "exec_start": "/usr/bin/sing-box run -c /etc/singbox/eu.json", + "hardening_enabled": false, + }, + } + backend := selectTransportBackend(client) + res := backend.Provision(client) + if !res.OK { + t.Fatalf("expected provision success, got %#v", res) + } + data, err := os.ReadFile(filepath.Join(tmpDir, "sg-soft.service")) + if err != nil { + t.Fatalf("failed to read unit: %v", err) + } + text := string(data) + blocked := []string{ + "NoNewPrivileges=", + "ProtectSystem=", + "ProtectHome=", + "RestrictSUIDSGID=", + "UMask=", + } + for _, part := range blocked { + if strings.Contains(text, part) { + t.Fatalf("expected hardening line %q to be absent when disabled: %s", part, text) + } + } +} + +func TestTransportSystemdBackendProvisionSSHHardeningOverrideDisabled(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + transportRunCommand = func(_ time.Duration, _ string, _ ...string) (string, string, int, error) { + return "", "", 0, nil + } + + client := TransportClient{ + ID: "dn-hard", + Kind: TransportClientDNSTT, + Config: map[string]any{ + "runner": "systemd", + "unit": "dn-hard.service", + "doh_url": "https://dns.google/dns-query", + "pubkey": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "domain": "tunnel.example.com", + "local_addr": "127.0.0.1:7004", + "ssh_tunnel": true, + "ssh_unit": "dn-hard-ssh.service", + "ssh_exec_start": "/usr/bin/ssh -N -D 127.0.0.1:1080 root@example.com", + "hardening_profile": "strict", + "ssh_hardening_enabled": false, + }, + } + backend := selectTransportBackend(client) + res := backend.Provision(client) + if !res.OK { + t.Fatalf("expected provision success, got %#v", res) + } + primaryData, err := os.ReadFile(filepath.Join(tmpDir, "dn-hard.service")) + if err != nil { + t.Fatalf("failed to read primary unit: %v", err) + } + sshData, err := os.ReadFile(filepath.Join(tmpDir, "dn-hard-ssh.service")) + if err != nil { + t.Fatalf("failed to read ssh unit: %v", err) + } + primaryText := string(primaryData) + sshText := string(sshData) + if !strings.Contains(primaryText, "ProtectSystem=strict") { + t.Fatalf("expected strict hardening in primary unit: %s", primaryText) + } + if !strings.Contains(primaryText, "PrivateDevices=yes") { + t.Fatalf("expected strict private devices in primary unit: %s", primaryText) + } + if strings.Contains(sshText, "NoNewPrivileges=") || strings.Contains(sshText, "ProtectSystem=") { + t.Fatalf("expected ssh unit hardening to be disabled by ssh_hardening_enabled=false: %s", sshText) + } +} diff --git a/selective-vpn-api/app/transport_bootstrap_bypass.go b/selective-vpn-api/app/transport_bootstrap_bypass.go new file mode 100644 index 0000000..5e2c7fa --- /dev/null +++ b/selective-vpn-api/app/transport_bootstrap_bypass.go @@ -0,0 +1,110 @@ +package app + +import ( + "fmt" + "strings" +) + +const transportBootstrapStateVersion = 1 + +var transportBootstrapStatePath = transportBootstrapPath + +type transportBootstrapState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + Clients map[string][]string `json:"clients,omitempty"` +} + +type transportMainRoute struct { + Dev string + Via string +} + +func transportMaybeSyncBootstrapBypass(client TransportClient, action string) (string, error) { + act := strings.ToLower(strings.TrimSpace(action)) + switch act { + case "start", "restart", "stop": + default: + return "", nil + } + if !transportBootstrapBypassEnabled(client) { + return "", nil + } + if act == "stop" { + if err := transportBootstrapRemoveClientRoutes(client.ID); err != nil { + return "", err + } + return "bootstrap bypass routes removed", nil + } + return transportBootstrapSyncClientRoutes(client) +} + +func transportBootstrapBypassEnabled(client TransportClient) bool { + if transportConfigHasKey(client.Config, "bootstrap_bypass") { + return transportConfigBool(client.Config, "bootstrap_bypass") + } + // Default: enable for SingBox only. + return client.Kind == TransportClientSingBox +} + +func transportBootstrapBypassStrict(client TransportClient) bool { + return transportConfigBool(client.Config, "bootstrap_bypass_strict") +} + +func transportBootstrapSyncClientRoutes(client TransportClient) (string, error) { + candidates := transportCollectBootstrapCandidates(client) + ips, resolveWarn := transportResolveBootstrapIPv4(candidates) + + if len(ips) == 0 { + if err := transportBootstrapReplaceClientRoutes(client.ID, nil, transportMainRoute{}); err != nil { + return "", err + } + if resolveWarn != nil { + return "", resolveWarn + } + return "bootstrap bypass: no endpoints found", nil + } + + mainRoute, err := transportDetectMainIPv4Route() + if err != nil { + return "", err + } + if err := transportBootstrapReplaceClientRoutes(client.ID, ips, mainRoute); err != nil { + return "", err + } + + msg := fmt.Sprintf( + "bootstrap bypass synced: ips=%d via=%s dev=%s", + len(ips), + transportOr(mainRoute.Via, "link"), + mainRoute.Dev, + ) + if resolveWarn != nil { + msg += "; " + resolveWarn.Error() + } + return msg, nil +} + +func transportCommandError(cmd, stdout, stderr string, code int, err error) error { + msg := strings.TrimSpace(stderr) + if msg == "" { + msg = strings.TrimSpace(stdout) + } + if msg == "" && err != nil { + msg = strings.TrimSpace(err.Error()) + } + if msg == "" { + msg = fmt.Sprintf("exit code %d", code) + } + if err != nil { + return fmt.Errorf("%s failed: %s", strings.TrimSpace(cmd), msg) + } + return fmt.Errorf("%s failed (code=%d): %s", strings.TrimSpace(cmd), code, msg) +} + +func transportOr(v, fallback string) string { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + return strings.TrimSpace(fallback) +} diff --git a/selective-vpn-api/app/transport_bootstrap_bypass_candidates.go b/selective-vpn-api/app/transport_bootstrap_bypass_candidates.go new file mode 100644 index 0000000..1729dff --- /dev/null +++ b/selective-vpn-api/app/transport_bootstrap_bypass_candidates.go @@ -0,0 +1,132 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +func transportCollectBootstrapCandidates(client TransportClient) []string { + candidates := make([]string, 0, 16) + keys := []string{ + "bootstrap_endpoint", + "bootstrap_endpoints", + "bootstrap_host", + "bootstrap_hosts", + "endpoint", + "endpoint_host", + "remote_host", + "server", + "host", + "url", + "uri", + "link", + "ssh_host", + } + for _, key := range keys { + candidates = append(candidates, transportConfigValues(client.Config, key)...) + } + if client.Kind == TransportClientSingBox { + candidates = append(candidates, transportCollectSingBoxConfigCandidates(client)...) + } + return dedupeStrings(candidates) +} + +func transportCollectSingBoxConfigCandidates(client TransportClient) []string { + path := transportSingBoxConfigPath(client) + if path == "" { + return nil + } + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return nil + } + var root map[string]any + if err := json.Unmarshal(data, &root); err != nil { + return nil + } + rawOutbounds, ok := root["outbounds"].([]any) + if !ok || len(rawOutbounds) == 0 { + return nil + } + out := make([]string, 0, 8) + for _, raw := range rawOutbounds { + transportCollectServerCandidatesRecursive(raw, &out) + } + return dedupeStrings(out) +} + +func transportCollectServerCandidatesRecursive(node any, out *[]string) { + switch v := node.(type) { + case map[string]any: + for key, val := range v { + k := strings.ToLower(strings.TrimSpace(key)) + if k == "server" || k == "address" || k == "host" { + *out = append(*out, transportValueToStrings(val)...) + } + transportCollectServerCandidatesRecursive(val, out) + } + case []any: + for _, item := range v { + transportCollectServerCandidatesRecursive(item, out) + } + } +} + +func transportConfigValues(cfg map[string]any, key string) []string { + if cfg == nil { + return nil + } + raw, ok := cfg[key] + if !ok || raw == nil { + return nil + } + return transportValueToStrings(raw) +} + +func transportValueToStrings(raw any) []string { + switch v := raw.(type) { + case string: + return transportSplitValue(v) + case []string: + out := make([]string, 0, len(v)) + for _, it := range v { + out = append(out, transportSplitValue(it)...) + } + return out + case []any: + out := make([]string, 0, len(v)) + for _, it := range v { + switch vv := it.(type) { + case string: + out = append(out, transportSplitValue(vv)...) + default: + out = append(out, transportSplitValue(fmt.Sprint(vv))...) + } + } + return out + default: + return transportSplitValue(fmt.Sprint(v)) + } +} + +func transportSplitValue(raw string) []string { + s := strings.TrimSpace(raw) + if s == "" { + return nil + } + if strings.ContainsAny(s, ",;\n\t") { + repl := strings.NewReplacer(",", " ", ";", " ", "\n", " ", "\t", " ") + fields := strings.Fields(repl.Replace(s)) + out := make([]string, 0, len(fields)) + for _, f := range fields { + ff := strings.TrimSpace(f) + if ff != "" { + out = append(out, ff) + } + } + return out + } + return []string{s} +} diff --git a/selective-vpn-api/app/transport_bootstrap_bypass_resolve.go b/selective-vpn-api/app/transport_bootstrap_bypass_resolve.go new file mode 100644 index 0000000..a60b78a --- /dev/null +++ b/selective-vpn-api/app/transport_bootstrap_bypass_resolve.go @@ -0,0 +1,137 @@ +package app + +import ( + "context" + "fmt" + "net" + "net/netip" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +func transportResolveBootstrapIPv4(candidates []string) ([]string, error) { + set := map[string]struct{}{} + var warns []string + + for _, raw := range candidates { + host := transportNormalizeBootstrapTarget(raw) + if host == "" { + continue + } + if addr, err := netip.ParseAddr(host); err == nil { + if addr.Is4() { + set[addr.String()] = struct{}{} + } + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), 2500*time.Millisecond) + ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + cancel() + if err != nil { + warns = append(warns, host+": "+strings.TrimSpace(err.Error())) + continue + } + resolved := 0 + for _, ipa := range ipAddrs { + v4 := ipa.IP.To4() + if v4 == nil { + continue + } + set[v4.String()] = struct{}{} + resolved++ + } + if resolved == 0 { + warns = append(warns, host+": no IPv4 records") + } + } + + ips := make([]string, 0, len(set)) + for ip := range set { + ips = append(ips, ip) + } + sort.Strings(ips) + + if len(warns) == 0 { + return ips, nil + } + if len(warns) > 3 { + warns = append(warns[:3], fmt.Sprintf("... and %d more", len(warns)-3)) + } + return ips, fmt.Errorf("bootstrap resolve warnings: %s", strings.Join(warns, "; ")) +} + +func transportNormalizeBootstrapTarget(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + + if strings.Contains(s, "://") { + if u, err := url.Parse(s); err == nil { + host := strings.TrimSpace(u.Host) + if host != "" { + s = host + } else if strings.TrimSpace(u.Opaque) != "" { + s = strings.TrimSpace(u.Opaque) + } + } + } + + if at := strings.LastIndex(s, "@"); at >= 0 { + s = s[at+1:] + } + if idx := strings.IndexAny(s, "/?#"); idx >= 0 { + s = s[:idx] + } + s = strings.TrimSpace(s) + if s == "" { + return "" + } + + if host, _, err := net.SplitHostPort(s); err == nil { + s = host + } else if strings.Count(s, ":") == 1 { + i := strings.LastIndex(s, ":") + if i > 0 { + if _, err := strconv.Atoi(strings.TrimSpace(s[i+1:])); err == nil { + s = s[:i] + } + } + } + s = strings.TrimSpace(strings.Trim(s, "[]")) + s = strings.TrimSuffix(s, ".") + if s == "" { + return "" + } + return strings.ToLower(s) +} + +func transportDetectMainIPv4Route() (transportMainRoute, error) { + stdout, stderr, code, err := transportRunCommand(5*time.Second, "ip", "-4", "route", "show", "table", "main", "default") + if err != nil || code != 0 { + return transportMainRoute{}, transportCommandError("ip -4 route show table main default", stdout, stderr, code, err) + } + for _, line := range strings.Split(stdout, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) == 0 || fields[0] != "default" { + continue + } + route := transportMainRoute{} + for i := 0; i < len(fields)-1; i++ { + switch fields[i] { + case "dev": + route.Dev = strings.TrimSpace(fields[i+1]) + case "via": + route.Via = strings.TrimSpace(fields[i+1]) + } + } + if route.Dev != "" { + return route, nil + } + } + return transportMainRoute{}, fmt.Errorf("main default route not found") +} diff --git a/selective-vpn-api/app/transport_bootstrap_bypass_routes.go b/selective-vpn-api/app/transport_bootstrap_bypass_routes.go new file mode 100644 index 0000000..7b60393 --- /dev/null +++ b/selective-vpn-api/app/transport_bootstrap_bypass_routes.go @@ -0,0 +1,89 @@ +package app + +import ( + "strings" + "time" +) + +func transportBootstrapReplaceClientRoutes(clientID string, next []string, route transportMainRoute) error { + id := sanitizeID(clientID) + if id == "" { + return nil + } + st := loadTransportBootstrapState() + prev := append([]string(nil), st.Clients[id]...) + next = normalizeBootstrapIPv4List(next) + + nextSet := map[string]struct{}{} + for _, ip := range next { + nextSet[ip] = struct{}{} + } + for _, ip := range prev { + if _, keep := nextSet[ip]; keep { + continue + } + if err := transportDeleteBootstrapRoute(ip); err != nil { + return err + } + } + for _, ip := range next { + if err := transportReplaceBootstrapRoute(ip, route); err != nil { + return err + } + } + + if len(st.Clients) == 0 { + st.Clients = map[string][]string{} + } + if len(next) == 0 { + delete(st.Clients, id) + } else { + st.Clients[id] = append([]string(nil), next...) + } + return saveTransportBootstrapState(st) +} + +func transportBootstrapRemoveClientRoutes(clientID string) error { + id := sanitizeID(clientID) + if id == "" { + return nil + } + st := loadTransportBootstrapState() + prev := append([]string(nil), st.Clients[id]...) + if len(prev) == 0 { + return nil + } + for _, ip := range prev { + if err := transportDeleteBootstrapRoute(ip); err != nil { + return err + } + } + delete(st.Clients, id) + return saveTransportBootstrapState(st) +} + +func transportReplaceBootstrapRoute(ip string, route transportMainRoute) error { + args := []string{"-4", "route", "replace", ip + "/32", "table", routesTableName()} + if strings.TrimSpace(route.Via) != "" { + args = append(args, "via", route.Via) + } + args = append(args, "dev", route.Dev) + stdout, stderr, code, err := transportRunCommand(5*time.Second, "ip", args...) + if err != nil || code != 0 { + return transportCommandError("ip "+strings.Join(args, " "), stdout, stderr, code, err) + } + return nil +} + +func transportDeleteBootstrapRoute(ip string) error { + args := []string{"-4", "route", "del", ip + "/32", "table", routesTableName()} + stdout, stderr, code, err := transportRunCommand(5*time.Second, "ip", args...) + if err == nil && code == 0 { + return nil + } + combined := strings.ToLower(strings.TrimSpace(stderr + " " + stdout)) + if strings.Contains(combined, "no such process") || strings.Contains(combined, "cannot find") { + return nil + } + return transportCommandError("ip "+strings.Join(args, " "), stdout, stderr, code, err) +} diff --git a/selective-vpn-api/app/transport_bootstrap_bypass_state.go b/selective-vpn-api/app/transport_bootstrap_bypass_state.go new file mode 100644 index 0000000..7db2503 --- /dev/null +++ b/selective-vpn-api/app/transport_bootstrap_bypass_state.go @@ -0,0 +1,108 @@ +package app + +import ( + "encoding/json" + "net/netip" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func loadTransportBootstrapState() transportBootstrapState { + st := transportBootstrapState{Version: transportBootstrapStateVersion} + data, err := os.ReadFile(transportBootstrapStatePath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return transportBootstrapState{Version: transportBootstrapStateVersion} + } + return normalizeTransportBootstrapState(st) +} + +func saveTransportBootstrapState(st transportBootstrapState) error { + st = normalizeTransportBootstrapState(st) + st.Version = transportBootstrapStateVersion + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportBootstrapStatePath), 0o755); err != nil { + return err + } + tmp := transportBootstrapStatePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportBootstrapStatePath) +} + +func normalizeTransportBootstrapState(st transportBootstrapState) transportBootstrapState { + st.Version = transportBootstrapStateVersion + if len(st.Clients) == 0 { + st.Clients = nil + return st + } + out := make(map[string][]string, len(st.Clients)) + for rawID, rawIPs := range st.Clients { + id := sanitizeID(rawID) + if id == "" { + continue + } + ips := normalizeBootstrapIPv4List(rawIPs) + if len(ips) == 0 { + continue + } + out[id] = ips + } + if len(out) == 0 { + st.Clients = nil + return st + } + st.Clients = out + return st +} + +func normalizeBootstrapIPv4List(in []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(in)) + for _, raw := range in { + ip := strings.TrimSpace(raw) + addr, err := netip.ParseAddr(ip) + if err != nil || !addr.Is4() { + continue + } + key := addr.String() + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + sort.Strings(out) + return out +} + +func dedupeStrings(in []string) []string { + if len(in) == 0 { + return nil + } + 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 +} diff --git a/selective-vpn-api/app/transport_bootstrap_bypass_test.go b/selective-vpn-api/app/transport_bootstrap_bypass_test.go new file mode 100644 index 0000000..46d3ead --- /dev/null +++ b/selective-vpn-api/app/transport_bootstrap_bypass_test.go @@ -0,0 +1,118 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestTransportNormalizeBootstrapTarget(t *testing.T) { + cases := map[string]string{ + "vless://user@example.com:443?type=tcp": "example.com", + "example.com:8443": "example.com", + "203.0.113.10:443": "203.0.113.10", + "[2001:db8::1]:443": "2001:db8::1", + "https://api.example.net/path?q=1": "api.example.net", + } + for in, want := range cases { + got := transportNormalizeBootstrapTarget(in) + if got != want { + t.Fatalf("normalize mismatch for %q: got=%q want=%q", in, got, want) + } + } +} + +func TestTransportCollectSingBoxConfigCandidates(t *testing.T) { + tmp := t.TempDir() + cfg := filepath.Join(tmp, "singbox.json") + body := `{ + "log": {"level":"info"}, + "outbounds": [ + {"type":"vless","server":"n3.elmprod.tech","server_port":40903}, + {"type":"shadowsocks","server":"198.51.100.5","server_port":443} + ] +}` + if err := os.WriteFile(cfg, []byte(body), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + client := TransportClient{ + ID: "sg-test", + Kind: TransportClientSingBox, + Config: map[string]any{ + "config_path": cfg, + }, + } + got := transportCollectSingBoxConfigCandidates(client) + joined := strings.Join(got, ",") + if !strings.Contains(joined, "n3.elmprod.tech") { + t.Fatalf("missing server host in candidates: %#v", got) + } + if !strings.Contains(joined, "198.51.100.5") { + t.Fatalf("missing server ip in candidates: %#v", got) + } +} + +func TestTransportSystemdActionAppliesSingBoxBootstrapBypass(t *testing.T) { + origRunner := transportRunCommand + origPath := transportBootstrapStatePath + defer func() { + transportRunCommand = origRunner + transportBootstrapStatePath = origPath + }() + + tmp := t.TempDir() + transportBootstrapStatePath = filepath.Join(tmp, "bootstrap-routes.json") + + calls := make([]string, 0, 8) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + switch cmd { + case "ip -4 route show table main default": + return "default via 192.0.2.1 dev eth0\n", "", 0, nil + case "ip -4 route replace 203.0.113.10/32 table agvpn via 192.0.2.1 dev eth0": + return "", "", 0, nil + case "systemctl start singbox@sg-test.service": + return "", "", 0, nil + default: + return "", "", 0, nil + } + } + + client := TransportClient{ + ID: "sg-test", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + "bootstrap_host": "203.0.113.10", + }, + } + + res := transportSystemdBackend{}.Action(client, "start") + if !res.OK { + t.Fatalf("expected action success, got %#v", res) + } + + gotCalls := strings.Join(calls, " | ") + want := []string{ + "ip -4 route show table main default", + "ip -4 route replace 203.0.113.10/32 table agvpn via 192.0.2.1 dev eth0", + "systemctl start singbox@sg-test.service", + } + for _, part := range want { + if !strings.Contains(gotCalls, part) { + t.Fatalf("expected call %q, got %s", part, gotCalls) + } + } + data, err := os.ReadFile(transportBootstrapStatePath) + if err != nil { + t.Fatalf("read bootstrap state: %v", err) + } + text := string(data) + if !strings.Contains(text, "203.0.113.10") || !strings.Contains(text, "sg-test") { + t.Fatalf("unexpected bootstrap state: %s", text) + } +} diff --git a/selective-vpn-api/app/transport_client_iface_test.go b/selective-vpn-api/app/transport_client_iface_test.go new file mode 100644 index 0000000..d7679ba --- /dev/null +++ b/selective-vpn-api/app/transport_client_iface_test.go @@ -0,0 +1,49 @@ +package app + +import "testing" + +func TestNormalizeTransportClientsStateSetsIfaceID(t *testing.T) { + st := transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + {ID: "A", Kind: TransportClientSingBox, IfaceID: ""}, + {ID: "B", Kind: TransportClientDNSTT, IfaceID: "Lab Net"}, + }, + } + + norm, changed := normalizeTransportClientsState(st, false) + if !changed { + t.Fatalf("expected normalize change for iface_id") + } + if len(norm.Items) != 2 { + t.Fatalf("unexpected items count: %d", len(norm.Items)) + } + if norm.Items[0].ID != "a" || norm.Items[0].IfaceID != transportDefaultIfaceID { + t.Fatalf("unexpected normalized first item: %#v", norm.Items[0]) + } + if norm.Items[1].ID != "b" || norm.Items[1].IfaceID != "lab-net" { + t.Fatalf("unexpected normalized second item: %#v", norm.Items[1]) + } +} + +func TestNormalizeTransportClientsStateKeepsExplicitRoutingTable(t *testing.T) { + st := transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "A", + Kind: TransportClientSingBox, + IfaceID: "edge-lab", + RoutingTable: "custom-edge-table", + }, + }, + } + + norm, _ := normalizeTransportClientsState(st, false) + if len(norm.Items) != 1 { + t.Fatalf("unexpected items count: %d", len(norm.Items)) + } + if got := norm.Items[0].RoutingTable; got != "agvpn_custom_edge_table" { + t.Fatalf("unexpected routing table: %q", got) + } +} diff --git a/selective-vpn-api/app/transport_client_runtime.go b/selective-vpn-api/app/transport_client_runtime.go new file mode 100644 index 0000000..122e181 --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime.go @@ -0,0 +1,114 @@ +package app + +import ( + "strings" + "time" +) + +func applyTransportLifecycleFailure(it *TransportClient, action string, now time.Time, backendID string, result transportBackendActionResult) { + ts := now.Format(time.RFC3339) + prev := it.Status + rt := transportRuntimeSnapshot(*it, now) + if strings.TrimSpace(backendID) != "" { + rt.Backend = backendID + } + rt.LastAction = action + rt.LastActionAt = ts + rt.LastExitCode = result.ExitCode + msg := strings.TrimSpace(result.Message) + if msg == "" { + msg = "transport backend action failed" + } + rt.LastError = TransportClientError{ + Code: strings.TrimSpace(result.Code), + Message: msg, + Retryable: result.Retryable, + At: ts, + } + switch action { + case "start", "restart": + it.Status = TransportClientDegraded + case "stop": + // Keep previous state on failed stop to avoid false down transitions. + } + if prev != it.Status { + rt.Metrics.StateChanges++ + rt.Metrics.LastTransitionAt = ts + } + rt.Metrics.UptimeSec = transportUptimeSec(it.Status, rt.StartedAt, now) + it.Health.LastCheck = ts + it.Health.LastError = msg + it.UpdatedAt = ts + it.Runtime = rt +} + +func applyTransportProvisionResult(it *TransportClient, now time.Time, backendID string, result transportBackendActionResult) { + ts := now.Format(time.RFC3339) + rt := transportRuntimeSnapshot(*it, now) + if strings.TrimSpace(backendID) != "" { + rt.Backend = backendID + } + rt.LastAction = "provision" + rt.LastActionAt = ts + rt.LastExitCode = result.ExitCode + if result.OK { + rt.LastError = TransportClientError{} + it.Health.LastError = "" + } else { + msg := strings.TrimSpace(result.Message) + if msg == "" { + msg = "transport backend provision failed" + } + rt.LastError = TransportClientError{ + Code: strings.TrimSpace(result.Code), + Message: msg, + Retryable: result.Retryable, + At: ts, + } + it.Health.LastError = msg + } + it.Health.LastCheck = ts + it.UpdatedAt = ts + it.Runtime = rt +} + +func applyTransportLifecycleAction(it *TransportClient, action string, now time.Time) { + ts := now.Format(time.RFC3339) + prev := it.Status + rt := transportRuntimeSnapshot(*it, now) + + rt.LastAction = action + rt.LastActionAt = ts + rt.LastExitCode = 0 + rt.LastError = TransportClientError{} + + switch action { + case "start": + it.Status = TransportClientUp + it.Enabled = true + case "stop": + it.Status = TransportClientDown + case "restart": + it.Status = TransportClientUp + rt.Metrics.Restarts++ + } + if prev != it.Status { + rt.Metrics.StateChanges++ + rt.Metrics.LastTransitionAt = ts + } + if it.Status == TransportClientUp { + if prev != TransportClientUp || strings.TrimSpace(rt.StartedAt) == "" { + rt.StartedAt = ts + } + } else if it.Status == TransportClientDown { + rt.StoppedAt = ts + } + rt.Metrics.UptimeSec = transportUptimeSec(it.Status, rt.StartedAt, now) + + it.Health.LastCheck = ts + if it.Status == TransportClientUp { + it.Health.LastError = "" + } + it.UpdatedAt = ts + it.Runtime = rt +} diff --git a/selective-vpn-api/app/transport_client_runtime_alloc.go b/selective-vpn-api/app/transport_client_runtime_alloc.go new file mode 100644 index 0000000..ebe901b --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_alloc.go @@ -0,0 +1,17 @@ +package app + +func allocateTransportSlots(items []TransportClient) (string, int) { + usedMarks := map[uint64]struct{}{} + usedPrefs := map[int]struct{}{} + for _, it := range items { + if n, ok := parseTransportMarkHex(it.MarkHex); ok { + usedMarks[n] = struct{}{} + } + if p, ok := parseTransportPref(it.PriorityBase); ok { + usedPrefs[p] = struct{}{} + } + } + mark := nextTransportMark(usedMarks) + pref := nextTransportPref(usedPrefs) + return formatTransportMarkHex(mark), pref +} diff --git a/selective-vpn-api/app/transport_client_runtime_alloc_normalize.go b/selective-vpn-api/app/transport_client_runtime_alloc_normalize.go new file mode 100644 index 0000000..740c8e5 --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_alloc_normalize.go @@ -0,0 +1,157 @@ +package app + +import ( + "sort" + "strings" + "time" +) + +func normalizeTransportClientsState(st transportClientsState, forceRebalance bool) (transportClientsState, bool) { + changed := false + st.Version = transportStateVersion + if st.Items == nil { + st.Items = nil + return st, false + } + + out := make([]TransportClient, 0, len(st.Items)) + byID := map[string]int{} + for _, raw := range st.Items { + it := raw + id := sanitizeID(it.ID) + if id == "" { + changed = true + continue + } + if it.ID != id { + it.ID = id + changed = true + } + ifaceID := normalizeTransportIfaceID(it.IfaceID) + if it.IfaceID != ifaceID { + it.IfaceID = ifaceID + changed = true + } + + kind := normalizeTransportKind(it.Kind) + if kind == "" { + kind = TransportClientSingBox + changed = true + } + it.Kind = kind + if normCfg, cfgChanged := normalizeTransportClientConfig(it.Kind, it.Config); cfgChanged { + it.Config = normCfg + changed = true + } + + if strings.TrimSpace(it.Name) == "" { + it.Name = id + changed = true + } + + status := normalizeTransportStatus(it.Status) + if status != it.Status { + it.Status = status + changed = true + } + + if !equalStringSlices(it.Capabilities, defaultTransportCapabilities(it.Kind)) { + it.Capabilities = defaultTransportCapabilities(it.Kind) + changed = true + } + if normRuntime, runtimeChanged := normalizeTransportRuntimeStored(it.Runtime, it.Kind, it.Config); runtimeChanged { + it.Runtime = normRuntime + changed = true + } + + if strings.TrimSpace(it.Health.LastCheck) == "" { + if strings.TrimSpace(it.UpdatedAt) != "" { + it.Health.LastCheck = it.UpdatedAt + } else { + it.Health.LastCheck = time.Now().UTC().Format(time.RFC3339) + } + changed = true + } + + if idx, ok := byID[it.ID]; ok { + // Keep deterministic winner for duplicate IDs. + if preferTransportClient(it, out[idx]) { + out[idx] = it + } + changed = true + continue + } + byID[it.ID] = len(out) + out = append(out, it) + } + + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + usedTables := map[string]struct{}{} + for i := range out { + if strings.TrimSpace(out[i].RoutingTable) == "" { + continue + } + wantTable := normalizeTransportRoutingTable(out[i].RoutingTable, transportRoutingTableForID(out[i].ID)) + if out[i].RoutingTable != wantTable { + out[i].RoutingTable = wantTable + changed = true + } + usedTables[wantTable] = struct{}{} + } + for i := range out { + if strings.TrimSpace(out[i].RoutingTable) != "" { + continue + } + wantTable := transportRoutingTableForIDUnique(out[i].ID, usedTables) + if out[i].RoutingTable != wantTable { + out[i].RoutingTable = wantTable + changed = true + } + } + + norm, allocChanged := reconcileTransportAllocations(out, forceRebalance) + if allocChanged { + changed = true + } + st.Items = norm + return st, changed +} + +func normalizeTransportStatus(st TransportClientStatus) TransportClientStatus { + switch st { + case TransportClientStarting, TransportClientUp, TransportClientDegraded, TransportClientDown: + return st + default: + return TransportClientDown + } +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func preferTransportClient(cand, cur TransportClient) bool { + cu := strings.TrimSpace(cand.UpdatedAt) + ou := strings.TrimSpace(cur.UpdatedAt) + if cu != ou { + if cu == "" { + return false + } + if ou == "" { + return true + } + return cu > ou + } + if cand.Enabled != cur.Enabled { + return cand.Enabled + } + return strings.TrimSpace(cand.Name) > strings.TrimSpace(cur.Name) +} diff --git a/selective-vpn-api/app/transport_client_runtime_alloc_reconcile.go b/selective-vpn-api/app/transport_client_runtime_alloc_reconcile.go new file mode 100644 index 0000000..ebbd6d9 --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_alloc_reconcile.go @@ -0,0 +1,88 @@ +package app + +import ( + "sort" + "strings" +) + +func reconcileTransportAllocations(items []TransportClient, forceRebalance bool) ([]TransportClient, bool) { + if len(items) == 0 { + return items, false + } + changed := false + + sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) + + usedMarks := map[uint64]struct{}{} + usedPrefs := map[int]struct{}{} + missingMark := make([]int, 0) + missingPref := make([]int, 0) + needRebalance := forceRebalance + + for i := range items { + if m, ok := parseTransportMarkHex(items[i].MarkHex); ok { + if _, exists := usedMarks[m]; exists { + needRebalance = true + } else { + usedMarks[m] = struct{}{} + } + } else if strings.TrimSpace(items[i].MarkHex) == "" { + missingMark = append(missingMark, i) + } else { + needRebalance = true + } + + if p, ok := parseTransportPref(items[i].PriorityBase); ok { + if _, exists := usedPrefs[p]; exists { + needRebalance = true + } else { + usedPrefs[p] = struct{}{} + } + } else if items[i].PriorityBase == 0 { + missingPref = append(missingPref, i) + } else { + needRebalance = true + } + } + + if needRebalance { + usedMarks = map[uint64]struct{}{} + usedPrefs = map[int]struct{}{} + for i := range items { + m := nextTransportMark(usedMarks) + p := nextTransportPref(usedPrefs) + usedMarks[m] = struct{}{} + usedPrefs[p] = struct{}{} + + markHex := formatTransportMarkHex(m) + if items[i].MarkHex != markHex { + items[i].MarkHex = markHex + changed = true + } + if items[i].PriorityBase != p { + items[i].PriorityBase = p + changed = true + } + } + return items, changed + } + + for _, idx := range missingMark { + m := nextTransportMark(usedMarks) + usedMarks[m] = struct{}{} + markHex := formatTransportMarkHex(m) + if items[idx].MarkHex != markHex { + items[idx].MarkHex = markHex + changed = true + } + } + for _, idx := range missingPref { + p := nextTransportPref(usedPrefs) + usedPrefs[p] = struct{}{} + if items[idx].PriorityBase != p { + items[idx].PriorityBase = p + changed = true + } + } + return items, changed +} diff --git a/selective-vpn-api/app/transport_client_runtime_alloc_slots.go b/selective-vpn-api/app/transport_client_runtime_alloc_slots.go new file mode 100644 index 0000000..054968b --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_alloc_slots.go @@ -0,0 +1,186 @@ +package app + +import ( + "fmt" + "strconv" + "strings" +) + +func transportRoutingTableForID(id string) string { + s := strings.TrimSpace(id) + if s == "" { + return "agvpn_client" + } + s = strings.ReplaceAll(s, "-", "_") + s = strings.ReplaceAll(s, ".", "_") + return normalizeTransportRoutingTable("agvpn_"+s, "agvpn_client") +} + +func transportRoutingTableForIfaceID(ifaceID string) string { + id := normalizeTransportIfaceID(ifaceID) + if id == transportDefaultIfaceID { + return "agvpn_shared" + } + s := strings.ReplaceAll(id, "-", "_") + return normalizeTransportRoutingTable("agvpn_if_"+s, "agvpn_client") +} + +func transportRoutingTableForIDUnique(id string, used map[string]struct{}) string { + base := transportRoutingTableForID(id) + if _, ok := used[base]; !ok { + used[base] = struct{}{} + return base + } + for i := 2; i < 1000; i++ { + suffix := "_" + strconv.Itoa(i) + maxBase := 31 - len(suffix) + if maxBase < 1 { + maxBase = 1 + } + prefix := base + if len(prefix) > maxBase { + prefix = prefix[:maxBase] + } + cand := prefix + suffix + if _, ok := used[cand]; ok { + continue + } + used[cand] = struct{}{} + return cand + } + used[base] = struct{}{} + return base +} + +func normalizeTransportRoutingTable(raw string, fallback string) string { + table := strings.ToLower(strings.TrimSpace(raw)) + if table == "" { + table = strings.ToLower(strings.TrimSpace(fallback)) + } + if table == "" { + return "agvpn_client" + } + table = strings.ReplaceAll(table, "-", "_") + table = strings.ReplaceAll(table, ".", "_") + table = strings.ReplaceAll(table, " ", "_") + var b strings.Builder + b.Grow(len(table)) + lastUnderscore := false + for i := 0; i < len(table); i++ { + ch := table[i] + isAZ := ch >= 'a' && ch <= 'z' + is09 := ch >= '0' && ch <= '9' + if isAZ || is09 { + b.WriteByte(ch) + lastUnderscore = false + continue + } + if !lastUnderscore { + b.WriteByte('_') + lastUnderscore = true + } + } + table = strings.Trim(b.String(), "_") + if table == "" { + table = "agvpn_client" + } + if !strings.HasPrefix(table, "agvpn_") { + table = "agvpn_" + table + } + if len(table) > 31 { + table = strings.Trim(table[:31], "_") + } + if table == "" { + return "agvpn_client" + } + return table +} + +func parseTransportMarkHex(raw string) (uint64, bool) { + s := strings.ToLower(strings.TrimSpace(raw)) + if !strings.HasPrefix(s, "0x") { + return 0, false + } + n, err := strconv.ParseUint(strings.TrimPrefix(s, "0x"), 16, 64) + if err != nil { + return 0, false + } + if !transportMarkAllowed(n) { + return 0, false + } + return n, true +} + +func parseTransportPref(raw int) (int, bool) { + if !transportPrefAllowed(raw) { + return 0, false + } + return raw, true +} + +func transportMarkAllowed(mark uint64) bool { + if mark < transportMarkStart || mark > transportMarkEnd { + return false + } + if mark <= transportMarkReserveEnd { + return false + } + return true +} + +func transportPrefAllowed(pref int) bool { + if pref < transportPrefStart || pref > transportPrefEnd { + return false + } + if pref <= transportPrefReserveEnd { + return false + } + if (pref-transportPrefStart)%transportPrefStep != 0 { + return false + } + return true +} + +func nextTransportMark(used map[uint64]struct{}) uint64 { + for v := transportMarkStart; v <= transportMarkEnd; v++ { + if !transportMarkAllowed(v) { + continue + } + if _, ok := used[v]; ok { + continue + } + return v + } + // Fallback: reuse first available from pool even if exhausted. + for v := transportMarkStart; v <= transportMarkEnd; v++ { + if !transportMarkAllowed(v) { + continue + } + return v + } + return transportMarkStart +} + +func nextTransportPref(used map[int]struct{}) int { + for p := transportPrefStart; p <= transportPrefEnd; p += transportPrefStep { + if !transportPrefAllowed(p) { + continue + } + if _, ok := used[p]; ok { + continue + } + return p + } + // Fallback: reuse first available from pool even if exhausted. + for p := transportPrefStart; p <= transportPrefEnd; p += transportPrefStep { + if !transportPrefAllowed(p) { + continue + } + return p + } + return transportPrefStart +} + +func formatTransportMarkHex(mark uint64) string { + return fmt.Sprintf("0x%x", mark) +} diff --git a/selective-vpn-api/app/transport_client_runtime_create.go b/selective-vpn-api/app/transport_client_runtime_create.go new file mode 100644 index 0000000..67d0cd7 --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_create.go @@ -0,0 +1,79 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +func buildTransportClientFromCreate(body TransportClientCreateRequest) (TransportClient, error) { + id := sanitizeID(body.ID) + if id == "" { + return TransportClient{}, fmt.Errorf("invalid id") + } + kind := normalizeTransportKind(body.Kind) + if kind == "" { + return TransportClient{}, fmt.Errorf("kind must be singbox|dnstt|phoenix") + } + enabled := true + if body.Enabled != nil { + enabled = *body.Enabled + } + name := strings.TrimSpace(body.Name) + if name == "" { + name = id + } + ifaceID := normalizeTransportIfaceID(body.IfaceID) + + client := TransportClient{ + ID: id, + Name: name, + Kind: kind, + Enabled: enabled, + Status: TransportClientDown, + IfaceID: ifaceID, + RoutingTable: transportRoutingTableForID(id), + Capabilities: defaultTransportCapabilities(kind), + Config: cloneMap(body.Config), + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Runtime: TransportClientRuntime{ + Backend: "", + AllowedActions: []string{"provision", "start", "stop", "restart"}, + LastExitCode: 0, + }, + } + if normCfg, _ := normalizeTransportClientConfig(client.Kind, client.Config); normCfg != nil || client.Config != nil { + client.Config = normCfg + } + client.Runtime.Backend = selectTransportBackend(client).ID() + client.Health = TransportClientHealth{ + LastCheck: client.UpdatedAt, + } + return client, nil +} + +func normalizeTransportKind(k TransportClientKind) TransportClientKind { + switch strings.ToLower(strings.TrimSpace(string(k))) { + case string(TransportClientSingBox): + return TransportClientSingBox + case string(TransportClientDNSTT): + return TransportClientDNSTT + case string(TransportClientPhoenix): + return TransportClientPhoenix + default: + return "" + } +} + +func defaultTransportCapabilities(kind TransportClientKind) []string { + switch kind { + case TransportClientSingBox: + return []string{"tcp", "udp", "dns_tunnel"} + case TransportClientDNSTT: + return []string{"tcp", "dns_tunnel"} + case TransportClientPhoenix: + return []string{"tcp", "udp", "ssh_tunnel"} + default: + return nil + } +} diff --git a/selective-vpn-api/app/transport_client_runtime_health.go b/selective-vpn-api/app/transport_client_runtime_health.go new file mode 100644 index 0000000..8831459 --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_health.go @@ -0,0 +1,85 @@ +package app + +import ( + "strings" + "time" +) + +func buildTransportHealthResponse(it TransportClient, now time.Time) TransportClientHealthResponse { + health := it.Health + if strings.TrimSpace(health.LastCheck) == "" { + if strings.TrimSpace(it.UpdatedAt) != "" { + health.LastCheck = it.UpdatedAt + } else { + health.LastCheck = now.Format(time.RFC3339) + } + } + if strings.TrimSpace(health.LastError) == "" { + health.LastError = strings.TrimSpace(it.Runtime.LastError.Message) + } + rt := transportRuntimeSnapshot(it, now) + code := "" + if it.Status == TransportClientDegraded || strings.TrimSpace(health.LastError) != "" { + code = "TRANSPORT_CLIENT_DEGRADED" + } + return TransportClientHealthResponse{ + OK: true, + Message: "ok", + Code: code, + ClientID: it.ID, + Kind: it.Kind, + Status: it.Status, + Latency: health.LatencyMS, + LastErr: health.LastError, + Health: health, + Runtime: rt, + } +} + +func buildTransportMetricsResponse(it TransportClient, now time.Time) TransportClientMetricsResponse { + rt := transportRuntimeSnapshot(it, now) + return TransportClientMetricsResponse{ + OK: true, + Message: "ok", + ClientID: it.ID, + Kind: it.Kind, + Status: it.Status, + Metrics: rt.Metrics, + Runtime: rt, + } +} + +func applyTransportHealthProbeSnapshot(it TransportClient, backendID string, probe transportBackendHealthResult, now time.Time) TransportClient { + snapshot := it + ts := now.Format(time.RFC3339) + if strings.TrimSpace(backendID) != "" { + snapshot.Runtime.Backend = backendID + } + snapshot.Health.LastCheck = ts + if probe.LatencyMS > 0 { + snapshot.Health.LatencyMS = probe.LatencyMS + } + if probe.Status != "" { + snapshot.Status = normalizeTransportStatus(probe.Status) + } + if probe.OK { + if snapshot.Status != TransportClientDegraded { + snapshot.Health.LastError = "" + } + snapshot.Runtime.LastError = TransportClientError{} + } else { + msg := strings.TrimSpace(probe.Message) + if msg == "" { + msg = "transport health probe failed" + } + snapshot.Health.LastError = msg + snapshot.Runtime.LastError = TransportClientError{ + Code: strings.TrimSpace(probe.Code), + Message: msg, + Retryable: probe.Retryable, + At: ts, + } + } + snapshot.Runtime = transportRuntimeSnapshot(snapshot, now) + return snapshot +} diff --git a/selective-vpn-api/app/transport_client_runtime_helpers.go b/selective-vpn-api/app/transport_client_runtime_helpers.go new file mode 100644 index 0000000..b6943a8 --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_helpers.go @@ -0,0 +1,21 @@ +package app + +func findTransportClientIndex(items []TransportClient, id string) int { + for i := range items { + if items[i].ID == id { + return i + } + } + return -1 +} + +func cloneMap(in map[string]any) map[string]any { + if in == nil { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/selective-vpn-api/app/transport_client_runtime_netns.go b/selective-vpn-api/app/transport_client_runtime_netns.go new file mode 100644 index 0000000..c353695 --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_netns.go @@ -0,0 +1,288 @@ +package app + +import ( + "strings" + "time" +) + +type transportNetnsPeerMatch struct { + Index int + Binding transportIfaceBinding +} + +type transportLifecyclePeerStopPlan struct { + ClientID string + Client TransportClient +} + +type transportLifecyclePeerStopExecution struct { + ClientID string + BackendID string + Result transportBackendActionResult +} + +type transportLifecycleStateChange struct { + ClientID string + From TransportClientStatus + To TransportClientStatus +} + +func matchTransportSingBoxPeersInSameNetns( + items []TransportClient, + targetIdx int, + ifaces transportInterfacesState, +) (transportIfaceBinding, []transportNetnsPeerMatch) { + if targetIdx < 0 || targetIdx >= len(items) { + return transportIfaceBinding{}, nil + } + target := items[targetIdx] + targetBinding := resolveTransportIfaceBinding(target, ifaces) + if target.Kind != TransportClientSingBox || !transportNetnsEnabled(target) { + return targetBinding, nil + } + targetNS := strings.TrimSpace(targetBinding.NetnsName) + if targetNS == "" { + return targetBinding, nil + } + + peers := make([]transportNetnsPeerMatch, 0, 2) + for i := range items { + if i == targetIdx { + continue + } + peer := items[i] + if peer.Kind != TransportClientSingBox { + continue + } + if !transportNetnsEnabled(peer) { + continue + } + if normalizeTransportStatus(peer.Status) == TransportClientDown { + continue + } + peerBinding := resolveTransportIfaceBinding(peer, ifaces) + if strings.TrimSpace(peerBinding.NetnsName) != targetNS { + continue + } + peers = append(peers, transportNetnsPeerMatch{ + Index: i, + Binding: peerBinding, + }) + } + return targetBinding, peers +} + +func matchTransportSingBoxPeersInSameNetnsForLock( + items []TransportClient, + targetIdx int, + ifaces transportInterfacesState, +) (transportIfaceBinding, []transportNetnsPeerMatch) { + if targetIdx < 0 || targetIdx >= len(items) { + return transportIfaceBinding{}, nil + } + target := items[targetIdx] + targetBinding := resolveTransportIfaceBinding(target, ifaces) + if target.Kind != TransportClientSingBox || !transportNetnsEnabled(target) { + return targetBinding, nil + } + targetNS := strings.TrimSpace(targetBinding.NetnsName) + if targetNS == "" { + return targetBinding, nil + } + + peers := make([]transportNetnsPeerMatch, 0, 2) + for i := range items { + if i == targetIdx { + continue + } + peer := items[i] + if peer.Kind != TransportClientSingBox { + continue + } + if !transportNetnsEnabled(peer) { + continue + } + peerBinding := resolveTransportIfaceBinding(peer, ifaces) + if strings.TrimSpace(peerBinding.NetnsName) != targetNS { + continue + } + peers = append(peers, transportNetnsPeerMatch{ + Index: i, + Binding: peerBinding, + }) + } + return targetBinding, peers +} + +func planTransportSingBoxPeerStops( + items []TransportClient, + targetIdx int, + ifaces transportInterfacesState, + now time.Time, +) []transportLifecyclePeerStopPlan { + _, peers := matchTransportSingBoxPeersInSameNetns(items, targetIdx, ifaces) + if len(peers) == 0 { + return nil + } + plans := make([]transportLifecyclePeerStopPlan, 0, len(peers)) + for _, peerMatch := range peers { + if peerMatch.Index < 0 || peerMatch.Index >= len(items) { + continue + } + peer := items[peerMatch.Index] + if bound, changed := applyTransportIfaceBinding(peer, ifaces, now); changed { + peer = bound + } + plans = append(plans, transportLifecyclePeerStopPlan{ + ClientID: peer.ID, + Client: peer, + }) + } + return plans +} + +func executeTransportLifecyclePeerStops( + plans []transportLifecyclePeerStopPlan, +) []transportLifecyclePeerStopExecution { + if len(plans) == 0 { + return nil + } + results := make([]transportLifecyclePeerStopExecution, 0, len(plans)) + for _, plan := range plans { + backend := selectTransportBackend(plan.Client) + stopRes := backend.Action(plan.Client, "stop") + results = append(results, transportLifecyclePeerStopExecution{ + ClientID: plan.ClientID, + BackendID: backend.ID(), + Result: stopRes, + }) + if !stopRes.OK { + break + } + } + return results +} + +func summarizeTransportLifecyclePeerStops( + results []transportLifecyclePeerStopExecution, +) transportBackendActionResult { + if len(results) == 0 { + return transportBackendActionResult{OK: true, ExitCode: 0} + } + + stopped := make([]string, 0, len(results)) + aggOut := make([]string, 0, len(results)) + aggErr := make([]string, 0, len(results)) + for _, res := range results { + if s := strings.TrimSpace(res.Result.Stdout); s != "" { + aggOut = append(aggOut, res.ClientID+": "+s) + } + if s := strings.TrimSpace(res.Result.Stderr); s != "" { + aggErr = append(aggErr, res.ClientID+": "+s) + } + if !res.Result.OK { + msg := strings.TrimSpace(res.Result.Message) + if msg == "" { + msg = "stop conflicting peer failed: " + res.ClientID + } + return transportBackendActionResult{ + OK: false, + Code: res.Result.Code, + Message: msg, + ExitCode: res.Result.ExitCode, + Stdout: strings.Join(aggOut, "\n"), + Stderr: strings.Join(aggErr, "\n"), + } + } + stopped = append(stopped, res.ClientID) + } + if len(stopped) == 0 { + return transportBackendActionResult{OK: true, ExitCode: 0} + } + return transportBackendActionResult{ + OK: true, + ExitCode: 0, + Message: "stopped conflicting peers: " + strings.Join(stopped, ", "), + Stdout: strings.Join(aggOut, "\n"), + Stderr: strings.Join(aggErr, "\n"), + } +} + +func applyTransportLifecyclePeerStopExecutionsLocked( + st *transportClientsState, + ifaces transportInterfacesState, + now time.Time, + results []transportLifecyclePeerStopExecution, +) []transportLifecycleStateChange { + if st == nil || len(results) == 0 { + return nil + } + changes := make([]transportLifecycleStateChange, 0, len(results)) + for _, res := range results { + idx := findTransportClientIndex(st.Items, res.ClientID) + if idx < 0 { + continue + } + peer := st.Items[idx] + if bound, changed := applyTransportIfaceBinding(peer, ifaces, now); changed { + peer = bound + } + prev := peer.Status + if res.Result.OK { + applyTransportLifecycleAction(&peer, "stop", now) + peer.Runtime.Backend = res.BackendID + } else { + applyTransportLifecycleFailure(&peer, "stop", now, res.BackendID, res.Result) + } + st.Items[idx] = peer + if prev != peer.Status { + changes = append(changes, transportLifecycleStateChange{ + ClientID: peer.ID, + From: prev, + To: peer.Status, + }) + } + } + return changes +} + +func joinNonEmptyLines(parts ...string) string { + lines := make([]string, 0, len(parts)) + for _, part := range parts { + v := strings.TrimSpace(part) + if v == "" { + continue + } + lines = append(lines, v) + } + return strings.Join(lines, "\n") +} + +func stopTransportSingBoxPeersInSameNetnsLocked( + st *transportClientsState, + targetIdx int, + now time.Time, +) transportBackendActionResult { + if st == nil || targetIdx < 0 || targetIdx >= len(st.Items) { + return transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_CLIENT_NOT_FOUND", + Message: "target client not found", + ExitCode: -1, + } + } + ifaces, err := syncTransportInterfacesWithClientsLocked(st.Items) + if err != nil { + return transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_INTERFACES_SAVE_FAILED", + Message: "interfaces sync failed: " + err.Error(), + ExitCode: -1, + } + } + plans := planTransportSingBoxPeerStops(st.Items, targetIdx, ifaces, now) + results := executeTransportLifecyclePeerStops(plans) + summary := summarizeTransportLifecyclePeerStops(results) + applyTransportLifecyclePeerStopExecutionsLocked(st, ifaces, now, results) + return summary +} diff --git a/selective-vpn-api/app/transport_client_runtime_normalize_config.go b/selective-vpn-api/app/transport_client_runtime_normalize_config.go new file mode 100644 index 0000000..889e304 --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_normalize_config.go @@ -0,0 +1,19 @@ +package app + +const transportSingBoxUnitTemplate = "singbox@.service" + +func normalizeTransportClientConfig(kind TransportClientKind, cfg map[string]any) (map[string]any, bool) { + if kind != TransportClientSingBox { + return cfg, false + } + out := cloneMap(cfg) + if out == nil { + out = map[string]any{} + } + cur := transportConfigString(out, "unit") + if cur == transportSingBoxUnitTemplate { + return out, cfg == nil + } + out["unit"] = transportSingBoxUnitTemplate + return out, true +} diff --git a/selective-vpn-api/app/transport_client_runtime_normalize_config_test.go b/selective-vpn-api/app/transport_client_runtime_normalize_config_test.go new file mode 100644 index 0000000..18c1696 --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_normalize_config_test.go @@ -0,0 +1,26 @@ +package app + +import "testing" + +func TestNormalizeTransportClientConfigForSingBoxForcesTemplateUnit(t *testing.T) { + cfg, changed := normalizeTransportClientConfig(TransportClientSingBox, map[string]any{ + "unit": "singbox-old.service", + }) + if !changed { + t.Fatalf("expected config change") + } + if got := transportConfigString(cfg, "unit"); got != transportSingBoxUnitTemplate { + t.Fatalf("unexpected unit: %q", got) + } +} + +func TestNormalizeTransportClientConfigForNonSingBoxKeepsConfig(t *testing.T) { + in := map[string]any{"unit": "dnstt-client.service"} + cfg, changed := normalizeTransportClientConfig(TransportClientDNSTT, in) + if changed { + t.Fatalf("did not expect change") + } + if got := transportConfigString(cfg, "unit"); got != "dnstt-client.service" { + t.Fatalf("unexpected unit: %q", got) + } +} diff --git a/selective-vpn-api/app/transport_client_runtime_state.go b/selective-vpn-api/app/transport_client_runtime_state.go new file mode 100644 index 0000000..6d51506 --- /dev/null +++ b/selective-vpn-api/app/transport_client_runtime_state.go @@ -0,0 +1,84 @@ +package app + +import ( + "strings" + "time" +) + +func transportRuntimeSnapshot(it TransportClient, now time.Time) TransportClientRuntime { + rt := it.Runtime + if strings.TrimSpace(rt.Backend) == "" { + rt.Backend = selectTransportBackend(it).ID() + } + if len(rt.AllowedActions) == 0 { + rt.AllowedActions = []string{"provision", "start", "stop", "restart"} + } + if rt.Metrics.Restarts < 0 { + rt.Metrics.Restarts = 0 + } + if rt.Metrics.StateChanges < 0 { + rt.Metrics.StateChanges = 0 + } + if strings.TrimSpace(rt.LastError.Message) == "" && strings.TrimSpace(it.Health.LastError) != "" { + rt.LastError = TransportClientError{ + Code: "BACKEND_RUNTIME_ERROR", + Message: strings.TrimSpace(it.Health.LastError), + Retryable: true, + At: strings.TrimSpace(it.Health.LastCheck), + } + } + rt.Metrics.UptimeSec = transportUptimeSec(it.Status, rt.StartedAt, now) + return rt +} + +func normalizeTransportRuntimeStored(rt TransportClientRuntime, kind TransportClientKind, cfg map[string]any) (TransportClientRuntime, bool) { + changed := false + if strings.TrimSpace(rt.Backend) == "" { + rt.Backend = selectTransportBackend(TransportClient{Kind: kind, Config: cfg}).ID() + changed = true + } + wantActions := []string{"provision", "start", "stop", "restart"} + if !equalStringSlices(rt.AllowedActions, wantActions) { + rt.AllowedActions = append([]string(nil), wantActions...) + changed = true + } + if rt.Metrics.Restarts < 0 { + rt.Metrics.Restarts = 0 + changed = true + } + if rt.Metrics.StateChanges < 0 { + rt.Metrics.StateChanges = 0 + changed = true + } + if rt.Metrics.UptimeSec < 0 { + rt.Metrics.UptimeSec = 0 + changed = true + } + if rt.LastExitCode < 0 { + rt.LastExitCode = 0 + changed = true + } + if strings.TrimSpace(rt.LastError.Message) == "" && strings.TrimSpace(rt.LastError.Code) != "" { + rt.LastError.Code = "" + changed = true + } + return rt, changed +} + +func transportUptimeSec(status TransportClientStatus, startedAt string, now time.Time) int64 { + if status != TransportClientUp { + return 0 + } + ts := strings.TrimSpace(startedAt) + if ts == "" { + return 0 + } + started, err := time.Parse(time.RFC3339, ts) + if err != nil { + return 0 + } + if started.After(now) { + return 0 + } + return int64(now.Sub(started).Seconds()) +} diff --git a/selective-vpn-api/app/transport_handlers_actions.go b/selective-vpn-api/app/transport_handlers_actions.go new file mode 100644 index 0000000..2e7cec7 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_actions.go @@ -0,0 +1,24 @@ +package app + +import ( + "net/http" +) + +func handleTransportClientAction(w http.ResponseWriter, r *http.Request, id, action string) { + if handleTransportVirtualClientAction(w, r, id, action) { + return + } + + switch action { + case "health": + handleTransportClientHealthAction(w, r, id) + case "metrics": + handleTransportClientMetricsAction(w, r, id) + case "provision": + handleTransportClientProvisionAction(w, r, id) + case "start", "stop", "restart": + handleTransportClientLifecycleAction(w, r, id, action) + default: + http.NotFound(w, r) + } +} diff --git a/selective-vpn-api/app/transport_handlers_actions_exec.go b/selective-vpn-api/app/transport_handlers_actions_exec.go new file mode 100644 index 0000000..e48b42c --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_actions_exec.go @@ -0,0 +1,6 @@ +package app + +// Transport action execution handlers are split by role: +// - health/metrics GET handlers: transport_handlers_actions_exec_health.go +// - provision POST handler: transport_handlers_actions_exec_provision.go +// - start/stop/restart lifecycle POST handler: transport_handlers_actions_exec_lifecycle.go diff --git a/selective-vpn-api/app/transport_handlers_actions_exec_health.go b/selective-vpn-api/app/transport_handlers_actions_exec_health.go new file mode 100644 index 0000000..923a99b --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_actions_exec_health.go @@ -0,0 +1,60 @@ +package app + +import ( + "net/http" + "strings" + "time" +) + +func handleTransportClientHealthAction(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + transportMu.Lock() + st := loadTransportClientsState() + transportMu.Unlock() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + writeJSON(w, http.StatusNotFound, TransportClientHealthResponse{ + OK: false, + Message: "not found", + Code: "TRANSPORT_CLIENT_NOT_FOUND", + }) + return + } + it := st.Items[idx] + now := time.Now().UTC() + backend := selectTransportBackend(it) + probe := backend.Health(it) + snapshot := applyTransportHealthProbeSnapshot(it, backend.ID(), probe, now) + resp := buildTransportHealthResponse(snapshot, now) + if !probe.OK && strings.TrimSpace(probe.Code) != "" { + resp.Code = probe.Code + if strings.TrimSpace(probe.Message) != "" { + resp.Message = probe.Message + } + } + writeJSON(w, http.StatusOK, resp) +} + +func handleTransportClientMetricsAction(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + transportMu.Lock() + st := loadTransportClientsState() + transportMu.Unlock() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + writeJSON(w, http.StatusNotFound, TransportClientMetricsResponse{ + OK: false, + Message: "not found", + Code: "TRANSPORT_CLIENT_NOT_FOUND", + }) + return + } + it := st.Items[idx] + writeJSON(w, http.StatusOK, buildTransportMetricsResponse(it, time.Now().UTC())) +} diff --git a/selective-vpn-api/app/transport_handlers_actions_exec_lifecycle.go b/selective-vpn-api/app/transport_handlers_actions_exec_lifecycle.go new file mode 100644 index 0000000..1939c8e --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_actions_exec_lifecycle.go @@ -0,0 +1,72 @@ +package app + +import ( + "net/http" +) + +func handleTransportClientLifecycleAction(w http.ResponseWriter, r *http.Request, id, action string) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + lockIDs, ok := resolveTransportLifecycleLockIDs(id, action) + if !ok { + writeJSON(w, http.StatusNotFound, TransportClientLifecycleResponse{ + OK: false, + Message: "not found", + Code: "TRANSPORT_CLIENT_NOT_FOUND", + }) + return + } + + status := http.StatusOK + resp := TransportClientLifecycleResponse{} + withTransportIfaceLocks(lockIDs, func() { + status, resp = executeTransportLifecycleActionWithPreflightLocked(id, action) + }) + writeJSON(w, status, resp) +} + +func executeTransportLifecycleActionWithPreflightLocked(id, action string) (int, TransportClientLifecycleResponse) { + if preStatus, preResp, handled := runTransportLifecyclePreflight(id, action); handled { + return preStatus, preResp + } + return executeTransportLifecycleActionLocked(id, action) +} + +func resolveTransportLifecycleLockIDsForSnapshot(items []TransportClient, ifaces transportInterfacesState, id, action string) ([]string, bool) { + idx := findTransportClientIndex(items, id) + if idx < 0 { + return nil, false + } + targetBinding := resolveTransportIfaceBinding(items[idx], ifaces) + lockIDs := []string{targetBinding.IfaceID} + if action == "start" || action == "restart" { + _, peers := matchTransportSingBoxPeersInSameNetnsForLock(items, idx, ifaces) + for _, peer := range peers { + lockIDs = append(lockIDs, peer.Binding.IfaceID) + } + } + return lockIDs, true +} + +func resolveTransportLifecycleLockIDs(id, action string) ([]string, bool) { + transportMu.Lock() + clientsState := loadTransportClientsState() + idx := findTransportClientIndex(clientsState.Items, id) + if idx < 0 { + transportMu.Unlock() + return nil, false + } + ifacesState := loadTransportInterfacesState() + ifacesSnapshot := captureTransportInterfacesStateSnapshot(clientsState, ifacesState) + transportMu.Unlock() + + normIfaces, changed := normalizeTransportInterfacesState(ifacesState, clientsState.Items) + if changed { + _ = saveTransportInterfacesIfSnapshotCurrent(ifacesSnapshot, normIfaces) + } + + return resolveTransportLifecycleLockIDsForSnapshot(clientsState.Items, normIfaces, id, action) +} diff --git a/selective-vpn-api/app/transport_handlers_actions_exec_lifecycle_locked.go b/selective-vpn-api/app/transport_handlers_actions_exec_lifecycle_locked.go new file mode 100644 index 0000000..a87bcd1 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_actions_exec_lifecycle_locked.go @@ -0,0 +1,264 @@ +package app + +import ( + "net/http" + "strings" + "time" +) + +func executeTransportLifecycleActionLocked(id, action string) (int, TransportClientLifecycleResponse) { + now := time.Now().UTC() + + transportMu.Lock() + st := loadTransportClientsState() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + transportMu.Unlock() + return http.StatusNotFound, TransportClientLifecycleResponse{ + OK: false, + Message: "not found", + Code: "TRANSPORT_CLIENT_NOT_FOUND", + } + } + it := st.Items[idx] + prev := it.Status + ifaces, err := syncTransportInterfacesWithClientsLocked(st.Items) + if err != nil { + transportMu.Unlock() + return http.StatusOK, TransportClientLifecycleResponse{ + OK: false, + Message: "interfaces sync failed: " + err.Error(), + Code: "TRANSPORT_INTERFACES_SAVE_FAILED", + ClientID: id, + Kind: it.Kind, + Action: action, + StatusBefore: prev, + StatusAfter: it.Status, + Health: it.Health, + Runtime: transportRuntimeSnapshot(it, now), + } + } + if bound, changed := applyTransportIfaceBinding(it, ifaces, now); changed { + it = bound + } + peerStopPlans := []transportLifecyclePeerStopPlan(nil) + if action == "start" || action == "restart" { + peerStopPlans = planTransportSingBoxPeerStops(st.Items, idx, ifaces, now) + } + transportMu.Unlock() + + peerStopResults := []transportLifecyclePeerStopExecution(nil) + peerStopSummary := transportBackendActionResult{OK: true, ExitCode: 0} + if action == "start" || action == "restart" { + peerStopResults = executeTransportLifecyclePeerStops(peerStopPlans) + peerStopSummary = summarizeTransportLifecyclePeerStops(peerStopResults) + } + + backend := selectTransportBackend(it) + actionResult := transportBackendActionResult{ + OK: true, + ExitCode: 0, + } + if action == "start" || action == "restart" { + if !peerStopSummary.OK { + transportMu.Lock() + st = loadTransportClientsState() + idx = findTransportClientIndex(st.Items, id) + if idx < 0 { + transportMu.Unlock() + return http.StatusNotFound, TransportClientLifecycleResponse{ + OK: false, + Message: "not found", + Code: "TRANSPORT_CLIENT_NOT_FOUND", + } + } + it = st.Items[idx] + ifaces, err = syncTransportInterfacesWithClientsLocked(st.Items) + if err != nil { + transportMu.Unlock() + return http.StatusOK, TransportClientLifecycleResponse{ + OK: false, + Message: "interfaces sync failed: " + err.Error(), + Code: "TRANSPORT_INTERFACES_SAVE_FAILED", + ClientID: id, + Kind: it.Kind, + Action: action, + StatusBefore: prev, + StatusAfter: it.Status, + Health: it.Health, + Runtime: transportRuntimeSnapshot(it, now), + } + } + if bound, changed := applyTransportIfaceBinding(it, ifaces, now); changed { + it = bound + } + peerEvents := applyTransportLifecyclePeerStopExecutionsLocked(&st, ifaces, now, peerStopResults) + if len(peerStopResults) > 0 { + if err := saveTransportClientsState(st); err != nil { + transportMu.Unlock() + return http.StatusOK, TransportClientLifecycleResponse{ + OK: false, + Message: "save failed: " + err.Error(), + Code: "TRANSPORT_CLIENT_SAVE_FAILED", + ClientID: id, + Kind: it.Kind, + Action: action, + ExitCode: peerStopSummary.ExitCode, + Stdout: peerStopSummary.Stdout, + Stderr: peerStopSummary.Stderr, + } + } + } + for _, peerEvent := range peerEvents { + events.push("transport_client_state_changed", map[string]any{ + "id": peerEvent.ClientID, + "from": peerEvent.From, + "to": peerEvent.To, + }) + } + transportMu.Unlock() + if len(peerEvents) > 0 { + clientIDs := make([]string, 0, len(peerEvents)) + for _, peerEvent := range peerEvents { + clientIDs = append(clientIDs, peerEvent.ClientID) + } + publishTransportRuntimeObservabilitySnapshotChanged( + "transport_client_state_changed", + clientIDs, + nil, + ) + } + + msg := strings.TrimSpace(peerStopSummary.Message) + if msg == "" { + msg = "failed to stop conflicting singbox peers" + } + return http.StatusOK, TransportClientLifecycleResponse{ + OK: false, + Message: msg, + Code: peerStopSummary.Code, + ExitCode: peerStopSummary.ExitCode, + Stdout: peerStopSummary.Stdout, + Stderr: peerStopSummary.Stderr, + ClientID: id, + Kind: it.Kind, + Action: action, + StatusBefore: prev, + StatusAfter: it.Status, + Health: it.Health, + Runtime: transportRuntimeSnapshot(it, now), + } + } + } + + if peerStopSummary.OK { + actionResult = backend.Action(it, action) + actionResult.Stdout = joinNonEmptyLines(peerStopSummary.Stdout, actionResult.Stdout) + actionResult.Stderr = joinNonEmptyLines(peerStopSummary.Stderr, actionResult.Stderr) + } + + transportMu.Lock() + st = loadTransportClientsState() + idx = findTransportClientIndex(st.Items, id) + if idx < 0 { + transportMu.Unlock() + return http.StatusNotFound, TransportClientLifecycleResponse{ + OK: false, + Message: "not found", + Code: "TRANSPORT_CLIENT_NOT_FOUND", + } + } + it = st.Items[idx] + ifaces, err = syncTransportInterfacesWithClientsLocked(st.Items) + if err != nil { + transportMu.Unlock() + return http.StatusOK, TransportClientLifecycleResponse{ + OK: false, + Message: "interfaces sync failed: " + err.Error(), + Code: "TRANSPORT_INTERFACES_SAVE_FAILED", + ClientID: id, + Kind: it.Kind, + Action: action, + StatusBefore: prev, + StatusAfter: it.Status, + Health: it.Health, + Runtime: transportRuntimeSnapshot(it, now), + } + } + if bound, changed := applyTransportIfaceBinding(it, ifaces, now); changed { + it = bound + } + peerEvents := applyTransportLifecyclePeerStopExecutionsLocked(&st, ifaces, now, peerStopResults) + if actionResult.OK { + applyTransportLifecycleAction(&it, action, now) + it.Runtime.Backend = backend.ID() + } else { + applyTransportLifecycleFailure(&it, action, now, backend.ID(), actionResult) + } + st.Items[idx] = it + if err := saveTransportClientsState(st); err != nil { + transportMu.Unlock() + return http.StatusOK, TransportClientLifecycleResponse{ + OK: false, + Message: "save failed: " + err.Error(), + Code: "TRANSPORT_CLIENT_SAVE_FAILED", + ClientID: id, + Kind: it.Kind, + Action: action, + ExitCode: actionResult.ExitCode, + Stdout: actionResult.Stdout, + Stderr: actionResult.Stderr, + } + } + for _, peerEvent := range peerEvents { + events.push("transport_client_state_changed", map[string]any{ + "id": peerEvent.ClientID, + "from": peerEvent.From, + "to": peerEvent.To, + }) + } + events.push("transport_client_state_changed", map[string]any{ + "id": id, + "from": prev, + "to": it.Status, + }) + queueRefresh := actionResult.OK && (action == "start" || action == "restart") + transportMu.Unlock() + publishClientIDs := make([]string, 0, len(peerEvents)+1) + publishClientIDs = append(publishClientIDs, id) + for _, peerEvent := range peerEvents { + publishClientIDs = append(publishClientIDs, peerEvent.ClientID) + } + publishTransportRuntimeObservabilitySnapshotChanged( + "transport_client_state_changed", + publishClientIDs, + nil, + ) + + msg := strings.TrimSpace(actionResult.Message) + if msg == "" { + msg = action + " done" + } + resp := TransportClientLifecycleResponse{ + OK: actionResult.OK, + Message: msg, + Code: actionResult.Code, + ExitCode: actionResult.ExitCode, + Stdout: actionResult.Stdout, + Stderr: actionResult.Stderr, + ClientID: id, + Kind: it.Kind, + Action: action, + StatusBefore: prev, + StatusAfter: it.Status, + Health: it.Health, + Runtime: transportRuntimeSnapshot(it, now), + } + if queueRefresh { + _, _ = egressIdentitySWR.queueRefresh([]string{"transport:" + id}, true) + } + if !actionResult.OK { + return http.StatusOK, resp + } + return http.StatusOK, resp +} diff --git a/selective-vpn-api/app/transport_handlers_actions_exec_lifecycle_preflight.go b/selective-vpn-api/app/transport_handlers_actions_exec_lifecycle_preflight.go new file mode 100644 index 0000000..ea4852c --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_actions_exec_lifecycle_preflight.go @@ -0,0 +1,67 @@ +package app + +import ( + "net/http" + "strings" + "time" +) + +func runTransportLifecyclePreflight(id, action string) (int, TransportClientLifecycleResponse, bool) { + if action != "start" && action != "restart" { + return 0, TransportClientLifecycleResponse{}, false + } + + transportMu.Lock() + preState := loadTransportClientsState() + preIdx := findTransportClientIndex(preState.Items, id) + if preIdx < 0 { + transportMu.Unlock() + return http.StatusNotFound, TransportClientLifecycleResponse{ + OK: false, + Message: "not found", + Code: "TRANSPORT_CLIENT_NOT_FOUND", + }, true + } + preClient := preState.Items[preIdx] + transportMu.Unlock() + + if preClient.Kind != TransportClientSingBox { + return 0, TransportClientLifecycleResponse{}, false + } + + checkBinary := true + if transportConfigHasKey(preClient.Config, "singbox_preflight_check_binary") { + checkBinary = transportConfigBool(preClient.Config, "singbox_preflight_check_binary") + } + preflight := prepareSingBoxClientProfile(preClient, checkBinary) + if preflight.OK { + return 0, TransportClientLifecycleResponse{}, false + } + + now := time.Now().UTC() + msg := strings.TrimSpace(preflight.Message) + if msg == "" { + msg = "singbox profile preflight failed" + } + events.push("transport_client_preflight_failed", map[string]any{ + "id": id, + "action": action, + "code": preflight.Code, + "error": msg, + }) + return http.StatusOK, TransportClientLifecycleResponse{ + OK: false, + Message: msg, + Code: preflight.Code, + ExitCode: preflight.ExitCode, + Stdout: preflight.Stdout, + Stderr: preflight.Stderr, + ClientID: id, + Kind: preClient.Kind, + Action: action, + StatusBefore: preClient.Status, + StatusAfter: preClient.Status, + Health: preClient.Health, + Runtime: transportRuntimeSnapshot(preClient, now), + }, true +} diff --git a/selective-vpn-api/app/transport_handlers_actions_exec_provision.go b/selective-vpn-api/app/transport_handlers_actions_exec_provision.go new file mode 100644 index 0000000..0573013 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_actions_exec_provision.go @@ -0,0 +1,137 @@ +package app + +import ( + "net/http" + "time" +) + +func handleTransportClientProvisionAction(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + ifaceID, ok := resolveTransportClientIfaceID(id) + if !ok { + writeJSON(w, http.StatusNotFound, TransportClientLifecycleResponse{ + OK: false, + Message: "not found", + Code: "TRANSPORT_CLIENT_NOT_FOUND", + }) + return + } + + status := http.StatusOK + resp := TransportClientLifecycleResponse{} + withTransportIfaceLock(ifaceID, func() { + status, resp = executeTransportClientProvisionActionLocked(id) + }) + writeJSON(w, status, resp) +} + +func executeTransportClientProvisionActionLocked(id string) (int, TransportClientLifecycleResponse) { + now := time.Now().UTC() + + transportMu.Lock() + st := loadTransportClientsState() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + transportMu.Unlock() + return http.StatusNotFound, TransportClientLifecycleResponse{ + OK: false, + Message: "not found", + Code: "TRANSPORT_CLIENT_NOT_FOUND", + } + } + it := st.Items[idx] + prev := it.Status + ifaces, err := syncTransportInterfacesWithClientsLocked(st.Items) + if err != nil { + transportMu.Unlock() + return http.StatusOK, TransportClientLifecycleResponse{ + OK: false, + Message: "interfaces sync failed: " + err.Error(), + Code: "TRANSPORT_INTERFACES_SAVE_FAILED", + ClientID: id, + Kind: it.Kind, + Action: "provision", + StatusBefore: prev, + StatusAfter: it.Status, + Health: it.Health, + Runtime: transportRuntimeSnapshot(it, now), + } + } + if bound, changed := applyTransportIfaceBinding(it, ifaces, now); changed { + it = bound + } + transportMu.Unlock() + + backend := selectTransportBackend(it) + provision := backend.Provision(it) + + transportMu.Lock() + defer transportMu.Unlock() + st = loadTransportClientsState() + idx = findTransportClientIndex(st.Items, id) + if idx < 0 { + return http.StatusNotFound, TransportClientLifecycleResponse{ + OK: false, + Message: "not found", + Code: "TRANSPORT_CLIENT_NOT_FOUND", + } + } + it = st.Items[idx] + ifaces, err = syncTransportInterfacesWithClientsLocked(st.Items) + if err != nil { + return http.StatusOK, TransportClientLifecycleResponse{ + OK: false, + Message: "interfaces sync failed: " + err.Error(), + Code: "TRANSPORT_INTERFACES_SAVE_FAILED", + ClientID: id, + Kind: it.Kind, + Action: "provision", + StatusBefore: prev, + StatusAfter: it.Status, + Health: it.Health, + Runtime: transportRuntimeSnapshot(it, now), + } + } + if bound, changed := applyTransportIfaceBinding(it, ifaces, now); changed { + it = bound + } + applyTransportProvisionResult(&it, now, backend.ID(), provision) + st.Items[idx] = it + if err := saveTransportClientsState(st); err != nil { + return http.StatusOK, TransportClientLifecycleResponse{ + OK: false, + Message: "save failed: " + err.Error(), + Code: "TRANSPORT_CLIENT_SAVE_FAILED", + ClientID: id, + Kind: it.Kind, + Action: "provision", + ExitCode: provision.ExitCode, + Stdout: provision.Stdout, + Stderr: provision.Stderr, + } + } + events.push("transport_client_provisioned", map[string]any{ + "id": id, + "ok": provision.OK, + "msg": provision.Message, + }) + return http.StatusOK, TransportClientLifecycleResponse{ + OK: provision.OK, + Message: provision.Message, + Code: provision.Code, + ExitCode: provision.ExitCode, + Stdout: provision.Stdout, + Stderr: provision.Stderr, + ClientID: id, + Kind: it.Kind, + Action: "provision", + StatusBefore: prev, + StatusAfter: it.Status, + Health: it.Health, + Runtime: transportRuntimeSnapshot(it, now), + } +} diff --git a/selective-vpn-api/app/transport_handlers_actions_virtual.go b/selective-vpn-api/app/transport_handlers_actions_virtual.go new file mode 100644 index 0000000..3be49f2 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_actions_virtual.go @@ -0,0 +1,214 @@ +package app + +import ( + "fmt" + "net/http" + "strings" + "time" +) + +func handleTransportVirtualClientAction(w http.ResponseWriter, r *http.Request, id, action string) bool { + cid := sanitizeID(id) + if !isTransportPolicyVirtualClientID(cid) { + return false + } + + switch action { + case "health": + handleTransportVirtualClientHealthAction(w, r, cid) + case "metrics": + handleTransportVirtualClientMetricsAction(w, r, cid) + case "provision": + handleTransportVirtualClientProvisionAction(w, r, cid) + case "start", "stop", "restart": + handleTransportVirtualClientLifecycleAction(w, r, cid, action) + default: + http.NotFound(w, r) + } + return true +} + +func handleTransportVirtualClientCardGet(w http.ResponseWriter, r *http.Request, id string) bool { + cid := sanitizeID(id) + if !isTransportPolicyVirtualClientID(cid) { + return false + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return true + } + item := transportVirtualAdGuardSnapshot() + writeJSON(w, http.StatusOK, TransportClientsResponse{OK: true, Message: "ok", Item: &item}) + return true +} + +func transportVirtualClientReadOnlyResponse() (int, TransportClientsResponse) { + return http.StatusOK, TransportClientsResponse{ + OK: false, + Message: "virtual control-plane client is read-only", + } +} + +func transportVirtualAdGuardSnapshot() TransportClient { + if item, ok := resolveTransportPolicyVirtualClient(transportPolicyTargetAdGuardID); ok { + return item + } + now := time.Now().UTC() + item := buildTransportPolicyAdGuardTargetFromObservation("inactive", "DISCONNECTED", "", now) + item.Health.LastError = "adguard autoloop state unavailable" + item.Runtime.LastError = TransportClientError{ + Code: "BACKEND_RUNTIME_ERROR", + Message: item.Health.LastError, + Retryable: true, + At: item.Health.LastCheck, + } + item.Runtime.LastAction = "observe" + item.Runtime.LastActionAt = item.Health.LastCheck + item.UpdatedAt = item.Health.LastCheck + return item +} + +func handleTransportVirtualClientHealthAction(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + item := transportVirtualAdGuardSnapshot() + resp := buildTransportHealthResponse(item, time.Now().UTC()) + if normalizeTransportStatus(item.Status) == TransportClientDown { + resp.Code = "TRANSPORT_CLIENT_DOWN" + } + writeJSON(w, http.StatusOK, resp) +} + +func handleTransportVirtualClientMetricsAction(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + item := transportVirtualAdGuardSnapshot() + writeJSON(w, http.StatusOK, buildTransportMetricsResponse(item, time.Now().UTC())) +} + +func handleTransportVirtualClientProvisionAction(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + item := transportVirtualAdGuardSnapshot() + now := time.Now().UTC() + msg := "virtual adapter uses existing adguard autoloop runtime; explicit provision skipped" + writeJSON(w, http.StatusOK, TransportClientLifecycleResponse{ + OK: true, + Message: msg, + Code: "", + ClientID: item.ID, + Kind: item.Kind, + Action: "provision", + StatusBefore: item.Status, + StatusAfter: item.Status, + Health: item.Health, + Runtime: transportRuntimeSnapshot(item, now), + }) +} + +func handleTransportVirtualClientLifecycleAction(w http.ResponseWriter, r *http.Request, id, action string) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + status, resp := runTransportVirtualClientLifecycleAction(id, action) + writeJSON(w, status, resp) +} + +func runTransportVirtualClientLifecycleAction(id, action string) (int, TransportClientLifecycleResponse) { + status := http.StatusOK + resp := TransportClientLifecycleResponse{} + withTransportIfaceLock(transportPolicyTargetAdGuardIfaceID, func() { + status, resp = executeTransportVirtualClientLifecycleAction(id, action) + }) + return status, resp +} + +func executeTransportVirtualClientLifecycleAction(id, action string) (int, TransportClientLifecycleResponse) { + before := transportVirtualAdGuardSnapshot() + beforeStatus := normalizeTransportStatus(before.Status) + now := time.Now().UTC() + + cmdAction, ok := transportVirtualAdGuardSystemdAction(action) + if !ok { + return http.StatusNotFound, TransportClientLifecycleResponse{ + OK: false, + Message: "unknown action", + Code: "TRANSPORT_ACTION_UNKNOWN", + } + } + + stdout, stderr, exitCode, err := runCommand("systemctl", cmdAction, adgvpnUnit) + actionOK := err == nil && exitCode == 0 + + after := transportVirtualAdGuardSnapshot() + afterStatus := normalizeTransportStatus(after.Status) + if !actionOK && afterStatus == beforeStatus { + afterStatus = TransportClientDegraded + after.Status = afterStatus + } + + msg := strings.TrimSpace(stdout) + if msg == "" { + msg = strings.TrimSpace(stderr) + } + if msg == "" && err != nil { + msg = strings.TrimSpace(err.Error()) + } + if msg == "" { + msg = fmt.Sprintf("adguardvpn %s done", cmdAction) + } + + code := "" + if !actionOK { + code = "TRANSPORT_ADGUARD_ACTION_FAILED" + } + + events.push("transport_client_state_changed", map[string]any{ + "id": id, + "from": beforeStatus, + "to": afterStatus, + }) + publishTransportRuntimeObservabilitySnapshotChanged( + "transport_client_state_changed", + []string{id}, + []string{transportPolicyTargetAdGuardIfaceID}, + ) + _, _ = egressIdentitySWR.queueRefresh([]string{"adguardvpn", "system"}, true) + + return http.StatusOK, TransportClientLifecycleResponse{ + OK: actionOK, + Message: msg, + Code: code, + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + ClientID: id, + Kind: after.Kind, + Action: action, + StatusBefore: beforeStatus, + StatusAfter: afterStatus, + Health: after.Health, + Runtime: transportRuntimeSnapshot(after, now), + } +} + +func transportVirtualAdGuardSystemdAction(action string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(action)) { + case "start": + return "start", true + case "stop": + return "stop", true + case "restart": + return "restart", true + default: + return "", false + } +} diff --git a/selective-vpn-api/app/transport_handlers_clients.go b/selective-vpn-api/app/transport_handlers_clients.go new file mode 100644 index 0000000..838fa4b --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_clients.go @@ -0,0 +1,55 @@ +package app + +import ( + "net/http" + "strings" +) + +func handleTransportClients(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + handleTransportClientsGet(w, r) + case http.MethodPost: + handleTransportClientsPost(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleTransportClientByID(w http.ResponseWriter, r *http.Request) { + const prefix = "/api/v1/transport/clients/" + rest := strings.TrimPrefix(r.URL.Path, prefix) + if rest == "" || rest == r.URL.Path { + http.NotFound(w, r) + return + } + parts := strings.Split(strings.Trim(rest, "/"), "/") + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { + http.NotFound(w, r) + return + } + id := sanitizeID(parts[0]) + if id == "" { + http.Error(w, "invalid client id", http.StatusBadRequest) + return + } + + if len(parts) == 1 { + handleTransportClientCard(w, r, id) + return + } + handleTransportClientAction(w, r, id, strings.ToLower(strings.TrimSpace(parts[1]))) +} + +func handleTransportClientCard(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + handleTransportClientCardGet(w, r, id) + case http.MethodPatch: + handleTransportClientCardPatch(w, r, id) + case http.MethodDelete: + handleTransportClientCardDelete(w, r, id) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/selective-vpn-api/app/transport_handlers_clients_card_ops.go b/selective-vpn-api/app/transport_handlers_clients_card_ops.go new file mode 100644 index 0000000..bea2600 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_clients_card_ops.go @@ -0,0 +1,201 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" +) + +func handleTransportClientCardGet(w http.ResponseWriter, r *http.Request, id string) { + if handleTransportVirtualClientCardGet(w, r, id) { + return + } + + transportMu.Lock() + st := loadTransportClientsState() + transportMu.Unlock() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + writeJSON(w, http.StatusNotFound, TransportClientsResponse{OK: false, Message: "not found"}) + return + } + item := st.Items[idx] + writeJSON(w, http.StatusOK, TransportClientsResponse{OK: true, Message: "ok", Item: &item}) +} + +func handleTransportClientCardPatch(w http.ResponseWriter, r *http.Request, id string) { + if isTransportPolicyVirtualClientID(id) { + status, resp := transportVirtualClientReadOnlyResponse() + writeJSON(w, status, resp) + return + } + + var body TransportClientPatchRequest + 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 + } + } + + lockIDs, ok := resolveTransportClientCardPatchLockIDs(id, body) + if !ok { + writeJSON(w, http.StatusNotFound, TransportClientsResponse{OK: false, Message: "not found"}) + return + } + + status := http.StatusOK + resp := TransportClientsResponse{} + withTransportIfaceLocks(lockIDs, func() { + status, resp = executeTransportClientCardPatchLocked(id, body) + }) + if resp.OK { + publishTransportRuntimeObservabilitySnapshotChanged("transport_client_updated", []string{id}, lockIDs) + } + writeJSON(w, status, resp) +} + +func resolveTransportClientCardPatchLockIDs(id string, body TransportClientPatchRequest) ([]string, bool) { + transportMu.Lock() + st := loadTransportClientsState() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + transportMu.Unlock() + return nil, false + } + lockIDs := []string{st.Items[idx].IfaceID} + if body.IfaceID != nil { + lockIDs = append(lockIDs, normalizeTransportIfaceID(*body.IfaceID)) + } + transportMu.Unlock() + return lockIDs, true +} + +func executeTransportClientCardPatchLocked(id string, body TransportClientPatchRequest) (int, TransportClientsResponse) { + transportMu.Lock() + defer transportMu.Unlock() + st := loadTransportClientsState() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + return http.StatusNotFound, TransportClientsResponse{OK: false, Message: "not found"} + } + it := st.Items[idx] + if body.Name != nil { + it.Name = strings.TrimSpace(*body.Name) + } + if body.IfaceID != nil { + it.IfaceID = normalizeTransportIfaceID(*body.IfaceID) + } + if body.Enabled != nil { + it.Enabled = *body.Enabled + } + if body.Config != nil { + it.Config = cloneMap(body.Config) + } + if normCfg, _ := normalizeTransportClientConfig(it.Kind, it.Config); normCfg != nil || it.Config != nil { + it.Config = normCfg + } + now := time.Now().UTC() + it.UpdatedAt = now.Format(time.RFC3339) + st.Items[idx] = it + ifaces, err := syncTransportInterfacesWithClientsLocked(st.Items) + if err != nil { + return http.StatusOK, TransportClientsResponse{OK: false, Message: "interfaces sync failed: " + err.Error()} + } + it, _ = applyTransportIfaceBinding(st.Items[idx], ifaces, now) + st.Items[idx] = it + if err := saveTransportClientsState(st); err != nil { + return http.StatusOK, TransportClientsResponse{OK: false, Message: "save failed: " + err.Error()} + } + return http.StatusOK, TransportClientsResponse{OK: true, Message: "updated", Item: &it} +} + +func handleTransportClientCardDelete(w http.ResponseWriter, r *http.Request, id string) { + if isTransportPolicyVirtualClientID(id) { + status, resp := transportVirtualClientReadOnlyResponse() + writeJSON(w, status, resp) + return + } + + force := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("force")), "true") + cleanupArtifacts := !strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("cleanup")), "false") + + lockIDs, ok := resolveTransportClientDeleteLockIDs(id) + if !ok { + writeJSON(w, http.StatusNotFound, TransportClientsResponse{OK: false, Message: "not found"}) + return + } + + status := http.StatusOK + resp := TransportClientsResponse{} + withTransportIfaceLocks(lockIDs, func() { + status, resp = executeTransportClientCardDeleteLocked(id, force, cleanupArtifacts) + }) + if resp.OK { + publishTransportRuntimeObservabilitySnapshotChanged("transport_client_deleted", []string{id}, lockIDs) + } + writeJSON(w, status, resp) +} + +func resolveTransportClientDeleteLockIDs(id string) ([]string, bool) { + transportMu.Lock() + st := loadTransportClientsState() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + transportMu.Unlock() + return nil, false + } + lockIDs := []string{st.Items[idx].IfaceID} + transportMu.Unlock() + return lockIDs, true +} + +func executeTransportClientCardDeleteLocked(id string, force bool, cleanupArtifacts bool) (int, TransportClientsResponse) { + transportMu.Lock() + st := loadTransportClientsState() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + transportMu.Unlock() + return http.StatusNotFound, TransportClientsResponse{OK: false, Message: "not found"} + } + + pol := loadTransportPolicyState() + if !force { + for _, it := range pol.Intents { + if strings.TrimSpace(it.ClientID) == id { + transportMu.Unlock() + return http.StatusOK, TransportClientsResponse{ + OK: false, + Message: "client is used by active policy; set force=true to remove", + } + } + } + } + + removed := st.Items[idx] + st.Items = append(st.Items[:idx], st.Items[idx+1:]...) + if err := saveTransportClientsState(st); err != nil { + transportMu.Unlock() + return http.StatusOK, TransportClientsResponse{OK: false, Message: "save failed: " + err.Error()} + } + transportMu.Unlock() + + msg := "deleted" + if cleanupArtifacts { + cleanup := selectTransportBackend(removed).Cleanup(removed) + if !cleanup.OK { + cleanupErr := strings.TrimSpace(cleanup.Stderr) + if cleanupErr == "" { + cleanupErr = strings.TrimSpace(cleanup.Message) + } + if cleanupErr == "" { + cleanupErr = "cleanup failed" + } + msg = msg + "; cleanup warning: " + cleanupErr + } + } + return http.StatusOK, TransportClientsResponse{OK: true, Message: msg} +} diff --git a/selective-vpn-api/app/transport_handlers_clients_list_create.go b/selective-vpn-api/app/transport_handlers_clients_list_create.go new file mode 100644 index 0000000..95cfd88 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_clients_list_create.go @@ -0,0 +1,135 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "sort" + "strings" + "time" +) + +func handleTransportClientsGet(w http.ResponseWriter, r *http.Request) { + enabledOnly := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("enabled_only")), "true") + includeVirtual := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include_virtual")), "true") + rawKindFilter := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("kind"))) + kindFilter := normalizeTransportKind(TransportClientKind(rawKindFilter)) + + transportMu.Lock() + st := loadTransportClientsState() + transportMu.Unlock() + + source := st.Items + if includeVirtual { + source = transportPolicyClientsWithVirtualTargets(st.Items) + } + + items := make([]TransportClient, 0, len(source)) + for _, it := range source { + if enabledOnly && !it.Enabled { + continue + } + if !transportClientMatchesKindFilter(it, rawKindFilter, kindFilter) { + continue + } + items = append(items, it) + } + sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) + transportScheduleBackgroundHealthRefresh(st.Items, transportPolicyPersistableClients(items)) + writeJSON(w, http.StatusOK, TransportClientsResponse{ + OK: true, + Message: "ok", + Count: len(items), + Items: items, + }) +} + +func transportClientMatchesKindFilter(it TransportClient, rawKind string, knownKind TransportClientKind) bool { + if strings.TrimSpace(rawKind) == "" { + return true + } + if knownKind != "" { + return it.Kind == knownKind + } + return strings.EqualFold(strings.TrimSpace(string(it.Kind)), strings.TrimSpace(rawKind)) +} + +func handleTransportClientsPost(w http.ResponseWriter, r *http.Request) { + var body TransportClientCreateRequest + 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 + } + } + + client, err := buildTransportClientFromCreate(body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + status := http.StatusOK + resp := TransportClientsResponse{} + withTransportIfaceLock(client.IfaceID, func() { + status, resp = executeTransportClientsPostLocked(client) + }) + if resp.OK && resp.Item != nil { + publishTransportRuntimeObservabilitySnapshotChanged( + "transport_client_state_changed", + []string{resp.Item.ID}, + []string{resp.Item.IfaceID}, + ) + } + writeJSON(w, status, resp) +} + +func executeTransportClientsPostLocked(client TransportClient) (int, TransportClientsResponse) { + transportMu.Lock() + defer transportMu.Unlock() + st := loadTransportClientsState() + for _, it := range st.Items { + if it.ID == client.ID { + return http.StatusOK, TransportClientsResponse{ + OK: false, + Message: "client already exists", + } + } + } + + markHex, pref := allocateTransportSlots(st.Items) + client.MarkHex = markHex + client.PriorityBase = pref + st.Items = append(st.Items, client) + ifaces, err := syncTransportInterfacesWithClientsLocked(st.Items) + if err != nil { + return http.StatusOK, TransportClientsResponse{ + OK: false, + Message: "interfaces sync failed: " + err.Error(), + } + } + last := len(st.Items) - 1 + bound, changed := applyTransportIfaceBinding(st.Items[last], ifaces, time.Now().UTC()) + if changed { + st.Items[last] = bound + client = bound + } + if err := saveTransportClientsState(st); err != nil { + return http.StatusOK, TransportClientsResponse{ + OK: false, + Message: "save failed: " + err.Error(), + } + } + + events.push("transport_client_state_changed", map[string]any{ + "id": client.ID, + "from": "", + "to": client.Status, + }) + return http.StatusOK, TransportClientsResponse{ + OK: true, + Message: "client created", + Item: &client, + } +} diff --git a/selective-vpn-api/app/transport_handlers_health_refresh.go b/selective-vpn-api/app/transport_handlers_health_refresh.go new file mode 100644 index 0000000..ffe8cf9 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_health_refresh.go @@ -0,0 +1,114 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" +) + +func handleTransportHealthRefresh(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var body TransportHealthRefreshRequest + 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 + } + } + + force := bool(body.Force) + + transportMu.Lock() + st := loadTransportClientsState() + transportHealthSWR.syncKnownClients(st.Items) + transportMu.Unlock() + + items := make([]TransportHealthRefreshItem, 0, len(st.Items)) + queued := 0 + skipped := 0 + seen := map[string]struct{}{} + + appendItem := func(it TransportClient, reason string, wasQueued bool) { + id := strings.TrimSpace(it.ID) + if id == "" { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + items = append(items, TransportHealthRefreshItem{ + ClientID: id, + Status: it.Status, + Queued: wasQueued, + Reason: strings.TrimSpace(reason), + }) + if wasQueued { + queued++ + } else { + skipped++ + } + } + + if len(body.ClientIDs) == 0 { + for _, it := range st.Items { + if !force && !transportHealthCandidate(it) { + appendItem(it, "not eligible for background probe", false) + continue + } + if transportQueueHealthProbe(it, force) { + appendItem(it, "queued", true) + } else { + appendItem(it, "throttled or already fresh", false) + } + } + } else { + for _, raw := range body.ClientIDs { + id := sanitizeID(raw) + if id == "" { + continue + } + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + items = append(items, TransportHealthRefreshItem{ + ClientID: id, + Queued: false, + Reason: "not found", + }) + skipped++ + continue + } + it := st.Items[idx] + if !force && !transportHealthCandidate(it) { + appendItem(it, "not eligible for background probe", false) + continue + } + if transportQueueHealthProbe(it, force) { + appendItem(it, "queued", true) + } else { + appendItem(it, "throttled or already fresh", false) + } + } + } + + resp := TransportHealthRefreshResponse{ + OK: true, + Message: "health refresh queued", + Count: len(items), + Queued: queued, + Skipped: skipped, + Items: items, + } + if resp.Count == 0 { + resp.OK = false + resp.Code = "TRANSPORT_HEALTH_REFRESH_NO_TARGETS" + resp.Message = "no target clients" + } + writeJSON(w, http.StatusOK, resp) +} diff --git a/selective-vpn-api/app/transport_handlers_interfaces.go b/selective-vpn-api/app/transport_handlers_interfaces.go new file mode 100644 index 0000000..54b531f --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_interfaces.go @@ -0,0 +1,40 @@ +package app + +import "net/http" + +func handleTransportInterfaces(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + transportMu.Lock() + clientsState := loadTransportClientsState() + ifacesState := loadTransportInterfacesState() + snapshot := captureTransportInterfacesStateSnapshot(clientsState, ifacesState) + transportMu.Unlock() + + normIfaces, changed := normalizeTransportInterfacesState(ifacesState, clientsState.Items) + if changed { + if err := saveTransportInterfacesIfSnapshotCurrent(snapshot, normIfaces); err != nil { + writeJSON(w, http.StatusOK, TransportInterfacesResponse{ + OK: false, + Code: "TRANSPORT_INTERFACES_SAVE_FAILED", + Message: "interfaces state save failed: " + err.Error(), + }) + return + } + } + + items := buildTransportInterfaceItems(normIfaces.Items, clientsState.Items) + if adguardTarget, ok := buildTransportPolicyAdGuardTarget(); ok { + items = appendTransportVirtualInterfaceItems(items, []TransportClient{adguardTarget}) + } + + writeJSON(w, http.StatusOK, TransportInterfacesResponse{ + OK: true, + Message: "ok", + Count: len(items), + Items: items, + }) +} diff --git a/selective-vpn-api/app/transport_handlers_netns.go b/selective-vpn-api/app/transport_handlers_netns.go new file mode 100644 index 0000000..60195e6 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_netns.go @@ -0,0 +1,226 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "time" +) + +func handleTransportNetnsToggle(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body TransportNetnsToggleRequest + 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 + } + } + + lockIDs := resolveTransportNetnsToggleLockIDs(body) + resp := TransportNetnsToggleResponse{} + withTransportIfaceLocks(lockIDs, func() { + resp = executeTransportNetnsToggleLocked(body, time.Now().UTC()) + }) + if resp.SuccessCount > 0 { + publishTransportRuntimeObservabilitySnapshotChanged("transport_netns_toggled", nil, lockIDs) + } + writeJSON(w, http.StatusOK, resp) +} + +func resolveTransportNetnsToggleLockIDs(req TransportNetnsToggleRequest) []string { + now := time.Now().UTC() + transportMu.Lock() + clientsState := loadTransportClientsState() + ifacesState := loadTransportInterfacesState() + ifacesSnapshot := captureTransportInterfacesStateSnapshot(clientsState, ifacesState) + transportMu.Unlock() + + normIfaces, changed := normalizeTransportInterfacesState(ifacesState, clientsState.Items) + if changed { + _ = saveTransportInterfacesIfSnapshotCurrent(ifacesSnapshot, normIfaces) + } + + indexes, _ := resolveTransportNetnsToggleTargets(clientsState.Items, req.ClientIDs) + lockIDs := make([]string, 0, len(indexes)) + targetEnabled := transportNetnsToggleTarget(req.Enabled, clientsState.Items, indexes) + provisionEnabled := true + if req.Provision != nil { + provisionEnabled = *req.Provision + } + restartRunning := true + if req.RestartRunning != nil { + restartRunning = *req.RestartRunning + } + if !provisionEnabled { + restartRunning = false + } + + plannedItems := make([]TransportClient, len(clientsState.Items)) + copy(plannedItems, clientsState.Items) + for _, idx := range indexes { + if idx < 0 || idx >= len(clientsState.Items) { + continue + } + lockIDs = append(lockIDs, clientsState.Items[idx].IfaceID) + _, updated := prepareTransportNetnsToggleClientLocked(plannedItems[idx], normIfaces, targetEnabled, now) + plannedItems[idx] = updated + lockIDs = append(lockIDs, updated.IfaceID) + } + if restartRunning { + for _, idx := range indexes { + if idx < 0 || idx >= len(plannedItems) { + continue + } + if normalizeTransportStatus(clientsState.Items[idx].Status) != TransportClientUp { + continue + } + _, peers := matchTransportSingBoxPeersInSameNetnsForLock(plannedItems, idx, normIfaces) + for _, peer := range peers { + lockIDs = append(lockIDs, peer.Binding.IfaceID) + } + } + } + return lockIDs +} + +func executeTransportNetnsToggleLocked(req TransportNetnsToggleRequest, now time.Time) TransportNetnsToggleResponse { + transportMu.Lock() + st := loadTransportClientsState() + rawIfaces := loadTransportInterfacesState() + ifacesSnapshot := captureTransportInterfacesOnlySnapshot(rawIfaces) + ifaces, ifacesChanged := normalizeTransportInterfacesState(rawIfaces, st.Items) + resp := TransportNetnsToggleResponse{} + var plans []transportNetnsToggleClientPlan + var missing []string + + indexes, missing := resolveTransportNetnsToggleTargets(st.Items, req.ClientIDs) + targetEnabled := transportNetnsToggleTarget(req.Enabled, st.Items, indexes) + provisionEnabled := true + if req.Provision != nil { + provisionEnabled = *req.Provision + } + restartRunning := true + if req.RestartRunning != nil { + restartRunning = *req.RestartRunning + } + if !provisionEnabled { + restartRunning = false + } + + resp = TransportNetnsToggleResponse{ + OK: true, + Enabled: targetEnabled, + Items: make([]TransportNetnsToggleItem, 0, len(indexes)+len(missing)), + } + plans = make([]transportNetnsToggleClientPlan, 0, len(indexes)) + for _, idx := range indexes { + if idx < 0 || idx >= len(st.Items) { + continue + } + item, updated := prepareTransportNetnsToggleClientLocked(st.Items[idx], ifaces, targetEnabled, now) + st.Items[idx] = updated + plans = append(plans, transportNetnsToggleClientPlan{ + Item: item, + NeedsProvision: item.OK && provisionEnabled, + NeedsRestart: item.OK && restartRunning && item.StatusBefore == TransportClientUp, + }) + } + if len(indexes) > 0 { + if err := saveTransportClientsState(st); err != nil { + transportMu.Unlock() + resp.Items = append(resp.Items, markTransportNetnsTogglePlansSaveFailed(plans, "save failed: "+err.Error())...) + for _, missingID := range missing { + resp.Items = append(resp.Items, TransportNetnsToggleItem{ + OK: false, + ClientID: missingID, + Code: "TRANSPORT_CLIENT_NOT_FOUND", + Message: "not found", + NetnsEnabled: targetEnabled, + }) + } + return finalizeTransportNetnsToggleResponse(resp) + } + } + transportMu.Unlock() + + if ifacesChanged { + _ = saveTransportInterfacesIfUnchanged(ifacesSnapshot, ifaces) + } + + for _, plan := range plans { + resp.Items = append(resp.Items, applyTransportNetnsToggleClientPlanLocked(plan)) + } + for _, missingID := range missing { + resp.Items = append(resp.Items, TransportNetnsToggleItem{ + OK: false, + ClientID: missingID, + Code: "TRANSPORT_CLIENT_NOT_FOUND", + Message: "not found", + NetnsEnabled: targetEnabled, + }) + } + refreshTransportNetnsToggleFinalStatuses(&resp) + return finalizeTransportNetnsToggleResponse(resp) +} + +func resolveTransportNetnsToggleTargets(items []TransportClient, clientIDs []string) ([]int, []string) { + if len(clientIDs) == 0 { + out := make([]int, 0, len(items)) + for i := range items { + if items[i].Kind == TransportClientSingBox { + out = append(out, i) + } + } + return out, nil + } + out := make([]int, 0, len(clientIDs)) + missing := make([]string, 0, 2) + seen := make(map[int]struct{}, len(clientIDs)) + for _, raw := range clientIDs { + id := sanitizeID(raw) + if id == "" { + continue + } + idx := findTransportClientIndex(items, id) + if idx < 0 { + missing = append(missing, id) + continue + } + if _, ok := seen[idx]; ok { + continue + } + seen[idx] = struct{}{} + out = append(out, idx) + } + return out, missing +} + +func transportNetnsToggleTarget(enabled *bool, items []TransportClient, indexes []int) bool { + if enabled != nil { + return *enabled + } + any := false + allEnabled := true + for _, idx := range indexes { + if idx < 0 || idx >= len(items) { + continue + } + it := items[idx] + if it.Kind != TransportClientSingBox { + continue + } + any = true + if !transportNetnsEnabled(it) { + allEnabled = false + } + } + if !any { + return false + } + return !allEnabled +} diff --git a/selective-vpn-api/app/transport_handlers_netns_apply.go b/selective-vpn-api/app/transport_handlers_netns_apply.go new file mode 100644 index 0000000..1630a63 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_netns_apply.go @@ -0,0 +1,198 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +type transportNetnsToggleClientPlan struct { + Item TransportNetnsToggleItem + NeedsProvision bool + NeedsRestart bool +} + +func prepareTransportNetnsToggleClientLocked( + it TransportClient, + ifaces transportInterfacesState, + targetEnabled bool, + now time.Time, +) (TransportNetnsToggleItem, TransportClient) { + item := TransportNetnsToggleItem{ + OK: true, + ClientID: it.ID, + Kind: it.Kind, + StatusBefore: it.Status, + StatusAfter: it.Status, + NetnsEnabled: targetEnabled, + } + if it.Kind != TransportClientSingBox { + item.OK = false + item.Code = "TRANSPORT_NETNS_KIND_UNSUPPORTED" + item.Message = "netns toggle is supported only for singbox clients" + return item, it + } + + cfg := cloneMap(it.Config) + if cfg == nil { + cfg = map[string]any{} + } + changed := false + prevEnabled := transportConfigBool(cfg, "netns_enabled") + if prevEnabled != targetEnabled { + changed = true + } + cfg["netns_enabled"] = targetEnabled + if targetEnabled && strings.TrimSpace(transportConfigString(cfg, "netns_name")) == "" { + probe := it + probe.Config = cfg + binding := resolveTransportIfaceBinding(probe, ifaces) + if strings.TrimSpace(binding.NetnsName) != "" { + cfg["netns_name"] = binding.NetnsName + changed = true + } + } + item.ConfigUpdated = changed + if changed { + it.Config = cfg + it.UpdatedAt = now.Format(time.RFC3339) + } + if rebound, reboundChanged := applyTransportIfaceBinding(it, ifaces, now); reboundChanged { + it = rebound + item.ConfigUpdated = true + } + return item, it +} + +func applyTransportNetnsToggleClientPlanLocked(plan transportNetnsToggleClientPlan) TransportNetnsToggleItem { + item := plan.Item + if !item.OK { + return item + } + + if plan.NeedsProvision { + _, provisionResp := executeTransportClientProvisionActionLocked(item.ClientID) + mergeTransportNetnsToggleProvisionResponse(&item, provisionResp) + if !item.OK { + return item + } + } + + if plan.NeedsRestart { + _, restartResp := executeTransportLifecycleActionWithPreflightLocked(item.ClientID, "restart") + mergeTransportNetnsToggleRestartResponse(&item, restartResp) + } + if strings.TrimSpace(item.Message) == "" { + item.Message = fmt.Sprintf("netns=%t", item.NetnsEnabled) + } + return item +} + +func mergeTransportNetnsToggleProvisionResponse(item *TransportNetnsToggleItem, resp TransportClientLifecycleResponse) { + if item == nil { + return + } + item.Provisioned = resp.OK + item.Stdout = joinNonEmptyLines(item.Stdout, resp.Stdout) + item.Stderr = joinNonEmptyLines(item.Stderr, resp.Stderr) + item.StatusAfter = resp.StatusAfter + if resp.OK { + return + } + item.OK = false + item.Code = strings.TrimSpace(resp.Code) + item.Message = strings.TrimSpace(resp.Message) + if item.Message == "" { + item.Message = "provision failed" + } +} + +func mergeTransportNetnsToggleRestartResponse(item *TransportNetnsToggleItem, resp TransportClientLifecycleResponse) { + if item == nil { + return + } + item.Restarted = resp.OK + item.Stdout = joinNonEmptyLines(item.Stdout, resp.Stdout) + item.Stderr = joinNonEmptyLines(item.Stderr, resp.Stderr) + item.StatusAfter = resp.StatusAfter + if resp.OK { + msg := strings.TrimSpace(resp.Message) + if msg == "" { + msg = "restart done" + } + item.Message = msg + return + } + item.OK = false + item.Code = strings.TrimSpace(resp.Code) + item.Message = strings.TrimSpace(resp.Message) + if item.Message == "" { + item.Message = "restart failed" + } +} + +func markTransportNetnsTogglePlansSaveFailed( + plans []transportNetnsToggleClientPlan, + msg string, +) []TransportNetnsToggleItem { + items := make([]TransportNetnsToggleItem, 0, len(plans)) + for _, plan := range plans { + item := plan.Item + if item.OK { + item.OK = false + item.Code = "TRANSPORT_CLIENT_SAVE_FAILED" + item.Message = msg + item.Provisioned = false + item.Restarted = false + } + items = append(items, item) + } + return items +} + +func refreshTransportNetnsToggleFinalStatuses(resp *TransportNetnsToggleResponse) { + if resp == nil || len(resp.Items) == 0 { + return + } + transportMu.Lock() + st := loadTransportClientsState() + transportMu.Unlock() + for i := range resp.Items { + idx := findTransportClientIndex(st.Items, resp.Items[i].ClientID) + if idx < 0 { + continue + } + resp.Items[i].StatusAfter = st.Items[idx].Status + resp.Items[i].NetnsEnabled = transportNetnsEnabled(st.Items[idx]) + } +} + +func finalizeTransportNetnsToggleResponse(resp TransportNetnsToggleResponse) TransportNetnsToggleResponse { + resp.Count = len(resp.Items) + for _, item := range resp.Items { + if item.OK { + resp.SuccessCount++ + } else { + resp.FailureCount++ + } + } + if resp.Count == 0 { + resp.OK = false + resp.Code = "TRANSPORT_NETNS_NO_TARGETS" + resp.Message = "no singbox clients found" + return resp + } + if resp.FailureCount > 0 { + resp.OK = false + } + if !resp.OK && resp.Code == "" { + resp.Code = "TRANSPORT_NETNS_TOGGLE_PARTIAL_FAILED" + } + resp.Message = fmt.Sprintf( + "netns=%t applied: success=%d failed=%d", + resp.Enabled, + resp.SuccessCount, + resp.FailureCount, + ) + return resp +} diff --git a/selective-vpn-api/app/transport_handlers_policy.go b/selective-vpn-api/app/transport_handlers_policy.go new file mode 100644 index 0000000..d79f7e8 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_policy.go @@ -0,0 +1,145 @@ +package app + +import ( + "net/http" +) + +func handleTransportPolicies(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + transportMu.Lock() + policy := loadTransportPolicyState() + clientsState := loadTransportClientsState() + plan := loadTransportPolicyCompilePlan() + planSnapshot := captureTransportPolicyPlanStateSnapshot(policy, plan) + transportMu.Unlock() + + policyTargets := transportPolicyClientsWithVirtualTargets(clientsState.Items) + nextPlan, planChanged := compileTransportPolicyPlanForSnapshot(policy, policyTargets, plan) + if planChanged { + _ = saveTransportPlanIfSnapshotCurrent(planSnapshot, nextPlan) + } + + writeJSON(w, http.StatusOK, TransportPolicyResponse{ + OK: true, + Message: "ok", + PolicyRevision: policy.Revision, + Intents: policy.Intents, + Plan: &nextPlan, + }) +} + +func handleTransportConflicts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + transportMu.Lock() + st := loadTransportConflictsState() + transportMu.Unlock() + writeJSON(w, http.StatusOK, TransportConflictsResponse{ + OK: true, + Message: "ok", + HasBlocking: st.HasBlocking, + Items: st.Items, + }) +} + +func handleTransportOwnership(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + transportMu.Lock() + policy := loadTransportPolicyState() + clientsState := loadTransportClientsState() + owners := loadTransportOwnershipState() + plan := loadTransportPolicyCompilePlan() + planSnapshot := captureTransportPolicyPlanStateSnapshot(policy, plan) + ownershipSnapshot := captureTransportOwnershipStateSnapshot(policy, owners) + transportMu.Unlock() + + policyTargets := transportPolicyClientsWithVirtualTargets(clientsState.Items) + nextPlan, planChanged := compileTransportPolicyPlanForSnapshot(policy, policyTargets, plan) + if planChanged { + _ = saveTransportPlanIfSnapshotCurrent(planSnapshot, nextPlan) + } + planDigest := digestTransportPolicyCompilePlan(nextPlan) + if transportOwnershipNeedsRebuild(policy.Revision, owners, planDigest) { + owners = buildTransportOwnershipStateFromPlan(nextPlan, policy.Revision) + _ = saveTransportOwnershipIfSnapshotCurrent(ownershipSnapshot, owners) + } + items, lockCount := attachTransportOwnershipLockState(owners.Items, policyTargets) + writeJSON(w, http.StatusOK, TransportOwnershipResponse{ + OK: true, + Message: "ok", + PolicyRevision: owners.PolicyRevision, + PlanDigest: owners.PlanDigest, + Count: len(items), + LockCount: lockCount, + Items: items, + }) +} + +func handleTransportOwnerLocks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + transportMu.Lock() + locks := loadTransportOwnerLocksState() + transportMu.Unlock() + writeJSON(w, http.StatusOK, TransportOwnerLocksResponse{ + OK: true, + Message: "ok", + PolicyRevision: locks.PolicyRevision, + Count: len(locks.Items), + Items: locks.Items, + }) +} + +func handleTransportCapabilities(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusOK, TransportCapabilitiesResponse{ + OK: true, + Message: "ok", + Clients: map[string]map[string]bool{ + "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}, + "adguardvpn": {"vpn": true, "autoloop": true, "dns_tunnel": false, "ssh_tunnel": false}, + }, + RuntimeModes: map[string]bool{ + "exec": true, + "embedded": false, + "sidecar": false, + }, + PackagingProfiles: map[string]bool{ + "system": true, + "bundled": true, + }, + Lifecycle: []string{"provision", "start", "stop", "restart"}, + HealthFields: []string{"status", "latency_ms", "last_error", "health.last_check"}, + MetricsFields: []string{"restarts", "state_changes", "uptime_sec", "last_transition_at"}, + ErrorCodes: []string{ + "TRANSPORT_CLIENT_NOT_FOUND", + "TRANSPORT_CLIENT_SAVE_FAILED", + "TRANSPORT_CLIENT_DEGRADED", + "BACKEND_RUNTIME_ERROR", + "TRANSPORT_BACKEND_NETNS_SETUP_FAILED", + "TRANSPORT_BACKEND_BOOTSTRAP_BYPASS_FAILED", + "TRANSPORT_BACKEND_SINGBOX_DNS_MIGRATE_FAILED", + "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", + }, + }) +} diff --git a/selective-vpn-api/app/transport_handlers_policy_mutations.go b/selective-vpn-api/app/transport_handlers_policy_mutations.go new file mode 100644 index 0000000..34186d8 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_policy_mutations.go @@ -0,0 +1,15 @@ +package app + +import "net/http" + +func handleTransportPoliciesValidate(w http.ResponseWriter, r *http.Request) { + handleTransportPoliciesValidateExec(w, r) +} + +func handleTransportPoliciesApply(w http.ResponseWriter, r *http.Request) { + handleTransportPoliciesApplyExec(w, r) +} + +func handleTransportPoliciesRollback(w http.ResponseWriter, r *http.Request) { + handleTransportPoliciesRollbackExec(w, r) +} diff --git a/selective-vpn-api/app/transport_handlers_policy_mutations_apply.go b/selective-vpn-api/app/transport_handlers_policy_mutations_apply.go new file mode 100644 index 0000000..6428746 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_policy_mutations_apply.go @@ -0,0 +1,260 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" +) + +func handleTransportPoliciesApplyExec(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body TransportPolicyApplyRequest + if r.Body != nil { + defer r.Body.Close() + if err := json.NewDecoder(io.LimitReader(r.Body, 2<<20)).Decode(&body); err != nil && err != io.EOF { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + } + idempotencyKey := normalizeTransportIdempotencyKey(r.Header.Get("Idempotency-Key")) + requestHash := hashTransportPolicyMutationRequest(body) + + transportMu.Lock() + publishRuntimeSnapshot := false + defer func() { + transportMu.Unlock() + if publishRuntimeSnapshot { + publishTransportRuntimeObservabilitySnapshotChanged("transport_policy_applied", nil, nil) + } + }() + respond := func(resp TransportPolicyResponse) { + persistTransportPolicyIdempotencyLocked(transportPolicyIdempotencyApplyScope, idempotencyKey, requestHash, resp) + writeJSON(w, http.StatusOK, resp) + } + if lookup := lookupTransportPolicyIdempotencyLocked(transportPolicyIdempotencyApplyScope, idempotencyKey, requestHash); lookup.Replay || lookup.Conflict { + writeJSON(w, http.StatusOK, lookup.Response) + return + } + + clientsState := loadTransportClientsState() + clients := transportPolicyClientsWithVirtualTargets(clientsState.Items) + current := loadTransportPolicyState() + locks := TransportOwnerLockState{} + if transportPolicyKernelConntrackStickyEnabled() { + locks = loadTransportOwnerLocksState() + } + if body.BaseRevision != current.Revision { + respond(TransportPolicyResponse{ + OK: false, + Message: "stale policy revision", + Code: "POLICY_REVISION_MISMATCH", + CurrentRevision: current.Revision, + }) + return + } + + result := validateTransportPolicy(body.Intents, current.Intents, clients) + ownerSwitchConflicts := detectTransportOwnerSwitchConflicts(current.Intents, result.Normalized) + if len(ownerSwitchConflicts) > 0 { + result.Conflicts = append(result.Conflicts, ownerSwitchConflicts...) + result.Conflicts = dedupeTransportConflicts(result.Conflicts) + result.Summary = summarizeTransportConflicts(result.Conflicts) + result.Valid = result.Summary.BlockCount == 0 + } + ownerLockConflicts := detectTransportOwnerLockConflicts(current.Intents, result.Normalized, clients) + if len(ownerLockConflicts) > 0 { + result.Conflicts = append(result.Conflicts, ownerLockConflicts...) + result.Conflicts = dedupeTransportConflicts(result.Conflicts) + result.Summary = summarizeTransportConflicts(result.Conflicts) + result.Valid = result.Summary.BlockCount == 0 + } + destinationLockConflicts := detectTransportDestinationLockConflicts(current.Intents, result.Normalized, locks) + if len(destinationLockConflicts) > 0 { + result.Conflicts = append(result.Conflicts, destinationLockConflicts...) + result.Conflicts = dedupeTransportConflicts(result.Conflicts) + result.Summary = summarizeTransportConflicts(result.Conflicts) + result.Valid = result.Summary.BlockCount == 0 + } + overridableBlocks, hardBlocks := splitTransportBlockingConflicts(result.Conflicts) + if len(hardBlocks) > 0 { + respond(TransportPolicyResponse{ + OK: false, + Message: "policy has non-overridable blocking conflicts", + Code: "POLICY_CONFLICT_BLOCK", + Conflicts: result.Conflicts, + }) + return + } + if len(overridableBlocks) > 0 && !body.Options.ForceOverride { + respond(TransportPolicyResponse{ + OK: false, + Message: "policy requires explicit force override", + Code: "POLICY_FORCE_OVERRIDE_REQUIRED", + Conflicts: result.Conflicts, + }) + return + } + + plan, compileConflicts := compileTransportPolicyPlan(result.Normalized, clients, current.Revision+1) + mergedConflicts := append([]TransportConflictRecord{}, result.Conflicts...) + if len(compileConflicts) > 0 { + mergedConflicts = append(mergedConflicts, compileConflicts...) + mergedConflicts = dedupeTransportConflicts(mergedConflicts) + sum := summarizeTransportConflicts(mergedConflicts) + if sum.BlockCount > 0 { + respond(TransportPolicyResponse{ + OK: false, + Message: "policy compile blocked by allocator/interface conflicts", + Code: "POLICY_COMPILE_BLOCK", + Conflicts: mergedConflicts, + Plan: &plan, + }) + return + } + } + conflictsSummary := summarizeTransportConflicts(mergedConflicts) + + digest := digestTransportIntents(result.Normalized) + if len(overridableBlocks) > 0 && body.Options.ForceOverride { + if !consumeTransportConfirmToken(strings.TrimSpace(body.Options.ConfirmToken), current.Revision, digest) { + respond(TransportPolicyResponse{ + OK: false, + Message: "invalid or expired confirm token", + Code: "FORCE_OVERRIDE_CONFIRM_REQUIRED", + }) + return + } + } + + if err := saveTransportPolicySnapshot(current); err != nil { + respond(TransportPolicyResponse{ + OK: false, + Message: "snapshot save failed: " + err.Error(), + }) + return + } + + next := TransportPolicyState{ + Version: transportStateVersion, + Revision: current.Revision + 1, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Intents: append([]TransportPolicyIntent(nil), result.Normalized...), + } + plan.PolicyRevision = next.Revision + applyID := "apl-" + newTransportToken(8) + appliedRuntime, err := applyTransportPolicyDataPlaneAtomicLocked(plan, applyID) + if err != nil { + respond(TransportPolicyResponse{ + OK: false, + Message: "policy runtime apply failed: " + err.Error(), + Code: "POLICY_RUNTIME_APPLY_FAILED", + Plan: &plan, + }) + return + } + healthCheck, updatedClients, clientsChanged := runTransportPolicyHealthCheck(clients, plan, time.Now().UTC()) + if clientsChanged { + persistClients := transportPolicyPersistableClients(updatedClients) + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: persistClients, + }); err != nil { + _ = rollbackTransportPolicyRuntimeToSnapshot(appliedRuntime) + respond(TransportPolicyResponse{ + OK: false, + Message: "health-check client state save failed: " + err.Error(), + Code: "POLICY_HEALTHCHECK_SAVE_FAILED", + Plan: &plan, + HealthCheck: &healthCheck, + }) + return + } + clients = updatedClients + } + if !healthCheck.OK { + rollbackErr := rollbackTransportPolicyRuntimeToSnapshot(appliedRuntime) + msg := healthCheck.Message + if rollbackErr != nil { + msg = strings.TrimSpace(msg + "; runtime rollback failed: " + rollbackErr.Error()) + } + events.push("transport_policy_healthcheck_failed", map[string]any{ + "apply_id": applyID, + "policy_revision": next.Revision, + "failed_count": healthCheck.FailedCount, + }) + respond(TransportPolicyResponse{ + OK: false, + Message: msg, + Code: "POLICY_HEALTHCHECK_FAILED", + Plan: &plan, + HealthCheck: &healthCheck, + }) + return + } + events.push("transport_policy_healthcheck_passed", map[string]any{ + "apply_id": applyID, + "policy_revision": next.Revision, + "checked_count": healthCheck.CheckedCount, + }) + + if err := saveTransportPolicyState(next); err != nil { + respond(TransportPolicyResponse{ + OK: false, + Message: "policy save failed: " + err.Error(), + }) + return + } + if err := saveTransportPolicyCompilePlan(plan); err != nil { + respond(TransportPolicyResponse{ + OK: false, + Message: "policy plan save failed: " + err.Error(), + Code: "POLICY_PLAN_SAVE_FAILED", + }) + return + } + ownership := buildTransportOwnershipStateFromPlan(plan, next.Revision) + if err := saveTransportOwnershipState(ownership); err != nil { + respond(TransportPolicyResponse{ + OK: false, + Message: "ownership save failed: " + err.Error(), + Code: "POLICY_OWNERSHIP_SAVE_FAILED", + }) + return + } + + conflicts := TransportConflictState{ + Version: transportStateVersion, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + HasBlocking: conflictsSummary.BlockCount > 0, + Items: append([]TransportConflictRecord(nil), mergedConflicts...), + } + _ = saveTransportConflictsState(conflicts) + + events.push("transport_policy_applied", map[string]any{ + "apply_id": applyID, + "policy_revision": next.Revision, + "iface_count": plan.InterfaceCount, + "rule_count": plan.RuleCount, + }) + if conflictsSummary.BlockCount > 0 { + events.push("transport_conflict_detected", map[string]any{ + "count": conflictsSummary.BlockCount, + }) + } + publishRuntimeSnapshot = true + + respond(TransportPolicyResponse{ + OK: true, + Message: "policy applied", + PolicyRevision: next.Revision, + ApplyID: applyID, + RollbackAvailable: true, + Plan: &plan, + HealthCheck: &healthCheck, + }) +} diff --git a/selective-vpn-api/app/transport_handlers_policy_mutations_rollback.go b/selective-vpn-api/app/transport_handlers_policy_mutations_rollback.go new file mode 100644 index 0000000..acafb38 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_policy_mutations_rollback.go @@ -0,0 +1,220 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" +) + +func handleTransportPoliciesRollbackExec(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body TransportPolicyRollbackRequest + 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 + } + } + idempotencyKey := normalizeTransportIdempotencyKey(r.Header.Get("Idempotency-Key")) + requestHash := hashTransportPolicyMutationRequest(body) + + transportMu.Lock() + publishRuntimeSnapshot := false + defer func() { + transportMu.Unlock() + if publishRuntimeSnapshot { + publishTransportRuntimeObservabilitySnapshotChanged("transport_policy_rollback_applied", nil, nil) + } + }() + respond := func(resp TransportPolicyResponse) { + persistTransportPolicyIdempotencyLocked(transportPolicyIdempotencyRollbackScope, idempotencyKey, requestHash, resp) + writeJSON(w, http.StatusOK, resp) + } + if lookup := lookupTransportPolicyIdempotencyLocked(transportPolicyIdempotencyRollbackScope, idempotencyKey, requestHash); lookup.Replay || lookup.Conflict { + writeJSON(w, http.StatusOK, lookup.Response) + return + } + + current := loadTransportPolicyState() + if body.BaseRevision > 0 && body.BaseRevision != current.Revision { + respond(TransportPolicyResponse{ + OK: false, + Message: "stale policy revision", + Code: "POLICY_REVISION_MISMATCH", + CurrentRevision: current.Revision, + }) + return + } + + snapshot, ok := loadTransportPolicySnapshot() + if !ok { + respond(TransportPolicyResponse{ + OK: false, + Message: "rollback snapshot not found", + Code: "ROLLBACK_SNAPSHOT_NOT_FOUND", + }) + return + } + + clientsState := loadTransportClientsState() + clients := transportPolicyClientsWithVirtualTargets(clientsState.Items) + result := validateTransportPolicy(snapshot.Intents, current.Intents, clients) + if result.Summary.BlockCount > 0 { + respond(TransportPolicyResponse{ + OK: false, + Message: "rollback blocked by policy conflicts", + Code: "ROLLBACK_BLOCKED", + Conflicts: result.Conflicts, + }) + return + } + plan, compileConflicts := compileTransportPolicyPlan(result.Normalized, clients, current.Revision+1) + mergedConflicts := append([]TransportConflictRecord{}, result.Conflicts...) + if len(compileConflicts) > 0 { + mergedConflicts = append(mergedConflicts, compileConflicts...) + mergedConflicts = dedupeTransportConflicts(mergedConflicts) + sum := summarizeTransportConflicts(mergedConflicts) + if sum.BlockCount > 0 { + respond(TransportPolicyResponse{ + OK: false, + Message: "rollback compile blocked by allocator/interface conflicts", + Code: "ROLLBACK_COMPILE_BLOCKED", + Conflicts: mergedConflicts, + Plan: &plan, + }) + return + } + } + conflictsSummary := summarizeTransportConflicts(mergedConflicts) + + // Keep current state as the next rollback candidate. + if err := saveTransportPolicySnapshot(current); err != nil { + respond(TransportPolicyResponse{ + OK: false, + Message: "snapshot save failed: " + err.Error(), + }) + return + } + + next := snapshot + next.Version = transportStateVersion + next.Revision = current.Revision + 1 + next.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + next.Intents = append([]TransportPolicyIntent(nil), result.Normalized...) + plan.PolicyRevision = next.Revision + applyID := "rbk-" + newTransportToken(8) + appliedRuntime, err := applyTransportPolicyDataPlaneAtomicLocked(plan, applyID) + if err != nil { + respond(TransportPolicyResponse{ + OK: false, + Message: "rollback runtime apply failed: " + err.Error(), + Code: "ROLLBACK_RUNTIME_APPLY_FAILED", + Plan: &plan, + }) + return + } + healthCheck, updatedClients, clientsChanged := runTransportPolicyHealthCheck(clients, plan, time.Now().UTC()) + if clientsChanged { + persistClients := transportPolicyPersistableClients(updatedClients) + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: persistClients, + }); err != nil { + _ = rollbackTransportPolicyRuntimeToSnapshot(appliedRuntime) + respond(TransportPolicyResponse{ + OK: false, + Message: "rollback health-check client state save failed: " + err.Error(), + Code: "ROLLBACK_HEALTHCHECK_SAVE_FAILED", + Plan: &plan, + HealthCheck: &healthCheck, + }) + return + } + clients = updatedClients + } + if !healthCheck.OK { + rollbackErr := rollbackTransportPolicyRuntimeToSnapshot(appliedRuntime) + msg := healthCheck.Message + if rollbackErr != nil { + msg = strings.TrimSpace(msg + "; runtime rollback failed: " + rollbackErr.Error()) + } + events.push("transport_policy_healthcheck_failed", map[string]any{ + "apply_id": applyID, + "policy_revision": next.Revision, + "rollback": true, + "failed_count": healthCheck.FailedCount, + }) + respond(TransportPolicyResponse{ + OK: false, + Message: msg, + Code: "ROLLBACK_HEALTHCHECK_FAILED", + Plan: &plan, + HealthCheck: &healthCheck, + }) + return + } + events.push("transport_policy_healthcheck_passed", map[string]any{ + "apply_id": applyID, + "policy_revision": next.Revision, + "rollback": true, + "checked_count": healthCheck.CheckedCount, + }) + + if err := saveTransportPolicyState(next); err != nil { + respond(TransportPolicyResponse{ + OK: false, + Message: "policy save failed: " + err.Error(), + }) + return + } + if err := saveTransportPolicyCompilePlan(plan); err != nil { + respond(TransportPolicyResponse{ + OK: false, + Message: "policy plan save failed: " + err.Error(), + Code: "POLICY_PLAN_SAVE_FAILED", + }) + return + } + ownership := buildTransportOwnershipStateFromPlan(plan, next.Revision) + if err := saveTransportOwnershipState(ownership); err != nil { + respond(TransportPolicyResponse{ + OK: false, + Message: "ownership save failed: " + err.Error(), + Code: "POLICY_OWNERSHIP_SAVE_FAILED", + }) + return + } + + conflicts := TransportConflictState{ + Version: transportStateVersion, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + HasBlocking: conflictsSummary.BlockCount > 0, + Items: append([]TransportConflictRecord(nil), mergedConflicts...), + } + _ = saveTransportConflictsState(conflicts) + + events.push("transport_policy_applied", map[string]any{ + "apply_id": applyID, + "policy_revision": next.Revision, + "rollback": true, + "iface_count": plan.InterfaceCount, + "rule_count": plan.RuleCount, + }) + publishRuntimeSnapshot = true + + respond(TransportPolicyResponse{ + OK: true, + Message: "policy rollback applied", + PolicyRevision: next.Revision, + ApplyID: applyID, + RollbackAvailable: true, + Plan: &plan, + HealthCheck: &healthCheck, + }) +} diff --git a/selective-vpn-api/app/transport_handlers_policy_mutations_validate.go b/selective-vpn-api/app/transport_handlers_policy_mutations_validate.go new file mode 100644 index 0000000..07d9d51 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_policy_mutations_validate.go @@ -0,0 +1,122 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +func handleTransportPoliciesValidateExec(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body TransportPolicyValidateRequest + if r.Body != nil { + defer r.Body.Close() + if err := json.NewDecoder(io.LimitReader(r.Body, 2<<20)).Decode(&body); err != nil && err != io.EOF { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + } + + transportMu.Lock() + clientsState := loadTransportClientsState() + current := loadTransportPolicyState() + locks := TransportOwnerLockState{} + if transportPolicyKernelConntrackStickyEnabled() { + locks = loadTransportOwnerLocksState() + } + transportMu.Unlock() + clients := transportPolicyClientsWithVirtualTargets(clientsState.Items) + + result := validateTransportPolicy(body.Intents, current.Intents, clients) + ownerSwitchConflicts := detectTransportOwnerSwitchConflicts(current.Intents, result.Normalized) + if len(ownerSwitchConflicts) > 0 { + result.Conflicts = append(result.Conflicts, ownerSwitchConflicts...) + result.Conflicts = dedupeTransportConflicts(result.Conflicts) + result.Summary = summarizeTransportConflicts(result.Conflicts) + result.Valid = result.Summary.BlockCount == 0 + } + ownerLockConflicts := detectTransportOwnerLockConflicts(current.Intents, result.Normalized, clients) + if len(ownerLockConflicts) > 0 { + result.Conflicts = append(result.Conflicts, ownerLockConflicts...) + result.Conflicts = dedupeTransportConflicts(result.Conflicts) + result.Summary = summarizeTransportConflicts(result.Conflicts) + result.Valid = result.Summary.BlockCount == 0 + } + destinationLockConflicts := detectTransportDestinationLockConflicts(current.Intents, result.Normalized, locks) + if len(destinationLockConflicts) > 0 { + result.Conflicts = append(result.Conflicts, destinationLockConflicts...) + result.Conflicts = dedupeTransportConflicts(result.Conflicts) + result.Summary = summarizeTransportConflicts(result.Conflicts) + result.Valid = result.Summary.BlockCount == 0 + } + if body.BaseRevision > 0 && body.BaseRevision != current.Revision { + result.Conflicts = append(result.Conflicts, TransportConflictRecord{ + Key: "base_revision", + Type: "stale_base", + Severity: "warn", + Reason: fmt.Sprintf("base_revision=%d differs from current=%d", body.BaseRevision, current.Revision), + SuggestedResolution: "refresh policy and retry validate/apply", + }) + result.Summary.WarnCount++ + result.Conflicts = dedupeTransportConflicts(result.Conflicts) + result.Summary = summarizeTransportConflicts(result.Conflicts) + result.Valid = result.Summary.BlockCount == 0 + } + + plan, compileConflicts := compileTransportPolicyPlan(result.Normalized, clients, current.Revision) + if len(compileConflicts) > 0 { + result.Conflicts = append(result.Conflicts, compileConflicts...) + result.Conflicts = dedupeTransportConflicts(result.Conflicts) + result.Summary = summarizeTransportConflicts(result.Conflicts) + result.Valid = result.Summary.BlockCount == 0 + } + + digest := digestTransportIntents(result.Normalized) + confirmToken := issueTransportConfirmToken(current.Revision, digest) + state := TransportConflictState{ + Version: transportStateVersion, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + HasBlocking: result.Summary.BlockCount > 0, + Items: append([]TransportConflictRecord(nil), result.Conflicts...), + } + + transportMu.Lock() + _ = saveTransportConflictsState(state) + transportMu.Unlock() + + msg := "validation complete" + code := "" + if result.Summary.BlockCount > 0 { + msg = "policy has blocking conflicts" + code = "POLICY_CONFLICT_BLOCK" + } + + events.push("transport_policy_validated", map[string]any{ + "valid": result.Valid, + "block_count": result.Summary.BlockCount, + "warn_count": result.Summary.WarnCount, + }) + if result.Summary.BlockCount > 0 { + events.push("transport_conflict_detected", map[string]any{ + "count": result.Summary.BlockCount, + }) + } + + writeJSON(w, http.StatusOK, TransportPolicyValidateResponse{ + OK: true, + Message: msg, + Code: code, + Valid: result.Valid, + BaseRevision: current.Revision, + ConfirmToken: confirmToken, + Summary: result.Summary, + Conflicts: result.Conflicts, + Diff: result.Diff, + Plan: &plan, + }) +} diff --git a/selective-vpn-api/app/transport_handlers_policy_owner_locks_clear.go b/selective-vpn-api/app/transport_handlers_policy_owner_locks_clear.go new file mode 100644 index 0000000..3cdcdb4 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_policy_owner_locks_clear.go @@ -0,0 +1,116 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" +) + +func handleTransportOwnerLocksClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var body TransportOwnerLocksClearRequest + 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 + } + } + + filter, err := normalizeTransportOwnerLockClearRequest(body) + if err != nil { + writeJSON(w, http.StatusOK, TransportOwnerLocksClearResponse{ + OK: false, + Message: err.Error(), + Code: "OWNER_LOCK_CLEAR_INVALID_REQUEST", + }) + return + } + + transportMu.Lock() + defer transportMu.Unlock() + + locks := loadTransportOwnerLocksState() + baseRevision := locks.PolicyRevision + if body.BaseRevision > 0 && body.BaseRevision != baseRevision { + writeJSON(w, http.StatusOK, TransportOwnerLocksClearResponse{ + OK: false, + Message: "stale owner-lock revision", + Code: "OWNER_LOCK_CLEAR_REVISION_MISMATCH", + BaseRevision: baseRevision, + }) + return + } + + matched, remaining := splitTransportOwnerLocksByFilter(locks.Items, filter) + if len(matched) == 0 { + writeJSON(w, http.StatusOK, TransportOwnerLocksClearResponse{ + OK: true, + Message: "no matching owner locks", + BaseRevision: baseRevision, + MatchCount: 0, + ClearedCount: 0, + RemainingCount: len(locks.Items), + }) + return + } + + digest := digestTransportOwnerLocksClear(baseRevision, filter, matched) + confirmToken := strings.TrimSpace(body.ConfirmToken) + if !consumeTransportOwnerLocksClearToken(confirmToken, baseRevision, digest) { + nextToken := issueTransportOwnerLocksClearToken(baseRevision, digest) + code := "OWNER_LOCK_CLEAR_CONFIRM_REQUIRED" + msg := "confirm token required to clear owner locks" + if confirmToken != "" { + code = "OWNER_LOCK_CLEAR_CONFIRM_INVALID" + msg = "invalid or expired confirm token" + } + writeJSON(w, http.StatusOK, TransportOwnerLocksClearResponse{ + OK: false, + Message: msg, + Code: code, + BaseRevision: baseRevision, + ConfirmRequired: true, + ConfirmToken: nextToken, + MatchCount: len(matched), + RemainingCount: len(locks.Items), + Items: matched, + }) + return + } + + next := locks + next.Items = append([]TransportOwnerLockRecord(nil), remaining...) + next.PolicyRevision = baseRevision + if err := saveTransportOwnerLocksState(next); err != nil { + writeJSON(w, http.StatusOK, TransportOwnerLocksClearResponse{ + OK: false, + Message: "owner-lock save failed: " + err.Error(), + Code: "OWNER_LOCK_CLEAR_SAVE_FAILED", + BaseRevision: baseRevision, + }) + return + } + + events.push("transport_owner_locks_cleared", map[string]any{ + "base_revision": baseRevision, + "cleared_count": len(matched), + "remaining_count": len(next.Items), + "client_id": filter.ClientID, + }) + + writeJSON(w, http.StatusOK, TransportOwnerLocksClearResponse{ + OK: true, + Message: "owner locks cleared", + BaseRevision: baseRevision, + MatchCount: len(matched), + ClearedCount: len(matched), + RemainingCount: len(next.Items), + Items: matched, + }) +} diff --git a/selective-vpn-api/app/transport_handlers_policy_ownership_test.go b/selective-vpn-api/app/transport_handlers_policy_ownership_test.go new file mode 100644 index 0000000..75929fa --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_policy_ownership_test.go @@ -0,0 +1,100 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHandleTransportOwnershipRebuildsOnPlanDigestDrift(t *testing.T) { + withTransportPolicyMutationTestPaths(t) + + policy := TransportPolicyState{ + Version: transportStateVersion, + Revision: 17, + Intents: []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "demo.invalid", ClientID: "sg-a", Priority: 100, Mode: "strict"}, + }, + } + if err := saveTransportPolicyState(policy); err != nil { + t.Fatalf("save policy: %v", err) + } + + clients := transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sg-a", + Kind: TransportClientSingBox, + IfaceID: "shared", + RoutingTable: "agvpn_sg_a", + MarkHex: "0x110", + PriorityBase: 13250, + Enabled: true, + Status: TransportClientUp, + }, + }, + } + if err := saveTransportClientsState(clients); err != nil { + t.Fatalf("save clients: %v", err) + } + + plan, conflicts := compileTransportPolicyPlan(policy.Intents, clients.Items, policy.Revision) + if len(conflicts) > 0 { + t.Fatalf("unexpected compile conflicts: %#v", conflicts) + } + if err := saveTransportPolicyCompilePlan(plan); err != nil { + t.Fatalf("save plan: %v", err) + } + + legacyOwners := TransportOwnershipState{ + Version: transportStateVersion, + PolicyRevision: policy.Revision, + PlanDigest: "legacy-digest", + Items: []TransportOwnershipRecord{ + { + Key: "domain:demo.invalid", + SelectorType: "domain", + SelectorValue: "demo.invalid", + ClientID: "sg-a", + IfaceID: "shared", + }, + }, + } + if err := saveTransportOwnershipState(legacyOwners); err != nil { + t.Fatalf("save owners: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/transport/owners", nil) + rec := httptest.NewRecorder() + handleTransportOwnership(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + + var resp TransportOwnershipResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + expectedDigest := digestTransportPolicyCompilePlan(plan) + if resp.PlanDigest != expectedDigest { + t.Fatalf("unexpected response plan_digest: got=%q want=%q", resp.PlanDigest, expectedDigest) + } + if len(resp.Items) != 1 { + t.Fatalf("unexpected ownership response items: %d", len(resp.Items)) + } + if resp.Items[0].OwnerScope == "" { + t.Fatalf("expected owner_scope in response item: %#v", resp.Items[0]) + } + + rebuilt := loadTransportOwnershipState() + if rebuilt.PlanDigest != expectedDigest { + t.Fatalf("ownership state plan_digest not rebuilt: got=%q want=%q", rebuilt.PlanDigest, expectedDigest) + } + if len(rebuilt.Items) != 1 || rebuilt.Items[0].OwnerScope == "" { + t.Fatalf("ownership state did not rebuild owner_scope: %#v", rebuilt.Items) + } +} diff --git a/selective-vpn-api/app/transport_handlers_runtime_observability.go b/selective-vpn-api/app/transport_handlers_runtime_observability.go new file mode 100644 index 0000000..27bf406 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_runtime_observability.go @@ -0,0 +1,14 @@ +package app + +import ( + "net/http" + "time" +) + +func handleTransportRuntimeObservability(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + writeJSON(w, http.StatusOK, transportRuntimeObservabilitySnapshotResponse(time.Now().UTC())) +} diff --git a/selective-vpn-api/app/transport_handlers_test.go b/selective-vpn-api/app/transport_handlers_test.go new file mode 100644 index 0000000..69f8e03 --- /dev/null +++ b/selective-vpn-api/app/transport_handlers_test.go @@ -0,0 +1,1191 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + transporttoken "selective-vpn-api/app/transporttoken" + "strings" + "testing" + "time" +) + +func TestValidateTransportPolicyOwnershipConflict(t *testing.T) { + clients := []TransportClient{ + {ID: "c1", Kind: TransportClientSingBox}, + {ID: "c2", Kind: TransportClientDNSTT}, + } + next := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c1"}, + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c2"}, + } + res := validateTransportPolicy(next, nil, clients) + if res.Valid { + t.Fatalf("expected invalid policy") + } + if res.Summary.BlockCount == 0 { + t.Fatalf("expected blocking conflicts") + } + found := false + for _, c := range res.Conflicts { + if c.Type == "ownership" && strings.Contains(c.Key, "domain:example.com") { + found = true + break + } + } + if !found { + t.Fatalf("ownership conflict not found: %#v", res.Conflicts) + } +} + +func TestValidateTransportPolicyCIDROverlap(t *testing.T) { + clients := []TransportClient{ + {ID: "c1", Kind: TransportClientSingBox}, + {ID: "c2", Kind: TransportClientPhoenix}, + } + next := []TransportPolicyIntent{ + {SelectorType: "cidr", SelectorValue: "10.0.0.0/24", ClientID: "c1"}, + {SelectorType: "cidr", SelectorValue: "10.0.0.128/25", ClientID: "c2"}, + } + res := validateTransportPolicy(next, nil, clients) + if res.Summary.BlockCount == 0 { + t.Fatalf("expected CIDR overlap block conflict") + } + found := false + for _, c := range res.Conflicts { + if c.Type == "cidr_overlap" { + found = true + break + } + } + if !found { + t.Fatalf("cidr overlap conflict not found: %#v", res.Conflicts) + } +} + +func TestCompileTransportPolicyPlanGroupsByIface(t *testing.T) { + clients := []TransportClient{ + { + ID: "c1", + Kind: TransportClientSingBox, + IfaceID: "edge-a", + MarkHex: "0x120", + PriorityBase: 13300, + }, + { + ID: "c2", + Kind: TransportClientDNSTT, + IfaceID: "edge-b", + MarkHex: "0x121", + PriorityBase: 13350, + }, + } + intents := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "a.example", ClientID: "c1", Mode: "strict", Priority: 100}, + {SelectorType: "cidr", SelectorValue: "10.1.0.0/24", ClientID: "c1", Mode: "strict", Priority: 90}, + {SelectorType: "domain", SelectorValue: "b.example", ClientID: "c2", Mode: "fallback", Priority: 80}, + } + plan, conflicts := compileTransportPolicyPlan(intents, clients, 7) + if len(conflicts) != 0 { + t.Fatalf("unexpected compile conflicts: %#v", conflicts) + } + if plan.PolicyRevision != 7 { + t.Fatalf("unexpected policy revision: %d", plan.PolicyRevision) + } + if plan.InterfaceCount != 2 { + t.Fatalf("unexpected interface count: %d", plan.InterfaceCount) + } + if plan.RuleCount != 3 { + t.Fatalf("unexpected rule count: %d", plan.RuleCount) + } + + var edgeA *TransportPolicyCompileInterface + for i := range plan.Interfaces { + if plan.Interfaces[i].IfaceID == "edge-a" { + edgeA = &plan.Interfaces[i] + break + } + } + if edgeA == nil { + t.Fatalf("edge-a plan not found: %#v", plan.Interfaces) + } + if edgeA.RoutingTable != "agvpn_if_edge_a" { + t.Fatalf("unexpected edge-a routing table: %q", edgeA.RoutingTable) + } + if edgeA.RuleCount != 2 { + t.Fatalf("unexpected edge-a rule count: %d", edgeA.RuleCount) + } + if len(edgeA.Sets) != 2 { + t.Fatalf("unexpected edge-a sets: %#v", edgeA.Sets) + } +} + +func TestCompileTransportPolicyPlanUsesOwnerScopedNftSets(t *testing.T) { + clients := []TransportClient{ + { + ID: "c1", + Kind: TransportClientSingBox, + IfaceID: "edge-a", + MarkHex: "0x120", + PriorityBase: 13300, + }, + { + ID: "c2", + Kind: TransportClientDNSTT, + IfaceID: "edge-a", + MarkHex: "0x121", + PriorityBase: 13350, + }, + } + intents := []TransportPolicyIntent{ + {SelectorType: "cidr", SelectorValue: "10.10.0.0/24", ClientID: "c1"}, + {SelectorType: "cidr", SelectorValue: "10.20.0.0/24", ClientID: "c2"}, + } + plan, conflicts := compileTransportPolicyPlan(intents, clients, 9) + if len(conflicts) != 0 { + t.Fatalf("unexpected compile conflicts: %#v", conflicts) + } + if plan.InterfaceCount != 1 || len(plan.Interfaces) != 1 { + t.Fatalf("unexpected interface shape: %#v", plan.Interfaces) + } + iface := plan.Interfaces[0] + if len(iface.Sets) != 2 { + t.Fatalf("expected 2 owner-scoped sets for same iface, got %#v", iface.Sets) + } + setByScope := map[string]string{} + for _, s := range iface.Sets { + if s.SelectorType != "cidr" { + continue + } + setByScope[s.OwnerScope] = s.Name + } + if len(setByScope) != 2 { + t.Fatalf("expected 2 cidr owner scopes, got %#v", setByScope) + } + if setByScope["edge_a_c1"] == "" || setByScope["edge_a_c2"] == "" { + t.Fatalf("unexpected owner scopes: %#v", setByScope) + } + if setByScope["edge_a_c1"] == setByScope["edge_a_c2"] { + t.Fatalf("owner-scoped set names must differ: %#v", setByScope) + } + for _, r := range iface.Rules { + if strings.TrimSpace(r.OwnerScope) == "" { + t.Fatalf("owner_scope must be filled for rule: %#v", r) + } + if strings.TrimSpace(r.NftSet) == "" { + t.Fatalf("nft_set must be filled for rule: %#v", r) + } + } +} + +func TestTransportPolicyNftSetNameDeterministicAndBounded(t *testing.T) { + scope := transportPolicyNftOwnerScope("very-very-long-interface-name-for-testing", "very-very-long-client-name-for-testing") + if scope == "" { + t.Fatalf("owner scope must not be empty") + } + setA := transportPolicyNftSetName(scope, "cidr") + setB := transportPolicyNftSetName(scope, "cidr") + if setA != setB { + t.Fatalf("set name must be deterministic: %q vs %q", setA, setB) + } + if len(setA) == 0 || len(setA) > 63 { + t.Fatalf("unexpected set name length %d: %q", len(setA), setA) + } + if !strings.HasPrefix(setA, "agvpn_pi_") { + t.Fatalf("unexpected set name prefix: %q", setA) + } +} + +func TestCompileTransportPolicyPlanDetectsAllocatorCollision(t *testing.T) { + clients := []TransportClient{ + { + ID: "c1", + Kind: TransportClientSingBox, + IfaceID: "edge-a", + MarkHex: "0x120", + PriorityBase: 13300, + }, + { + ID: "c2", + Kind: TransportClientDNSTT, + IfaceID: "edge-b", + MarkHex: "0x120", + PriorityBase: 13300, + }, + } + intents := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "a.example", ClientID: "c1"}, + {SelectorType: "domain", SelectorValue: "b.example", ClientID: "c2"}, + } + _, conflicts := compileTransportPolicyPlan(intents, clients, 8) + found := false + for _, c := range conflicts { + if c.Type == "allocator_collision" { + found = true + break + } + } + if !found { + t.Fatalf("expected allocator_collision, got: %#v", conflicts) + } +} + +func TestTransportConfirmTokenLifecycle(t *testing.T) { + transportConfirmStore = transporttoken.NewStore(transportConfirmTTL) + + token := issueTransportConfirmToken(7, "digest-a") + if token == "" { + t.Fatalf("empty token") + } + if !consumeTransportConfirmToken(token, 7, "digest-a") { + t.Fatalf("expected token to be consumed") + } + if consumeTransportConfirmToken(token, 7, "digest-a") { + t.Fatalf("token must be single-use") + } +} + +func TestTransportConfirmStoreExpiresToken(t *testing.T) { + store := transporttoken.NewStore(50 * time.Millisecond) + token := store.Issue("cnf-", 1, "digest-b") + if token == "" { + t.Fatalf("empty token") + } + time.Sleep(80 * time.Millisecond) + if store.Consume(token, 1, "digest-b") { + t.Fatalf("expired token should not be consumed") + } +} + +func withTransportLifecycleTestPaths(t *testing.T) { + t.Helper() + tmp := t.TempDir() + + prevClients := transportClientsStatePath + prevIfaces := transportInterfacesStatePath + prevSingBoxState := singBoxProfilesStatePath + prevSingBoxRendered := singBoxRenderedRootDir + prevSingBoxApplied := singBoxAppliedRootDir + + transportClientsStatePath = filepath.Join(tmp, "transport-clients.json") + transportInterfacesStatePath = filepath.Join(tmp, "transport-interfaces.json") + singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json") + singBoxRenderedRootDir = filepath.Join(tmp, "transport", "singbox-rendered") + singBoxAppliedRootDir = filepath.Join(tmp, "transport", "singbox-applied") + + t.Cleanup(func() { + transportClientsStatePath = prevClients + transportInterfacesStatePath = prevIfaces + singBoxProfilesStatePath = prevSingBoxState + singBoxRenderedRootDir = prevSingBoxRendered + singBoxAppliedRootDir = prevSingBoxApplied + }) +} + +func saveLinkedSingBoxProfile(t *testing.T, clientID string) { + t.Helper() + + st := loadSingBoxProfilesState() + profile := SingBoxProfile{ + ID: sanitizeID(clientID), + Name: clientID, + Mode: SingBoxProfileModeRaw, + Protocol: "vless", + Enabled: true, + SchemaVersion: 1, + RawConfig: map[string]any{ + "inbounds": []any{ + map[string]any{ + "type": "socks", + "tag": "socks-in", + "listen": "127.0.0.1", + "listen_port": 10808, + }, + }, + "outbounds": []any{ + map[string]any{ + "type": "vless", + "tag": "proxy", + "server": "example.com", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111", + "packet_encoding": "none", + }, + }, + }, + Meta: map[string]any{ + "client_id": sanitizeID(clientID), + }, + ProfileRevision: 1, + } + st.Items = append(st.Items, profile) + st.Revision++ + if err := saveSingBoxProfilesState(st); err != nil { + t.Fatalf("save singbox profile state: %v", err) + } +} + +func TestNormalizeTransportClientsStateDeterministicRebalance(t *testing.T) { + st := transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "Client-B", + Kind: TransportClientPhoenix, + MarkHex: "0x110", + PriorityBase: 13250, + }, + { + ID: "client-a", + Kind: TransportClientDNSTT, + MarkHex: "0x110", // duplicate mark -> should force rebalance + PriorityBase: 13250, // duplicate pref -> should force rebalance + }, + { + ID: "client-c", + Kind: TransportClientSingBox, + MarkHex: "", + PriorityBase: 0, + }, + }, + } + + norm, changed := normalizeTransportClientsState(st, false) + if !changed { + t.Fatalf("expected normalization changes") + } + if len(norm.Items) != 3 { + t.Fatalf("unexpected item count: %d", len(norm.Items)) + } + + seenMarks := map[string]struct{}{} + seenPrefs := map[int]struct{}{} + for i, it := range norm.Items { + if i > 0 && norm.Items[i-1].ID > it.ID { + t.Fatalf("items not sorted by id") + } + if _, ok := parseTransportMarkHex(it.MarkHex); !ok { + t.Fatalf("invalid mark after normalize: %q", it.MarkHex) + } + if _, exists := seenMarks[it.MarkHex]; exists { + t.Fatalf("duplicate mark after normalize: %q", it.MarkHex) + } + seenMarks[it.MarkHex] = struct{}{} + + if _, ok := parseTransportPref(it.PriorityBase); !ok { + t.Fatalf("invalid pref after normalize: %d", it.PriorityBase) + } + if _, exists := seenPrefs[it.PriorityBase]; exists { + t.Fatalf("duplicate pref after normalize: %d", it.PriorityBase) + } + seenPrefs[it.PriorityBase] = struct{}{} + } + + norm2, changed2 := normalizeTransportClientsState(norm, false) + if changed2 { + t.Fatalf("expected stable deterministic state without changes") + } + if len(norm2.Items) != len(norm.Items) { + t.Fatalf("unexpected length after second normalize") + } + for i := range norm.Items { + if norm.Items[i].ID != norm2.Items[i].ID || + norm.Items[i].MarkHex != norm2.Items[i].MarkHex || + norm.Items[i].PriorityBase != norm2.Items[i].PriorityBase || + norm.Items[i].RoutingTable != norm2.Items[i].RoutingTable { + t.Fatalf("state not deterministic at index %d: %#v != %#v", i, norm.Items[i], norm2.Items[i]) + } + } +} + +func TestAllocateTransportSlotsSkipsReservedRanges(t *testing.T) { + items := make([]TransportClient, 0, 3) + for i := 0; i < 3; i++ { + mark, pref := allocateTransportSlots(items) + items = append(items, TransportClient{ + ID: fmt.Sprintf("c-%d", i), + Kind: TransportClientSingBox, + MarkHex: mark, + PriorityBase: pref, + }) + } + + for _, it := range items { + m, ok := parseTransportMarkHex(it.MarkHex) + if !ok { + t.Fatalf("allocated invalid mark %q", it.MarkHex) + } + if m <= transportMarkReserveEnd { + t.Fatalf("allocator used reserved mark: %s", it.MarkHex) + } + if !transportPrefAllowed(it.PriorityBase) { + t.Fatalf("allocated invalid pref %d", it.PriorityBase) + } + if it.PriorityBase <= transportPrefReserveEnd { + t.Fatalf("allocator used reserved pref: %d", it.PriorityBase) + } + } +} + +func TestTransportRoutingTableUniqueOnLongIDs(t *testing.T) { + st := transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + {ID: "client-super-long-identifier-aaaaaaaaaaaa-1", Kind: TransportClientSingBox}, + {ID: "client-super-long-identifier-aaaaaaaaaaaa-2", Kind: TransportClientDNSTT}, + }, + } + norm, _ := normalizeTransportClientsState(st, false) + if len(norm.Items) != 2 { + t.Fatalf("unexpected count: %d", len(norm.Items)) + } + a := norm.Items[0].RoutingTable + b := norm.Items[1].RoutingTable + if a == "" || b == "" { + t.Fatalf("empty routing table values") + } + if a == b { + t.Fatalf("routing tables must be unique: %q", a) + } + if len(a) > 31 || len(b) > 31 { + t.Fatalf("routing table name exceeds 31 chars: %q / %q", a, b) + } +} + +func TestApplyTransportLifecycleActionMetrics(t *testing.T) { + base := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.UTC) + c := TransportClient{ + ID: "c1", + Kind: TransportClientSingBox, + Status: TransportClientDown, + Enabled: false, + } + + applyTransportLifecycleAction(&c, "start", base) + if c.Status != TransportClientUp { + t.Fatalf("expected up after start, got %s", c.Status) + } + if !c.Enabled { + t.Fatalf("start must enable client") + } + if c.Runtime.Backend != "mock" { + t.Fatalf("unexpected backend: %q", c.Runtime.Backend) + } + if c.Runtime.Metrics.StateChanges != 1 { + t.Fatalf("expected state_changes=1, got %d", c.Runtime.Metrics.StateChanges) + } + if c.Runtime.StartedAt == "" { + t.Fatalf("started_at must be set on start") + } + + applyTransportLifecycleAction(&c, "restart", base.Add(2*time.Second)) + if c.Runtime.Metrics.Restarts != 1 { + t.Fatalf("expected restarts=1, got %d", c.Runtime.Metrics.Restarts) + } + if c.Runtime.Metrics.StateChanges != 1 { + t.Fatalf("restart on up should not change state counter, got %d", c.Runtime.Metrics.StateChanges) + } + + applyTransportLifecycleAction(&c, "stop", base.Add(4*time.Second)) + if c.Status != TransportClientDown { + t.Fatalf("expected down after stop, got %s", c.Status) + } + if c.Runtime.Metrics.StateChanges != 2 { + t.Fatalf("expected state_changes=2 after stop, got %d", c.Runtime.Metrics.StateChanges) + } + if c.Runtime.StoppedAt == "" { + t.Fatalf("stopped_at must be set on stop") + } + if c.Runtime.Metrics.UptimeSec != 0 { + t.Fatalf("uptime must be reset on down status, got %d", c.Runtime.Metrics.UptimeSec) + } +} + +func TestBuildTransportHealthResponseDegradedCode(t *testing.T) { + base := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.UTC) + c := TransportClient{ + ID: "c1", + Kind: TransportClientPhoenix, + Status: TransportClientDegraded, + Health: TransportClientHealth{ + LastCheck: base.Format(time.RFC3339), + LastError: "timeout", + }, + } + resp := buildTransportHealthResponse(c, base.Add(5*time.Second)) + if !resp.OK { + t.Fatalf("expected ok response") + } + if resp.Code != "TRANSPORT_CLIENT_DEGRADED" { + t.Fatalf("expected degraded code, got %q", resp.Code) + } + if strings.TrimSpace(resp.Runtime.Backend) == "" { + t.Fatalf("expected runtime backend to be present") + } + if resp.LastErr == "" { + t.Fatalf("expected last_error to be present") + } +} + +func TestNormalizeTransportRuntimeStoredDefaults(t *testing.T) { + raw := TransportClientRuntime{ + LastExitCode: -10, + Metrics: TransportClientMetrics{ + Restarts: -2, + StateChanges: -3, + UptimeSec: -4, + }, + LastError: TransportClientError{ + Code: "X", + }, + } + norm, changed := normalizeTransportRuntimeStored(raw, TransportClientDNSTT, nil) + if !changed { + t.Fatalf("expected runtime normalization changes") + } + if norm.Backend != "mock" { + t.Fatalf("backend not normalized: %q", norm.Backend) + } + if !equalStringSlices(norm.AllowedActions, []string{"provision", "start", "stop", "restart"}) { + t.Fatalf("allowed_actions not normalized: %#v", norm.AllowedActions) + } + if norm.Metrics.Restarts != 0 || norm.Metrics.StateChanges != 0 || norm.Metrics.UptimeSec != 0 { + t.Fatalf("metrics must be clamped to zero: %#v", norm.Metrics) + } + if norm.LastExitCode != 0 { + t.Fatalf("last_exit_code must be clamped to zero, got %d", norm.LastExitCode) + } + if norm.LastError.Code != "" { + t.Fatalf("orphan error code must be cleared, got %q", norm.LastError.Code) + } +} + +func TestApplyTransportProvisionResultFailure(t *testing.T) { + base := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.UTC) + c := TransportClient{ + ID: "c1", + Kind: TransportClientDNSTT, + Status: TransportClientDown, + } + applyTransportProvisionResult(&c, base, "systemd", transportBackendActionResult{ + OK: false, + Code: "TRANSPORT_BACKEND_PROVISION_FAILED", + Message: "write unit failed", + ExitCode: 1, + Retryable: true, + }) + if c.Runtime.LastAction != "provision" { + t.Fatalf("unexpected last action: %q", c.Runtime.LastAction) + } + if c.Runtime.Backend != "systemd" { + t.Fatalf("unexpected backend: %q", c.Runtime.Backend) + } + if c.Runtime.LastError.Code != "TRANSPORT_BACKEND_PROVISION_FAILED" { + t.Fatalf("unexpected last error: %#v", c.Runtime.LastError) + } + if c.Health.LastError == "" { + t.Fatalf("health last_error must be set on provision failure") + } +} + +func TestHandleTransportCapabilitiesRuntimeModes(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/transport/capabilities", nil) + rec := httptest.NewRecorder() + handleTransportCapabilities(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", rec.Code) + } + var resp TransportCapabilitiesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK { + t.Fatalf("expected ok capabilities response: %#v", resp) + } + if !resp.RuntimeModes["exec"] { + t.Fatalf("runtime_modes.exec must be true") + } + if resp.RuntimeModes["embedded"] { + t.Fatalf("runtime_modes.embedded must be false until embedded backend is implemented") + } + if resp.RuntimeModes["sidecar"] { + t.Fatalf("runtime_modes.sidecar must be false until sidecar backend is implemented") + } + if !resp.PackagingProfiles["system"] || !resp.PackagingProfiles["bundled"] { + t.Fatalf("packaging_profiles must advertise system and bundled support: %#v", resp.PackagingProfiles) + } + found := false + for _, code := range resp.ErrorCodes { + if code == "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED" { + found = true + break + } + } + if !found { + t.Fatalf("runtime-mode error code not advertised: %#v", resp.ErrorCodes) + } +} + +func TestApplyTransportNetnsToggleLockedSuccess(t *testing.T) { + withTransportLifecycleTestPaths(t) + + now := time.Date(2026, time.March, 9, 20, 0, 0, 0, time.UTC) + configPath := filepath.Join(t.TempDir(), "sb-one.json") + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sb-one", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + Config: map[string]any{ + "runner": "mock", + "netns_enabled": false, + "config_path": configPath, + "singbox_preflight_check_binary": false, + }, + }, + }, + }); err != nil { + t.Fatalf("save clients state: %v", err) + } + saveLinkedSingBoxProfile(t, "sb-one") + enable := true + provision := true + restart := true + resp := executeTransportNetnsToggleLocked(TransportNetnsToggleRequest{ + Enabled: &enable, + ClientIDs: []string{"sb-one"}, + Provision: &provision, + RestartRunning: &restart, + }, now) + + if !resp.OK { + t.Fatalf("expected ok response, got %#v", resp) + } + if !resp.Enabled { + t.Fatalf("enabled flag must be true") + } + if resp.Count != 1 || resp.SuccessCount != 1 || resp.FailureCount != 0 { + t.Fatalf("unexpected counters: %#v", resp) + } + if len(resp.Items) != 1 { + t.Fatalf("expected one item, got %d", len(resp.Items)) + } + item := resp.Items[0] + if !item.OK { + t.Fatalf("expected successful item: %#v", item) + } + if !item.ConfigUpdated || !item.Provisioned || !item.Restarted { + t.Fatalf("expected config+provision+restart to be applied: %#v", item) + } + if item.StatusBefore != TransportClientUp || item.StatusAfter != TransportClientUp { + t.Fatalf("unexpected status transition: %#v", item) + } + current := loadTransportClientsState() + cfg := current.Items[0].Config + if !transportConfigBool(cfg, "netns_enabled") { + t.Fatalf("state config netns_enabled not updated: %#v", cfg) + } + if got := strings.TrimSpace(transportConfigString(cfg, "netns_name")); got != "svpn-sb-one" { + t.Fatalf("unexpected netns_name: %q", got) + } +} + +func TestApplyTransportNetnsToggleLockedPartialFailure(t *testing.T) { + withTransportLifecycleTestPaths(t) + + now := time.Date(2026, time.March, 9, 20, 5, 0, 0, time.UTC) + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sb-one", + Kind: TransportClientSingBox, + Status: TransportClientDown, + Enabled: true, + Config: map[string]any{ + "runner": "mock", + }, + }, + }, + }); err != nil { + t.Fatalf("save clients state: %v", err) + } + enable := true + provision := false + resp := executeTransportNetnsToggleLocked(TransportNetnsToggleRequest{ + Enabled: &enable, + ClientIDs: []string{"sb-one", "missing-id"}, + Provision: &provision, + }, now) + + if resp.OK { + t.Fatalf("expected partial failure response") + } + if resp.Code != "TRANSPORT_NETNS_TOGGLE_PARTIAL_FAILED" { + t.Fatalf("unexpected code: %q", resp.Code) + } + if resp.Count != 2 || resp.SuccessCount != 1 || resp.FailureCount != 1 { + t.Fatalf("unexpected counters: %#v", resp) + } + foundMissing := false + for _, item := range resp.Items { + if item.ClientID != "missing-id" { + continue + } + foundMissing = true + if item.OK { + t.Fatalf("missing client must fail: %#v", item) + } + if item.Code != "TRANSPORT_CLIENT_NOT_FOUND" { + t.Fatalf("unexpected missing code: %#v", item) + } + } + if !foundMissing { + t.Fatalf("missing-id result not found: %#v", resp.Items) + } +} + +func TestApplyTransportNetnsToggleLockedNoTargets(t *testing.T) { + withTransportLifecycleTestPaths(t) + + now := time.Date(2026, time.March, 9, 20, 10, 0, 0, time.UTC) + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + {ID: "dnstt-1", Kind: TransportClientDNSTT}, + }, + }); err != nil { + t.Fatalf("save clients state: %v", err) + } + resp := executeTransportNetnsToggleLocked(TransportNetnsToggleRequest{}, now) + if resp.OK { + t.Fatalf("expected failure for empty target set") + } + if resp.Code != "TRANSPORT_NETNS_NO_TARGETS" { + t.Fatalf("unexpected code: %q", resp.Code) + } + if resp.Count != 0 || len(resp.Items) != 0 { + t.Fatalf("unexpected non-empty response: %#v", resp) + } +} + +func TestResolveTransportNetnsToggleLockIDsIncludesSameNetnsPeerIface(t *testing.T) { + withTransportLifecycleTestPaths(t) + + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sg-a", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + IfaceID: "edge-a", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": false, + "singbox_preflight_check_binary": false, + }, + }, + { + ID: "sg-b", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + IfaceID: "edge-b", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "singbox_preflight_check_binary": false, + }, + }, + }, + }); err != nil { + t.Fatalf("save clients state: %v", err) + } + if err := saveTransportInterfacesState(transportInterfacesState{ + Version: transportStateVersion, + Items: []TransportInterface{ + {ID: transportDefaultIfaceID, Name: "Shared interface", Mode: TransportInterfaceModeShared}, + {ID: "edge-a", Name: "Edge A", Mode: TransportInterfaceModeDedicated, NetnsName: "svpn-shared"}, + {ID: "edge-b", Name: "Edge B", Mode: TransportInterfaceModeDedicated, NetnsName: "svpn-shared"}, + }, + }); err != nil { + t.Fatalf("save interfaces state: %v", err) + } + + enable := true + lockIDs := resolveTransportNetnsToggleLockIDs(TransportNetnsToggleRequest{ + Enabled: &enable, + ClientIDs: []string{"sg-a"}, + }) + got := strings.Join(normalizeTransportIfaceLockIDs(lockIDs), ",") + if got != "edge-a,edge-b" { + t.Fatalf("unexpected lock ids: %q", got) + } +} + +func TestExecuteTransportNetnsToggleLockedUsesLifecycleForSameNetnsRestart(t *testing.T) { + withTransportLifecycleTestPaths(t) + + configPathA := filepath.Join(t.TempDir(), "sg-a.json") + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sg-a", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + IfaceID: "edge-a", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "netns_name": "svpn-shared", + "config_path": configPathA, + "singbox_preflight_check_binary": false, + }, + }, + { + ID: "sg-b", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + IfaceID: "edge-b", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "netns_name": "svpn-shared", + }, + }, + }, + }); err != nil { + t.Fatalf("save clients state: %v", err) + } + saveLinkedSingBoxProfile(t, "sg-a") + + enable := true + resp := executeTransportNetnsToggleLocked(TransportNetnsToggleRequest{ + Enabled: &enable, + ClientIDs: []string{"sg-a"}, + }, time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC)) + + if !resp.OK || resp.SuccessCount != 1 || len(resp.Items) != 1 { + t.Fatalf("unexpected response: %#v", resp) + } + item := resp.Items[0] + if !item.Provisioned || !item.Restarted { + t.Fatalf("expected provision+restart path, got %#v", item) + } + + current := loadTransportClientsState() + if got := current.Items[0].Status; got != TransportClientUp { + t.Fatalf("target must stay up after toggle restart, got=%s", got) + } + if got := current.Items[1].Status; got != TransportClientDown { + t.Fatalf("same-netns peer must be stopped by lifecycle path, got=%s", got) + } +} + +func TestStopTransportSingBoxPeersInSameNetnsLocked(t *testing.T) { + now := time.Date(2026, time.March, 10, 11, 0, 0, 0, time.UTC) + st := transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sg-a", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "netns_name": "svpn-shared", + }, + }, + { + ID: "sg-b", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "netns_name": "svpn-shared", + }, + }, + { + ID: "sg-c", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "netns_name": "svpn-other", + }, + }, + }, + } + + res := stopTransportSingBoxPeersInSameNetnsLocked(&st, 0, now) + if !res.OK { + t.Fatalf("stop peers failed: %#v", res) + } + if st.Items[1].Status != TransportClientDown { + t.Fatalf("peer in same netns must be stopped, got=%s", st.Items[1].Status) + } + if st.Items[2].Status != TransportClientUp { + t.Fatalf("peer in another netns must stay up, got=%s", st.Items[2].Status) + } + if !strings.Contains(res.Message, "sg-b") { + t.Fatalf("result message must include stopped peer id: %#v", res) + } +} + +func TestResolveTransportLifecycleLockIDsUsesIfaceNetnsBinding(t *testing.T) { + clients := []TransportClient{ + { + ID: "sg-a", + Kind: TransportClientSingBox, + Status: TransportClientDown, + Enabled: true, + IfaceID: "edge-a", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + }, + }, + { + ID: "sg-b", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + IfaceID: "edge-b", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + }, + }, + { + ID: "sg-c", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + IfaceID: "edge-c", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + }, + }, + } + ifaces := transportInterfacesState{ + Version: transportStateVersion, + Items: []TransportInterface{ + {ID: transportDefaultIfaceID, Name: "Shared interface", Mode: TransportInterfaceModeShared}, + {ID: "edge-a", Name: "Edge A", Mode: TransportInterfaceModeDedicated, NetnsName: "svpn-shared"}, + {ID: "edge-b", Name: "Edge B", Mode: TransportInterfaceModeDedicated, NetnsName: "svpn-shared"}, + {ID: "edge-c", Name: "Edge C", Mode: TransportInterfaceModeDedicated, NetnsName: "svpn-other"}, + }, + } + + lockIDs, ok := resolveTransportLifecycleLockIDsForSnapshot(clients, ifaces, "sg-a", "start") + if !ok { + t.Fatalf("expected lock resolution to succeed") + } + got := strings.Join(normalizeTransportIfaceLockIDs(lockIDs), ",") + if got != "edge-a,edge-b" { + t.Fatalf("unexpected lock ids: %q", got) + } +} +func TestExecuteTransportLifecycleActionLockedStopsSameNetnsPeersAcrossIfaces(t *testing.T) { + withTransportLifecycleTestPaths(t) + + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sg-a", + Kind: TransportClientSingBox, + Status: TransportClientDown, + Enabled: true, + IfaceID: "edge-a", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "netns_name": "svpn-shared", + }, + }, + { + ID: "sg-b", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + IfaceID: "edge-b", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "netns_name": "svpn-shared", + }, + }, + { + ID: "sg-c", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + IfaceID: "edge-c", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "netns_name": "svpn-other", + }, + }, + }, + }); err != nil { + t.Fatalf("save clients state: %v", err) + } + + status, resp := executeTransportLifecycleActionLocked("sg-a", "start") + if status != http.StatusOK { + t.Fatalf("unexpected status: %d", status) + } + if !resp.OK || resp.StatusBefore != TransportClientDown || resp.StatusAfter != TransportClientUp { + t.Fatalf("unexpected lifecycle response: %#v", resp) + } + + current := loadTransportClientsState() + if got := current.Items[0].Status; got != TransportClientUp { + t.Fatalf("target must be up after start, got=%s", got) + } + if got := current.Items[1].Status; got != TransportClientDown { + t.Fatalf("same-netns peer must be down after start, got=%s", got) + } + if got := current.Items[2].Status; got != TransportClientUp { + t.Fatalf("other-netns peer must stay up, got=%s", got) + } +} + +func TestExecuteTransportLifecycleActionLockedPersistsPeerStopsOnPeerFailure(t *testing.T) { + withTransportLifecycleTestPaths(t) + + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sg-a", + Kind: TransportClientSingBox, + Status: TransportClientDown, + Enabled: true, + IfaceID: "edge-a", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "netns_name": "svpn-shared", + }, + }, + { + ID: "sg-b", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + IfaceID: "edge-b", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": true, + "netns_name": "svpn-shared", + }, + }, + { + ID: "sg-c", + Kind: TransportClientSingBox, + Status: TransportClientUp, + Enabled: true, + IfaceID: "edge-c", + Config: map[string]any{ + "runtime_mode": "embedded", + "netns_enabled": true, + "netns_name": "svpn-shared", + }, + }, + }, + }); err != nil { + t.Fatalf("save clients state: %v", err) + } + + status, resp := executeTransportLifecycleActionLocked("sg-a", "start") + if status != http.StatusOK { + t.Fatalf("unexpected status: %d", status) + } + if resp.OK || resp.Code != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED" { + t.Fatalf("unexpected lifecycle failure response: %#v", resp) + } + + current := loadTransportClientsState() + if got := current.Items[0].Status; got != TransportClientDown { + t.Fatalf("target must stay down after peer stop failure, got=%s", got) + } + if got := current.Items[1].Status; got != TransportClientDown { + t.Fatalf("successful peer stop must persist, got=%s", got) + } + if got := current.Items[2].Status; got != TransportClientUp { + t.Fatalf("failed peer stop must keep previous status, got=%s", got) + } + if got := current.Items[2].Runtime.LastAction; got != "stop" { + t.Fatalf("failed peer stop must update runtime action, got=%q", got) + } +} + +func TestExecuteTransportNetnsToggleLockedPersistsInterfacesAfterClientsCommit(t *testing.T) { + withTransportLifecycleTestPaths(t) + + now := time.Date(2026, time.March, 19, 19, 0, 0, 0, time.UTC) + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sb-edge", + Kind: TransportClientSingBox, + Status: TransportClientDown, + Enabled: true, + IfaceID: "edge-a", + Config: map[string]any{ + "runner": "mock", + "netns_enabled": false, + }, + }, + }, + }); err != nil { + t.Fatalf("save clients state: %v", err) + } + if err := saveTransportInterfacesState(transportInterfacesState{ + Version: transportStateVersion, + Items: []TransportInterface{ + {ID: transportDefaultIfaceID, Name: "Shared interface", Mode: TransportInterfaceModeShared}, + }, + }); err != nil { + t.Fatalf("save interfaces state: %v", err) + } + + enable := true + provision := false + restart := false + resp := executeTransportNetnsToggleLocked(TransportNetnsToggleRequest{ + Enabled: &enable, + ClientIDs: []string{"sb-edge"}, + Provision: &provision, + RestartRunning: &restart, + }, now) + if !resp.OK || resp.SuccessCount != 1 || resp.FailureCount != 0 { + t.Fatalf("unexpected toggle response: %#v", resp) + } + + ifaces := loadTransportInterfacesState() + if _, ok := findTransportInterfaceByID(ifaces.Items, "edge-a"); !ok { + t.Fatalf("expected edge-a iface to be persisted in interfaces state: %#v", ifaces.Items) + } +} diff --git a/selective-vpn-api/app/transport_health_refresh.go b/selective-vpn-api/app/transport_health_refresh.go new file mode 100644 index 0000000..0408a2e --- /dev/null +++ b/selective-vpn-api/app/transport_health_refresh.go @@ -0,0 +1,37 @@ +package app + +import ( + "sync" + "time" +) + +const ( + transportHealthFreshTTL = 45 * time.Second + transportHealthBackoffMin = 5 * time.Second + transportHealthBackoffMax = 2 * time.Minute + transportHealthPersistMinAge = 90 * time.Second + transportHealthMaxConcurrentProbe = 2 +) + +type transportHealthRefreshEntry struct { + swr refreshCoordinator +} + +type transportHealthRefresher struct { + mu sync.Mutex + entries map[string]*transportHealthRefreshEntry + sem chan struct{} +} + +func newTransportHealthRefresher(maxConcurrent int) *transportHealthRefresher { + n := maxConcurrent + if n <= 0 { + n = transportHealthMaxConcurrentProbe + } + return &transportHealthRefresher{ + entries: map[string]*transportHealthRefreshEntry{}, + sem: make(chan struct{}, n), + } +} + +var transportHealthSWR = newTransportHealthRefresher(envInt("SVPN_TRANSPORT_HEALTH_MAX_PARALLEL", transportHealthMaxConcurrentProbe)) diff --git a/selective-vpn-api/app/transport_health_refresh_compare.go b/selective-vpn-api/app/transport_health_refresh_compare.go new file mode 100644 index 0000000..be61960 --- /dev/null +++ b/selective-vpn-api/app/transport_health_refresh_compare.go @@ -0,0 +1,46 @@ +package app + +import ( + "strings" + "time" +) + +func transportShouldPersistHealthSnapshot(prev, next TransportClient, now time.Time) bool { + if transportHealthChanged(prev, next) { + return true + } + prevTS, ok := parseTransportHealthLastCheck(prev) + if !ok { + return true + } + return now.Sub(prevTS) >= transportHealthPersistMinAge +} + +func transportHealthChanged(prev, next TransportClient) bool { + if normalizeTransportStatus(prev.Status) != normalizeTransportStatus(next.Status) { + return true + } + if strings.TrimSpace(prev.Health.LastError) != strings.TrimSpace(next.Health.LastError) { + return true + } + if transportHealthLatencyBucket(prev.Health.LatencyMS) != transportHealthLatencyBucket(next.Health.LatencyMS) { + return true + } + return false +} + +func transportHealthLatencyBucket(ms int) string { + if ms <= 0 { + return "none" + } + switch { + case ms < 100: + return "lt100" + case ms < 300: + return "100-299" + case ms < 800: + return "300-799" + default: + return "ge800" + } +} diff --git a/selective-vpn-api/app/transport_health_refresh_probe.go b/selective-vpn-api/app/transport_health_refresh_probe.go new file mode 100644 index 0000000..ae86d91 --- /dev/null +++ b/selective-vpn-api/app/transport_health_refresh_probe.go @@ -0,0 +1,81 @@ +package app + +import ( + "strings" + "time" +) + +func transportRunHealthProbe(clientID string) { + id := strings.TrimSpace(clientID) + if id == "" { + return + } + transportHealthSWR.acquire() + defer transportHealthSWR.release() + + now := time.Now().UTC() + + transportMu.Lock() + st := loadTransportClientsState() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + transportMu.Unlock() + transportHealthSWR.finishError(id, "client not found", now) + return + } + prev := st.Items[idx] + transportMu.Unlock() + + backend := selectTransportBackend(prev) + probe := backend.Health(prev) + backendID := backend.ID() + + transportMu.Lock() + st = loadTransportClientsState() + idx = findTransportClientIndex(st.Items, id) + if idx < 0 { + transportMu.Unlock() + transportHealthSWR.finishError(id, "client not found", now) + return + } + prev = st.Items[idx] + next := applyTransportHealthProbeSnapshot(prev, backendID, probe, now) + + healthChanged := transportHealthChanged(prev, next) + persist := transportShouldPersistHealthSnapshot(prev, next, now) + + if persist { + st.Items[idx] = next + if err := saveTransportClientsState(st); err != nil { + transportMu.Unlock() + transportHealthSWR.finishError(id, "save failed: "+err.Error(), now) + return + } + } + transportMu.Unlock() + + if healthChanged { + events.push("transport_client_health_changed", map[string]any{ + "id": id, + "status": next.Status, + "latency_ms": next.Health.LatencyMS, + "last_error": strings.TrimSpace(next.Health.LastError), + "last_check": strings.TrimSpace(next.Health.LastCheck), + }) + publishTransportRuntimeObservabilitySnapshotChanged( + "transport_client_health_changed", + []string{id}, + nil, + ) + } + + if probe.OK { + transportHealthSWR.finishSuccess(id, now) + return + } + msg := strings.TrimSpace(probe.Message) + if msg == "" { + msg = "transport health probe failed" + } + transportHealthSWR.finishError(id, msg, now) +} diff --git a/selective-vpn-api/app/transport_health_refresh_queue.go b/selective-vpn-api/app/transport_health_refresh_queue.go new file mode 100644 index 0000000..410515f --- /dev/null +++ b/selective-vpn-api/app/transport_health_refresh_queue.go @@ -0,0 +1,48 @@ +package app + +import ( + "strings" + "time" +) + +func transportHealthCandidate(item TransportClient) bool { + if strings.TrimSpace(item.ID) == "" { + return false + } + if !item.Enabled { + return false + } + switch normalizeTransportStatus(item.Status) { + case TransportClientUp, TransportClientStarting, TransportClientDegraded: + return true + default: + return false + } +} + +func transportQueueHealthProbe(item TransportClient, force bool) bool { + now := time.Now() + if !transportHealthSWR.begin(item, force, now) { + return false + } + id := strings.TrimSpace(item.ID) + if id == "" { + transportHealthSWR.finishError(id, "missing client id", now) + return false + } + go transportRunHealthProbe(id) + return true +} + +func transportScheduleBackgroundHealthRefresh(known []TransportClient, targets []TransportClient) { + if len(known) == 0 || len(targets) == 0 { + return + } + transportHealthSWR.syncKnownClients(known) + for _, it := range targets { + if !transportHealthCandidate(it) { + continue + } + transportQueueHealthProbe(it, false) + } +} diff --git a/selective-vpn-api/app/transport_health_refresh_state.go b/selective-vpn-api/app/transport_health_refresh_state.go new file mode 100644 index 0000000..6e26df5 --- /dev/null +++ b/selective-vpn-api/app/transport_health_refresh_state.go @@ -0,0 +1,118 @@ +package app + +import ( + "strings" + "time" +) + +func (r *transportHealthRefresher) syncKnownClients(items []TransportClient) { + r.mu.Lock() + defer r.mu.Unlock() + + known := make(map[string]struct{}, len(items)) + for _, it := range items { + id := strings.TrimSpace(it.ID) + if id == "" { + continue + } + known[id] = struct{}{} + entry := r.entries[id] + if entry == nil { + entry = &transportHealthRefreshEntry{ + swr: newRefreshCoordinator( + transportHealthFreshTTL, + transportHealthBackoffMin, + transportHealthBackoffMax, + ), + } + r.entries[id] = entry + } + if ts, ok := parseTransportHealthLastCheck(it); ok { + entry.swr.setUpdatedAt(ts) + } + } + + for id := range r.entries { + if _, ok := known[id]; !ok { + delete(r.entries, id) + } + } +} + +func (r *transportHealthRefresher) begin(item TransportClient, force bool, now time.Time) bool { + id := strings.TrimSpace(item.ID) + if id == "" { + return false + } + + r.mu.Lock() + defer r.mu.Unlock() + + entry := r.entries[id] + if entry == nil { + entry = &transportHealthRefreshEntry{ + swr: newRefreshCoordinator( + transportHealthFreshTTL, + transportHealthBackoffMin, + transportHealthBackoffMax, + ), + } + r.entries[id] = entry + } + if ts, ok := parseTransportHealthLastCheck(item); ok { + entry.swr.setUpdatedAt(ts) + } + hasData := strings.TrimSpace(item.Health.LastCheck) != "" + return entry.swr.beginRefresh(now, force, hasData) +} + +func (r *transportHealthRefresher) finishSuccess(id string, now time.Time) { + id = strings.TrimSpace(id) + if id == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + entry := r.entries[id] + if entry == nil { + return + } + entry.swr.finishSuccess(now) +} + +func (r *transportHealthRefresher) finishError(id, msg string, now time.Time) { + id = strings.TrimSpace(id) + if id == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + entry := r.entries[id] + if entry == nil { + return + } + entry.swr.finishError(msg, now) +} + +func (r *transportHealthRefresher) acquire() { + r.sem <- struct{}{} +} + +func (r *transportHealthRefresher) release() { + select { + case <-r.sem: + default: + } +} + +func parseTransportHealthLastCheck(item TransportClient) (time.Time, bool) { + ts := strings.TrimSpace(item.Health.LastCheck) + if ts == "" { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return time.Time{}, false + } + return t, true +} diff --git a/selective-vpn-api/app/transport_iface_orchestrator_lock.go b/selective-vpn-api/app/transport_iface_orchestrator_lock.go new file mode 100644 index 0000000..fa03d98 --- /dev/null +++ b/selective-vpn-api/app/transport_iface_orchestrator_lock.go @@ -0,0 +1,94 @@ +package app + +import ( + "sort" + "sync" +) + +type transportIfaceOrchestrator struct { + mu sync.Mutex + locks map[string]*sync.Mutex +} + +var transportIfaceRuntime = newTransportIfaceOrchestrator() + +func newTransportIfaceOrchestrator() *transportIfaceOrchestrator { + return &transportIfaceOrchestrator{ + locks: map[string]*sync.Mutex{}, + } +} + +func (o *transportIfaceOrchestrator) lockFor(ifaceID string) *sync.Mutex { + key := normalizeTransportIfaceID(ifaceID) + o.mu.Lock() + defer o.mu.Unlock() + if m, ok := o.locks[key]; ok { + return m + } + m := &sync.Mutex{} + o.locks[key] = m + return m +} + +func withTransportIfaceLock(ifaceID string, fn func()) { + m := transportIfaceRuntime.lockFor(ifaceID) + m.Lock() + defer m.Unlock() + fn() +} + +func withTransportIfaceLocks(ifaceIDs []string, fn func()) { + ids := normalizeTransportIfaceLockIDs(ifaceIDs) + if len(ids) == 0 { + fn() + return + } + locks := make([]*sync.Mutex, 0, len(ids)) + for _, ifaceID := range ids { + locks = append(locks, transportIfaceRuntime.lockFor(ifaceID)) + } + for _, m := range locks { + m.Lock() + } + defer func() { + for i := len(locks) - 1; i >= 0; i-- { + locks[i].Unlock() + } + }() + fn() +} + +func normalizeTransportIfaceLockIDs(ifaceIDs []string) []string { + if len(ifaceIDs) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]string, 0, len(ifaceIDs)) + for _, raw := range ifaceIDs { + id := normalizeTransportIfaceID(raw) + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Strings(out) + return out +} + +func resolveTransportClientIfaceID(clientID string) (string, bool) { + id := sanitizeID(clientID) + if id == "" { + return "", false + } + transportMu.Lock() + st := loadTransportClientsState() + idx := findTransportClientIndex(st.Items, id) + if idx < 0 { + transportMu.Unlock() + return "", false + } + ifaceID := normalizeTransportIfaceID(st.Items[idx].IfaceID) + transportMu.Unlock() + return ifaceID, true +} diff --git a/selective-vpn-api/app/transport_iface_orchestrator_lock_test.go b/selective-vpn-api/app/transport_iface_orchestrator_lock_test.go new file mode 100644 index 0000000..066f269 --- /dev/null +++ b/selective-vpn-api/app/transport_iface_orchestrator_lock_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "sync/atomic" + "testing" + "time" +) + +func TestWithTransportIfaceLockSerializesSameIface(t *testing.T) { + transportIfaceRuntime = newTransportIfaceOrchestrator() + + firstEntered := make(chan struct{}) + firstRelease := make(chan struct{}) + secondEntered := make(chan struct{}) + + go withTransportIfaceLock("edge-a", func() { + close(firstEntered) + <-firstRelease + }) + <-firstEntered + + go withTransportIfaceLock("edge-a", func() { + close(secondEntered) + }) + + select { + case <-secondEntered: + t.Fatalf("second lock entered before first released") + case <-time.After(80 * time.Millisecond): + // expected: still blocked + } + + close(firstRelease) + select { + case <-secondEntered: + // ok + case <-time.After(200 * time.Millisecond): + t.Fatalf("second lock did not enter after first released") + } +} + +func TestWithTransportIfaceLockAllowsDifferentIfaces(t *testing.T) { + transportIfaceRuntime = newTransportIfaceOrchestrator() + + var entered int32 + done := make(chan struct{}) + + go withTransportIfaceLock("edge-a", func() { + atomic.AddInt32(&entered, 1) + time.Sleep(80 * time.Millisecond) + close(done) + }) + + time.Sleep(20 * time.Millisecond) + start := time.Now() + withTransportIfaceLock("edge-b", func() { + atomic.AddInt32(&entered, 1) + }) + elapsed := time.Since(start) + if elapsed > 40*time.Millisecond { + t.Fatalf("different iface lock was blocked too long: %s", elapsed) + } + + <-done + if atomic.LoadInt32(&entered) != 2 { + t.Fatalf("expected both locks to enter, got %d", entered) + } +} + +func TestWithTransportIfaceLocksSerializesOverlappingSets(t *testing.T) { + transportIfaceRuntime = newTransportIfaceOrchestrator() + + firstEntered := make(chan struct{}) + firstRelease := make(chan struct{}) + secondEntered := make(chan struct{}) + + go withTransportIfaceLocks([]string{"edge-b", "edge-a", "edge-a"}, func() { + close(firstEntered) + <-firstRelease + }) + <-firstEntered + + go withTransportIfaceLocks([]string{"edge-c", "edge-b"}, func() { + close(secondEntered) + }) + + select { + case <-secondEntered: + t.Fatalf("overlapping iface set entered before first released") + case <-time.After(80 * time.Millisecond): + // expected: blocked by shared edge-b lock + } + + close(firstRelease) + select { + case <-secondEntered: + // ok + case <-time.After(200 * time.Millisecond): + t.Fatalf("overlapping iface set did not enter after release") + } +} + +func TestWithTransportIfaceLocksAllowsDisjointSets(t *testing.T) { + transportIfaceRuntime = newTransportIfaceOrchestrator() + + var entered int32 + done := make(chan struct{}) + + go withTransportIfaceLocks([]string{"edge-a", "edge-b"}, func() { + atomic.AddInt32(&entered, 1) + time.Sleep(80 * time.Millisecond) + close(done) + }) + + time.Sleep(20 * time.Millisecond) + start := time.Now() + withTransportIfaceLocks([]string{"edge-c", "edge-d"}, func() { + atomic.AddInt32(&entered, 1) + }) + elapsed := time.Since(start) + if elapsed > 40*time.Millisecond { + t.Fatalf("disjoint iface set was blocked too long: %s", elapsed) + } + + <-done + if atomic.LoadInt32(&entered) != 2 { + t.Fatalf("expected both iface sets to enter, got %d", entered) + } +} diff --git a/selective-vpn-api/app/transport_iface_orchestrator_mapping.go b/selective-vpn-api/app/transport_iface_orchestrator_mapping.go new file mode 100644 index 0000000..b6325e8 --- /dev/null +++ b/selective-vpn-api/app/transport_iface_orchestrator_mapping.go @@ -0,0 +1,117 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +type transportIfaceBinding struct { + IfaceID string + Mode TransportInterfaceMode + RuntimeIface string + NetnsName string + RoutingTable string +} + +func syncTransportInterfacesWithClientsLocked(clients []TransportClient) (transportInterfacesState, error) { + ifaces := loadTransportInterfacesState() + norm, changed := normalizeTransportInterfacesState(ifaces, clients) + if changed { + if err := saveTransportInterfacesState(norm); err != nil { + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("interfaces sync warning: save failed: %v", err), + 20*time.Second, + ) + } + } + return norm, nil +} + +func findTransportInterfaceByID(items []TransportInterface, ifaceID string) (TransportInterface, bool) { + id := normalizeTransportIfaceID(ifaceID) + for _, it := range items { + if normalizeTransportIfaceID(it.ID) == id { + return it, true + } + } + return TransportInterface{}, false +} + +func resolveTransportIfaceBinding(client TransportClient, ifaces transportInterfacesState) transportIfaceBinding { + ifaceID := normalizeTransportIfaceID(client.IfaceID) + binding := transportIfaceBinding{ + IfaceID: ifaceID, + Mode: normalizeTransportInterfaceMode("", ifaceID), + } + + if iface, ok := findTransportInterfaceByID(ifaces.Items, ifaceID); ok { + binding.Mode = normalizeTransportInterfaceMode(iface.Mode, ifaceID) + binding.RuntimeIface = strings.TrimSpace(iface.RuntimeIface) + if strings.TrimSpace(iface.NetnsName) != "" { + binding.NetnsName = normalizeTransportNetnsName(strings.TrimSpace(iface.NetnsName), client.ID) + } + if hint := resolveTransportInterfaceRoutingTable(iface, ifaceID); strings.TrimSpace(hint) != "" { + binding.RoutingTable = hint + } + } + + if strings.TrimSpace(binding.RuntimeIface) == "" { + binding.RuntimeIface = strings.TrimSpace(client.Iface) + } + + if strings.TrimSpace(binding.RoutingTable) == "" { + if binding.IfaceID != transportDefaultIfaceID { + binding.RoutingTable = transportRoutingTableForIfaceID(binding.IfaceID) + } else { + binding.RoutingTable = normalizeTransportRoutingTable(client.RoutingTable, transportRoutingTableForID(client.ID)) + } + } + + if transportNetnsEnabled(client) { + explicit := strings.TrimSpace(transportConfigString(client.Config, "netns_name")) + if explicit != "" { + binding.NetnsName = transportNetnsName(client) + } else if strings.TrimSpace(binding.NetnsName) == "" { + if binding.IfaceID != transportDefaultIfaceID { + binding.NetnsName = normalizeTransportNetnsName("svpn-"+binding.IfaceID, client.ID) + } else { + binding.NetnsName = normalizeTransportNetnsName("svpn-"+sanitizeID(client.ID), client.ID) + } + } + } + return binding +} + +func applyTransportIfaceBinding(client TransportClient, ifaces transportInterfacesState, now time.Time) (TransportClient, bool) { + updated := client + changed := false + binding := resolveTransportIfaceBinding(client, ifaces) + + if updated.IfaceID != binding.IfaceID { + updated.IfaceID = binding.IfaceID + changed = true + } + if strings.TrimSpace(updated.Iface) == "" && strings.TrimSpace(binding.RuntimeIface) != "" { + updated.Iface = binding.RuntimeIface + changed = true + } + if strings.TrimSpace(binding.RoutingTable) != "" && strings.TrimSpace(updated.RoutingTable) != strings.TrimSpace(binding.RoutingTable) { + updated.RoutingTable = strings.TrimSpace(binding.RoutingTable) + changed = true + } + if transportNetnsEnabled(updated) && strings.TrimSpace(transportConfigString(updated.Config, "netns_name")) == "" && strings.TrimSpace(binding.NetnsName) != "" { + cfg := cloneMap(updated.Config) + if cfg == nil { + cfg = map[string]any{} + } + cfg["netns_name"] = binding.NetnsName + updated.Config = cfg + changed = true + } + if changed { + updated.UpdatedAt = now.Format(time.RFC3339) + } + return updated, changed +} diff --git a/selective-vpn-api/app/transport_iface_orchestrator_mapping_test.go b/selective-vpn-api/app/transport_iface_orchestrator_mapping_test.go new file mode 100644 index 0000000..99b62dd --- /dev/null +++ b/selective-vpn-api/app/transport_iface_orchestrator_mapping_test.go @@ -0,0 +1,125 @@ +package app + +import ( + "testing" + "time" +) + +func TestResolveTransportIfaceBindingNetnsDefaults(t *testing.T) { + ifaces := transportInterfacesState{ + Version: transportStateVersion, + Items: []TransportInterface{ + {ID: "edge-lab", Mode: TransportInterfaceModeDedicated}, + }, + } + client := TransportClient{ + ID: "sb-one", + IfaceID: "edge-lab", + Config: map[string]any{ + "netns_enabled": true, + }, + } + binding := resolveTransportIfaceBinding(client, ifaces) + if binding.IfaceID != "edge-lab" { + t.Fatalf("unexpected iface_id: %q", binding.IfaceID) + } + if binding.NetnsName != "svpn-edge-lab" { + t.Fatalf("unexpected netns_name: %q", binding.NetnsName) + } + if binding.RoutingTable != "agvpn_if_edge_lab" { + t.Fatalf("unexpected routing_table: %q", binding.RoutingTable) + } +} + +func TestResolveTransportIfaceBindingSharedFallbackPerClient(t *testing.T) { + client := TransportClient{ + ID: "sb-two", + IfaceID: "", + Config: map[string]any{ + "netns_enabled": true, + }, + } + binding := resolveTransportIfaceBinding(client, transportInterfacesState{}) + if binding.IfaceID != transportDefaultIfaceID { + t.Fatalf("unexpected iface_id: %q", binding.IfaceID) + } + if binding.NetnsName != "svpn-sb-two" { + t.Fatalf("unexpected shared netns fallback: %q", binding.NetnsName) + } + if binding.RoutingTable != "agvpn_sb_two" { + t.Fatalf("unexpected shared routing_table fallback: %q", binding.RoutingTable) + } +} + +func TestResolveTransportIfaceBindingUsesInterfaceNetnsHint(t *testing.T) { + ifaces := transportInterfacesState{ + Version: transportStateVersion, + Items: []TransportInterface{ + {ID: "edge-lab", NetnsName: "svpn-lab-shared"}, + }, + } + client := TransportClient{ + ID: "sb-three", + IfaceID: "edge-lab", + Config: map[string]any{ + "netns_enabled": true, + }, + } + binding := resolveTransportIfaceBinding(client, ifaces) + if binding.NetnsName != "svpn-lab-shared" { + t.Fatalf("unexpected netns_name from iface hint: %q", binding.NetnsName) + } + if binding.RoutingTable != "agvpn_if_edge_lab" { + t.Fatalf("unexpected routing_table from dedicated iface: %q", binding.RoutingTable) + } +} + +func TestApplyTransportIfaceBindingSetsMissingNetnsName(t *testing.T) { + now := time.Date(2026, time.March, 14, 18, 0, 0, 0, time.UTC) + ifaces := transportInterfacesState{ + Version: transportStateVersion, + Items: []TransportInterface{ + {ID: "edge-lab", NetnsName: "svpn-edge-lab"}, + }, + } + client := TransportClient{ + ID: "sb-four", + IfaceID: "edge-lab", + Config: map[string]any{ + "netns_enabled": true, + }, + } + updated, changed := applyTransportIfaceBinding(client, ifaces, now) + if !changed { + t.Fatalf("expected binding update") + } + if got := transportConfigString(updated.Config, "netns_name"); got != "svpn-edge-lab" { + t.Fatalf("unexpected netns_name in config: %q", got) + } + if got := updated.RoutingTable; got != "agvpn_if_edge_lab" { + t.Fatalf("unexpected routing table: %q", got) + } + if updated.UpdatedAt != now.Format(time.RFC3339) { + t.Fatalf("unexpected updated_at: %q", updated.UpdatedAt) + } +} + +func TestResolveTransportIfaceBindingUsesInterfaceRoutingTableHint(t *testing.T) { + ifaces := transportInterfacesState{ + Version: transportStateVersion, + Items: []TransportInterface{ + { + ID: "edge-lab", + RoutingTable: "custom_edge_table", + }, + }, + } + client := TransportClient{ + ID: "sb-five", + IfaceID: "edge-lab", + } + binding := resolveTransportIfaceBinding(client, ifaces) + if binding.RoutingTable != "agvpn_custom_edge_table" { + t.Fatalf("unexpected routing_table from iface hint: %q", binding.RoutingTable) + } +} diff --git a/selective-vpn-api/app/transport_interfaces_state.go b/selective-vpn-api/app/transport_interfaces_state.go new file mode 100644 index 0000000..84bd7dc --- /dev/null +++ b/selective-vpn-api/app/transport_interfaces_state.go @@ -0,0 +1,284 @@ +package app + +import ( + "sort" + "strings" +) + +func normalizeTransportIfaceID(raw string) string { + id := sanitizeID(raw) + if strings.TrimSpace(id) == "" { + return transportDefaultIfaceID + } + return id +} + +func normalizeTransportInterfaceMode(raw TransportInterfaceMode, ifaceID string) TransportInterfaceMode { + mode := TransportInterfaceMode(strings.ToLower(strings.TrimSpace(string(raw)))) + switch mode { + case TransportInterfaceModeShared, TransportInterfaceModeDedicated: + return mode + default: + if normalizeTransportIfaceID(ifaceID) == transportDefaultIfaceID { + return TransportInterfaceModeShared + } + return TransportInterfaceModeDedicated + } +} + +func defaultTransportInterfaceName(ifaceID string) string { + if normalizeTransportIfaceID(ifaceID) == transportDefaultIfaceID { + return "Shared interface" + } + return ifaceID +} + +func transportInterfaceRoutingTableHint(iface TransportInterface) string { + if strings.TrimSpace(iface.RoutingTable) != "" { + return strings.TrimSpace(iface.RoutingTable) + } + if iface.Config == nil { + return "" + } + if v := strings.TrimSpace(transportConfigString(iface.Config, "routing_table")); v != "" { + return v + } + if v := strings.TrimSpace(transportConfigString(iface.Config, "table")); v != "" { + return v + } + return "" +} + +func resolveTransportInterfaceRoutingTable(iface TransportInterface, ifaceID string) string { + hint := transportInterfaceRoutingTableHint(iface) + if hint != "" { + return normalizeTransportRoutingTable(hint, transportRoutingTableForIfaceID(ifaceID)) + } + if normalizeTransportIfaceID(ifaceID) == transportDefaultIfaceID { + return "" + } + return transportRoutingTableForIfaceID(ifaceID) +} + +func normalizeTransportInterfacesState(st transportInterfacesState, clients []TransportClient) (transportInterfacesState, bool) { + changed := false + st.Version = transportStateVersion + if st.Items == nil { + st.Items = nil + } + + byID := map[string]int{} + out := make([]TransportInterface, 0, len(st.Items)+1) + for _, raw := range st.Items { + it := raw + id := normalizeTransportIfaceID(it.ID) + if id == "" { + changed = true + continue + } + if it.ID != id { + it.ID = id + changed = true + } + if strings.TrimSpace(it.Name) == "" { + it.Name = defaultTransportInterfaceName(id) + changed = true + } + mode := normalizeTransportInterfaceMode(it.Mode, id) + if it.Mode != mode { + it.Mode = mode + changed = true + } + wantTable := resolveTransportInterfaceRoutingTable(it, id) + if strings.TrimSpace(it.RoutingTable) != wantTable { + it.RoutingTable = wantTable + changed = true + } + if it.Config != nil { + it.Config = cloneMap(it.Config) + } + if idx, ok := byID[id]; ok { + if preferTransportInterface(it, out[idx]) { + out[idx] = it + } + changed = true + continue + } + byID[id] = len(out) + out = append(out, it) + } + + ensureIface := func(id string) { + normID := normalizeTransportIfaceID(id) + if normID == "" { + return + } + if _, ok := byID[normID]; ok { + return + } + it := TransportInterface{ + ID: normID, + Name: defaultTransportInterfaceName(normID), + Mode: normalizeTransportInterfaceMode("", normID), + } + it.RoutingTable = resolveTransportInterfaceRoutingTable(it, normID) + out = append(out, it) + byID[normID] = len(out) - 1 + changed = true + } + + ensureIface(transportDefaultIfaceID) + for _, it := range clients { + ensureIface(it.IfaceID) + } + + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + st.Items = out + return st, changed +} + +func preferTransportInterface(cand, cur TransportInterface) bool { + cu := strings.TrimSpace(cand.UpdatedAt) + ou := strings.TrimSpace(cur.UpdatedAt) + if cu != ou { + if cu == "" { + return false + } + if ou == "" { + return true + } + return cu > ou + } + return strings.TrimSpace(cand.Name) > strings.TrimSpace(cur.Name) +} + +func buildTransportInterfaceItems(ifaces []TransportInterface, clients []TransportClient) []TransportInterfaceItem { + group := map[string][]TransportClient{} + for _, it := range clients { + id := normalizeTransportIfaceID(it.IfaceID) + group[id] = append(group[id], it) + } + + items := make([]TransportInterfaceItem, 0, len(ifaces)) + seen := map[string]struct{}{} + for _, iface := range ifaces { + id := normalizeTransportIfaceID(iface.ID) + if id == "" { + continue + } + seen[id] = struct{}{} + clientsForIface := append([]TransportClient(nil), group[id]...) + sort.Slice(clientsForIface, func(i, j int) bool { return clientsForIface[i].ID < clientsForIface[j].ID }) + clientIDs := make([]string, 0, len(clientsForIface)) + upCount := 0 + for _, cl := range clientsForIface { + clientIDs = append(clientIDs, cl.ID) + if cl.Status == TransportClientUp { + upCount++ + } + } + items = append(items, TransportInterfaceItem{ + ID: id, + Name: strings.TrimSpace(iface.Name), + Mode: normalizeTransportInterfaceMode(iface.Mode, id), + RuntimeIface: strings.TrimSpace(iface.RuntimeIface), + NetnsName: strings.TrimSpace(iface.NetnsName), + RoutingTable: strings.TrimSpace(iface.RoutingTable), + Config: cloneMap(iface.Config), + UpdatedAt: strings.TrimSpace(iface.UpdatedAt), + ClientIDs: clientIDs, + ClientCount: len(clientIDs), + UpCount: upCount, + }) + } + + for id, clientsForIface := range group { + if _, ok := seen[id]; ok { + continue + } + sort.Slice(clientsForIface, func(i, j int) bool { return clientsForIface[i].ID < clientsForIface[j].ID }) + clientIDs := make([]string, 0, len(clientsForIface)) + upCount := 0 + routingTable := "" + for _, cl := range clientsForIface { + clientIDs = append(clientIDs, cl.ID) + if cl.Status == TransportClientUp { + upCount++ + } + if routingTable == "" && strings.TrimSpace(cl.RoutingTable) != "" { + routingTable = strings.TrimSpace(cl.RoutingTable) + } + } + if routingTable == "" && id != transportDefaultIfaceID { + routingTable = transportRoutingTableForIfaceID(id) + } + items = append(items, TransportInterfaceItem{ + ID: id, + Name: defaultTransportInterfaceName(id), + Mode: normalizeTransportInterfaceMode("", id), + RoutingTable: routingTable, + ClientIDs: clientIDs, + ClientCount: len(clientIDs), + UpCount: upCount, + }) + } + + sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) + return items +} + +func appendTransportVirtualInterfaceItems(items []TransportInterfaceItem, clients []TransportClient) []TransportInterfaceItem { + if len(clients) == 0 { + return items + } + byID := map[string]int{} + for idx, it := range items { + byID[normalizeTransportIfaceID(it.ID)] = idx + if id := strings.TrimSpace(sanitizeID(it.ID)); id != "" { + byID[id] = idx + } + } + for _, client := range clients { + if !isTransportPolicyVirtualClient(client) { + continue + } + id := strings.TrimSpace(sanitizeID(client.ID)) + if id == "" { + continue + } + item := TransportInterfaceItem{ + ID: id, + Name: strings.TrimSpace(client.Name), + Mode: normalizeTransportInterfaceMode("", client.IfaceID), + RuntimeIface: strings.TrimSpace(client.Iface), + NetnsName: "", + RoutingTable: strings.TrimSpace(client.RoutingTable), + UpdatedAt: strings.TrimSpace(client.UpdatedAt), + ClientIDs: []string{id}, + ClientCount: 1, + UpCount: transportInterfaceItemUpCountForStatus(client.Status), + Config: map[string]any{ + "virtual": true, + "virtual_owner": id, + }, + } + if item.Name == "" { + item.Name = id + } + if idx, ok := byID[id]; ok { + items[idx] = item + continue + } + items = append(items, item) + byID[id] = len(items) - 1 + } + sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) + return items +} + +func transportInterfaceItemUpCountForStatus(status TransportClientStatus) int { + if normalizeTransportStatus(status) == TransportClientUp { + return 1 + } + return 0 +} diff --git a/selective-vpn-api/app/transport_interfaces_state_test.go b/selective-vpn-api/app/transport_interfaces_state_test.go new file mode 100644 index 0000000..641cfb7 --- /dev/null +++ b/selective-vpn-api/app/transport_interfaces_state_test.go @@ -0,0 +1,144 @@ +package app + +import "testing" + +func TestNormalizeTransportIfaceID(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", transportDefaultIfaceID}, + {" ", transportDefaultIfaceID}, + {"Shared", "shared"}, + {"sb Main", "sb-main"}, + } + for _, tc := range cases { + if got := normalizeTransportIfaceID(tc.in); got != tc.want { + t.Fatalf("normalizeTransportIfaceID(%q)=%q want=%q", tc.in, got, tc.want) + } + } +} + +func TestNormalizeTransportInterfacesStateAddsSharedAndClientIfaceIDs(t *testing.T) { + st := transportInterfacesState{ + Version: transportStateVersion, + Items: []TransportInterface{ + {ID: "edge-eu", Name: "", Mode: ""}, + }, + } + clients := []TransportClient{ + {ID: "c1", IfaceID: ""}, + {ID: "c2", IfaceID: "lab-net"}, + } + norm, changed := normalizeTransportInterfacesState(st, clients) + if !changed { + t.Fatalf("expected normalization changes") + } + if len(norm.Items) != 3 { + t.Fatalf("expected 3 interfaces, got %d", len(norm.Items)) + } + want := map[string]TransportInterfaceMode{ + "edge-eu": TransportInterfaceModeDedicated, + transportDefaultIfaceID: TransportInterfaceModeShared, + "lab-net": TransportInterfaceModeDedicated, + } + wantTable := map[string]string{ + "edge-eu": "agvpn_if_edge_eu", + transportDefaultIfaceID: "", + "lab-net": "agvpn_if_lab_net", + } + for _, it := range norm.Items { + mode, ok := want[it.ID] + if !ok { + t.Fatalf("unexpected interface id %q", it.ID) + } + if it.Mode != mode { + t.Fatalf("interface %q mode=%q want=%q", it.ID, it.Mode, mode) + } + if it.Name == "" { + t.Fatalf("interface %q has empty name", it.ID) + } + if got := it.RoutingTable; got != wantTable[it.ID] { + t.Fatalf("interface %q routing_table=%q want=%q", it.ID, got, wantTable[it.ID]) + } + delete(want, it.ID) + } + if len(want) != 0 { + t.Fatalf("missing interfaces: %#v", want) + } +} + +func TestBuildTransportInterfaceItemsAggregatesClients(t *testing.T) { + ifaces := []TransportInterface{ + {ID: transportDefaultIfaceID, Name: "Shared interface", Mode: TransportInterfaceModeShared}, + {ID: "lab-net", Name: "Lab net", Mode: TransportInterfaceModeDedicated, RoutingTable: "agvpn_if_lab_net"}, + } + clients := []TransportClient{ + {ID: "c-up-1", IfaceID: "lab-net", Status: TransportClientUp}, + {ID: "c-down", IfaceID: "lab-net", Status: TransportClientDown}, + {ID: "c-up-2", IfaceID: "", Status: TransportClientUp}, + } + + items := buildTransportInterfaceItems(ifaces, clients) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].ID != "lab-net" { + t.Fatalf("expected first item lab-net, got %q", items[0].ID) + } + if items[0].ClientCount != 2 || items[0].UpCount != 1 { + t.Fatalf("unexpected lab-net counters: %#v", items[0]) + } + if items[0].RoutingTable != "agvpn_if_lab_net" { + t.Fatalf("unexpected lab-net routing_table: %q", items[0].RoutingTable) + } + if items[1].ID != transportDefaultIfaceID { + t.Fatalf("expected second item shared, got %q", items[1].ID) + } + if items[1].ClientCount != 1 || items[1].UpCount != 1 { + t.Fatalf("unexpected shared counters: %#v", items[1]) + } +} + +func TestAppendTransportVirtualInterfaceItemsAddsAdGuardRow(t *testing.T) { + items := []TransportInterfaceItem{ + {ID: transportDefaultIfaceID, Name: "Shared interface", Mode: TransportInterfaceModeShared, ClientCount: 2, UpCount: 1}, + } + virtual := []TransportClient{{ + ID: transportPolicyTargetAdGuardID, + Name: "AdGuard VPN", + Kind: TransportClientKind(transportPolicyTargetAdGuardID), + IfaceID: transportPolicyTargetAdGuardIfaceID, + Iface: "tun0", + RoutingTable: transportPolicyTargetAdGuardTable, + Status: TransportClientUp, + UpdatedAt: "2026-03-22T00:00:00Z", + }} + + out := appendTransportVirtualInterfaceItems(items, virtual) + if len(out) != 2 { + t.Fatalf("expected 2 rows with virtual item, got %d", len(out)) + } + var adg *TransportInterfaceItem + var shared *TransportInterfaceItem + for i := range out { + if out[i].ID == transportPolicyTargetAdGuardID { + adg = &out[i] + } + if out[i].ID == transportDefaultIfaceID { + shared = &out[i] + } + } + if adg == nil { + t.Fatalf("missing adguard virtual row") + } + if adg.ClientCount != 1 || adg.UpCount != 1 { + t.Fatalf("unexpected adguard counters: %#v", *adg) + } + if adg.RoutingTable != transportPolicyTargetAdGuardTable { + t.Fatalf("unexpected adguard routing table: %q", adg.RoutingTable) + } + if shared == nil || shared.ClientCount != 2 || shared.UpCount != 1 { + t.Fatalf("shared row was modified unexpectedly: %#v", shared) + } +} diff --git a/selective-vpn-api/app/transport_netns.go b/selective-vpn-api/app/transport_netns.go new file mode 100644 index 0000000..737dbfd --- /dev/null +++ b/selective-vpn-api/app/transport_netns.go @@ -0,0 +1,144 @@ +package app + +import ( + "errors" + "fmt" + "net/netip" + "strconv" + "strings" + "time" +) + +const ( + transportNetnsNATTable = "svpn_netns" +) + +type transportNetnsSpec struct { + Name string + HostVeth string + PeerVeth string + Prefix netip.Prefix + HostIP netip.Addr + PeerIP netip.Addr + Uplink string +} + +func transportNetnsEnabled(client TransportClient) bool { + if !transportConfigHasKey(client.Config, "netns_enabled") { + return false + } + return transportConfigBool(client.Config, "netns_enabled") +} + +func transportNetnsStrict(client TransportClient) bool { + return transportConfigBool(client.Config, "netns_setup_strict") +} + +func transportNetnsAutoCleanup(client TransportClient) bool { + return transportConfigBool(client.Config, "netns_auto_cleanup") +} + +func transportEnsureNetnsForClient(client TransportClient) (string, error) { + spec, err := transportBuildNetnsSpec(client) + if err != nil { + return "", err + } + + created := false + exists, err := transportNetnsExists(spec.Name) + if err != nil { + return "", err + } + if !exists { + if err := transportRunMust(6*time.Second, "ip", "netns", "add", spec.Name); err != nil { + return "", err + } + created = true + } + + // Recreate veth pair for deterministic state before every start/restart. + _ = transportRunSoft(3*time.Second, "ip", "link", "del", spec.HostVeth) + _ = transportRunInNetnsSoft(3*time.Second, client, spec.Name, "ip", "link", "del", spec.PeerVeth) + if err := transportRunMust(5*time.Second, "ip", "link", "add", spec.HostVeth, "type", "veth", "peer", "name", spec.PeerVeth); err != nil { + return "", err + } + if err := transportRunMust(5*time.Second, "ip", "link", "set", spec.PeerVeth, "netns", spec.Name); err != nil { + return "", err + } + + mask := strconv.Itoa(spec.Prefix.Bits()) + if err := transportRunMust(5*time.Second, "ip", "addr", "replace", spec.HostIP.String()+"/"+mask, "dev", spec.HostVeth); err != nil { + return "", err + } + if err := transportRunMust(5*time.Second, "ip", "link", "set", spec.HostVeth, "up"); err != nil { + return "", err + } + if err := transportRunInNetnsMust(5*time.Second, client, spec.Name, "ip", "link", "set", "lo", "up"); err != nil { + return "", err + } + if err := transportRunInNetnsMust(5*time.Second, client, spec.Name, "ip", "addr", "replace", spec.PeerIP.String()+"/"+mask, "dev", spec.PeerVeth); err != nil { + return "", err + } + if err := transportRunInNetnsMust(5*time.Second, client, spec.Name, "ip", "link", "set", spec.PeerVeth, "up"); err != nil { + return "", err + } + if err := transportRunInNetnsMust(5*time.Second, client, spec.Name, "ip", "route", "replace", "default", "via", spec.HostIP.String(), "dev", spec.PeerVeth); err != nil { + return "", err + } + + if err := transportRunMust(5*time.Second, "sysctl", "-w", "net.ipv4.ip_forward=1"); err != nil { + return "", err + } + if err := transportEnsureNetnsNAT(spec); err != nil { + return "", err + } + if err := transportEnsureNetnsPolicyRoute(spec); err != nil { + return "", err + } + + msg := fmt.Sprintf( + "netns ready: %s (%s<->%s, subnet=%s, uplink=%s)", + spec.Name, + spec.HostVeth, + spec.PeerVeth, + spec.Prefix.String(), + spec.Uplink, + ) + if created { + msg += "; created" + } + return msg, nil +} + +func transportCleanupNetnsForClient(client TransportClient) (string, error) { + spec, err := transportBuildNetnsSpec(client) + if err != nil { + return "", err + } + exists, err := transportNetnsExists(spec.Name) + if err != nil { + return "", err + } + if !exists { + _ = transportNetnsDeleteNATRule(spec.Name) + return "netns not found (skip cleanup)", nil + } + + var warnings []string + if err := transportNetnsDeleteNATRule(spec.Name); err != nil { + warnings = append(warnings, err.Error()) + } + if err := transportDeleteNetnsPolicyRoute(spec); err != nil { + warnings = append(warnings, err.Error()) + } + if err := transportRunSoft(4*time.Second, "ip", "netns", "del", spec.Name); err != nil { + warnings = append(warnings, err.Error()) + } + _ = transportRunSoft(3*time.Second, "ip", "link", "del", spec.HostVeth) + + msg := "netns cleaned: " + spec.Name + if len(warnings) > 0 { + return msg, errors.New(strings.Join(warnings, "; ")) + } + return msg, nil +} diff --git a/selective-vpn-api/app/transport_netns_exec.go b/selective-vpn-api/app/transport_netns_exec.go new file mode 100644 index 0000000..90aa68b --- /dev/null +++ b/selective-vpn-api/app/transport_netns_exec.go @@ -0,0 +1,97 @@ +package app + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +func transportWrapExecWithNetns(client TransportClient, command string) string { + cmd := strings.TrimSpace(command) + if cmd == "" || !transportNetnsEnabled(client) { + return cmd + } + ns := transportNetnsName(client) + if ns == "" { + return cmd + } + name, args, err := transportNetnsExecCommand(client, ns, "/bin/sh", "-lc", cmd) + if err != nil { + return cmd + } + return shellJoinArgs(append([]string{name}, args...)) +} + +func transportResolveNetnsNsenterBin(client TransportClient) string { + if explicit := strings.TrimSpace(transportConfigString(client.Config, "netns_nsenter_bin")); explicit != "" { + return explicit + } + if p, err := exec.LookPath("nsenter"); err == nil { + return strings.TrimSpace(p) + } + candidates := []string{ + "/usr/bin/nsenter", + "/bin/nsenter", + "/usr/sbin/nsenter", + "/sbin/nsenter", + } + for _, cand := range candidates { + if _, err := os.Stat(cand); err == nil { + return cand + } + } + return "" +} + +func transportNetnsExecCommand(client TransportClient, ns string, command ...string) (string, []string, error) { + if strings.TrimSpace(ns) == "" { + return "", nil, fmt.Errorf("netns name is empty") + } + if len(command) == 0 { + return "", nil, fmt.Errorf("netns command is empty") + } + mode := strings.ToLower(strings.TrimSpace(transportConfigString(client.Config, "netns_exec_mode"))) + useNsenter := false + switch mode { + case "ip", "ip-netns", "ip_netns": + useNsenter = false + case "nsenter": + useNsenter = true + case "", "auto": + useNsenter = true + default: + useNsenter = true + } + if useNsenter { + if bin := transportResolveNetnsNsenterBin(client); bin != "" { + args := []string{"--net=/var/run/netns/" + ns, "--"} + args = append(args, command...) + return bin, args, nil + } + } + ipBin := strings.TrimSpace(transportConfigString(client.Config, "netns_ip_bin")) + if ipBin == "" { + ipBin = "ip" + } + args := []string{"netns", "exec", ns} + args = append(args, command...) + return ipBin, args, nil +} + +func transportRunInNetnsMust(timeout time.Duration, client TransportClient, ns string, command ...string) error { + name, args, err := transportNetnsExecCommand(client, ns, command...) + if err != nil { + return err + } + return transportRunMust(timeout, name, args...) +} + +func transportRunInNetnsSoft(timeout time.Duration, client TransportClient, ns string, command ...string) error { + name, args, err := transportNetnsExecCommand(client, ns, command...) + if err != nil { + return err + } + return transportRunSoft(timeout, name, args...) +} diff --git a/selective-vpn-api/app/transport_netns_rules.go b/selective-vpn-api/app/transport_netns_rules.go new file mode 100644 index 0000000..4fb1dc8 --- /dev/null +++ b/selective-vpn-api/app/transport_netns_rules.go @@ -0,0 +1,90 @@ +package app + +import ( + "strconv" + "strings" + "time" +) + +func transportEnsureNetnsNAT(spec transportNetnsSpec) error { + _ = transportRunSoft(4*time.Second, "nft", "add", "table", "ip", transportNetnsNATTable) + _ = transportRunSoft( + 4*time.Second, + "nft", "add", "chain", "ip", transportNetnsNATTable, "postrouting", + "{", "type", "nat", "hook", "postrouting", "priority", "srcnat;", "policy", "accept;", "}", + ) + + if err := transportNetnsDeleteNATRule(spec.Name); err != nil { + return err + } + tag := transportNetnsNATCommentTag(spec.Name) + return transportRunMust( + 4*time.Second, + "nft", "add", "rule", "ip", transportNetnsNATTable, "postrouting", + "ip", "saddr", spec.Prefix.String(), + "oifname", spec.Uplink, + "masquerade", + "comment", tag, + ) +} + +func transportNetnsDeleteNATRule(nsName string) error { + stdout, stderr, code, err := transportRunCommand(4*time.Second, "nft", "-a", "list", "chain", "ip", transportNetnsNATTable, "postrouting") + if err != nil || code != 0 { + if strings.Contains(strings.ToLower(strings.TrimSpace(stderr+" "+stdout)), "no such file") { + return nil + } + return transportCommandError("nft -a list chain ip "+transportNetnsNATTable+" postrouting", stdout, stderr, code, err) + } + tag := `comment "` + transportNetnsNATCommentTag(nsName) + `"` + legacyTag := `comment "svpn_netns:` + nsName + `"` + for _, line := range strings.Split(stdout, "\n") { + if !strings.Contains(line, tag) && !strings.Contains(line, legacyTag) { + continue + } + h := parseNftHandle(line) + if h <= 0 { + continue + } + _ = transportRunSoft(3*time.Second, "nft", "delete", "rule", "ip", transportNetnsNATTable, "postrouting", "handle", strconv.Itoa(h)) + } + return nil +} + +func transportEnsureNetnsPolicyRoute(spec transportNetnsSpec) error { + table := strings.TrimSpace(routesTableName()) + if table == "" { + return nil + } + return transportRunMust( + 4*time.Second, + "ip", "-4", "route", "replace", + spec.Prefix.String(), + "dev", spec.HostVeth, + "table", table, + ) +} + +func transportDeleteNetnsPolicyRoute(spec transportNetnsSpec) error { + table := strings.TrimSpace(routesTableName()) + if table == "" { + return nil + } + if err := transportRunSoft( + 4*time.Second, + "ip", "-4", "route", "del", + spec.Prefix.String(), + "table", table, + ); err != nil { + return err + } + return nil +} + +func transportNetnsNATCommentTag(nsName string) string { + base := sanitizeID(strings.TrimSpace(nsName)) + if base == "" { + base = "ns" + } + return "svpn_netns_" + base +} diff --git a/selective-vpn-api/app/transport_netns_run.go b/selective-vpn-api/app/transport_netns_run.go new file mode 100644 index 0000000..07f439c --- /dev/null +++ b/selective-vpn-api/app/transport_netns_run.go @@ -0,0 +1,29 @@ +package app + +import ( + "strings" + "time" +) + +func transportRunMust(timeout time.Duration, name string, args ...string) error { + stdout, stderr, code, err := transportRunCommand(timeout, name, args...) + if err != nil || code != 0 { + return transportCommandError(name+" "+strings.Join(args, " "), stdout, stderr, code, err) + } + return nil +} + +func transportRunSoft(timeout time.Duration, name string, args ...string) error { + stdout, stderr, code, err := transportRunCommand(timeout, name, args...) + if err != nil || code != 0 { + combined := strings.ToLower(strings.TrimSpace(stderr + " " + stdout)) + if strings.Contains(combined, "no such file") || + strings.Contains(combined, "not found") || + strings.Contains(combined, "cannot find") || + strings.Contains(combined, "does not exist") { + return nil + } + return transportCommandError(name+" "+strings.Join(args, " "), stdout, stderr, code, err) + } + return nil +} diff --git a/selective-vpn-api/app/transport_netns_spec.go b/selective-vpn-api/app/transport_netns_spec.go new file mode 100644 index 0000000..38195b8 --- /dev/null +++ b/selective-vpn-api/app/transport_netns_spec.go @@ -0,0 +1,127 @@ +package app + +import ( + "fmt" + "hash/fnv" + "net/netip" + "strconv" + "strings" + "time" +) + +func transportBuildNetnsSpec(client TransportClient) (transportNetnsSpec, error) { + ns := transportNetnsName(client) + if ns == "" { + return transportNetnsSpec{}, fmt.Errorf("netns name is empty") + } + pfx, err := transportNetnsPrefix(client) + if err != nil { + return transportNetnsSpec{}, err + } + hostIP := pfx.Addr().Next() + peerIP := hostIP.Next() + if !hostIP.IsValid() || !peerIP.IsValid() || !pfx.Contains(peerIP) { + return transportNetnsSpec{}, fmt.Errorf("netns subnet has no usable host pair: %s", pfx.String()) + } + + uplink := strings.TrimSpace(transportConfigString(client.Config, "netns_uplink_iface")) + if uplink == "" { + mainRoute, err := transportDetectMainIPv4Route() + if err != nil { + return transportNetnsSpec{}, err + } + uplink = strings.TrimSpace(mainRoute.Dev) + } + if uplink == "" { + return transportNetnsSpec{}, fmt.Errorf("netns uplink iface is empty") + } + + tag := transportShortHash("netns:"+ns, 8) + return transportNetnsSpec{ + Name: ns, + HostVeth: "svh" + tag, + PeerVeth: "svn" + tag, + Prefix: pfx, + HostIP: hostIP, + PeerIP: peerIP, + Uplink: uplink, + }, nil +} + +func transportNetnsName(client TransportClient) string { + raw := strings.TrimSpace(transportConfigString(client.Config, "netns_name")) + if raw == "" { + base := sanitizeID(client.ID) + if base == "" { + base = "client" + } + raw = "svpn-" + base + } + return normalizeTransportNetnsName(raw, client.ID) +} + +func normalizeTransportNetnsName(raw, fallbackClientID string) string { + ns := sanitizeID(raw) + if ns == "" { + base := sanitizeID(fallbackClientID) + if base == "" { + base = "client" + } + ns = "svpn-" + base + } + if len(ns) > 63 { + ns = ns[:63] + } + return ns +} + +func transportNetnsPrefix(client TransportClient) (netip.Prefix, error) { + raw := strings.TrimSpace(transportConfigString(client.Config, "netns_subnet")) + if raw == "" { + seed := transportShortHash(client.ID, 2) + n, _ := strconv.ParseUint(seed, 16, 8) + raw = fmt.Sprintf("10.240.%d.0/30", 10+int(n)%200) + } + pfx, err := netip.ParsePrefix(raw) + if err != nil { + return netip.Prefix{}, fmt.Errorf("invalid netns_subnet: %q", raw) + } + pfx = pfx.Masked() + if !pfx.Addr().Is4() { + return netip.Prefix{}, fmt.Errorf("netns_subnet must be IPv4: %q", raw) + } + if pfx.Bits() > 30 { + return netip.Prefix{}, fmt.Errorf("netns_subnet must provide at least 2 host addresses: %q", raw) + } + return pfx, nil +} + +func transportNetnsExists(name string) (bool, error) { + stdout, stderr, code, err := transportRunCommand(4*time.Second, "ip", "netns", "list") + if err != nil || code != 0 { + return false, transportCommandError("ip netns list", stdout, stderr, code, err) + } + for _, line := range strings.Split(stdout, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) == 0 { + continue + } + if fields[0] == name { + return true, nil + } + } + return false, nil +} + +func transportShortHash(raw string, n int) string { + if n <= 0 { + n = 8 + } + h := fnv.New32a() + _, _ = h.Write([]byte(strings.TrimSpace(raw))) + s := fmt.Sprintf("%08x", h.Sum32()) + if n >= len(s) { + return s + } + return s[:n] +} diff --git a/selective-vpn-api/app/transport_netns_test.go b/selective-vpn-api/app/transport_netns_test.go new file mode 100644 index 0000000..700b1fc --- /dev/null +++ b/selective-vpn-api/app/transport_netns_test.go @@ -0,0 +1,199 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestTransportWrapExecWithNetns(t *testing.T) { + client := TransportClient{ + ID: "sg-1", + Config: map[string]any{ + "netns_enabled": true, + "netns_name": "svpn-test", + "netns_exec_mode": "ip", + }, + } + cmd := transportWrapExecWithNetns(client, "/usr/bin/sing-box run -c /etc/singbox/test.json") + if !strings.Contains(cmd, "'ip' 'netns' 'exec' 'svpn-test'") { + t.Fatalf("expected wrapped command with netns exec, got: %s", cmd) + } + if !strings.Contains(cmd, "'/bin/sh' '-lc'") { + t.Fatalf("expected wrapped command to keep payload shell execution, got: %s", cmd) + } +} + +func TestTransportWrapExecWithNetnsNsenter(t *testing.T) { + client := TransportClient{ + ID: "sg-1", + Config: map[string]any{ + "netns_enabled": true, + "netns_name": "svpn-test", + "netns_exec_mode": "nsenter", + "netns_nsenter_bin": "/usr/bin/nsenter", + }, + } + cmd := transportWrapExecWithNetns(client, "/usr/bin/sing-box run -c /etc/singbox/test.json") + if !strings.Contains(cmd, "'/usr/bin/nsenter' '--net=/var/run/netns/svpn-test' '--'") { + t.Fatalf("expected wrapped command with nsenter, got: %s", cmd) + } + if !strings.Contains(cmd, "'/bin/sh' '-lc'") { + t.Fatalf("expected wrapped command to keep payload shell execution, got: %s", cmd) + } +} + +func TestTransportNetnsExecCommandModes(t *testing.T) { + clientNS := TransportClient{ + ID: "sg-1", + Config: map[string]any{ + "netns_exec_mode": "nsenter", + "netns_nsenter_bin": "/usr/bin/nsenter", + }, + } + name, args, err := transportNetnsExecCommand(clientNS, "svpn-test", "ip", "link", "show") + if err != nil { + t.Fatalf("nsenter mode command error: %v", err) + } + if name != "/usr/bin/nsenter" { + t.Fatalf("expected nsenter binary, got: %q", name) + } + if len(args) < 5 || args[0] != "--net=/var/run/netns/svpn-test" || args[1] != "--" || args[2] != "ip" { + t.Fatalf("unexpected nsenter args: %#v", args) + } + + clientIP := TransportClient{ + ID: "sg-1", + Config: map[string]any{ + "netns_exec_mode": "ip", + "netns_ip_bin": "/sbin/ip", + }, + } + name, args, err = transportNetnsExecCommand(clientIP, "svpn-test", "ip", "link", "show") + if err != nil { + t.Fatalf("ip mode command error: %v", err) + } + if name != "/sbin/ip" { + t.Fatalf("expected ip binary, got: %q", name) + } + if len(args) < 6 || args[0] != "netns" || args[1] != "exec" || args[2] != "svpn-test" || args[3] != "ip" { + t.Fatalf("unexpected ip args: %#v", args) + } +} + +func TestTransportSystemdBackendProvisionWrapsNetnsExec(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + if cmd == "systemctl daemon-reload" { + return "", "", 0, nil + } + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-netns", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "sg-netns.service", + "exec_start": "/usr/bin/sing-box run -c /etc/singbox/eu.json", + "netns_enabled": true, + "netns_name": "svpn-test", + }, + } + res := selectTransportBackend(client).Provision(client) + if !res.OK { + t.Fatalf("expected provision success, got %#v", res) + } + data, err := os.ReadFile(filepath.Join(tmpDir, "sg-netns.service")) + if err != nil { + t.Fatalf("failed to read unit: %v", err) + } + text := string(data) + if !strings.Contains(text, "netns") || !strings.Contains(text, "svpn-test") { + t.Fatalf("expected netns exec in unit ExecStart, got: %s", text) + } +} + +func TestTransportSystemdBackendActionNetnsStrictFailure(t *testing.T) { + origRunner := transportRunCommand + defer func() { transportRunCommand = origRunner }() + + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + if cmd == "ip netns list" { + return "", "cannot list netns", 1, nil + } + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-netns", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox-test.service", + "netns_enabled": true, + "netns_setup_strict": true, + }, + } + res := selectTransportBackend(client).Action(client, "start") + if res.OK { + t.Fatalf("expected strict netns setup failure, got %#v", res) + } + if res.Code != "TRANSPORT_BACKEND_NETNS_SETUP_FAILED" { + t.Fatalf("unexpected error code: %#v", res) + } +} + +func TestTransportSystemdBackendActionNetnsWarningNonStrict(t *testing.T) { + origRunner := transportRunCommand + defer func() { transportRunCommand = origRunner }() + + calls := make([]string, 0, 4) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + if cmd == "ip netns list" { + return "", "cannot list netns", 1, nil + } + if cmd == "systemctl start singbox-test.service" { + return "", "", 0, nil + } + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-netns", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox-test.service", + "netns_enabled": true, + }, + } + res := selectTransportBackend(client).Action(client, "start") + if !res.OK { + t.Fatalf("expected non-strict start success with warning, got %#v", res) + } + if !strings.Contains(strings.ToLower(res.Message), "warnings") { + t.Fatalf("expected warning marker in message, got %#v", res) + } + if !strings.Contains(strings.ToLower(res.Stderr), "netns:") { + t.Fatalf("expected netns warning in stderr, got %#v", res) + } + if !strings.Contains(strings.Join(calls, " | "), "systemctl start singbox-test.service") { + t.Fatalf("expected systemctl start call, got %#v", calls) + } +} diff --git a/selective-vpn-api/app/transport_owner_locks_clear.go b/selective-vpn-api/app/transport_owner_locks_clear.go new file mode 100644 index 0000000..5348ec6 --- /dev/null +++ b/selective-vpn-api/app/transport_owner_locks_clear.go @@ -0,0 +1,105 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/netip" + "sort" + "strings" +) + +type transportOwnerLockClearFilter struct { + ClientID string + DestinationIPs []string +} + +func normalizeTransportOwnerLockClearRequest(in TransportOwnerLocksClearRequest) (transportOwnerLockClearFilter, error) { + filter := transportOwnerLockClearFilter{ + ClientID: sanitizeID(in.ClientID), + } + seen := map[string]struct{}{} + appendIP := func(raw string) error { + v := strings.TrimSpace(raw) + if v == "" { + return nil + } + addr, err := netip.ParseAddr(v) + if err != nil || !addr.Is4() { + return fmt.Errorf("invalid destination ip: %q", raw) + } + key := addr.String() + if _, ok := seen[key]; ok { + return nil + } + seen[key] = struct{}{} + filter.DestinationIPs = append(filter.DestinationIPs, key) + return nil + } + if err := appendIP(in.DestinationIP); err != nil { + return transportOwnerLockClearFilter{}, err + } + for _, raw := range in.DestinationIPs { + if err := appendIP(raw); err != nil { + return transportOwnerLockClearFilter{}, err + } + } + sort.Strings(filter.DestinationIPs) + if filter.ClientID == "" && len(filter.DestinationIPs) == 0 { + return transportOwnerLockClearFilter{}, fmt.Errorf("at least one selector is required: client_id or destination_ip(s)") + } + return filter, nil +} + +func splitTransportOwnerLocksByFilter(items []TransportOwnerLockRecord, filter transportOwnerLockClearFilter) (matched, remaining []TransportOwnerLockRecord) { + matchIPs := map[string]struct{}{} + for _, ip := range filter.DestinationIPs { + matchIPs[ip] = struct{}{} + } + matched = make([]TransportOwnerLockRecord, 0, len(items)) + remaining = make([]TransportOwnerLockRecord, 0, len(items)) + for _, it := range items { + match := true + if filter.ClientID != "" && sanitizeID(it.ClientID) != filter.ClientID { + match = false + } + if len(matchIPs) > 0 { + dst := strings.TrimSpace(it.DestinationIP) + if addr, err := netip.ParseAddr(dst); err == nil && addr.Is4() { + dst = addr.String() + } + if _, ok := matchIPs[dst]; !ok { + match = false + } + } + if match { + matched = append(matched, it) + } else { + remaining = append(remaining, it) + } + } + return matched, remaining +} + +func digestTransportOwnerLocksClear(baseRevision int64, filter transportOwnerLockClearFilter, matched []TransportOwnerLockRecord) string { + keys := make([]string, 0, len(matched)) + for _, it := range matched { + keys = append(keys, strings.TrimSpace(it.ClientID)+"|"+strings.TrimSpace(it.DestinationIP)) + } + sort.Strings(keys) + payload := struct { + BaseRevision int64 `json:"base_revision"` + ClientID string `json:"client_id,omitempty"` + DestinationIPs []string `json:"destination_ips,omitempty"` + Matches []string `json:"matches,omitempty"` + }{ + BaseRevision: baseRevision, + ClientID: filter.ClientID, + DestinationIPs: append([]string(nil), filter.DestinationIPs...), + Matches: keys, + } + b, _ := json.Marshal(payload) + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} diff --git a/selective-vpn-api/app/transport_owner_locks_clear_test.go b/selective-vpn-api/app/transport_owner_locks_clear_test.go new file mode 100644 index 0000000..caf443d --- /dev/null +++ b/selective-vpn-api/app/transport_owner_locks_clear_test.go @@ -0,0 +1,90 @@ +package app + +import ( + transporttoken "selective-vpn-api/app/transporttoken" + "testing" +) + +func TestNormalizeTransportOwnerLockClearRequest(t *testing.T) { + filter, err := normalizeTransportOwnerLockClearRequest(TransportOwnerLocksClearRequest{ + ClientID: " SG-REALNETNS ", + DestinationIP: "10.1.0.11", + DestinationIPs: []string{ + "10.1.0.12", + "10.1.0.11", + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if filter.ClientID != "sg-realnetns" { + t.Fatalf("unexpected client_id: %q", filter.ClientID) + } + if len(filter.DestinationIPs) != 2 { + t.Fatalf("unexpected destination count: %d (%#v)", len(filter.DestinationIPs), filter.DestinationIPs) + } +} + +func TestNormalizeTransportOwnerLockClearRequestRequiresSelector(t *testing.T) { + _, err := normalizeTransportOwnerLockClearRequest(TransportOwnerLocksClearRequest{}) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestSplitTransportOwnerLocksByFilter(t *testing.T) { + items := []TransportOwnerLockRecord{ + {DestinationIP: "10.1.0.11", ClientID: "c1"}, + {DestinationIP: "10.1.0.12", ClientID: "c1"}, + {DestinationIP: "10.2.0.11", ClientID: "c2"}, + } + filter := transportOwnerLockClearFilter{ + ClientID: "c1", + DestinationIPs: []string{"10.1.0.12"}, + } + matched, remaining := splitTransportOwnerLocksByFilter(items, filter) + if len(matched) != 1 { + t.Fatalf("expected 1 matched, got %d (%#v)", len(matched), matched) + } + if matched[0].DestinationIP != "10.1.0.12" { + t.Fatalf("unexpected matched destination: %q", matched[0].DestinationIP) + } + if len(remaining) != 2 { + t.Fatalf("expected 2 remaining, got %d", len(remaining)) + } +} + +func TestDigestTransportOwnerLocksClearDeterministic(t *testing.T) { + filter := transportOwnerLockClearFilter{ + ClientID: "c1", + DestinationIPs: []string{"10.1.0.11", "10.1.0.12"}, + } + matchedA := []TransportOwnerLockRecord{ + {DestinationIP: "10.1.0.12", ClientID: "c1"}, + {DestinationIP: "10.1.0.11", ClientID: "c1"}, + } + matchedB := []TransportOwnerLockRecord{ + {DestinationIP: "10.1.0.11", ClientID: "c1"}, + {DestinationIP: "10.1.0.12", ClientID: "c1"}, + } + a := digestTransportOwnerLocksClear(7, filter, matchedA) + b := digestTransportOwnerLocksClear(7, filter, matchedB) + if a != b { + t.Fatalf("digest must be deterministic: %q != %q", a, b) + } +} + +func TestTransportOwnerLocksClearTokenLifecycle(t *testing.T) { + transportConfirmStore = transporttoken.NewStore(transportConfirmTTL) + digest := "owner-lock-clear-digest" + token := issueTransportOwnerLocksClearToken(11, digest) + if token == "" { + t.Fatalf("empty token") + } + if !consumeTransportOwnerLocksClearToken(token, 11, digest) { + t.Fatalf("expected token to be consumed") + } + if consumeTransportOwnerLocksClearToken(token, 11, digest) { + t.Fatalf("token must be single-use") + } +} diff --git a/selective-vpn-api/app/transport_policy_apply_executor.go b/selective-vpn-api/app/transport_policy_apply_executor.go new file mode 100644 index 0000000..3c6d007 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_apply_executor.go @@ -0,0 +1,118 @@ +package app + +import ( + "fmt" + "strings" + "time" +) + +func applyTransportPolicyDataPlaneAtomicLocked(plan TransportPolicyCompilePlan, applyID string) (transportPolicyRuntimeState, error) { + current := loadTransportPolicyRuntimeState() + if err := saveTransportPolicyRuntimeSnapshot(current); err != nil { + return current, fmt.Errorf("runtime snapshot save failed: %w", err) + } + + staged := transportPolicyRuntimeState{ + Version: transportStateVersion, + PolicyRevision: plan.PolicyRevision, + ApplyID: strings.TrimSpace(applyID), + InterfaceCount: plan.InterfaceCount, + RuleCount: plan.RuleCount, + Interfaces: cloneTransportPolicyCompileInterfaces(plan.Interfaces), + } + + if err := executeTransportPolicyCompilePlan(current, staged); err != nil { + _ = rollbackTransportPolicyRuntimeToSnapshot(staged) + return current, err + } + + if err := saveTransportPolicyRuntimeState(staged); err != nil { + _ = rollbackTransportPolicyRuntimeToSnapshot(staged) + return current, fmt.Errorf("runtime state save failed: %w", err) + } + return staged, nil +} + +func rollbackTransportPolicyRuntimeToSnapshot(current transportPolicyRuntimeState) error { + rollbackRuntime, ok := loadTransportPolicyRuntimeSnapshot() + if !ok { + return fmt.Errorf("runtime snapshot not found") + } + if err := executeTransportPolicyCompilePlan(current, rollbackRuntime); err != nil { + return err + } + return saveTransportPolicyRuntimeState(rollbackRuntime) +} + +func executeTransportPolicyCompilePlan(current, staged transportPolicyRuntimeState) error { + for _, iface := range staged.Interfaces { + if err := validateTransportPolicyCompileInterface(iface); err != nil { + return err + } + appendTraceLineRateLimited( + "transport", + fmt.Sprintf( + "policy runtime stage: iface=%s table=%s rules=%d sets=%d", + iface.IfaceID, + iface.RoutingTable, + iface.RuleCount, + len(iface.Sets), + ), + 3*time.Second, + ) + } + if err := applyTransportPolicyKernelStage(current, staged); err != nil { + return err + } + return nil +} + +func validateTransportPolicyCompileInterface(iface TransportPolicyCompileInterface) error { + ifaceID := normalizeTransportIfaceID(iface.IfaceID) + if strings.TrimSpace(ifaceID) == "" { + return fmt.Errorf("compile interface has empty iface_id") + } + if iface.RuleCount < 0 { + return fmt.Errorf("compile interface %s has invalid rule_count", ifaceID) + } + if iface.RuleCount != len(iface.Rules) { + return fmt.Errorf("compile interface %s rule_count mismatch", ifaceID) + } + if ifaceID != transportDefaultIfaceID && strings.TrimSpace(iface.RoutingTable) == "" { + return fmt.Errorf("compile interface %s has empty routing_table", ifaceID) + } + for _, rule := range iface.Rules { + if strings.TrimSpace(rule.ClientID) == "" { + return fmt.Errorf("compile interface %s has rule without client_id", ifaceID) + } + if strings.TrimSpace(rule.SelectorType) == "" || strings.TrimSpace(rule.SelectorValue) == "" { + return fmt.Errorf("compile interface %s has invalid selector", ifaceID) + } + } + return nil +} + +func cloneTransportPolicyCompileInterfaces(in []TransportPolicyCompileInterface) []TransportPolicyCompileInterface { + if len(in) == 0 { + return nil + } + out := make([]TransportPolicyCompileInterface, len(in)) + for i := range in { + it := in[i] + it.ClientIDs = append([]string(nil), it.ClientIDs...) + it.MarkHexes = append([]string(nil), it.MarkHexes...) + it.PriorityBase = append([]int(nil), it.PriorityBase...) + if len(it.Sets) > 0 { + it.Sets = append([]TransportPolicyCompileSet(nil), it.Sets...) + } else { + it.Sets = nil + } + if len(it.Rules) > 0 { + it.Rules = append([]TransportPolicyCompileRule(nil), it.Rules...) + } else { + it.Rules = nil + } + out[i] = it + } + return out +} diff --git a/selective-vpn-api/app/transport_policy_apply_executor_test.go b/selective-vpn-api/app/transport_policy_apply_executor_test.go new file mode 100644 index 0000000..5dae949 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_apply_executor_test.go @@ -0,0 +1,118 @@ +package app + +import ( + "path/filepath" + "testing" +) + +func withTransportPolicyRuntimeTestPaths(t *testing.T) { + t.Helper() + tmp := t.TempDir() + prevState := transportPolicyRuntimeStatePath + prevSnap := transportPolicyRuntimeSnapPath + transportPolicyRuntimeStatePath = filepath.Join(tmp, "transport-policies.runtime.json") + transportPolicyRuntimeSnapPath = filepath.Join(tmp, "transport-policies.runtime.prev.json") + t.Cleanup(func() { + transportPolicyRuntimeStatePath = prevState + transportPolicyRuntimeSnapPath = prevSnap + }) +} + +func TestApplyTransportPolicyDataPlaneAtomicLockedSuccess(t *testing.T) { + withTransportPolicyRuntimeTestPaths(t) + + plan := TransportPolicyCompilePlan{ + PolicyRevision: 9, + InterfaceCount: 1, + RuleCount: 1, + Interfaces: []TransportPolicyCompileInterface{ + { + IfaceID: "edge-a", + RoutingTable: "agvpn_if_edge_a", + RuleCount: 1, + Rules: []TransportPolicyCompileRule{ + { + SelectorType: "domain", + SelectorValue: "example.com", + ClientID: "c1", + }, + }, + }, + }, + } + + runtime, err := applyTransportPolicyDataPlaneAtomicLocked(plan, "apl-test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if runtime.PolicyRevision != 9 { + t.Fatalf("unexpected runtime revision: %d", runtime.PolicyRevision) + } + if runtime.ApplyID != "apl-test" { + t.Fatalf("unexpected apply_id: %q", runtime.ApplyID) + } + stored := loadTransportPolicyRuntimeState() + if stored.ApplyID != "apl-test" || stored.PolicyRevision != 9 { + t.Fatalf("unexpected stored runtime state: %#v", stored) + } + if _, ok := loadTransportPolicyRuntimeSnapshot(); !ok { + t.Fatalf("expected runtime snapshot to be saved") + } +} + +func TestApplyTransportPolicyDataPlaneAtomicLockedRollbackOnFailure(t *testing.T) { + withTransportPolicyRuntimeTestPaths(t) + + prev := transportPolicyRuntimeState{ + Version: transportStateVersion, + PolicyRevision: 3, + ApplyID: "apl-prev", + InterfaceCount: 1, + RuleCount: 1, + Interfaces: []TransportPolicyCompileInterface{ + { + IfaceID: "edge-prev", + RoutingTable: "agvpn_if_edge_prev", + RuleCount: 1, + Rules: []TransportPolicyCompileRule{ + { + SelectorType: "domain", + SelectorValue: "prev.example", + ClientID: "c-prev", + }, + }, + }, + }, + } + if err := saveTransportPolicyRuntimeState(prev); err != nil { + t.Fatalf("save prev runtime: %v", err) + } + + badPlan := TransportPolicyCompilePlan{ + PolicyRevision: 10, + InterfaceCount: 1, + RuleCount: 1, + Interfaces: []TransportPolicyCompileInterface{ + { + IfaceID: "edge-bad", + RoutingTable: "", + RuleCount: 1, + Rules: []TransportPolicyCompileRule{ + { + SelectorType: "domain", + SelectorValue: "bad.example", + ClientID: "c-bad", + }, + }, + }, + }, + } + + if _, err := applyTransportPolicyDataPlaneAtomicLocked(badPlan, "apl-bad"); err == nil { + t.Fatalf("expected error for invalid plan") + } + rolled := loadTransportPolicyRuntimeState() + if rolled.ApplyID != "apl-prev" || rolled.PolicyRevision != 3 { + t.Fatalf("runtime state must be rolled back to previous: %#v", rolled) + } +} diff --git a/selective-vpn-api/app/transport_policy_apply_health.go b/selective-vpn-api/app/transport_policy_apply_health.go new file mode 100644 index 0000000..143c6ea --- /dev/null +++ b/selective-vpn-api/app/transport_policy_apply_health.go @@ -0,0 +1,227 @@ +package app + +import ( + "fmt" + "sort" + "strings" + "time" +) + +func runTransportPolicyHealthCheck(clients []TransportClient, plan TransportPolicyCompilePlan, now time.Time) (TransportPolicyHealthCheck, []TransportClient, bool) { + updated := append([]TransportClient(nil), clients...) + clientIDs := collectTransportPolicyHealthCheckClientIDs(plan) + items := make([]TransportPolicyHealthCheckItem, 0, len(clientIDs)) + itemByClientID := make(map[string]TransportPolicyHealthCheckItem, len(clientIDs)) + checkedCount := 0 + failedCount := 0 + changed := false + + for _, clientID := range clientIDs { + idx := findTransportClientIndex(updated, clientID) + if idx < 0 { + item := TransportPolicyHealthCheckItem{ + ClientID: clientID, + Required: true, + OK: false, + Code: "TRANSPORT_CLIENT_NOT_FOUND", + Message: "client not found during health-check", + } + items = append(items, item) + itemByClientID[clientID] = item + checkedCount++ + failedCount++ + continue + } + + current := updated[idx] + required := transportPolicyHealthCheckRequired(current) + item := TransportPolicyHealthCheckItem{ + ClientID: current.ID, + Kind: string(current.Kind), + Required: required, + OK: true, + Status: string(normalizeTransportStatus(current.Status)), + } + if !required { + item.Message = "skipped inactive draft client" + items = append(items, item) + itemByClientID[current.ID] = item + continue + } + + checkedCount++ + backend := selectTransportBackend(current) + probe := backend.Health(current) + next := applyTransportHealthProbeSnapshot(current, backend.ID(), probe, now) + updated[idx] = next + if transportHealthChanged(current, next) || transportShouldPersistHealthSnapshot(current, next, now) { + changed = true + } + + item.Status = string(normalizeTransportStatus(next.Status)) + item.Code = strings.TrimSpace(probe.Code) + if probe.OK && next.Status != TransportClientDown { + item.Message = "ok" + items = append(items, item) + itemByClientID[current.ID] = item + continue + } + + item.OK = false + if item.Code == "" && next.Status == TransportClientDown { + item.Code = "TRANSPORT_POLICY_HEALTH_DOWN" + } + msg := strings.TrimSpace(probe.Message) + if msg == "" && next.Status == TransportClientDown { + msg = "transport client is down after apply" + } + if msg == "" { + msg = "transport policy health-check failed" + } + item.Message = msg + items = append(items, item) + itemByClientID[current.ID] = item + failedCount++ + } + + clientByID := make(map[string]TransportClient, len(updated)) + for _, client := range updated { + clientByID[client.ID] = client + } + interfaces := buildTransportPolicyHealthCheckInterfaces(plan, itemByClientID, clientByID, now) + + resp := TransportPolicyHealthCheck{ + OK: failedCount == 0, + CheckedCount: checkedCount, + FailedCount: failedCount, + InterfaceCount: len(interfaces), + Interfaces: interfaces, + Items: items, + } + switch { + case checkedCount == 0: + resp.OK = true + resp.Message = "health-check skipped: no active transport clients in policy" + case failedCount == 0: + resp.Message = fmt.Sprintf("health-check passed for %d client(s)", checkedCount) + default: + resp.Message = fmt.Sprintf("health-check failed for %d of %d client(s)", failedCount, checkedCount) + } + return resp, updated, changed +} + +func buildTransportPolicyHealthCheckInterfaces( + plan TransportPolicyCompilePlan, + itemByClientID map[string]TransportPolicyHealthCheckItem, + clientByID map[string]TransportClient, + now time.Time, +) []TransportPolicyHealthCheckInterface { + if len(plan.Interfaces) == 0 { + return nil + } + out := make([]TransportPolicyHealthCheckInterface, 0, len(plan.Interfaces)) + for _, iface := range plan.Interfaces { + summary := TransportPolicyHealthCheckInterface{ + IfaceID: normalizeTransportIfaceID(iface.IfaceID), + Mode: strings.TrimSpace(iface.Mode), + RuntimeIface: strings.TrimSpace(iface.RuntimeIface), + NetnsName: strings.TrimSpace(iface.NetnsName), + RoutingTable: strings.TrimSpace(iface.RoutingTable), + Status: string(TransportClientDown), + OK: true, + } + seen := map[string]struct{}{} + members := make([]TransportClient, 0, len(iface.ClientIDs)) + for _, rawClientID := range iface.ClientIDs { + clientID := sanitizeID(rawClientID) + if clientID == "" { + continue + } + if _, ok := seen[clientID]; ok { + continue + } + seen[clientID] = struct{}{} + summary.ClientIDs = append(summary.ClientIDs, clientID) + summary.ClientCount++ + if client, ok := clientByID[clientID]; ok { + members = append(members, client) + } + item, ok := itemByClientID[clientID] + if !ok { + continue + } + if item.Required { + summary.CheckedCount++ + if !item.OK { + summary.FailedCount++ + } + continue + } + summary.SkippedCount++ + } + if len(members) > 0 { + counters := buildTransportRuntimeObservabilityCounters(members) + summary.Status = string(aggregateTransportRuntimeObservabilityStatus(counters)) + if primary, ok := selectTransportRuntimeObservabilityPrimaryClient(members); ok { + summary.ActiveClientID = primary.ID + summary.LatencyMS = primary.Health.LatencyMS + if summary.RuntimeIface == "" { + summary.RuntimeIface = strings.TrimSpace(primary.Iface) + } + if summary.NetnsName == "" && transportNetnsEnabled(primary) { + summary.NetnsName = transportNetnsName(primary) + } + if summary.RoutingTable == "" { + summary.RoutingTable = strings.TrimSpace(primary.RoutingTable) + } + } + if errClient, ok := selectTransportRuntimeObservabilityErrorClient(members); ok { + summary.LastError = transportRuntimeObservabilityClientError(errClient, now) + } + } + switch { + case summary.ClientCount == 0: + summary.Message = "health-check skipped: no compiled clients on interface" + case summary.CheckedCount == 0: + summary.Message = "health-check skipped: no active transport clients on interface" + case summary.FailedCount == 0: + summary.Message = fmt.Sprintf("health-check passed for %d client(s) on interface", summary.CheckedCount) + default: + summary.OK = false + summary.Message = fmt.Sprintf( + "health-check failed for %d of %d client(s) on interface", + summary.FailedCount, + summary.CheckedCount, + ) + } + out = append(out, summary) + } + return out +} + +func collectTransportPolicyHealthCheckClientIDs(plan TransportPolicyCompilePlan) []string { + seen := map[string]struct{}{} + out := make([]string, 0, plan.RuleCount) + for _, iface := range plan.Interfaces { + for _, clientID := range iface.ClientIDs { + id := sanitizeID(clientID) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + } + sort.Strings(out) + return out +} + +func transportPolicyHealthCheckRequired(client TransportClient) bool { + if client.Enabled { + return true + } + return normalizeTransportStatus(client.Status) != TransportClientDown +} diff --git a/selective-vpn-api/app/transport_policy_apply_health_test.go b/selective-vpn-api/app/transport_policy_apply_health_test.go new file mode 100644 index 0000000..9ee1af6 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_apply_health_test.go @@ -0,0 +1,211 @@ +package app + +import ( + "testing" + "time" +) + +func TestRunTransportPolicyHealthCheckSkipsInactiveDraftClient(t *testing.T) { + now := time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC) + clients := []TransportClient{ + { + ID: "draft-a", + Kind: TransportClientSingBox, + Enabled: false, + Status: TransportClientDown, + }, + } + plan := TransportPolicyCompilePlan{ + Interfaces: []TransportPolicyCompileInterface{ + {IfaceID: "edge-a", ClientIDs: []string{"draft-a"}}, + }, + } + + res, updated, changed := runTransportPolicyHealthCheck(clients, plan, now) + if !res.OK { + t.Fatalf("expected skipped draft client to keep health-check green: %#v", res) + } + if res.CheckedCount != 0 || res.FailedCount != 0 { + t.Fatalf("unexpected counts: %#v", res) + } + if len(res.Items) != 1 || res.Items[0].Required { + t.Fatalf("expected one skipped item: %#v", res.Items) + } + if res.InterfaceCount != 1 || len(res.Interfaces) != 1 { + t.Fatalf("expected one interface summary: %#v", res) + } + if !res.Interfaces[0].OK || res.Interfaces[0].CheckedCount != 0 || res.Interfaces[0].SkippedCount != 1 { + t.Fatalf("unexpected interface summary: %#v", res.Interfaces[0]) + } + if changed { + t.Fatalf("inactive draft client should not mutate health snapshot") + } + if updated[0].Health.LastCheck != "" { + t.Fatalf("unexpected health update for skipped client: %#v", updated[0]) + } +} + +func TestRunTransportPolicyHealthCheckFailsEnabledDownClient(t *testing.T) { + now := time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC) + clients := []TransportClient{ + { + ID: "edge-a-main", + Kind: TransportClientSingBox, + Enabled: true, + Status: TransportClientDown, + Config: map[string]any{ + "runner": "mock", + }, + }, + } + plan := TransportPolicyCompilePlan{ + Interfaces: []TransportPolicyCompileInterface{ + {IfaceID: "edge-a", ClientIDs: []string{"edge-a-main"}}, + }, + } + + res, updated, changed := runTransportPolicyHealthCheck(clients, plan, now) + if res.OK { + t.Fatalf("expected enabled down client to fail health-check: %#v", res) + } + if res.CheckedCount != 1 || res.FailedCount != 1 { + t.Fatalf("unexpected counts: %#v", res) + } + if len(res.Items) != 1 || res.Items[0].Code != "TRANSPORT_POLICY_HEALTH_DOWN" { + t.Fatalf("unexpected health-check item: %#v", res.Items) + } + if res.InterfaceCount != 1 || len(res.Interfaces) != 1 { + t.Fatalf("expected one interface summary: %#v", res) + } + if res.Interfaces[0].OK || res.Interfaces[0].CheckedCount != 1 || res.Interfaces[0].FailedCount != 1 { + t.Fatalf("unexpected interface summary: %#v", res.Interfaces[0]) + } + if !changed { + t.Fatalf("expected health snapshot to be updated") + } + if updated[0].Health.LastCheck != now.Format(time.RFC3339) { + t.Fatalf("missing health timestamp update: %#v", updated[0]) + } +} + +func TestRunTransportPolicyHealthCheckFailsUnsupportedRuntime(t *testing.T) { + now := time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC) + clients := []TransportClient{ + { + ID: "edge-b-embedded", + Kind: TransportClientPhoenix, + Enabled: true, + Status: TransportClientUp, + Config: map[string]any{ + "runtime_mode": "embedded", + }, + }, + } + plan := TransportPolicyCompilePlan{ + Interfaces: []TransportPolicyCompileInterface{ + {IfaceID: "edge-b", ClientIDs: []string{"edge-b-embedded"}}, + }, + } + + res, updated, changed := runTransportPolicyHealthCheck(clients, plan, now) + if res.OK { + t.Fatalf("expected unsupported runtime to fail health-check: %#v", res) + } + if len(res.Items) != 1 || res.Items[0].Code != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED" { + t.Fatalf("unexpected health-check item: %#v", res.Items) + } + if res.InterfaceCount != 1 || len(res.Interfaces) != 1 { + t.Fatalf("expected one interface summary: %#v", res) + } + if res.Interfaces[0].OK || res.Interfaces[0].CheckedCount != 1 || res.Interfaces[0].FailedCount != 1 { + t.Fatalf("unexpected interface summary: %#v", res.Interfaces[0]) + } + if !changed { + t.Fatalf("expected unsupported runtime probe to update snapshot") + } + if updated[0].Status != TransportClientDegraded { + t.Fatalf("unexpected degraded status after failed probe: %#v", updated[0]) + } + if updated[0].Health.LastError == "" { + t.Fatalf("expected last_error to be persisted after failed probe") + } +} + +func TestRunTransportPolicyHealthCheckBuildsPerInterfaceSummary(t *testing.T) { + now := time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC) + clients := []TransportClient{ + { + ID: "edge-a-main", + Kind: TransportClientSingBox, + Enabled: true, + Status: TransportClientUp, + Config: map[string]any{ + "runner": "mock", + }, + }, + { + ID: "edge-a-draft", + Kind: TransportClientSingBox, + Enabled: false, + Status: TransportClientDown, + }, + { + ID: "edge-b-main", + Kind: TransportClientPhoenix, + Enabled: true, + Status: TransportClientUp, + Config: map[string]any{ + "runtime_mode": "embedded", + }, + }, + } + plan := TransportPolicyCompilePlan{ + Interfaces: []TransportPolicyCompileInterface{ + { + IfaceID: "edge-a", + Mode: "dedicated", + RuntimeIface: "tun-a", + NetnsName: "svpn-edge-a", + RoutingTable: "agvpn_if_edge_a", + ClientIDs: []string{"edge-a-main", "edge-a-draft"}, + }, + { + IfaceID: "edge-b", + Mode: "dedicated", + RuntimeIface: "tun-b", + RoutingTable: "agvpn_if_edge_b", + ClientIDs: []string{"edge-b-main"}, + }, + }, + } + + res, _, _ := runTransportPolicyHealthCheck(clients, plan, now) + if res.InterfaceCount != 2 || len(res.Interfaces) != 2 { + t.Fatalf("expected two interface summaries: %#v", res) + } + + ifaceA := res.Interfaces[0] + if ifaceA.IfaceID != "edge-a" || !ifaceA.OK || ifaceA.ClientCount != 2 || ifaceA.CheckedCount != 1 || ifaceA.SkippedCount != 1 { + t.Fatalf("unexpected edge-a summary: %#v", ifaceA) + } + if ifaceA.RuntimeIface != "tun-a" || ifaceA.NetnsName != "svpn-edge-a" || ifaceA.RoutingTable != "agvpn_if_edge_a" { + t.Fatalf("edge-a runtime metadata missing: %#v", ifaceA) + } + if ifaceA.ActiveClientID != "edge-a-main" || ifaceA.Status != string(TransportClientUp) { + t.Fatalf("edge-a runtime status fields mismatch: %#v", ifaceA) + } + + ifaceB := res.Interfaces[1] + if ifaceB.IfaceID != "edge-b" || ifaceB.OK || ifaceB.ClientCount != 1 || ifaceB.CheckedCount != 1 || ifaceB.FailedCount != 1 { + t.Fatalf("unexpected edge-b summary: %#v", ifaceB) + } + if ifaceB.RuntimeIface != "tun-b" || ifaceB.RoutingTable != "agvpn_if_edge_b" { + t.Fatalf("edge-b runtime metadata missing: %#v", ifaceB) + } + if ifaceB.ActiveClientID != "edge-b-main" || ifaceB.Status != string(TransportClientDegraded) { + t.Fatalf("edge-b runtime status fields mismatch: %#v", ifaceB) + } + if ifaceB.LastError == "" { + t.Fatalf("edge-b expected last_error from degraded probe: %#v", ifaceB) + } +} diff --git a/selective-vpn-api/app/transport_policy_apply_kernel.go b/selective-vpn-api/app/transport_policy_apply_kernel.go new file mode 100644 index 0000000..1e3707d --- /dev/null +++ b/selective-vpn-api/app/transport_policy_apply_kernel.go @@ -0,0 +1,270 @@ +package app + +import ( + "context" + "fmt" + "net/netip" + "os" + "sort" + "strconv" + "strings" + "time" +) + +const ( + transportPolicyKernelEnvEnable = "SVPN_TRANSPORT_POLICY_KERNEL_APPLY" + transportPolicyKernelEnvIPRules = "SVPN_TRANSPORT_POLICY_KERNEL_IPRULES" +) + +var ( + transportPolicyKernelRunCommand = runCommandTimeout + transportPolicyKernelUpdateSet = func(ctx context.Context, setName string, ips []string) error { + return nftUpdateSetIPsSmart(ctx, setName, ips, nil) + } +) + +func applyTransportPolicyKernelStage(current, staged transportPolicyRuntimeState) error { + if !transportPolicyKernelApplyEnabled() { + return nil + } + if err := applyTransportPolicyKernelNftSets(current, staged); err != nil { + return err + } + if transportPolicyKernelIPRulesEnabled() { + if err := applyTransportPolicyKernelIPRules(staged); err != nil { + return err + } + } + if transportPolicyKernelConntrackStickyEnabled() { + if err := refreshTransportPolicyOwnerLocksFromConntrack(staged); err != nil { + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("policy conntrack sticky refresh warning: %v", err), + 5*time.Second, + ) + } + } + return nil +} + +func transportPolicyKernelApplyEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(transportPolicyKernelEnvEnable))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func transportPolicyKernelIPRulesEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(transportPolicyKernelEnvIPRules))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func applyTransportPolicyKernelNftSets(current, staged transportPolicyRuntimeState) error { + desired := buildTransportPolicyCIDRSetElements(staged.Interfaces) + currentSets := collectTransportPolicyCIDRSetNames(current.Interfaces) + desiredSets := collectMapKeys(desired) + + _, _, _, _ = transportPolicyKernelRunCommand(5*time.Second, "nft", "add", "table", "inet", "agvpn") + + for _, setName := range desiredSets { + if strings.TrimSpace(setName) == "" { + continue + } + _, _, _, _ = transportPolicyKernelRunCommand( + 5*time.Second, + "nft", "add", "set", "inet", "agvpn", setName, + "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}", + ) + ips := desired[setName] + if len(ips) == 0 { + _, stderr, _, err := transportPolicyKernelRunCommand(10*time.Second, "nft", "flush", "set", "inet", "agvpn", setName) + if err != nil && !strings.Contains(strings.ToLower(stderr), "no such file") { + return fmt.Errorf("nft flush %s failed: %v (%s)", setName, err, strings.TrimSpace(stderr)) + } + continue + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err := transportPolicyKernelUpdateSet(ctx, setName, ips) + cancel() + if err != nil { + return fmt.Errorf("nft update set %s failed: %w", setName, err) + } + } + + for _, stale := range diffStringSet(currentSets, desiredSets) { + if strings.TrimSpace(stale) == "" { + continue + } + _, _, _, _ = transportPolicyKernelRunCommand(5*time.Second, "nft", "flush", "set", "inet", "agvpn", stale) + _, _, _, _ = transportPolicyKernelRunCommand(5*time.Second, "nft", "delete", "set", "inet", "agvpn", stale) + } + + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("policy kernel nft stage: sets=%d stale=%d", len(desiredSets), len(diffStringSet(currentSets, desiredSets))), + 5*time.Second, + ) + return nil +} + +func applyTransportPolicyKernelIPRules(staged transportPolicyRuntimeState) error { + type tuple struct { + pref int + mark string + table string + } + seen := map[string]struct{}{} + rules := make([]tuple, 0, 16) + for _, iface := range staged.Interfaces { + table := strings.TrimSpace(iface.RoutingTable) + if normalizeTransportIfaceID(iface.IfaceID) == transportDefaultIfaceID { + continue + } + if table == "" { + continue + } + for _, r := range iface.Rules { + mark := strings.ToLower(strings.TrimSpace(r.MarkHex)) + if _, ok := parseTransportMarkHex(mark); !ok { + continue + } + if _, ok := parseTransportPref(r.PriorityBase); !ok { + continue + } + key := strconv.Itoa(r.PriorityBase) + "|" + mark + "|" + table + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + rules = append(rules, tuple{pref: r.PriorityBase, mark: mark, table: table}) + } + } + sort.Slice(rules, func(i, j int) bool { return rules[i].pref < rules[j].pref }) + for _, rule := range rules { + _, _, _, _ = transportPolicyKernelRunCommand(4*time.Second, "ip", "-4", "rule", "del", "pref", strconv.Itoa(rule.pref)) + stdout, stderr, code, err := transportPolicyKernelRunCommand( + 5*time.Second, + "ip", "-4", "rule", "add", + "pref", strconv.Itoa(rule.pref), + "fwmark", rule.mark, + "lookup", rule.table, + ) + if err != nil || code != 0 { + return fmt.Errorf("ip rule add pref=%d mark=%s table=%s failed: %v (stdout=%s stderr=%s code=%d)", + rule.pref, rule.mark, rule.table, err, strings.TrimSpace(stdout), strings.TrimSpace(stderr), code) + } + } + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("policy kernel iprule stage: rules=%d", len(rules)), + 5*time.Second, + ) + return nil +} + +func buildTransportPolicyCIDRSetElements(interfaces []TransportPolicyCompileInterface) map[string][]string { + out := map[string][]string{} + for _, iface := range interfaces { + for _, rule := range iface.Rules { + if strings.ToLower(strings.TrimSpace(rule.SelectorType)) != "cidr" { + continue + } + setName := strings.TrimSpace(rule.NftSet) + if setName == "" { + continue + } + pfx, err := parseIntentCIDR(rule.SelectorValue) + if err != nil { + continue + } + ip := cidrToNftElement(pfx) + if ip == "" { + continue + } + out[setName] = appendUniqueString(out[setName], ip) + } + } + for k := range out { + sort.Strings(out[k]) + } + return out +} + +func cidrToNftElement(pfx netip.Prefix) string { + if !pfx.IsValid() || !pfx.Addr().Is4() { + return "" + } + if pfx.Bits() == 32 { + return pfx.Addr().String() + } + return pfx.Masked().String() +} + +func collectTransportPolicyCIDRSetNames(interfaces []TransportPolicyCompileInterface) []string { + seen := map[string]struct{}{} + for _, iface := range interfaces { + for _, it := range iface.Sets { + if strings.ToLower(strings.TrimSpace(it.SelectorType)) != "cidr" { + continue + } + name := strings.TrimSpace(it.Name) + if name == "" { + continue + } + seen[name] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for k := range seen { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func collectMapKeys(m map[string][]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func diffStringSet(a, b []string) []string { + if len(a) == 0 { + return nil + } + inB := map[string]struct{}{} + for _, it := range b { + inB[it] = struct{}{} + } + out := make([]string, 0, len(a)) + for _, it := range a { + if _, ok := inB[it]; ok { + continue + } + out = append(out, it) + } + sort.Strings(out) + return out +} + +func appendUniqueString(in []string, v string) []string { + val := strings.TrimSpace(v) + if val == "" { + return in + } + for _, it := range in { + if it == val { + return in + } + } + return append(in, val) +} diff --git a/selective-vpn-api/app/transport_policy_apply_kernel_conntrack.go b/selective-vpn-api/app/transport_policy_apply_kernel_conntrack.go new file mode 100644 index 0000000..34a1246 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_apply_kernel_conntrack.go @@ -0,0 +1,197 @@ +package app + +import ( + "fmt" + "net/netip" + "os" + "sort" + "strconv" + "strings" + "time" +) + +const transportPolicyKernelEnvConntrackSticky = "SVPN_TRANSPORT_POLICY_CONNTRACK_STICKY" + +var ( + transportPolicyKernelConntrackOutput = func(timeout time.Duration) (string, error) { + stdout, stderr, code, err := transportPolicyKernelRunCommand(timeout, "conntrack", "-L", "-f", "ipv4") + if err != nil || code != 0 { + return "", fmt.Errorf("conntrack list failed: %v (stderr=%s code=%d)", err, strings.TrimSpace(stderr), code) + } + return stdout, nil + } + transportPolicyKernelSaveOwnerLocksState = saveTransportOwnerLocksState +) + +type transportPolicyMarkOwner struct { + ClientID string + ClientKind string + IfaceID string + MarkHex string +} + +func transportPolicyKernelConntrackStickyEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(transportPolicyKernelEnvConntrackSticky))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func refreshTransportPolicyOwnerLocksFromConntrack(staged transportPolicyRuntimeState) error { + markOwner := collectTransportPolicyMarkOwners(staged.Interfaces) + if len(markOwner) == 0 { + _ = transportPolicyKernelSaveOwnerLocksState(TransportOwnerLockState{ + Version: transportStateVersion, + PolicyRevision: staged.PolicyRevision, + }) + return nil + } + output, err := transportPolicyKernelConntrackOutput(8 * time.Second) + if err != nil { + return err + } + locks := parseTransportOwnerLocksFromConntrack(output, markOwner, staged.PolicyRevision) + if err := transportPolicyKernelSaveOwnerLocksState(locks); err != nil { + return fmt.Errorf("save owner locks failed: %w", err) + } + appendTraceLineRateLimited( + "transport", + fmt.Sprintf("policy conntrack sticky refresh: locks=%d revision=%d", len(locks.Items), staged.PolicyRevision), + 5*time.Second, + ) + return nil +} + +func collectTransportPolicyMarkOwners(interfaces []TransportPolicyCompileInterface) map[uint32]transportPolicyMarkOwner { + out := map[uint32]transportPolicyMarkOwner{} + for _, iface := range interfaces { + ifaceID := normalizeTransportIfaceID(iface.IfaceID) + for _, rule := range iface.Rules { + markHex := strings.ToLower(strings.TrimSpace(rule.MarkHex)) + markRaw, ok := parseTransportMarkHex(markHex) + if !ok { + continue + } + if markRaw > uint64(^uint32(0)) { + continue + } + mark := uint32(markRaw) + if strings.TrimSpace(rule.ClientID) == "" { + continue + } + if _, exists := out[mark]; exists { + continue + } + out[mark] = transportPolicyMarkOwner{ + ClientID: strings.TrimSpace(rule.ClientID), + ClientKind: strings.TrimSpace(rule.ClientKind), + IfaceID: ifaceID, + MarkHex: markHex, + } + } + } + return out +} + +func parseTransportOwnerLocksFromConntrack(raw string, markOwner map[uint32]transportPolicyMarkOwner, policyRevision int64) TransportOwnerLockState { + now := time.Now().UTC().Format(time.RFC3339) + byDst := map[string]TransportOwnerLockRecord{} + lines := strings.Split(raw, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + dst, mark, proto, ok := parseTransportConntrackLockLine(line) + if !ok { + continue + } + owner, exists := markOwner[mark] + if !exists { + continue + } + key := dst.String() + if _, exists := byDst[key]; exists { + continue + } + byDst[key] = TransportOwnerLockRecord{ + DestinationIP: key, + ClientID: owner.ClientID, + ClientKind: owner.ClientKind, + IfaceID: owner.IfaceID, + MarkHex: owner.MarkHex, + Proto: proto, + UpdatedAt: now, + } + } + + items := make([]TransportOwnerLockRecord, 0, len(byDst)) + for _, item := range byDst { + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].DestinationIP != items[j].DestinationIP { + return items[i].DestinationIP < items[j].DestinationIP + } + return items[i].ClientID < items[j].ClientID + }) + return TransportOwnerLockState{ + Version: transportStateVersion, + UpdatedAt: now, + PolicyRevision: policyRevision, + Count: len(items), + Items: items, + } +} + +func parseTransportConntrackLockLine(line string) (dst netip.Addr, mark uint32, proto string, ok bool) { + tokens := strings.Fields(strings.TrimSpace(line)) + if len(tokens) == 0 { + return netip.Addr{}, 0, "", false + } + proto = strings.ToLower(strings.TrimSpace(tokens[0])) + var gotDst bool + var gotMark bool + for _, tok := range tokens { + if !gotDst && strings.HasPrefix(tok, "dst=") { + val := strings.TrimPrefix(tok, "dst=") + addr, err := netip.ParseAddr(strings.TrimSpace(val)) + if err == nil && addr.Is4() { + dst = addr + gotDst = true + } + continue + } + if !gotMark && strings.HasPrefix(tok, "mark=") { + val := strings.TrimPrefix(tok, "mark=") + parsed, ok := parseTransportConntrackMark(val) + if ok { + mark = parsed + gotMark = true + } + } + } + if !gotDst || !gotMark { + return netip.Addr{}, 0, "", false + } + return dst, mark, proto, true +} + +func parseTransportConntrackMark(raw string) (uint32, bool) { + v := strings.TrimSpace(raw) + if v == "" { + return 0, false + } + base := 10 + if strings.HasPrefix(strings.ToLower(v), "0x") { + base = 16 + v = v[2:] + } + n, err := strconv.ParseUint(v, base, 32) + if err != nil { + return 0, false + } + return uint32(n), true +} diff --git a/selective-vpn-api/app/transport_policy_apply_kernel_conntrack_test.go b/selective-vpn-api/app/transport_policy_apply_kernel_conntrack_test.go new file mode 100644 index 0000000..d2a1a7e --- /dev/null +++ b/selective-vpn-api/app/transport_policy_apply_kernel_conntrack_test.go @@ -0,0 +1,62 @@ +package app + +import "testing" + +func TestParseTransportConntrackLockLine(t *testing.T) { + line := "tcp 6 431999 ESTABLISHED src=10.0.0.2 dst=10.1.0.11 sport=12345 dport=443 src=10.1.0.11 dst=10.0.0.2 sport=443 dport=12345 [ASSURED] mark=0x120 use=1" + dst, mark, proto, ok := parseTransportConntrackLockLine(line) + if !ok { + t.Fatalf("expected line to parse") + } + if dst.String() != "10.1.0.11" { + t.Fatalf("unexpected dst: %s", dst.String()) + } + if mark != 0x120 { + t.Fatalf("unexpected mark: %#x", mark) + } + if proto != "tcp" { + t.Fatalf("unexpected proto: %s", proto) + } +} + +func TestParseTransportOwnerLocksFromConntrack(t *testing.T) { + raw := "" + + "tcp 6 431999 ESTABLISHED src=10.0.0.2 dst=10.1.0.11 sport=1 dport=2 mark=0x120 use=1\n" + + "udp 17 60 src=10.0.0.2 dst=10.1.0.12 sport=1 dport=2 mark=288 use=1\n" + + "udp 17 60 src=10.0.0.2 dst=10.9.0.12 sport=1 dport=2 mark=0x222 use=1\n" + markOwner := map[uint32]transportPolicyMarkOwner{ + 0x120: { + ClientID: "c1", + ClientKind: "singbox", + IfaceID: "edge-a", + MarkHex: "0x120", + }, + } + st := parseTransportOwnerLocksFromConntrack(raw, markOwner, 13) + if st.PolicyRevision != 13 { + t.Fatalf("unexpected revision: %d", st.PolicyRevision) + } + if len(st.Items) != 2 { + t.Fatalf("expected 2 locks, got %d (%#v)", len(st.Items), st.Items) + } + for _, item := range st.Items { + if item.ClientID != "c1" { + t.Fatalf("unexpected client id: %q", item.ClientID) + } + if item.MarkHex != "0x120" { + t.Fatalf("unexpected mark hex: %q", item.MarkHex) + } + } +} + +func TestParseTransportConntrackMark(t *testing.T) { + if v, ok := parseTransportConntrackMark("0x120"); !ok || v != 0x120 { + t.Fatalf("hex mark parse failed: %v %v", v, ok) + } + if v, ok := parseTransportConntrackMark("288"); !ok || v != 288 { + t.Fatalf("dec mark parse failed: %v %v", v, ok) + } + if _, ok := parseTransportConntrackMark("bad"); ok { + t.Fatalf("expected parse error") + } +} diff --git a/selective-vpn-api/app/transport_policy_apply_kernel_test.go b/selective-vpn-api/app/transport_policy_apply_kernel_test.go new file mode 100644 index 0000000..178e406 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_apply_kernel_test.go @@ -0,0 +1,105 @@ +package app + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestBuildTransportPolicyCIDRSetElements(t *testing.T) { + in := []TransportPolicyCompileInterface{ + { + IfaceID: "edge-a", + Rules: []TransportPolicyCompileRule{ + {SelectorType: "cidr", SelectorValue: "10.1.0.0/24", NftSet: "agvpn_pi_edge_a_cidr"}, + {SelectorType: "cidr", SelectorValue: "10.1.0.5", NftSet: "agvpn_pi_edge_a_cidr"}, + {SelectorType: "domain", SelectorValue: "example.com", NftSet: "agvpn_pi_edge_a_domain"}, + }, + }, + } + got := buildTransportPolicyCIDRSetElements(in) + ips := got["agvpn_pi_edge_a_cidr"] + if len(ips) != 2 { + t.Fatalf("unexpected cidr elements: %#v", got) + } +} + +func TestApplyTransportPolicyKernelNftSets(t *testing.T) { + prevRun := transportPolicyKernelRunCommand + prevUpdate := transportPolicyKernelUpdateSet + defer func() { + transportPolicyKernelRunCommand = prevRun + transportPolicyKernelUpdateSet = prevUpdate + }() + + var cmds []string + transportPolicyKernelRunCommand = func(timeout time.Duration, name string, args ...string) (string, string, int, error) { + cmds = append(cmds, name+" "+strings.Join(args, " ")) + return "", "", 0, nil + } + updates := map[string][]string{} + transportPolicyKernelUpdateSet = func(_ context.Context, setName string, ips []string) error { + updates[setName] = append([]string(nil), ips...) + return nil + } + + current := transportPolicyRuntimeState{ + Interfaces: []TransportPolicyCompileInterface{ + { + IfaceID: "edge-old", + Sets: []TransportPolicyCompileSet{ + {SelectorType: "cidr", Name: "agvpn_pi_edge_old_cidr"}, + }, + }, + }, + } + staged := transportPolicyRuntimeState{ + Interfaces: []TransportPolicyCompileInterface{ + { + IfaceID: "edge-a", + Rules: []TransportPolicyCompileRule{ + {SelectorType: "cidr", SelectorValue: "10.1.0.0/24", NftSet: "agvpn_pi_edge_a_cidr"}, + }, + }, + }, + } + if err := applyTransportPolicyKernelNftSets(current, staged); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(updates["agvpn_pi_edge_a_cidr"]) != 1 { + t.Fatalf("expected update for desired set, got: %#v", updates) + } + hasDeleteStale := false + for _, cmd := range cmds { + if strings.Contains(cmd, "delete set inet agvpn agvpn_pi_edge_old_cidr") { + hasDeleteStale = true + break + } + } + if !hasDeleteStale { + t.Fatalf("expected stale set cleanup command, got: %#v", cmds) + } +} + +func TestApplyTransportPolicyKernelStageDisabled(t *testing.T) { + prevRun := transportPolicyKernelRunCommand + defer func() { transportPolicyKernelRunCommand = prevRun }() + called := false + transportPolicyKernelRunCommand = func(timeout time.Duration, name string, args ...string) (string, string, int, error) { + called = true + return "", "", 0, nil + } + + prev := os.Getenv(transportPolicyKernelEnvEnable) + _ = os.Setenv(transportPolicyKernelEnvEnable, "0") + defer func() { _ = os.Setenv(transportPolicyKernelEnvEnable, prev) }() + + if err := applyTransportPolicyKernelStage(transportPolicyRuntimeState{}, transportPolicyRuntimeState{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if called { + t.Fatalf("kernel stage must be skipped when disabled") + } +} diff --git a/selective-vpn-api/app/transport_policy_compile.go b/selective-vpn-api/app/transport_policy_compile.go new file mode 100644 index 0000000..912e51d --- /dev/null +++ b/selective-vpn-api/app/transport_policy_compile.go @@ -0,0 +1,303 @@ +package app + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +type transportPolicyCompileIfaceBucket struct { + Iface TransportPolicyCompileInterface + sets map[string]*TransportPolicyCompileSet +} + +func compileTransportPolicyPlan(intents []TransportPolicyIntent, clients []TransportClient, policyRevision int64) (TransportPolicyCompilePlan, []TransportConflictRecord) { + clientByID := make(map[string]TransportClient, len(clients)) + for _, it := range clients { + clientByID[it.ID] = it + } + ifaces, _ := normalizeTransportInterfacesState(loadTransportInterfacesState(), clients) + + plan := TransportPolicyCompilePlan{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + PolicyRevision: policyRevision, + } + buckets := map[string]*transportPolicyCompileIfaceBucket{} + markOwner := map[string]string{} + prefOwner := map[int]string{} + tableOwner := map[string]string{} + conflicts := make([]TransportConflictRecord, 0) + + for _, it := range intents { + client, ok := clientByID[it.ClientID] + if !ok { + conflicts = append(conflicts, TransportConflictRecord{ + Key: "compile:client:" + it.ClientID, + Type: "unknown_client", + Severity: "block", + Owners: []string{it.ClientID}, + Reason: "client not found during compile", + SuggestedResolution: "refresh clients and re-validate policy", + }) + continue + } + binding := resolveTransportIfaceBinding(client, ifaces) + ifaceID := normalizeTransportIfaceID(binding.IfaceID) + routingTable := normalizeTransportRoutingTable(binding.RoutingTable, transportRoutingTableForID(client.ID)) + if ifaceID != transportDefaultIfaceID && strings.TrimSpace(routingTable) != "" { + if owner, exists := tableOwner[routingTable]; exists && owner != ifaceID { + conflicts = append(conflicts, TransportConflictRecord{ + Key: "allocator:table:" + routingTable, + Type: "allocator_collision", + Severity: "block", + Owners: []string{owner, ifaceID}, + Reason: "routing table is shared across different iface_id", + SuggestedResolution: "assign unique routing_table per interface", + }) + } else { + tableOwner[routingTable] = ifaceID + } + } + + markHex := strings.TrimSpace(client.MarkHex) + if markHex != "" { + markHex = strings.ToLower(markHex) + if _, ok := parseTransportMarkHex(markHex); !ok { + conflicts = append(conflicts, TransportConflictRecord{ + Key: "allocator:mark:" + client.ID, + Type: "allocator_invalid", + Severity: "block", + Owners: []string{client.ID}, + Reason: "invalid mark_hex", + SuggestedResolution: "reconcile clients allocation state", + }) + } else if owner, exists := markOwner[markHex]; exists && owner != ifaceID { + conflicts = append(conflicts, TransportConflictRecord{ + Key: "allocator:mark:" + markHex, + Type: "allocator_collision", + Severity: "block", + Owners: []string{owner, ifaceID}, + Reason: "mark_hex is shared across different iface_id", + SuggestedResolution: "assign unique mark pool per interface", + }) + } else { + markOwner[markHex] = ifaceID + } + } + + prefBase := client.PriorityBase + if prefBase > 0 { + if _, ok := parseTransportPref(prefBase); !ok { + conflicts = append(conflicts, TransportConflictRecord{ + Key: "allocator:pref:" + client.ID, + Type: "allocator_invalid", + Severity: "block", + Owners: []string{client.ID}, + Reason: "invalid priority_base", + SuggestedResolution: "reconcile clients allocation state", + }) + } else if owner, exists := prefOwner[prefBase]; exists && owner != ifaceID { + conflicts = append(conflicts, TransportConflictRecord{ + Key: "allocator:pref:" + strconv.Itoa(prefBase), + Type: "allocator_collision", + Severity: "block", + Owners: []string{owner, ifaceID}, + Reason: "priority_base is shared across different iface_id", + SuggestedResolution: "assign unique pref pool per interface", + }) + } else { + prefOwner[prefBase] = ifaceID + } + } + + b, ok := buckets[ifaceID] + if !ok { + mode := normalizeTransportInterfaceMode("", ifaceID) + b = &transportPolicyCompileIfaceBucket{ + Iface: TransportPolicyCompileInterface{ + IfaceID: ifaceID, + Mode: string(mode), + RuntimeIface: strings.TrimSpace(binding.RuntimeIface), + NetnsName: strings.TrimSpace(binding.NetnsName), + RoutingTable: routingTable, + ClientIDs: nil, + MarkHexes: nil, + PriorityBase: nil, + Sets: nil, + Rules: nil, + }, + sets: map[string]*TransportPolicyCompileSet{}, + } + buckets[ifaceID] = b + } + if strings.TrimSpace(b.Iface.RoutingTable) == "" { + b.Iface.RoutingTable = routingTable + } + addUniqueString(&b.Iface.ClientIDs, client.ID) + if markHex != "" { + addUniqueString(&b.Iface.MarkHexes, markHex) + } + if prefBase > 0 { + addUniqueInt(&b.Iface.PriorityBase, prefBase) + } + ownerScope := transportPolicyNftOwnerScope(ifaceID, client.ID) + setName := transportPolicyNftSetName(ownerScope, it.SelectorType) + setKey := ownerScope + "|" + it.SelectorType + "|" + setName + if _, exists := b.sets[setKey]; !exists { + b.sets[setKey] = &TransportPolicyCompileSet{ + SelectorType: it.SelectorType, + OwnerScope: ownerScope, + Name: setName, + } + } + b.sets[setKey].RuleCount++ + b.Iface.RuleCount++ + b.Iface.Rules = append(b.Iface.Rules, TransportPolicyCompileRule{ + SelectorType: it.SelectorType, + SelectorValue: it.SelectorValue, + ClientID: client.ID, + ClientKind: string(client.Kind), + OwnerScope: ownerScope, + Mode: it.Mode, + Priority: it.Priority, + MarkHex: markHex, + PriorityBase: prefBase, + NftSet: setName, + }) + plan.RuleCount++ + } + + keys := make([]string, 0, len(buckets)) + for ifaceID := range buckets { + keys = append(keys, ifaceID) + } + sort.Strings(keys) + for _, ifaceID := range keys { + b := buckets[ifaceID] + sort.Strings(b.Iface.ClientIDs) + sort.Strings(b.Iface.MarkHexes) + sort.Ints(b.Iface.PriorityBase) + sort.Slice(b.Iface.Rules, func(i, j int) bool { + a := b.Iface.Rules[i] + c := b.Iface.Rules[j] + if a.SelectorType != c.SelectorType { + return a.SelectorType < c.SelectorType + } + if a.SelectorValue != c.SelectorValue { + return a.SelectorValue < c.SelectorValue + } + return a.ClientID < c.ClientID + }) + sets := make([]TransportPolicyCompileSet, 0, len(b.sets)) + for _, it := range b.sets { + sets = append(sets, *it) + } + sort.Slice(sets, func(i, j int) bool { + if sets[i].SelectorType != sets[j].SelectorType { + return sets[i].SelectorType < sets[j].SelectorType + } + if sets[i].OwnerScope != sets[j].OwnerScope { + return sets[i].OwnerScope < sets[j].OwnerScope + } + return sets[i].Name < sets[j].Name + }) + b.Iface.Sets = sets + plan.Interfaces = append(plan.Interfaces, b.Iface) + } + plan.InterfaceCount = len(plan.Interfaces) + conflicts = dedupeTransportConflicts(conflicts) + return plan, conflicts +} + +func transportPolicyNftSetName(ownerScope, selectorType string) string { + scope := strings.TrimSpace(ownerScope) + scope = strings.ReplaceAll(sanitizeID(scope), "-", "_") + if scope == "" { + scope = "shared_client" + } + selector := strings.ToLower(strings.TrimSpace(selectorType)) + switch selector { + case "domain", "cidr", "app_key", "cgroup", "uid": + default: + selector = "misc" + } + name := fmt.Sprintf("agvpn_pi_%s_%s", scope, selector) + if len(name) > 63 { + hash := transportPolicyShortHash(name) + // nft set name max is 63 chars: agvpn_pi___ + budget := 63 - len("agvpn_pi_") - len(selector) - len(hash) - 2 + if budget < 6 { + budget = 6 + } + if len(scope) > budget { + scope = scope[:budget] + } + name = fmt.Sprintf("agvpn_pi_%s_%s_%s", strings.Trim(scope, "_"), selector, hash) + if len(name) > 63 { + name = name[:63] + } + } + return strings.Trim(name, "_") +} + +func transportPolicyNftOwnerScope(ifaceID, clientID string) string { + iface := strings.TrimSpace(ifaceID) + if iface == "" { + iface = transportDefaultIfaceID + } + iface = strings.ReplaceAll(sanitizeID(iface), "-", "_") + if iface == "" { + iface = transportDefaultIfaceID + } + client := strings.ReplaceAll(sanitizeID(clientID), "-", "_") + if client == "" { + client = "client" + } + scope := strings.Trim(iface+"_"+client, "_") + if len(scope) <= 32 { + return scope + } + hash := transportPolicyShortHash(scope) + if len(scope) > 20 { + scope = scope[:20] + } + scope = strings.Trim(scope, "_") + "_" + hash + if len(scope) > 32 { + scope = scope[:32] + } + return strings.Trim(scope, "_") +} + +func transportPolicyShortHash(raw string) string { + sum := sha1.Sum([]byte(raw)) + return hex.EncodeToString(sum[:])[:10] +} + +func addUniqueString(dst *[]string, v string) { + val := strings.TrimSpace(v) + if val == "" { + return + } + for _, it := range *dst { + if it == val { + return + } + } + *dst = append(*dst, val) +} + +func addUniqueInt(dst *[]int, v int) { + if v <= 0 { + return + } + for _, it := range *dst { + if it == v { + return + } + } + *dst = append(*dst, v) +} diff --git a/selective-vpn-api/app/transport_policy_conflicts.go b/selective-vpn-api/app/transport_policy_conflicts.go new file mode 100644 index 0000000..0f46d16 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_conflicts.go @@ -0,0 +1,35 @@ +package app + +import "strings" + +func isTransportConflictBlock(conflict TransportConflictRecord) bool { + return strings.EqualFold(strings.TrimSpace(conflict.Severity), "block") +} + +func isTransportConflictForceOverridable(conflict TransportConflictRecord) bool { + if !isTransportConflictBlock(conflict) { + return false + } + switch strings.ToLower(strings.TrimSpace(conflict.Type)) { + case "owner_switch": + return true + default: + return false + } +} + +func splitTransportBlockingConflicts(conflicts []TransportConflictRecord) (overridable []TransportConflictRecord, hard []TransportConflictRecord) { + overridable = make([]TransportConflictRecord, 0) + hard = make([]TransportConflictRecord, 0) + for _, conflict := range conflicts { + if !isTransportConflictBlock(conflict) { + continue + } + if isTransportConflictForceOverridable(conflict) { + overridable = append(overridable, conflict) + continue + } + hard = append(hard, conflict) + } + return overridable, hard +} diff --git a/selective-vpn-api/app/transport_policy_conflicts_test.go b/selective-vpn-api/app/transport_policy_conflicts_test.go new file mode 100644 index 0000000..657709f --- /dev/null +++ b/selective-vpn-api/app/transport_policy_conflicts_test.go @@ -0,0 +1,60 @@ +package app + +import "testing" + +func TestSplitTransportBlockingConflicts(t *testing.T) { + conflicts := []TransportConflictRecord{ + { + Key: "domain:example.com", + Type: "owner_switch", + Severity: "block", + }, + { + Key: "domain:dup.example", + Type: "ownership", + Severity: "block", + }, + { + Key: "cidr:10.0.0.0/24", + Type: "cidr_overlap", + Severity: "block", + }, + { + Key: "domain:warn.example", + Type: "duplicate_intent", + Severity: "warn", + }, + } + + overridable, hard := splitTransportBlockingConflicts(conflicts) + if len(overridable) != 1 { + t.Fatalf("expected 1 overridable conflict, got %d (%#v)", len(overridable), overridable) + } + if overridable[0].Type != "owner_switch" { + t.Fatalf("unexpected overridable type: %q", overridable[0].Type) + } + if len(hard) != 2 { + t.Fatalf("expected 2 hard conflicts, got %d (%#v)", len(hard), hard) + } +} + +func TestIsTransportConflictForceOverridable(t *testing.T) { + if !isTransportConflictForceOverridable(TransportConflictRecord{ + Type: "owner_switch", + Severity: "block", + }) { + t.Fatalf("owner_switch block must be overridable") + } + if isTransportConflictForceOverridable(TransportConflictRecord{ + Type: "ownership", + Severity: "block", + }) { + t.Fatalf("ownership block must not be overridable") + } + if isTransportConflictForceOverridable(TransportConflictRecord{ + Type: "owner_switch", + Severity: "warn", + }) { + t.Fatalf("owner_switch warn must not be overridable") + } +} diff --git a/selective-vpn-api/app/transport_policy_idempotency_state.go b/selective-vpn-api/app/transport_policy_idempotency_state.go new file mode 100644 index 0000000..2065165 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_idempotency_state.go @@ -0,0 +1,209 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + transportPolicyIdempotencyTTL = 24 * time.Hour + transportPolicyIdempotencyMaxItems = 256 + transportPolicyIdempotencyApplyScope = "transport_policy_apply" + transportPolicyIdempotencyRollbackScope = "transport_policy_rollback" +) + +type transportPolicyIdempotencyRecord struct { + Key string `json:"key"` + Scope string `json:"scope"` + RequestHash string `json:"request_hash,omitempty"` + Response TransportPolicyResponse `json:"response"` + CreatedAt string `json:"created_at,omitempty"` +} + +type transportPolicyIdempotencyState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + Items []transportPolicyIdempotencyRecord `json:"items,omitempty"` +} + +type transportPolicyIdempotencyLookup struct { + Replay bool + Conflict bool + Response TransportPolicyResponse +} + +func normalizeTransportIdempotencyKey(raw string) string { + return strings.TrimSpace(raw) +} + +func hashTransportPolicyMutationRequest(v any) string { + data, _ := json.Marshal(v) + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func loadTransportPolicyIdempotencyState() transportPolicyIdempotencyState { + st := transportPolicyIdempotencyState{Version: transportStateVersion} + data, err := os.ReadFile(transportPolicyIdempotencyStatePath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return transportPolicyIdempotencyState{Version: transportStateVersion} + } + if st.Version == 0 { + st.Version = transportStateVersion + } + if st.Items == nil { + st.Items = nil + } + norm, changed := normalizeTransportPolicyIdempotencyState(st, time.Now().UTC()) + if changed { + _ = saveTransportPolicyIdempotencyState(norm) + return norm + } + return norm +} + +func saveTransportPolicyIdempotencyState(st transportPolicyIdempotencyState) error { + st.Version = transportStateVersion + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportPolicyIdempotencyStatePath), 0o755); err != nil { + return err + } + tmp := transportPolicyIdempotencyStatePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportPolicyIdempotencyStatePath) +} + +func normalizeTransportPolicyIdempotencyState(st transportPolicyIdempotencyState, now time.Time) (transportPolicyIdempotencyState, bool) { + changed := false + st.Version = transportStateVersion + if st.Items == nil { + st.Items = nil + } + out := make([]transportPolicyIdempotencyRecord, 0, len(st.Items)) + for _, raw := range st.Items { + rec := raw + rec.Key = normalizeTransportIdempotencyKey(rec.Key) + rec.Scope = strings.TrimSpace(rec.Scope) + rec.RequestHash = strings.TrimSpace(rec.RequestHash) + if rec.Key == "" || rec.Scope == "" || rec.RequestHash == "" { + changed = true + continue + } + if transportPolicyIdempotencyExpired(rec, now) { + changed = true + continue + } + out = append(out, rec) + } + sort.Slice(out, func(i, j int) bool { + return strings.TrimSpace(out[i].CreatedAt) > strings.TrimSpace(out[j].CreatedAt) + }) + if len(out) > transportPolicyIdempotencyMaxItems { + out = out[:transportPolicyIdempotencyMaxItems] + changed = true + } + st.Items = out + return st, changed +} + +func transportPolicyIdempotencyExpired(rec transportPolicyIdempotencyRecord, now time.Time) bool { + if transportPolicyIdempotencyTTL <= 0 { + return false + } + ts := strings.TrimSpace(rec.CreatedAt) + if ts == "" { + return false + } + parsed, err := time.Parse(time.RFC3339, ts) + if err != nil { + return false + } + return now.Sub(parsed) > transportPolicyIdempotencyTTL +} + +func lookupTransportPolicyIdempotencyLocked(scope, key, requestHash string) transportPolicyIdempotencyLookup { + key = normalizeTransportIdempotencyKey(key) + scope = strings.TrimSpace(scope) + requestHash = strings.TrimSpace(requestHash) + if key == "" || scope == "" || requestHash == "" { + return transportPolicyIdempotencyLookup{} + } + st := loadTransportPolicyIdempotencyState() + for _, rec := range st.Items { + if rec.Scope != scope || rec.Key != key { + continue + } + if rec.RequestHash == requestHash { + return transportPolicyIdempotencyLookup{ + Replay: true, + Response: rec.Response, + } + } + return transportPolicyIdempotencyLookup{ + Conflict: true, + Response: TransportPolicyResponse{ + OK: false, + Message: "idempotency key already used for different request payload", + Code: "IDEMPOTENCY_KEY_REUSED", + }, + } + } + return transportPolicyIdempotencyLookup{} +} + +func saveTransportPolicyIdempotencyLocked(scope, key, requestHash string, resp TransportPolicyResponse) error { + key = normalizeTransportIdempotencyKey(key) + scope = strings.TrimSpace(scope) + requestHash = strings.TrimSpace(requestHash) + if key == "" || scope == "" || requestHash == "" { + return nil + } + now := time.Now().UTC() + st := loadTransportPolicyIdempotencyState() + st, _ = normalizeTransportPolicyIdempotencyState(st, now) + record := transportPolicyIdempotencyRecord{ + Key: key, + Scope: scope, + RequestHash: requestHash, + Response: resp, + CreatedAt: now.Format(time.RFC3339), + } + replaced := false + for i := range st.Items { + if st.Items[i].Scope == scope && st.Items[i].Key == key { + st.Items[i] = record + replaced = true + break + } + } + if !replaced { + st.Items = append(st.Items, record) + } + st, _ = normalizeTransportPolicyIdempotencyState(st, now) + return saveTransportPolicyIdempotencyState(st) +} + +func persistTransportPolicyIdempotencyLocked(scope, key, requestHash string, resp TransportPolicyResponse) { + if err := saveTransportPolicyIdempotencyLocked(scope, key, requestHash, resp); err != nil { + appendTraceLineRateLimited( + "transport", + "policy idempotency save warning: "+err.Error(), + 5*time.Second, + ) + } +} diff --git a/selective-vpn-api/app/transport_policy_idempotency_test.go b/selective-vpn-api/app/transport_policy_idempotency_test.go new file mode 100644 index 0000000..8601544 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_idempotency_test.go @@ -0,0 +1,166 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func withTransportPolicyMutationTestPaths(t *testing.T) { + t.Helper() + tmp := t.TempDir() + + prevClients := transportClientsStatePath + prevIfaces := transportInterfacesStatePath + prevPolicy := transportPolicyStatePath + prevPolicySnap := transportPolicySnapshotPath + prevPlan := transportPolicyPlanStatePath + prevRuntime := transportPolicyRuntimeStatePath + prevRuntimeSnap := transportPolicyRuntimeSnapPath + prevOwners := transportOwnershipStatePath + prevConflicts := transportConflictsStatePath + prevIdem := transportPolicyIdempotencyStatePath + + transportClientsStatePath = filepath.Join(tmp, "transport-clients.json") + transportInterfacesStatePath = filepath.Join(tmp, "transport-interfaces.json") + transportPolicyStatePath = filepath.Join(tmp, "transport-policies.json") + transportPolicySnapshotPath = filepath.Join(tmp, "transport-policies.prev.json") + transportPolicyPlanStatePath = filepath.Join(tmp, "transport-policies.plan.json") + transportPolicyRuntimeStatePath = filepath.Join(tmp, "transport-policies.runtime.json") + transportPolicyRuntimeSnapPath = filepath.Join(tmp, "transport-policies.runtime.prev.json") + transportOwnershipStatePath = filepath.Join(tmp, "transport-ownership.json") + transportConflictsStatePath = filepath.Join(tmp, "transport-conflicts.json") + transportPolicyIdempotencyStatePath = filepath.Join(tmp, "transport-policy-idempotency.json") + + t.Cleanup(func() { + transportClientsStatePath = prevClients + transportInterfacesStatePath = prevIfaces + transportPolicyStatePath = prevPolicy + transportPolicySnapshotPath = prevPolicySnap + transportPolicyPlanStatePath = prevPlan + transportPolicyRuntimeStatePath = prevRuntime + transportPolicyRuntimeSnapPath = prevRuntimeSnap + transportOwnershipStatePath = prevOwners + transportConflictsStatePath = prevConflicts + transportPolicyIdempotencyStatePath = prevIdem + }) +} + +func TestTransportPolicyApplyIdempotencyReplay(t *testing.T) { + withTransportPolicyMutationTestPaths(t) + + body := `{"base_revision":0,"intents":[]}` + req1 := httptest.NewRequest(http.MethodPost, "/api/v1/transport/policies/apply", strings.NewReader(body)) + req1.Header.Set("Idempotency-Key", "apply-replay-1") + rec1 := httptest.NewRecorder() + handleTransportPoliciesApplyExec(rec1, req1) + if rec1.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec1.Code, rec1.Body.String()) + } + var resp1 TransportPolicyResponse + if err := json.Unmarshal(rec1.Body.Bytes(), &resp1); err != nil { + t.Fatalf("decode first apply response: %v", err) + } + if !resp1.OK || resp1.PolicyRevision != 1 || strings.TrimSpace(resp1.ApplyID) == "" { + t.Fatalf("unexpected first apply response: %#v", resp1) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/transport/policies/apply", strings.NewReader(body)) + req2.Header.Set("Idempotency-Key", "apply-replay-1") + rec2 := httptest.NewRecorder() + handleTransportPoliciesApplyExec(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("unexpected replay status: %d body=%s", rec2.Code, rec2.Body.String()) + } + var resp2 TransportPolicyResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil { + t.Fatalf("decode replay apply response: %v", err) + } + if !resp2.OK || resp2.PolicyRevision != 1 || resp2.ApplyID != resp1.ApplyID { + t.Fatalf("unexpected replay response: first=%#v second=%#v", resp1, resp2) + } + + current := loadTransportPolicyState() + if current.Revision != 1 { + t.Fatalf("policy revision must remain 1 after replay, got %d", current.Revision) + } + idem := loadTransportPolicyIdempotencyState() + if len(idem.Items) != 1 { + t.Fatalf("expected one idempotency record, got %#v", idem) + } +} + +func TestTransportPolicyApplyIdempotencyRejectsPayloadReuse(t *testing.T) { + withTransportPolicyMutationTestPaths(t) + + req1 := httptest.NewRequest(http.MethodPost, "/api/v1/transport/policies/apply", strings.NewReader(`{"base_revision":0,"intents":[]}`)) + req1.Header.Set("Idempotency-Key", "apply-conflict-1") + rec1 := httptest.NewRecorder() + handleTransportPoliciesApplyExec(rec1, req1) + + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/transport/policies/apply", strings.NewReader(`{"base_revision":1,"intents":[]}`)) + req2.Header.Set("Idempotency-Key", "apply-conflict-1") + rec2 := httptest.NewRecorder() + handleTransportPoliciesApplyExec(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("unexpected conflict status: %d body=%s", rec2.Code, rec2.Body.String()) + } + var resp2 TransportPolicyResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil { + t.Fatalf("decode conflict apply response: %v", err) + } + if resp2.OK || resp2.Code != "IDEMPOTENCY_KEY_REUSED" { + t.Fatalf("unexpected idempotency conflict response: %#v", resp2) + } + + current := loadTransportPolicyState() + if current.Revision != 1 { + t.Fatalf("policy revision must remain 1 after conflicting replay, got %d", current.Revision) + } +} + +func TestTransportPolicyRollbackIdempotencyReplay(t *testing.T) { + withTransportPolicyMutationTestPaths(t) + + applyReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/policies/apply", strings.NewReader(`{"base_revision":0,"intents":[]}`)) + applyRec := httptest.NewRecorder() + handleTransportPoliciesApplyExec(applyRec, applyReq) + + req1 := httptest.NewRequest(http.MethodPost, "/api/v1/transport/policies/rollback", strings.NewReader(`{"base_revision":1}`)) + req1.Header.Set("Idempotency-Key", "rollback-replay-1") + rec1 := httptest.NewRecorder() + handleTransportPoliciesRollbackExec(rec1, req1) + if rec1.Code != http.StatusOK { + t.Fatalf("unexpected rollback status: %d body=%s", rec1.Code, rec1.Body.String()) + } + var resp1 TransportPolicyResponse + if err := json.Unmarshal(rec1.Body.Bytes(), &resp1); err != nil { + t.Fatalf("decode first rollback response: %v", err) + } + if !resp1.OK || resp1.PolicyRevision != 2 || strings.TrimSpace(resp1.ApplyID) == "" { + t.Fatalf("unexpected first rollback response: %#v", resp1) + } + + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/transport/policies/rollback", strings.NewReader(`{"base_revision":1}`)) + req2.Header.Set("Idempotency-Key", "rollback-replay-1") + rec2 := httptest.NewRecorder() + handleTransportPoliciesRollbackExec(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("unexpected rollback replay status: %d body=%s", rec2.Code, rec2.Body.String()) + } + var resp2 TransportPolicyResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil { + t.Fatalf("decode replay rollback response: %v", err) + } + if !resp2.OK || resp2.PolicyRevision != 2 || resp2.ApplyID != resp1.ApplyID { + t.Fatalf("unexpected rollback replay response: first=%#v second=%#v", resp1, resp2) + } + + current := loadTransportPolicyState() + if current.Revision != 2 { + t.Fatalf("policy revision must remain 2 after rollback replay, got %d", current.Revision) + } +} diff --git a/selective-vpn-api/app/transport_policy_owner_destination_lock.go b/selective-vpn-api/app/transport_policy_owner_destination_lock.go new file mode 100644 index 0000000..73c1b3d --- /dev/null +++ b/selective-vpn-api/app/transport_policy_owner_destination_lock.go @@ -0,0 +1,162 @@ +package app + +import ( + "fmt" + "net/netip" + "strings" +) + +var ( + transportPolicyDomainCachePath = stateDir + "/domain-cache.json" + transportPolicyLoadDomainCacheState = loadDomainCacheState +) + +func detectTransportDestinationLockConflicts(current, next []TransportPolicyIntent, locks TransportOwnerLockState) []TransportConflictRecord { + if len(locks.Items) == 0 { + return nil + } + + currentOwners := map[string]string{} + for _, raw := range current { + norm, key, _, err := normalizeTransportIntent(raw) + if err != nil || key == "" || strings.TrimSpace(norm.ClientID) == "" { + continue + } + currentOwners[key] = norm.ClientID + } + if len(currentOwners) == 0 { + return nil + } + + conflicts := make([]TransportConflictRecord, 0, 4) + var domainCache domainCacheState + domainCacheLoaded := false + domainResolved := map[string]map[string]struct{}{} + + for _, raw := range next { + norm, key, pfx, err := normalizeTransportIntent(raw) + if err != nil || key == "" || strings.TrimSpace(norm.ClientID) == "" { + continue + } + prevOwner, exists := currentOwners[key] + if !exists || prevOwner == norm.ClientID { + continue + } + + switch strings.ToLower(strings.TrimSpace(norm.SelectorType)) { + case "cidr": + if !pfx.IsValid() { + continue + } + for _, lock := range locks.Items { + if strings.TrimSpace(lock.ClientID) != prevOwner { + continue + } + dst, err := netip.ParseAddr(strings.TrimSpace(lock.DestinationIP)) + if err != nil || !dst.Is4() { + continue + } + if !pfx.Contains(dst) { + continue + } + conflicts = append(conflicts, TransportConflictRecord{ + Key: fmt.Sprintf("destination_lock:%s:%s", key, dst.String()), + Type: "destination_lock", + Severity: "block", + Owners: []string{prevOwner, norm.ClientID}, + Reason: fmt.Sprintf( + "destination %s is sticky-locked to %s by conntrack state", + dst.String(), + prevOwner, + ), + SuggestedResolution: "wait conntrack expiry or clear conntrack state for destination before owner switch", + }) + } + case "domain": + ipsBySelector, ok := domainResolved[key] + if !ok { + if !domainCacheLoaded { + domainCache = transportPolicyLoadDomainCacheState(transportPolicyDomainCachePath, nil) + domainCacheLoaded = true + } + ipsBySelector = transportPolicyResolveDomainSelectorIPs(norm.SelectorValue, domainCache) + domainResolved[key] = ipsBySelector + } + if len(ipsBySelector) == 0 { + continue + } + for _, lock := range locks.Items { + if strings.TrimSpace(lock.ClientID) != prevOwner { + continue + } + dstRaw := strings.TrimSpace(lock.DestinationIP) + dst, err := netip.ParseAddr(dstRaw) + if err != nil || !dst.Is4() { + continue + } + dstKey := dst.String() + if _, hit := ipsBySelector[dstKey]; !hit { + continue + } + conflicts = append(conflicts, TransportConflictRecord{ + Key: fmt.Sprintf("destination_lock:%s:%s", key, dstKey), + Type: "destination_lock", + Severity: "block", + Owners: []string{prevOwner, norm.ClientID}, + Reason: fmt.Sprintf( + "domain %s resolves to destination %s sticky-locked to %s by conntrack state", + norm.SelectorValue, + dstKey, + prevOwner, + ), + SuggestedResolution: "wait conntrack expiry or clear conntrack state for destination before owner switch", + }) + } + } + } + return dedupeTransportConflicts(conflicts) +} + +func transportPolicyResolveDomainSelectorIPs(selector string, cache domainCacheState) map[string]struct{} { + out := map[string]struct{}{} + sel := strings.Trim(strings.ToLower(strings.TrimSpace(selector)), ".") + if sel == "" { + return out + } + wildcard := false + if strings.HasPrefix(sel, "*.") { + wildcard = true + sel = strings.Trim(strings.TrimPrefix(sel, "*."), ".") + } + if sel == "" { + return out + } + + for host := range cache.Domains { + h := strings.Trim(strings.ToLower(strings.TrimSpace(host)), ".") + if h == "" { + continue + } + if wildcard { + if h != sel && !strings.HasSuffix(h, "."+sel) { + continue + } + } else { + if h != sel && !strings.HasSuffix(h, "."+sel) { + continue + } + } + for _, src := range []domainCacheSource{domainCacheSourceDirect, domainCacheSourceWildcard} { + ips := cache.getStoredIPs(h, src) + for _, ip := range ips { + addr, err := netip.ParseAddr(strings.TrimSpace(ip)) + if err != nil || !addr.Is4() { + continue + } + out[addr.String()] = struct{}{} + } + } + } + + return out +} diff --git a/selective-vpn-api/app/transport_policy_owner_destination_lock_test.go b/selective-vpn-api/app/transport_policy_owner_destination_lock_test.go new file mode 100644 index 0000000..70c7b55 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_owner_destination_lock_test.go @@ -0,0 +1,114 @@ +package app + +import "testing" + +func TestDetectTransportDestinationLockConflicts(t *testing.T) { + current := []TransportPolicyIntent{ + {SelectorType: "cidr", SelectorValue: "10.1.0.0/24", ClientID: "c1"}, + } + next := []TransportPolicyIntent{ + {SelectorType: "cidr", SelectorValue: "10.1.0.0/24", ClientID: "c2"}, + } + locks := TransportOwnerLockState{ + Items: []TransportOwnerLockRecord{ + {DestinationIP: "10.1.0.11", ClientID: "c1"}, + {DestinationIP: "10.9.0.11", ClientID: "c1"}, + }, + } + + conflicts := detectTransportDestinationLockConflicts(current, next, locks) + if len(conflicts) != 1 { + t.Fatalf("expected 1 conflict, got %d (%#v)", len(conflicts), conflicts) + } + if conflicts[0].Type != "destination_lock" { + t.Fatalf("unexpected conflict type: %q", conflicts[0].Type) + } +} + +func TestDetectTransportDestinationLockConflictsDomainFromCache(t *testing.T) { + prevLoader := transportPolicyLoadDomainCacheState + t.Cleanup(func() { transportPolicyLoadDomainCacheState = prevLoader }) + + cache := newDomainCacheState() + cache.set("example.com", domainCacheSourceDirect, []string{"1.1.1.1"}, 1) + transportPolicyLoadDomainCacheState = func(path string, logf func(string, ...any)) domainCacheState { + return cache + } + + current := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c1"}, + } + next := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c2"}, + } + locks := TransportOwnerLockState{ + Items: []TransportOwnerLockRecord{ + {DestinationIP: "1.1.1.1", ClientID: "c1"}, + }, + } + + conflicts := detectTransportDestinationLockConflicts(current, next, locks) + if len(conflicts) != 1 { + t.Fatalf("expected 1 conflict for domain selector, got %#v", conflicts) + } + if conflicts[0].Type != "destination_lock" { + t.Fatalf("unexpected conflict type: %q", conflicts[0].Type) + } +} + +func TestDetectTransportDestinationLockConflictsWildcardDomainFromCache(t *testing.T) { + prevLoader := transportPolicyLoadDomainCacheState + t.Cleanup(func() { transportPolicyLoadDomainCacheState = prevLoader }) + + cache := newDomainCacheState() + cache.set("api.example.com", domainCacheSourceWildcard, []string{"2.2.2.2"}, 1) + cache.set("cdn.example.com", domainCacheSourceDirect, []string{"2.2.2.3"}, 1) + transportPolicyLoadDomainCacheState = func(path string, logf func(string, ...any)) domainCacheState { + return cache + } + + current := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "*.example.com", ClientID: "c1"}, + } + next := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "*.example.com", ClientID: "c2"}, + } + locks := TransportOwnerLockState{ + Items: []TransportOwnerLockRecord{ + {DestinationIP: "2.2.2.2", ClientID: "c1"}, + }, + } + + conflicts := detectTransportDestinationLockConflicts(current, next, locks) + if len(conflicts) != 1 { + t.Fatalf("expected wildcard conflict, got %#v", conflicts) + } +} + +func TestDetectTransportDestinationLockConflictsSkipsDomainWithoutCacheHit(t *testing.T) { + prevLoader := transportPolicyLoadDomainCacheState + t.Cleanup(func() { transportPolicyLoadDomainCacheState = prevLoader }) + + cache := newDomainCacheState() + cache.set("example.com", domainCacheSourceDirect, []string{"9.9.9.9"}, 1) + transportPolicyLoadDomainCacheState = func(path string, logf func(string, ...any)) domainCacheState { + return cache + } + + current := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c1"}, + } + next := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c2"}, + } + locks := TransportOwnerLockState{ + Items: []TransportOwnerLockRecord{ + {DestinationIP: "1.1.1.1", ClientID: "c1"}, + }, + } + + conflicts := detectTransportDestinationLockConflicts(current, next, locks) + if len(conflicts) != 0 { + t.Fatalf("expected no conflicts for domain selector without cache hit, got %#v", conflicts) + } +} diff --git a/selective-vpn-api/app/transport_policy_owner_lock.go b/selective-vpn-api/app/transport_policy_owner_lock.go new file mode 100644 index 0000000..8f07144 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_owner_lock.go @@ -0,0 +1,59 @@ +package app + +import "strings" + +func isTransportClientOwnerLockActive(st TransportClientStatus) bool { + switch st { + case TransportClientUp, TransportClientStarting, TransportClientDegraded: + return true + default: + return false + } +} + +func detectTransportOwnerLockConflicts(current, next []TransportPolicyIntent, clients []TransportClient) []TransportConflictRecord { + currentOwners := map[string]string{} + for _, raw := range current { + norm, key, _, err := normalizeTransportIntent(raw) + if err != nil || key == "" || strings.TrimSpace(norm.ClientID) == "" { + continue + } + currentOwners[key] = norm.ClientID + } + if len(currentOwners) == 0 { + return nil + } + + activeByClientID := map[string]bool{} + for _, client := range clients { + id := strings.TrimSpace(client.ID) + if id == "" { + continue + } + activeByClientID[id] = isTransportClientOwnerLockActive(client.Status) + } + + conflicts := make([]TransportConflictRecord, 0, 4) + for _, raw := range next { + norm, key, _, err := normalizeTransportIntent(raw) + if err != nil || key == "" || strings.TrimSpace(norm.ClientID) == "" { + continue + } + prevOwner, exists := currentOwners[key] + if !exists || prevOwner == norm.ClientID { + continue + } + if !activeByClientID[prevOwner] { + continue + } + conflicts = append(conflicts, TransportConflictRecord{ + Key: key, + Type: "owner_lock", + Severity: "block", + Owners: []string{prevOwner, norm.ClientID}, + Reason: "selector is locked to active owner; stop previous owner before switching", + SuggestedResolution: "disconnect/stop current owner client, then validate/apply switch again", + }) + } + return dedupeTransportConflicts(conflicts) +} diff --git a/selective-vpn-api/app/transport_policy_owner_lock_state.go b/selective-vpn-api/app/transport_policy_owner_lock_state.go new file mode 100644 index 0000000..8c5f0f1 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_owner_lock_state.go @@ -0,0 +1,30 @@ +package app + +import "strings" + +func attachTransportOwnershipLockState(items []TransportOwnershipRecord, clients []TransportClient) (out []TransportOwnershipRecord, lockCount int) { + if len(items) == 0 { + return nil, 0 + } + statusByClientID := map[string]TransportClientStatus{} + for _, client := range clients { + id := strings.TrimSpace(client.ID) + if id == "" { + continue + } + statusByClientID[id] = client.Status + } + + out = make([]TransportOwnershipRecord, len(items)) + for i := range items { + rec := items[i] + st := statusByClientID[strings.TrimSpace(rec.ClientID)] + rec.OwnerStatus = string(st) + rec.LockActive = isTransportClientOwnerLockActive(st) + if rec.LockActive { + lockCount++ + } + out[i] = rec + } + return out, lockCount +} diff --git a/selective-vpn-api/app/transport_policy_owner_lock_state_test.go b/selective-vpn-api/app/transport_policy_owner_lock_state_test.go new file mode 100644 index 0000000..eec8cd8 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_owner_lock_state_test.go @@ -0,0 +1,33 @@ +package app + +import "testing" + +func TestAttachTransportOwnershipLockState(t *testing.T) { + items := []TransportOwnershipRecord{ + {Key: "domain:a.example", ClientID: "c1"}, + {Key: "domain:b.example", ClientID: "c2"}, + {Key: "domain:c.example", ClientID: "c3"}, + } + clients := []TransportClient{ + {ID: "c1", Status: TransportClientUp}, + {ID: "c2", Status: TransportClientDown}, + {ID: "c3", Status: TransportClientDegraded}, + } + + annotated, lockCount := attachTransportOwnershipLockState(items, clients) + if len(annotated) != len(items) { + t.Fatalf("unexpected items len: %d", len(annotated)) + } + if lockCount != 2 { + t.Fatalf("expected lockCount=2, got %d", lockCount) + } + if !annotated[0].LockActive || annotated[0].OwnerStatus != "up" { + t.Fatalf("unexpected c1 state: %#v", annotated[0]) + } + if annotated[1].LockActive || annotated[1].OwnerStatus != "down" { + t.Fatalf("unexpected c2 state: %#v", annotated[1]) + } + if !annotated[2].LockActive || annotated[2].OwnerStatus != "degraded" { + t.Fatalf("unexpected c3 state: %#v", annotated[2]) + } +} diff --git a/selective-vpn-api/app/transport_policy_owner_lock_test.go b/selective-vpn-api/app/transport_policy_owner_lock_test.go new file mode 100644 index 0000000..3165f67 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_owner_lock_test.go @@ -0,0 +1,60 @@ +package app + +import "testing" + +func TestDetectTransportOwnerLockConflicts(t *testing.T) { + current := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c1"}, + } + next := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c2"}, + } + clients := []TransportClient{ + {ID: "c1", Status: TransportClientUp}, + {ID: "c2", Status: TransportClientUp}, + } + + conflicts := detectTransportOwnerLockConflicts(current, next, clients) + if len(conflicts) != 1 { + t.Fatalf("expected 1 conflict, got %d (%#v)", len(conflicts), conflicts) + } + if conflicts[0].Type != "owner_lock" { + t.Fatalf("unexpected conflict type: %q", conflicts[0].Type) + } + if conflicts[0].Severity != "block" { + t.Fatalf("unexpected conflict severity: %q", conflicts[0].Severity) + } +} + +func TestDetectTransportOwnerLockConflictsAllowsSwitchWhenPreviousOwnerDown(t *testing.T) { + current := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c1"}, + } + next := []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c2"}, + } + clients := []TransportClient{ + {ID: "c1", Status: TransportClientDown}, + {ID: "c2", Status: TransportClientUp}, + } + + conflicts := detectTransportOwnerLockConflicts(current, next, clients) + if len(conflicts) != 0 { + t.Fatalf("expected no conflicts, got %#v", conflicts) + } +} + +func TestIsTransportClientOwnerLockActive(t *testing.T) { + if !isTransportClientOwnerLockActive(TransportClientUp) { + t.Fatalf("up must be active") + } + if !isTransportClientOwnerLockActive(TransportClientStarting) { + t.Fatalf("starting must be active") + } + if !isTransportClientOwnerLockActive(TransportClientDegraded) { + t.Fatalf("degraded must be active") + } + if isTransportClientOwnerLockActive(TransportClientDown) { + t.Fatalf("down must be inactive") + } +} diff --git a/selective-vpn-api/app/transport_policy_ownership.go b/selective-vpn-api/app/transport_policy_ownership.go new file mode 100644 index 0000000..5abef08 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_ownership.go @@ -0,0 +1,103 @@ +package app + +import ( + "fmt" + "sort" + "strings" + "time" +) + +func detectTransportOwnerSwitchConflicts(current, next []TransportPolicyIntent) []TransportConflictRecord { + curOwners := map[string]string{} + for _, raw := range current { + norm, key, _, err := normalizeTransportIntent(raw) + if err != nil || key == "" || strings.TrimSpace(norm.ClientID) == "" { + continue + } + curOwners[key] = norm.ClientID + } + if len(curOwners) == 0 { + return nil + } + + conflicts := make([]TransportConflictRecord, 0, 4) + for _, raw := range next { + norm, key, _, err := normalizeTransportIntent(raw) + if err != nil || key == "" || strings.TrimSpace(norm.ClientID) == "" { + continue + } + prevOwner, exists := curOwners[key] + if !exists || prevOwner == norm.ClientID { + continue + } + conflicts = append(conflicts, TransportConflictRecord{ + Key: key, + Type: "owner_switch", + Severity: "block", + Owners: []string{prevOwner, norm.ClientID}, + Reason: fmt.Sprintf( + "selector owner switch %s -> %s requires explicit override", + prevOwner, + norm.ClientID, + ), + SuggestedResolution: "use force_override + confirm token to switch owner", + }) + } + return dedupeTransportConflicts(conflicts) +} + +func transportOwnershipNeedsRebuild(policyRevision int64, owners TransportOwnershipState, planDigest string) bool { + if owners.PolicyRevision != policyRevision { + return true + } + expected := strings.TrimSpace(planDigest) + if expected == "" { + return false + } + if strings.TrimSpace(owners.PlanDigest) != expected { + return true + } + return false +} + +func buildTransportOwnershipStateFromPlan(plan TransportPolicyCompilePlan, policyRevision int64) TransportOwnershipState { + now := time.Now().UTC().Format(time.RFC3339) + items := make([]TransportOwnershipRecord, 0, plan.RuleCount) + seen := map[string]struct{}{} + for _, iface := range plan.Interfaces { + for _, rule := range iface.Rules { + key := strings.TrimSpace(rule.SelectorType) + ":" + strings.TrimSpace(rule.SelectorValue) + if key == ":" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + items = append(items, TransportOwnershipRecord{ + Key: key, + SelectorType: strings.TrimSpace(rule.SelectorType), + SelectorValue: strings.TrimSpace(rule.SelectorValue), + ClientID: strings.TrimSpace(rule.ClientID), + ClientKind: strings.TrimSpace(rule.ClientKind), + OwnerScope: strings.TrimSpace(rule.OwnerScope), + IfaceID: strings.TrimSpace(iface.IfaceID), + RoutingTable: strings.TrimSpace(iface.RoutingTable), + MarkHex: strings.TrimSpace(rule.MarkHex), + PriorityBase: rule.PriorityBase, + Mode: strings.TrimSpace(rule.Mode), + Priority: rule.Priority, + UpdatedAt: now, + }) + } + } + sort.Slice(items, func(i, j int) bool { return items[i].Key < items[j].Key }) + return TransportOwnershipState{ + Version: transportStateVersion, + UpdatedAt: now, + PolicyRevision: policyRevision, + PlanDigest: digestTransportPolicyCompilePlan(plan), + Count: len(items), + Items: items, + } +} diff --git a/selective-vpn-api/app/transport_policy_ownership_test.go b/selective-vpn-api/app/transport_policy_ownership_test.go new file mode 100644 index 0000000..870b29a --- /dev/null +++ b/selective-vpn-api/app/transport_policy_ownership_test.go @@ -0,0 +1,78 @@ +package app + +import "testing" + +func TestTransportOwnershipNeedsRebuildByPlanDigest(t *testing.T) { + owners := TransportOwnershipState{ + PolicyRevision: 11, + PlanDigest: "digest-old", + } + if !transportOwnershipNeedsRebuild(11, owners, "digest-new") { + t.Fatalf("expected rebuild on plan digest mismatch") + } +} + +func TestTransportOwnershipNeedsRebuildByPolicyRevision(t *testing.T) { + owners := TransportOwnershipState{ + PolicyRevision: 10, + PlanDigest: "digest-same", + } + if !transportOwnershipNeedsRebuild(11, owners, "digest-same") { + t.Fatalf("expected rebuild on policy revision mismatch") + } +} + +func TestTransportOwnershipNeedsRebuildNoDigestNoRebuild(t *testing.T) { + owners := TransportOwnershipState{ + PolicyRevision: 11, + PlanDigest: "digest-existing", + } + if transportOwnershipNeedsRebuild(11, owners, "") { + t.Fatalf("did not expect rebuild when expected digest is empty") + } +} + +func TestBuildTransportOwnershipStateFromPlanIncludesOwnerScopeAndDigest(t *testing.T) { + plan := TransportPolicyCompilePlan{ + PolicyRevision: 23, + Interfaces: []TransportPolicyCompileInterface{ + { + IfaceID: "edge-a", + RoutingTable: "agvpn_if_edge_a", + RuntimeIface: "tun99", + RuleCount: 1, + Rules: []TransportPolicyCompileRule{ + { + SelectorType: "domain", + SelectorValue: "example.com", + ClientID: "sg-a", + ClientKind: "singbox", + OwnerScope: "edge_a_sg_a", + NftSet: "agvpn_pi_edge_a_sg_a_domain", + MarkHex: "0x121", + PriorityBase: 13300, + Mode: "strict", + Priority: 100, + }, + }, + }, + }, + } + + st := buildTransportOwnershipStateFromPlan(plan, 23) + if st.PolicyRevision != 23 { + t.Fatalf("unexpected policy revision: %d", st.PolicyRevision) + } + if st.PlanDigest == "" { + t.Fatalf("expected non-empty plan digest") + } + if st.PlanDigest != digestTransportPolicyCompilePlan(plan) { + t.Fatalf("unexpected plan digest: got=%q want=%q", st.PlanDigest, digestTransportPolicyCompilePlan(plan)) + } + if len(st.Items) != 1 { + t.Fatalf("unexpected ownership item count: %d", len(st.Items)) + } + if st.Items[0].OwnerScope != "edge_a_sg_a" { + t.Fatalf("unexpected owner_scope: %#v", st.Items[0]) + } +} diff --git a/selective-vpn-api/app/transport_policy_runtime_state.go b/selective-vpn-api/app/transport_policy_runtime_state.go new file mode 100644 index 0000000..822c75a --- /dev/null +++ b/selective-vpn-api/app/transport_policy_runtime_state.go @@ -0,0 +1,91 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +var ( + transportPolicyRuntimeStatePath = transportPolicyRuntimePath + transportPolicyRuntimeSnapPath = transportPolicyRuntimeSnap +) + +type transportPolicyRuntimeState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + PolicyRevision int64 `json:"policy_revision,omitempty"` + ApplyID string `json:"apply_id,omitempty"` + InterfaceCount int `json:"interface_count"` + RuleCount int `json:"rule_count"` + Interfaces []TransportPolicyCompileInterface `json:"interfaces,omitempty"` +} + +func loadTransportPolicyRuntimeState() transportPolicyRuntimeState { + st := transportPolicyRuntimeState{Version: transportStateVersion} + data, err := os.ReadFile(transportPolicyRuntimeStatePath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return transportPolicyRuntimeState{Version: transportStateVersion} + } + if st.Version == 0 { + st.Version = transportStateVersion + } + if st.Interfaces == nil { + st.Interfaces = nil + } + return st +} + +func saveTransportPolicyRuntimeState(st transportPolicyRuntimeState) error { + st.Version = transportStateVersion + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportPolicyRuntimeStatePath), 0o755); err != nil { + return err + } + tmp := transportPolicyRuntimeStatePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportPolicyRuntimeStatePath) +} + +func saveTransportPolicyRuntimeSnapshot(st transportPolicyRuntimeState) error { + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportPolicyRuntimeSnapPath), 0o755); err != nil { + return err + } + tmp := transportPolicyRuntimeSnapPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportPolicyRuntimeSnapPath) +} + +func loadTransportPolicyRuntimeSnapshot() (transportPolicyRuntimeState, bool) { + data, err := os.ReadFile(transportPolicyRuntimeSnapPath) + if err != nil { + return transportPolicyRuntimeState{}, false + } + var st transportPolicyRuntimeState + if err := json.Unmarshal(data, &st); err != nil { + return transportPolicyRuntimeState{}, false + } + if st.Version == 0 { + st.Version = transportStateVersion + } + if st.Interfaces == nil { + st.Interfaces = nil + } + return st, true +} diff --git a/selective-vpn-api/app/transport_policy_targets.go b/selective-vpn-api/app/transport_policy_targets.go new file mode 100644 index 0000000..023cf21 --- /dev/null +++ b/selective-vpn-api/app/transport_policy_targets.go @@ -0,0 +1,164 @@ +package app + +import ( + "strings" + "time" +) + +const ( + transportPolicyTargetAdGuardID = "adguardvpn" + transportPolicyTargetAdGuardIfaceID = transportDefaultIfaceID + transportPolicyTargetAdGuardTable = "agvpn" +) + +var transportPolicyTargetAdGuardAllowedActions = []string{"start", "stop", "restart"} + +func isTransportPolicyVirtualClientID(id string) bool { + return sanitizeID(id) == transportPolicyTargetAdGuardID +} + +func isTransportPolicyVirtualClient(client TransportClient) bool { + if isTransportPolicyVirtualClientID(client.ID) { + return true + } + return strings.EqualFold(strings.TrimSpace(string(client.Kind)), transportPolicyTargetAdGuardID) +} + +func resolveTransportPolicyVirtualClient(id string) (TransportClient, bool) { + if !isTransportPolicyVirtualClientID(id) { + return TransportClient{}, false + } + return buildTransportPolicyAdGuardTarget() +} + +func transportPolicyPersistableClients(items []TransportClient) []TransportClient { + if len(items) == 0 { + return nil + } + out := make([]TransportClient, 0, len(items)) + for _, it := range items { + if isTransportPolicyVirtualClient(it) { + continue + } + out = append(out, it) + } + return out +} + +func transportPolicyClientsWithVirtualTargets(base []TransportClient) []TransportClient { + out := append([]TransportClient(nil), base...) + for i := range out { + out[i].ID = sanitizeID(out[i].ID) + } + if adg, ok := buildTransportPolicyAdGuardTarget(); ok { + if idx := findTransportClientIndex(out, adg.ID); idx >= 0 { + out[idx] = adg + } else { + out = append(out, adg) + } + } + return out +} + +func buildTransportPolicyAdGuardTarget() (TransportClient, bool) { + now := time.Now().UTC() + + stdout, _, _, err := runCommandTimeout(1500*time.Millisecond, "systemctl", "is-active", adgvpnUnit) + unitState := strings.TrimSpace(stdout) + if err != nil && unitState == "" { + return TransportClient{}, false + } + + word, raw := parseAutoloopStatus(tailFile(autoloopLogPath, 120)) + return buildTransportPolicyAdGuardTargetFromObservation(unitState, word, raw, now), true +} + +func buildTransportPolicyAdGuardTargetFromObservation(unitState, statusWord, raw string, observedAt time.Time) TransportClient { + ts := observedAt.UTC().Format(time.RFC3339) + status := adGuardPolicyTargetStatus(unitState, statusWord) + iface := adGuardPolicyTargetIface(raw, status) + + lastError := "" + if status == TransportClientDown || status == TransportClientDegraded { + msg := strings.TrimSpace(unitState) + if msg == "" { + msg = strings.TrimSpace(statusWord) + } + lastError = msg + } + + return TransportClient{ + ID: transportPolicyTargetAdGuardID, + Name: "AdGuard VPN", + Kind: TransportClientKind(transportPolicyTargetAdGuardID), + Enabled: true, + Status: status, + IfaceID: transportPolicyTargetAdGuardIfaceID, + Iface: iface, + RoutingTable: transportPolicyTargetAdGuardTable, + Capabilities: []string{"vpn", "systemd", "autoloop"}, + Health: TransportClientHealth{ + LastCheck: ts, + LastError: lastError, + }, + Runtime: TransportClientRuntime{ + Backend: "adguard", + AllowedActions: append([]string(nil), transportPolicyTargetAdGuardAllowedActions...), + LastAction: "observe", + LastActionAt: ts, + }, + UpdatedAt: ts, + } +} + +func adGuardPolicyTargetStatus(unitState, statusWord string) TransportClientStatus { + word := strings.ToUpper(strings.TrimSpace(statusWord)) + switch word { + case "CONNECTED": + return TransportClientUp + case "RECONNECTING": + return TransportClientStarting + case "DISCONNECTED": + return TransportClientDown + case "ERROR": + return TransportClientDegraded + } + + state := strings.ToLower(strings.TrimSpace(unitState)) + switch state { + case "active": + return TransportClientUp + case "activating", "reloading": + return TransportClientStarting + case "failed", "inactive", "deactivating": + return TransportClientDown + default: + return TransportClientDown + } +} + +func adGuardPolicyTargetIface(raw string, status TransportClientStatus) string { + v := strings.TrimSpace(raw) + if v != "" { + low := strings.ToLower(v) + needle := "running on " + if idx := strings.LastIndex(low, needle); idx >= 0 { + tail := strings.TrimSpace(v[idx+len(needle):]) + if tail != "" { + iface := strings.Trim(tail, ".,;:()[]{}") + if parts := strings.Fields(iface); len(parts) > 0 { + if strings.TrimSpace(parts[0]) != "" { + return strings.TrimSpace(parts[0]) + } + } + } + } + } + if iface, _ := resolveTrafficIface(loadTrafficModeState().PreferredIface); strings.TrimSpace(iface) != "" { + return strings.TrimSpace(iface) + } + if status == TransportClientUp || status == TransportClientStarting { + return "tun0" + } + return "" +} diff --git a/selective-vpn-api/app/transport_policy_validate.go b/selective-vpn-api/app/transport_policy_validate.go new file mode 100644 index 0000000..f2e419b --- /dev/null +++ b/selective-vpn-api/app/transport_policy_validate.go @@ -0,0 +1,126 @@ +package app + +import ( + "fmt" + "sort" +) + +func validateTransportPolicy(next []TransportPolicyIntent, current []TransportPolicyIntent, clients []TransportClient) transportValidationResult { + clientByID := map[string]TransportClient{} + for _, c := range clients { + clientByID[c.ID] = c + } + + type ownerSet map[string]struct{} + ownership := map[string]ownerSet{} + seenSameOwner := map[string]int{} + cidrs := make([]cidrIntent, 0) + conflicts := make([]TransportConflictRecord, 0) + block := 0 + warn := 0 + + normalized := make([]TransportPolicyIntent, 0, len(next)) + for i, raw := range next { + norm, key, pfx, err := normalizeTransportIntent(raw) + if err != nil { + conflicts = append(conflicts, TransportConflictRecord{ + Key: fmt.Sprintf("intent:%d", i), + Type: "invalid_intent", + Severity: "block", + Reason: err.Error(), + SuggestedResolution: "fix selector/client fields", + }) + block++ + continue + } + if _, ok := clientByID[norm.ClientID]; !ok { + conflicts = append(conflicts, TransportConflictRecord{ + Key: key, + Type: "unknown_client", + Severity: "block", + Owners: []string{norm.ClientID}, + Reason: "client not found", + SuggestedResolution: "create client or fix client_id", + }) + block++ + continue + } + + if ownership[key] == nil { + ownership[key] = ownerSet{} + } + ownership[key][norm.ClientID] = struct{}{} + seenSameOwner[key+"|"+norm.ClientID]++ + if seenSameOwner[key+"|"+norm.ClientID] > 1 { + conflicts = append(conflicts, TransportConflictRecord{ + Key: key, + Type: "duplicate_intent", + Severity: "warn", + Owners: []string{norm.ClientID}, + Reason: "duplicate selector for same client", + SuggestedResolution: "deduplicate identical intents", + }) + warn++ + } + if pfx.IsValid() { + cidrs = append(cidrs, cidrIntent{ClientID: norm.ClientID, Prefix: pfx, Key: key}) + } + normalized = append(normalized, norm) + } + + for key, owners := range ownership { + if len(owners) <= 1 { + continue + } + own := make([]string, 0, len(owners)) + for id := range owners { + own = append(own, id) + } + sort.Strings(own) + conflicts = append(conflicts, TransportConflictRecord{ + Key: key, + Type: "ownership", + Severity: "block", + Owners: own, + Reason: "selector is assigned to multiple clients", + SuggestedResolution: "keep exactly one owner for selector", + }) + block++ + } + + for i := 0; i < len(cidrs); i++ { + for j := i + 1; j < len(cidrs); j++ { + a, b := cidrs[i], cidrs[j] + if a.ClientID == b.ClientID { + continue + } + if !prefixOverlap(a.Prefix, b.Prefix) { + continue + } + key := "cidr_overlap:" + a.Prefix.String() + "|" + b.Prefix.String() + conflicts = append(conflicts, TransportConflictRecord{ + Key: key, + Type: "cidr_overlap", + Severity: "block", + Owners: []string{a.ClientID, b.ClientID}, + Reason: "CIDR selectors overlap across different clients", + SuggestedResolution: "split CIDR ranges or keep one owner", + }) + block++ + } + } + + conflicts = dedupeTransportConflicts(conflicts) + diff := computeTransportPolicyDiff(current, normalized) + + return transportValidationResult{ + Normalized: normalized, + Conflicts: conflicts, + Summary: TransportPolicyValidateSummary{ + BlockCount: block, + WarnCount: warn, + }, + Diff: diff, + Valid: block == 0, + } +} diff --git a/selective-vpn-api/app/transport_policy_validate_diff.go b/selective-vpn-api/app/transport_policy_validate_diff.go new file mode 100644 index 0000000..104106a --- /dev/null +++ b/selective-vpn-api/app/transport_policy_validate_diff.go @@ -0,0 +1,81 @@ +package app + +import ( + "sort" + "strings" +) + +func dedupeTransportConflicts(in []TransportConflictRecord) []TransportConflictRecord { + if len(in) <= 1 { + return in + } + seen := map[string]struct{}{} + out := make([]TransportConflictRecord, 0, len(in)) + for _, it := range in { + key := it.Severity + "|" + it.Type + "|" + it.Key + "|" + strings.Join(it.Owners, ",") + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, it) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Severity != out[j].Severity { + return out[i].Severity > out[j].Severity + } + if out[i].Type != out[j].Type { + return out[i].Type < out[j].Type + } + return out[i].Key < out[j].Key + }) + return out +} + +func computeTransportPolicyDiff(current, next []TransportPolicyIntent) TransportPolicyDiff { + cur := map[string]TransportPolicyIntent{} + for _, it := range current { + n, key, _, err := normalizeTransportIntent(it) + if err != nil || key == "" { + continue + } + cur[key] = n + } + nxt := map[string]TransportPolicyIntent{} + for _, it := range next { + n, key, _, err := normalizeTransportIntent(it) + if err != nil || key == "" { + continue + } + nxt[key] = n + } + diff := TransportPolicyDiff{} + for k, n := range nxt { + c, ok := cur[k] + if !ok { + diff.Added++ + continue + } + if c.ClientID != n.ClientID || c.Mode != n.Mode || c.Priority != n.Priority { + diff.Changed++ + } + } + for k := range cur { + if _, ok := nxt[k]; !ok { + diff.Removed++ + } + } + return diff +} + +func summarizeTransportConflicts(items []TransportConflictRecord) TransportPolicyValidateSummary { + s := TransportPolicyValidateSummary{} + for _, it := range items { + switch strings.ToLower(strings.TrimSpace(it.Severity)) { + case "block": + s.BlockCount++ + case "warn": + s.WarnCount++ + } + } + return s +} diff --git a/selective-vpn-api/app/transport_policy_validate_normalize.go b/selective-vpn-api/app/transport_policy_validate_normalize.go new file mode 100644 index 0000000..99f17cc --- /dev/null +++ b/selective-vpn-api/app/transport_policy_validate_normalize.go @@ -0,0 +1,74 @@ +package app + +import ( + "fmt" + "net/netip" + "strings" +) + +func normalizeTransportIntent(in TransportPolicyIntent) (TransportPolicyIntent, string, netip.Prefix, error) { + out := TransportPolicyIntent{ + SelectorType: strings.ToLower(strings.TrimSpace(in.SelectorType)), + SelectorValue: strings.TrimSpace(in.SelectorValue), + ClientID: sanitizeID(in.ClientID), + Priority: in.Priority, + Mode: strings.ToLower(strings.TrimSpace(in.Mode)), + } + if out.ClientID == "" { + return out, "", netip.Prefix{}, fmt.Errorf("missing client_id") + } + if out.Priority <= 0 { + out.Priority = 100 + } + if out.Mode == "" { + out.Mode = "strict" + } + if out.Mode != "strict" && out.Mode != "fallback" { + return out, "", netip.Prefix{}, fmt.Errorf("mode must be strict|fallback") + } + switch out.SelectorType { + case "domain": + out.SelectorValue = strings.ToLower(out.SelectorValue) + case "cidr": + pfx, err := parseIntentCIDR(out.SelectorValue) + if err != nil { + return out, "", netip.Prefix{}, err + } + out.SelectorValue = pfx.String() + return out, out.SelectorType + ":" + out.SelectorValue, pfx, nil + case "app_key", "cgroup", "uid": + // keep as-is + default: + return out, "", netip.Prefix{}, fmt.Errorf("selector_type must be domain|cidr|app_key|cgroup|uid") + } + if out.SelectorValue == "" { + return out, "", netip.Prefix{}, fmt.Errorf("selector_value is empty") + } + return out, out.SelectorType + ":" + out.SelectorValue, netip.Prefix{}, nil +} + +func parseIntentCIDR(raw string) (netip.Prefix, error) { + v := strings.TrimSpace(raw) + if v == "" { + return netip.Prefix{}, fmt.Errorf("selector_value is empty") + } + if strings.Contains(v, "/") { + pfx, err := netip.ParsePrefix(v) + if err != nil || !pfx.Addr().Is4() { + return netip.Prefix{}, fmt.Errorf("invalid cidr: %q", raw) + } + return pfx.Masked(), nil + } + ip, err := netip.ParseAddr(v) + if err != nil || !ip.Is4() { + return netip.Prefix{}, fmt.Errorf("invalid cidr: %q", raw) + } + return netip.PrefixFrom(ip, 32), nil +} + +func prefixOverlap(a, b netip.Prefix) bool { + if !a.IsValid() || !b.IsValid() { + return false + } + return a.Contains(b.Addr()) || b.Contains(a.Addr()) +} diff --git a/selective-vpn-api/app/transport_runtime_observability.go b/selective-vpn-api/app/transport_runtime_observability.go new file mode 100644 index 0000000..b6c932a --- /dev/null +++ b/selective-vpn-api/app/transport_runtime_observability.go @@ -0,0 +1,482 @@ +package app + +import ( + "sort" + "strings" + "time" +) + +type transportRuntimeObservabilityEgressLookup func(clientID string) EgressIdentity + +func transportRuntimeObservabilitySnapshotResponse(now time.Time) TransportRuntimeObservabilityResponse { + transportMu.Lock() + clientsState := loadTransportClientsState() + ifacesState := loadTransportInterfacesState() + policy := loadTransportPolicyState() + plan := loadTransportPolicyCompilePlan() + ifacesSnapshot := captureTransportInterfacesStateSnapshot(clientsState, ifacesState) + planSnapshot := captureTransportPolicyPlanStateSnapshot(policy, plan) + transportMu.Unlock() + + normIfaces, changed := normalizeTransportInterfacesState(ifacesState, clientsState.Items) + if changed { + if err := saveTransportInterfacesIfSnapshotCurrent(ifacesSnapshot, normIfaces); err != nil { + return TransportRuntimeObservabilityResponse{ + OK: false, + Code: "TRANSPORT_INTERFACES_SAVE_FAILED", + Message: "interfaces state save failed: " + err.Error(), + } + } + } + + policyTargets := transportPolicyClientsWithVirtualTargets(clientsState.Items) + nextPlan, planChanged := compileTransportPolicyPlanForSnapshot(policy, policyTargets, plan) + if planChanged { + _ = saveTransportPlanIfSnapshotCurrent(planSnapshot, nextPlan) + } + return buildTransportRuntimeObservabilityResponse(now, normIfaces.Items, policyTargets, nextPlan) +} + +func buildTransportRuntimeObservabilityResponse( + now time.Time, + ifaces []TransportInterface, + clients []TransportClient, + plan TransportPolicyCompilePlan, +) TransportRuntimeObservabilityResponse { + items := buildTransportRuntimeObservabilityItems( + ifaces, + clients, + plan, + transportRuntimeObservabilityEgressSnapshot, + now, + ) + return TransportRuntimeObservabilityResponse{ + OK: true, + Message: "ok", + GeneratedAt: now.Format(time.RFC3339), + Count: len(items), + Items: items, + } +} + +func buildTransportRuntimeObservabilityItems( + ifaces []TransportInterface, + clients []TransportClient, + plan TransportPolicyCompilePlan, + egressLookup transportRuntimeObservabilityEgressLookup, + now time.Time, +) []TransportRuntimeObservabilityItem { + realClients, virtualClients := splitTransportRuntimeObservabilityClients(clients) + + baseItems := buildTransportInterfaceItems(ifaces, realClients) + if len(virtualClients) > 0 { + baseItems = appendTransportVirtualInterfaceItems(baseItems, virtualClients) + } + if len(baseItems) == 0 { + return nil + } + + clientsByIface := map[string][]TransportClient{} + for _, client := range realClients { + ifaceID := normalizeTransportIfaceID(client.IfaceID) + clientsByIface[ifaceID] = append(clientsByIface[ifaceID], client) + } + for ifaceID := range clientsByIface { + sort.Slice(clientsByIface[ifaceID], func(i, j int) bool { + return clientsByIface[ifaceID][i].ID < clientsByIface[ifaceID][j].ID + }) + } + + virtualByID := map[string]TransportClient{} + for _, client := range virtualClients { + id := sanitizeID(client.ID) + if id == "" { + continue + } + virtualByID[id] = client + } + + ruleCountByIface := map[string]int{} + ruleCountByClient := map[string]int{} + for _, iface := range plan.Interfaces { + ruleCountByIface[normalizeTransportIfaceID(iface.IfaceID)] = iface.RuleCount + for _, rule := range iface.Rules { + id := sanitizeID(rule.ClientID) + if id == "" { + continue + } + ruleCountByClient[id]++ + } + } + + out := make([]TransportRuntimeObservabilityItem, 0, len(baseItems)) + for _, base := range baseItems { + if virtual, ok := virtualByID[sanitizeID(base.ID)]; ok { + item := buildTransportRuntimeObservabilityVirtualItem(base, virtual, ruleCountByClient, egressLookup, now) + out = append(out, item) + continue + } + + members := clientsByIface[normalizeTransportIfaceID(base.ID)] + counters := buildTransportRuntimeObservabilityCounters(members) + counters.RuleCount = ruleCountByIface[normalizeTransportIfaceID(base.ID)] + + item := TransportRuntimeObservabilityItem{ + IfaceID: base.ID, + Name: strings.TrimSpace(base.Name), + Mode: base.Mode, + RuntimeIface: strings.TrimSpace(base.RuntimeIface), + ActiveIface: strings.TrimSpace(base.RuntimeIface), + NetnsName: strings.TrimSpace(base.NetnsName), + RoutingTable: strings.TrimSpace(base.RoutingTable), + ClientIDs: append([]string(nil), base.ClientIDs...), + Status: string(aggregateTransportRuntimeObservabilityStatus(counters)), + Counters: counters, + EngineCounts: buildTransportRuntimeObservabilityEngineCounts(members), + } + + primary, ok := selectTransportRuntimeObservabilityPrimaryClient(members) + if ok { + item.ClientID = primary.ID + if item.RuntimeIface == "" { + item.RuntimeIface = strings.TrimSpace(primary.Iface) + } + if active := strings.TrimSpace(primary.Iface); active != "" { + item.ActiveIface = active + } + if item.RoutingTable == "" { + item.RoutingTable = strings.TrimSpace(primary.RoutingTable) + } + if item.NetnsName == "" && transportNetnsEnabled(primary) { + item.NetnsName = transportNetnsName(primary) + } + item.LatencyMS = primary.Health.LatencyMS + item.LastCheck = strings.TrimSpace(primary.Health.LastCheck) + item.Egress = lookupTransportRuntimeObservabilityEgress(egressLookup, primary.ID) + } + + if errClient, ok := selectTransportRuntimeObservabilityErrorClient(members); ok { + item.LastError = transportRuntimeObservabilityClientError(errClient, now) + if item.LastCheck == "" { + item.LastCheck = transportRuntimeObservabilityClientLastCheck(errClient, now) + } + } + if item.LastCheck == "" && ok { + item.LastCheck = transportRuntimeObservabilityClientLastCheck(primary, now) + } + out = append(out, item) + } + return out +} + +func splitTransportRuntimeObservabilityClients(clients []TransportClient) ([]TransportClient, []TransportClient) { + if len(clients) == 0 { + return nil, nil + } + realClients := make([]TransportClient, 0, len(clients)) + virtualClients := make([]TransportClient, 0, 1) + for _, client := range clients { + if isTransportPolicyVirtualClient(client) { + virtualClients = append(virtualClients, client) + continue + } + realClients = append(realClients, client) + } + sort.Slice(virtualClients, func(i, j int) bool { return virtualClients[i].ID < virtualClients[j].ID }) + return realClients, virtualClients +} + +func buildTransportRuntimeObservabilityVirtualItem( + base TransportInterfaceItem, + virtual TransportClient, + ruleCountByClient map[string]int, + egressLookup transportRuntimeObservabilityEgressLookup, + now time.Time, +) TransportRuntimeObservabilityItem { + members := []TransportClient{virtual} + counters := buildTransportRuntimeObservabilityCounters(members) + counters.RuleCount = ruleCountByClient[sanitizeID(virtual.ID)] + + runtimeIface := strings.TrimSpace(base.RuntimeIface) + if runtimeIface == "" { + runtimeIface = strings.TrimSpace(virtual.Iface) + } + activeIface := strings.TrimSpace(virtual.Iface) + if activeIface == "" { + activeIface = runtimeIface + } + + item := TransportRuntimeObservabilityItem{ + IfaceID: base.ID, + Name: strings.TrimSpace(base.Name), + Mode: base.Mode, + RuntimeIface: runtimeIface, + ActiveIface: activeIface, + NetnsName: strings.TrimSpace(base.NetnsName), + RoutingTable: strings.TrimSpace(base.RoutingTable), + ClientID: virtual.ID, + ClientIDs: []string{virtual.ID}, + Status: string(normalizeTransportStatus(virtual.Status)), + LatencyMS: virtual.Health.LatencyMS, + LastError: transportRuntimeObservabilityClientError(virtual, now), + LastCheck: transportRuntimeObservabilityClientLastCheck(virtual, now), + Egress: lookupTransportRuntimeObservabilityEgress(egressLookup, virtual.ID), + Counters: counters, + EngineCounts: buildTransportRuntimeObservabilityEngineCounts(members), + } + if item.RoutingTable == "" { + item.RoutingTable = strings.TrimSpace(virtual.RoutingTable) + } + if item.Status == "" { + item.Status = string(aggregateTransportRuntimeObservabilityStatus(counters)) + } + return item +} + +func buildTransportRuntimeObservabilityCounters(clients []TransportClient) TransportRuntimeObservabilityCounters { + counters := TransportRuntimeObservabilityCounters{ + ClientCount: len(clients), + } + for _, client := range clients { + if client.Enabled { + counters.EnabledCount++ + } + switch normalizeTransportStatus(client.Status) { + case TransportClientUp: + counters.UpCount++ + case TransportClientStarting: + counters.StartingCount++ + case TransportClientDegraded: + counters.DegradedCount++ + default: + counters.DownCount++ + } + } + return counters +} + +func buildTransportRuntimeObservabilityEngineCounts(clients []TransportClient) []TransportRuntimeObservabilityEngineCounter { + if len(clients) == 0 { + return nil + } + type counts struct { + total int + up int + starting int + degraded int + down int + } + byKind := map[string]counts{} + for _, client := range clients { + kind := strings.TrimSpace(string(client.Kind)) + if kind == "" { + kind = "unknown" + } + cur := byKind[kind] + cur.total++ + switch normalizeTransportStatus(client.Status) { + case TransportClientUp: + cur.up++ + case TransportClientStarting: + cur.starting++ + case TransportClientDegraded: + cur.degraded++ + default: + cur.down++ + } + byKind[kind] = cur + } + kinds := make([]string, 0, len(byKind)) + for kind := range byKind { + kinds = append(kinds, kind) + } + sort.Strings(kinds) + out := make([]TransportRuntimeObservabilityEngineCounter, 0, len(kinds)) + for _, kind := range kinds { + cur := byKind[kind] + out = append(out, TransportRuntimeObservabilityEngineCounter{ + Kind: kind, + Count: cur.total, + UpCount: cur.up, + StartingCount: cur.starting, + DegradedCount: cur.degraded, + DownCount: cur.down, + }) + } + return out +} + +func aggregateTransportRuntimeObservabilityStatus(counters TransportRuntimeObservabilityCounters) TransportClientStatus { + switch { + case counters.DegradedCount > 0: + return TransportClientDegraded + case counters.UpCount > 0: + return TransportClientUp + case counters.StartingCount > 0: + return TransportClientStarting + default: + return TransportClientDown + } +} + +func selectTransportRuntimeObservabilityPrimaryClient(clients []TransportClient) (TransportClient, bool) { + var best TransportClient + found := false + for _, client := range clients { + if !found || preferTransportRuntimeObservabilityPrimaryClient(client, best) { + best = client + found = true + } + } + return best, found +} + +func preferTransportRuntimeObservabilityPrimaryClient(candidate, current TransportClient) bool { + candRank := transportRuntimeObservabilityPrimaryRank(candidate) + currRank := transportRuntimeObservabilityPrimaryRank(current) + if candRank != currRank { + return candRank < currRank + } + if preferTransportClient(candidate, current) { + return true + } + if preferTransportClient(current, candidate) { + return false + } + return candidate.ID < current.ID +} + +func transportRuntimeObservabilityPrimaryRank(client TransportClient) int { + switch normalizeTransportStatus(client.Status) { + case TransportClientUp: + return 0 + case TransportClientDegraded: + return 1 + case TransportClientStarting: + return 2 + default: + if client.Enabled { + return 3 + } + return 4 + } +} + +func selectTransportRuntimeObservabilityErrorClient(clients []TransportClient) (TransportClient, bool) { + var best TransportClient + found := false + for _, client := range clients { + if transportRuntimeObservabilityClientError(client, time.Time{}) == "" { + continue + } + if !found || preferTransportRuntimeObservabilityErrorClient(client, best) { + best = client + found = true + } + } + return best, found +} + +func preferTransportRuntimeObservabilityErrorClient(candidate, current TransportClient) bool { + candRank := transportRuntimeObservabilityErrorRank(candidate) + currRank := transportRuntimeObservabilityErrorRank(current) + if candRank != currRank { + return candRank < currRank + } + if preferTransportClient(candidate, current) { + return true + } + if preferTransportClient(current, candidate) { + return false + } + return candidate.ID < current.ID +} + +func transportRuntimeObservabilityErrorRank(client TransportClient) int { + switch normalizeTransportStatus(client.Status) { + case TransportClientDegraded: + return 0 + case TransportClientStarting: + return 1 + case TransportClientUp: + return 2 + default: + return 3 + } +} + +func transportRuntimeObservabilityClientError(client TransportClient, now time.Time) string { + if msg := strings.TrimSpace(client.Health.LastError); msg != "" { + return msg + } + runtime := transportRuntimeSnapshot(client, fallbackTransportRuntimeObservabilityNow(now)) + return strings.TrimSpace(runtime.LastError.Message) +} + +func transportRuntimeObservabilityClientLastCheck(client TransportClient, now time.Time) string { + if ts := strings.TrimSpace(client.Health.LastCheck); ts != "" { + return ts + } + runtime := transportRuntimeSnapshot(client, fallbackTransportRuntimeObservabilityNow(now)) + if ts := strings.TrimSpace(runtime.LastError.At); ts != "" { + return ts + } + if ts := strings.TrimSpace(client.UpdatedAt); ts != "" { + return ts + } + return "" +} + +func fallbackTransportRuntimeObservabilityNow(now time.Time) time.Time { + if !now.IsZero() { + return now + } + return time.Now().UTC() +} + +func lookupTransportRuntimeObservabilityEgress(lookup transportRuntimeObservabilityEgressLookup, clientID string) EgressIdentity { + id := sanitizeID(clientID) + if id == "" || lookup == nil { + return EgressIdentity{} + } + return lookup(id) +} + +func transportRuntimeObservabilityScopeForClientID(id string) string { + cid := sanitizeID(id) + if cid == "" { + return "" + } + if isTransportPolicyVirtualClientID(cid) { + return transportPolicyTargetAdGuardID + } + return "transport:" + cid +} + +func transportRuntimeObservabilityEgressSnapshot(clientID string) EgressIdentity { + id := sanitizeID(clientID) + if id == "" { + return EgressIdentity{} + } + scope := transportRuntimeObservabilityScopeForClientID(id) + if scope == "" { + return EgressIdentity{} + } + item, err := egressIdentitySWR.getSnapshot(scope, false) + if err == nil { + return item + } + source := "transport" + sourceID := id + if scope == transportPolicyTargetAdGuardID { + source = transportPolicyTargetAdGuardID + sourceID = transportPolicyTargetAdGuardID + } + return EgressIdentity{ + Scope: scope, + Source: source, + SourceID: sourceID, + Stale: true, + LastError: err.Error(), + } +} diff --git a/selective-vpn-api/app/transport_runtime_observability_events.go b/selective-vpn-api/app/transport_runtime_observability_events.go new file mode 100644 index 0000000..396a268 --- /dev/null +++ b/selective-vpn-api/app/transport_runtime_observability_events.go @@ -0,0 +1,83 @@ +package app + +import "time" + +func publishTransportRuntimeObservabilitySnapshotChanged(reason string, clientIDs []string, ifaceIDs []string) { + now := time.Now().UTC() + snapshot := transportRuntimeObservabilitySnapshotResponse(now) + if !snapshot.OK { + return + } + normalizedClientIDs := normalizeTransportRuntimeObservabilityClientIDs(clientIDs) + normalizedIfaceIDs := resolveTransportRuntimeObservabilityEventIfaceIDs(snapshot.Items, normalizedClientIDs, ifaceIDs) + events.push("transport_runtime_snapshot_changed", TransportRuntimeObservabilityChangedEvent{ + Reason: reason, + GeneratedAt: snapshot.GeneratedAt, + Count: snapshot.Count, + IfaceIDs: normalizedIfaceIDs, + ClientIDs: normalizedClientIDs, + Items: snapshot.Items, + }) +} + +func normalizeTransportRuntimeObservabilityClientIDs(clientIDs []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(clientIDs)) + for _, raw := range clientIDs { + id := sanitizeID(raw) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func resolveTransportRuntimeObservabilityEventIfaceIDs( + items []TransportRuntimeObservabilityItem, + clientIDs []string, + ifaceIDs []string, +) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(ifaceIDs)+len(clientIDs)) + add := func(raw string) { + id := normalizeTransportIfaceID(raw) + if id == "" { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + out = append(out, id) + } + + for _, raw := range ifaceIDs { + add(raw) + } + for _, clientID := range clientIDs { + for _, item := range items { + if item.ClientID == clientID { + add(item.IfaceID) + continue + } + for _, memberID := range item.ClientIDs { + if memberID == clientID { + add(item.IfaceID) + break + } + } + } + } + if len(out) > 0 { + return out + } + for _, item := range items { + add(item.IfaceID) + } + return out +} diff --git a/selective-vpn-api/app/transport_runtime_observability_events_test.go b/selective-vpn-api/app/transport_runtime_observability_events_test.go new file mode 100644 index 0000000..2675e40 --- /dev/null +++ b/selective-vpn-api/app/transport_runtime_observability_events_test.go @@ -0,0 +1,132 @@ +package app + +import "testing" + +func TestPublishTransportRuntimeObservabilitySnapshotChanged(t *testing.T) { + withTransportPolicyMutationTestPaths(t) + + prevEvents := events + events = newEventBus(32) + t.Cleanup(func() { + events = prevEvents + }) + + prevEgress := egressIdentitySWR + egressIdentitySWR = newEgressIdentityService(1) + t.Cleanup(func() { + egressIdentitySWR = prevEgress + }) + + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sb-one", + Kind: TransportClientSingBox, + Enabled: true, + IfaceID: "edge-a", + Iface: "tun-edge0", + RoutingTable: "agvpn_if_edge_a", + Status: TransportClientUp, + Health: TransportClientHealth{ + LastCheck: "2026-03-16T12:11:00Z", + LatencyMS: 44, + }, + }, + }, + }); err != nil { + t.Fatalf("save clients state: %v", err) + } + if err := saveTransportInterfacesState(transportInterfacesState{ + Version: transportStateVersion, + Items: []TransportInterface{ + { + ID: "edge-a", + Name: "Edge A", + Mode: TransportInterfaceModeDedicated, + RuntimeIface: "tun-edge", + }, + }, + }); err != nil { + t.Fatalf("save interfaces state: %v", err) + } + if err := saveTransportPolicyState(TransportPolicyState{ + Version: transportStateVersion, + Revision: 3, + Intents: []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.org", ClientID: "sb-one"}, + }, + }); err != nil { + t.Fatalf("save policy state: %v", err) + } + if err := saveTransportPolicyCompilePlan(TransportPolicyCompilePlan{ + PolicyRevision: 3, + InterfaceCount: 1, + RuleCount: 1, + Interfaces: []TransportPolicyCompileInterface{ + {IfaceID: "edge-a", RuleCount: 1, ClientIDs: []string{"sb-one"}}, + }, + }); err != nil { + t.Fatalf("save policy plan: %v", err) + } + + egressIdentitySWR.mu.Lock() + egressIdentitySWR.entries["transport:sb-one"] = &egressIdentityEntry{ + item: EgressIdentity{ + Scope: "transport:sb-one", + Source: "transport", + SourceID: "sb-one", + IP: "198.51.100.77", + CountryCode: "FR", + }, + } + egressIdentitySWR.mu.Unlock() + + publishTransportRuntimeObservabilitySnapshotChanged( + "transport_client_state_changed", + []string{"sb-one"}, + nil, + ) + + evs := events.since(0) + if len(evs) != 1 { + t.Fatalf("expected one event, got %d", len(evs)) + } + if evs[0].Kind != "transport_runtime_snapshot_changed" { + t.Fatalf("unexpected event kind: %#v", evs[0]) + } + payload, ok := evs[0].Data.(TransportRuntimeObservabilityChangedEvent) + if !ok { + t.Fatalf("unexpected event payload type: %T", evs[0].Data) + } + if payload.Reason != "transport_client_state_changed" { + t.Fatalf("unexpected event reason: %#v", payload) + } + if len(payload.ClientIDs) != 1 || payload.ClientIDs[0] != "sb-one" { + t.Fatalf("unexpected client ids: %#v", payload) + } + if len(payload.IfaceIDs) != 1 || payload.IfaceIDs[0] != "edge-a" { + t.Fatalf("unexpected iface ids: %#v", payload) + } + if payload.Count != len(payload.Items) || payload.Count < 2 { + t.Fatalf("unexpected snapshot payload: %#v", payload) + } +} + +func TestResolveTransportRuntimeObservabilityEventIfaceIDsUsesExplicitFallback(t *testing.T) { + items := []TransportRuntimeObservabilityItem{ + { + IfaceID: "edge-a", + ClientID: "sb-one", + ClientIDs: []string{"sb-one", "dnstt-one"}, + }, + } + ifaceIDs := resolveTransportRuntimeObservabilityEventIfaceIDs( + items, + []string{"deleted-client"}, + []string{"edge-z", "edge-a"}, + ) + if len(ifaceIDs) != 2 || ifaceIDs[0] != "edge-z" || ifaceIDs[1] != "edge-a" { + t.Fatalf("unexpected iface ids: %#v", ifaceIDs) + } +} diff --git a/selective-vpn-api/app/transport_runtime_observability_test.go b/selective-vpn-api/app/transport_runtime_observability_test.go new file mode 100644 index 0000000..15eeb59 --- /dev/null +++ b/selective-vpn-api/app/transport_runtime_observability_test.go @@ -0,0 +1,395 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestBuildTransportRuntimeObservabilityItemsAggregatesInterfaces(t *testing.T) { + now := time.Date(2026, time.March, 16, 12, 0, 0, 0, time.UTC) + ifaces := []TransportInterface{ + { + ID: transportDefaultIfaceID, + Name: "Shared interface", + Mode: TransportInterfaceModeShared, + RuntimeIface: "tun-shared", + }, + { + ID: "edge-a", + Name: "Edge A", + Mode: TransportInterfaceModeDedicated, + RuntimeIface: "tun-edge", + NetnsName: "svpn-edge-a", + RoutingTable: "agvpn_if_edge_a", + }, + } + clients := []TransportClient{ + { + ID: "sb-up", + Kind: TransportClientSingBox, + Enabled: true, + IfaceID: "edge-a", + Iface: "tun-edge0", + Status: TransportClientUp, + Health: TransportClientHealth{ + LastCheck: "2026-03-16T11:59:00Z", + LatencyMS: 81, + }, + }, + { + ID: "dn-down", + Kind: TransportClientDNSTT, + Enabled: false, + IfaceID: "edge-a", + Status: TransportClientDown, + }, + { + ID: "ph-shared", + Kind: TransportClientPhoenix, + Enabled: true, + IfaceID: transportDefaultIfaceID, + Iface: "tun-shared0", + Status: TransportClientDegraded, + Health: TransportClientHealth{ + LastCheck: "2026-03-16T11:58:00Z", + LatencyMS: 320, + LastError: "upstream timeout", + }, + }, + } + plan := TransportPolicyCompilePlan{ + Interfaces: []TransportPolicyCompileInterface{ + {IfaceID: "edge-a", RuleCount: 3}, + {IfaceID: transportDefaultIfaceID, RuleCount: 1}, + }, + } + egressByClient := map[string]EgressIdentity{ + "sb-up": { + Scope: "transport:sb-up", + Source: "transport", + SourceID: "sb-up", + IP: "203.0.113.10", + CountryCode: "SG", + }, + "ph-shared": { + Scope: "transport:ph-shared", + Source: "transport", + SourceID: "ph-shared", + IP: "198.51.100.44", + CountryCode: "NL", + }, + } + + items := buildTransportRuntimeObservabilityItems( + ifaces, + clients, + plan, + func(clientID string) EgressIdentity { return egressByClient[clientID] }, + now, + ) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + + edge := items[0] + if edge.IfaceID != "edge-a" { + t.Fatalf("expected edge-a item first, got %q", edge.IfaceID) + } + if edge.ClientID != "sb-up" { + t.Fatalf("unexpected edge-a primary client: %#v", edge) + } + if edge.ActiveIface != "tun-edge0" || edge.RuntimeIface != "tun-edge" { + t.Fatalf("unexpected edge-a iface binding: %#v", edge) + } + if edge.Status != string(TransportClientUp) { + t.Fatalf("unexpected edge-a status: %#v", edge) + } + if edge.Counters.ClientCount != 2 || edge.Counters.UpCount != 1 || edge.Counters.DownCount != 1 || edge.Counters.RuleCount != 3 { + t.Fatalf("unexpected edge-a counters: %#v", edge.Counters) + } + if edge.Egress.IP != "203.0.113.10" || edge.Egress.CountryCode != "SG" { + t.Fatalf("unexpected edge-a egress: %#v", edge.Egress) + } + if len(edge.EngineCounts) != 2 || edge.EngineCounts[0].Kind != "dnstt" || edge.EngineCounts[1].Kind != "singbox" { + t.Fatalf("unexpected edge-a engine counts: %#v", edge.EngineCounts) + } + + shared := items[1] + if shared.IfaceID != transportDefaultIfaceID { + t.Fatalf("expected shared item second, got %q", shared.IfaceID) + } + if shared.ClientID != "ph-shared" || shared.Status != string(TransportClientDegraded) { + t.Fatalf("unexpected shared snapshot: %#v", shared) + } + if shared.LatencyMS != 320 || shared.LastError != "upstream timeout" || shared.LastCheck != "2026-03-16T11:58:00Z" { + t.Fatalf("unexpected shared health snapshot: %#v", shared) + } + if shared.Counters.ClientCount != 1 || shared.Counters.DegradedCount != 1 || shared.Counters.RuleCount != 1 { + t.Fatalf("unexpected shared counters: %#v", shared.Counters) + } +} + +func TestBuildTransportRuntimeObservabilityItemsKeepsActiveClientAndAggregateError(t *testing.T) { + now := time.Date(2026, time.March, 16, 12, 5, 0, 0, time.UTC) + ifaces := []TransportInterface{ + { + ID: "edge-a", + Name: "Edge A", + Mode: TransportInterfaceModeDedicated, + RuntimeIface: "tun-edge", + }, + } + clients := []TransportClient{ + { + ID: "sb-up", + Kind: TransportClientSingBox, + Enabled: true, + IfaceID: "edge-a", + Iface: "tun-edge0", + Status: TransportClientUp, + Health: TransportClientHealth{ + LastCheck: "2026-03-16T12:04:00Z", + LatencyMS: 55, + }, + }, + { + ID: "ph-bad", + Kind: TransportClientPhoenix, + Enabled: true, + IfaceID: "edge-a", + Status: TransportClientDegraded, + Health: TransportClientHealth{ + LastCheck: "2026-03-16T12:04:30Z", + LastError: "probe failed", + }, + }, + } + + items := buildTransportRuntimeObservabilityItems( + ifaces, + clients, + TransportPolicyCompilePlan{}, + func(clientID string) EgressIdentity { + if clientID == "sb-up" { + return EgressIdentity{IP: "203.0.113.55", CountryCode: "DE"} + } + return EgressIdentity{} + }, + now, + ) + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + item := items[0] + if item.ClientID != "sb-up" { + t.Fatalf("expected active client to stay sb-up, got %#v", item) + } + if item.Status != string(TransportClientDegraded) { + t.Fatalf("expected aggregate degraded status, got %#v", item) + } + if item.LatencyMS != 55 { + t.Fatalf("expected latency from active client, got %#v", item) + } + if item.LastError != "probe failed" { + t.Fatalf("expected aggregate last_error from degraded peer, got %#v", item) + } + if item.LastCheck != "2026-03-16T12:04:00Z" { + t.Fatalf("expected active client last_check to stay stable, got %#v", item) + } +} + +func TestHandleTransportRuntimeObservability(t *testing.T) { + withTransportPolicyMutationTestPaths(t) + + prevEgress := egressIdentitySWR + egressIdentitySWR = newEgressIdentityService(1) + t.Cleanup(func() { + egressIdentitySWR = prevEgress + }) + + if err := saveTransportClientsState(transportClientsState{ + Version: transportStateVersion, + Items: []TransportClient{ + { + ID: "sb-one", + Kind: TransportClientSingBox, + Enabled: true, + IfaceID: "edge-a", + Iface: "tun-edge0", + Status: TransportClientUp, + Health: TransportClientHealth{ + LastCheck: "2026-03-16T12:09:00Z", + LatencyMS: 64, + }, + }, + }, + }); err != nil { + t.Fatalf("save clients state: %v", err) + } + if err := saveTransportInterfacesState(transportInterfacesState{ + Version: transportStateVersion, + Items: []TransportInterface{ + { + ID: "edge-a", + Name: "Edge A", + Mode: TransportInterfaceModeDedicated, + RuntimeIface: "tun-edge", + RoutingTable: "agvpn_if_edge_a", + }, + }, + }); err != nil { + t.Fatalf("save interfaces state: %v", err) + } + if err := saveTransportPolicyState(TransportPolicyState{ + Version: transportStateVersion, + Revision: 5, + UpdatedAt: "2026-03-16T12:00:00Z", + Intents: []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.org", ClientID: "sb-one"}, + {SelectorType: "cidr", SelectorValue: "10.0.0.0/24", ClientID: "sb-one"}, + }, + }); err != nil { + t.Fatalf("save policy state: %v", err) + } + if err := saveTransportPolicyCompilePlan(TransportPolicyCompilePlan{ + GeneratedAt: "2026-03-16T12:00:00Z", + PolicyRevision: 5, + InterfaceCount: 1, + RuleCount: 2, + Interfaces: []TransportPolicyCompileInterface{ + {IfaceID: "edge-a", RuleCount: 2, ClientIDs: []string{"sb-one"}}, + }, + }); err != nil { + t.Fatalf("save policy plan: %v", err) + } + + egressIdentitySWR.mu.Lock() + egressIdentitySWR.entries["transport:sb-one"] = &egressIdentityEntry{ + item: EgressIdentity{ + Scope: "transport:sb-one", + Source: "transport", + SourceID: "sb-one", + IP: "198.51.100.8", + CountryCode: "NL", + }, + } + egressIdentitySWR.mu.Unlock() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/transport/runtime/observability", nil) + rec := httptest.NewRecorder() + handleTransportRuntimeObservability(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + + var resp TransportRuntimeObservabilityResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK || resp.Count < 2 || resp.Count != len(resp.Items) || resp.GeneratedAt == "" { + t.Fatalf("unexpected response envelope: %#v", resp) + } + var item TransportRuntimeObservabilityItem + found := false + for _, current := range resp.Items { + if current.IfaceID != "edge-a" { + continue + } + item = current + found = true + break + } + if !found { + t.Fatalf("edge-a snapshot not found: %#v", resp.Items) + } + if item.IfaceID != "edge-a" || item.ClientID != "sb-one" { + t.Fatalf("unexpected runtime item identity: %#v", item) + } + if item.ActiveIface != "tun-edge0" || item.Counters.RuleCount != 2 { + t.Fatalf("unexpected runtime item details: %#v", item) + } + if item.Egress.IP != "198.51.100.8" || item.Egress.CountryCode != "NL" { + t.Fatalf("unexpected runtime item egress: %#v", item.Egress) + } +} + +func TestBuildTransportRuntimeObservabilityItemsAddsVirtualAdGuardRow(t *testing.T) { + now := time.Date(2026, time.March, 23, 19, 10, 0, 0, time.UTC) + ifaces := []TransportInterface{ + { + ID: transportDefaultIfaceID, + Name: "Shared interface", + Mode: TransportInterfaceModeShared, + }, + } + virtual := buildTransportPolicyAdGuardTargetFromObservation( + "active", + "CONNECTED", + "after connect: CONNECTED; raw: Connected to HELSINKI in TUN mode, running on tun0", + now, + ) + clients := []TransportClient{ + { + ID: "sb-one", + Kind: TransportClientSingBox, + Enabled: true, + IfaceID: transportDefaultIfaceID, + Status: TransportClientDown, + }, + virtual, + } + plan := TransportPolicyCompilePlan{ + Interfaces: []TransportPolicyCompileInterface{ + { + IfaceID: transportDefaultIfaceID, + RuleCount: 2, + Rules: []TransportPolicyCompileRule{ + {ClientID: "sb-one"}, + {ClientID: "adguardvpn"}, + }, + }, + }, + } + + items := buildTransportRuntimeObservabilityItems( + ifaces, + clients, + plan, + func(clientID string) EgressIdentity { + if clientID == "adguardvpn" { + return EgressIdentity{Scope: "adguardvpn", IP: "185.77.216.28", CountryCode: "EE"} + } + return EgressIdentity{} + }, + now, + ) + if len(items) != 2 { + t.Fatalf("expected 2 rows, got %d", len(items)) + } + + var found bool + for _, item := range items { + if item.IfaceID != "adguardvpn" { + continue + } + found = true + if item.ClientID != "adguardvpn" || item.Status != string(TransportClientUp) { + t.Fatalf("unexpected virtual row identity: %#v", item) + } + if item.RuntimeIface != "tun0" || item.ActiveIface != "tun0" { + t.Fatalf("unexpected virtual iface binding: %#v", item) + } + if item.Counters.ClientCount != 1 || item.Counters.RuleCount != 1 { + t.Fatalf("unexpected virtual counters: %#v", item.Counters) + } + if item.Egress.IP != "185.77.216.28" || item.Egress.CountryCode != "EE" { + t.Fatalf("unexpected virtual egress: %#v", item.Egress) + } + } + if !found { + t.Fatalf("virtual adguard row not found: %#v", items) + } +} diff --git a/selective-vpn-api/app/transport_shared.go b/selective-vpn-api/app/transport_shared.go new file mode 100644 index 0000000..955d82e --- /dev/null +++ b/selective-vpn-api/app/transport_shared.go @@ -0,0 +1,46 @@ +package app + +import ( + "net/netip" + transporttoken "selective-vpn-api/app/transporttoken" + "sync" + "time" +) + +const ( + transportStateVersion = 1 + transportDefaultIfaceID = "shared" + transportConfirmTTL = 10 * time.Minute + transportMarkStart uint64 = 0x100 + transportMarkEnd uint64 = 0x1ff + transportMarkReserveEnd uint64 = 0x10f + transportPrefStart = 13000 + transportPrefStep = 50 + transportPrefEnd = 19999 + transportPrefReserveEnd = 13249 +) + +type transportClientsState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + Items []TransportClient `json:"items,omitempty"` +} + +type cidrIntent struct { + ClientID string + Prefix netip.Prefix + Key string +} + +type transportValidationResult struct { + Normalized []TransportPolicyIntent + Conflicts []TransportConflictRecord + Summary TransportPolicyValidateSummary + Diff TransportPolicyDiff + Valid bool +} + +var ( + transportMu sync.Mutex + transportConfirmStore = transporttoken.NewStore(transportConfirmTTL) +) diff --git a/selective-vpn-api/app/transport_singbox_dns_migration.go b/selective-vpn-api/app/transport_singbox_dns_migration.go new file mode 100644 index 0000000..3997617 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_dns_migration.go @@ -0,0 +1,25 @@ +package app + +import "strings" + +func transportSingBoxDNSMigrationEnabled(client TransportClient) bool { + if transportConfigHasKey(client.Config, "singbox_dns_migrate_legacy") { + return transportConfigBool(client.Config, "singbox_dns_migrate_legacy") + } + return client.Kind == TransportClientSingBox +} + +func transportSingBoxDNSMigrationStrict(client TransportClient) bool { + return transportConfigBool(client.Config, "singbox_dns_migrate_strict") +} + +func transportSingBoxConfigPath(client TransportClient) string { + configPath := strings.TrimSpace(transportConfigString(client.Config, "singbox_config_path")) + if configPath == "" { + configPath = strings.TrimSpace(transportConfigString(client.Config, "config_path")) + } + if configPath == "" { + configPath = defaultTransportConfigPath(client.ID, "singbox.json") + } + return strings.TrimSpace(configPath) +} diff --git a/selective-vpn-api/app/transport_singbox_dns_migration_apply.go b/selective-vpn-api/app/transport_singbox_dns_migration_apply.go new file mode 100644 index 0000000..fe93284 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_dns_migration_apply.go @@ -0,0 +1,67 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +func transportMaybeMigrateSingBoxDNSConfig(client TransportClient) (string, error) { + if client.Kind != TransportClientSingBox { + return "", nil + } + if !transportSingBoxDNSMigrationEnabled(client) { + return "", nil + } + path := transportSingBoxConfigPath(client) + if path == "" { + return "", nil + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read singbox config failed: %w", err) + } + var root map[string]any + if err := json.Unmarshal(data, &root); err != nil { + return "", fmt.Errorf("parse singbox config failed: %w", err) + } + + changed, warnings := transportMigrateSingBoxDNSConfigMap(root) + if !changed { + if len(warnings) == 0 { + return "", nil + } + return "", errors.New(strings.Join(warnings, "; ")) + } + + backup := path + ".legacy-dns.bak" + if _, err := os.Stat(backup); os.IsNotExist(err) { + if writeErr := os.WriteFile(backup, data, 0o644); writeErr != nil { + return "", fmt.Errorf("write backup failed: %w", writeErr) + } + } + + out, err := json.MarshalIndent(root, "", " ") + if err != nil { + return "", fmt.Errorf("marshal migrated config failed: %w", err) + } + tmp := path + ".tmp" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", fmt.Errorf("ensure config dir failed: %w", err) + } + if err := os.WriteFile(tmp, append(out, '\n'), 0o644); err != nil { + return "", fmt.Errorf("write migrated config failed: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return "", fmt.Errorf("replace migrated config failed: %w", err) + } + + msg := "singbox dns migrated to new server format" + if len(warnings) > 0 { + return msg, errors.New(strings.Join(warnings, "; ")) + } + return msg, nil +} diff --git a/selective-vpn-api/app/transport_singbox_dns_migration_convert.go b/selective-vpn-api/app/transport_singbox_dns_migration_convert.go new file mode 100644 index 0000000..306f6d0 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_dns_migration_convert.go @@ -0,0 +1,112 @@ +package app + +import ( + "net" + "net/netip" + "net/url" + "strings" +) + +func convertLegacyDNSServer(address string) (map[string]any, string, bool) { + a := strings.TrimSpace(address) + if a == "" { + return nil, "empty address", false + } + low := strings.ToLower(a) + switch low { + case "local": + return map[string]any{"type": "local"}, "", true + case "fakeip": + return map[string]any{"type": "fakeip"}, "", true + } + if strings.HasPrefix(low, "rcode://") { + return nil, "rcode:// legacy server is not auto-migrated (use dns rule action)", false + } + if strings.HasPrefix(low, "dhcp://") { + iface := strings.TrimSpace(a[len("dhcp://"):]) + out := map[string]any{"type": "dhcp"} + if iface != "" && !strings.EqualFold(iface, "auto") { + out["interface"] = iface + } + return out, "", true + } + + if strings.Contains(a, "://") { + u, err := url.Parse(a) + if err != nil { + return nil, "invalid URL address", false + } + scheme := strings.ToLower(strings.TrimSpace(u.Scheme)) + host := strings.TrimSpace(u.Hostname()) + if host == "" { + return nil, "empty host in URL address", false + } + port := parsePort(u.Port()) + out := map[string]any{} + switch scheme { + case "tcp", "udp": + out["type"] = scheme + case "tls": + out["type"] = "tls" + case "quic": + out["type"] = "quic" + case "https": + out["type"] = "https" + if p := strings.TrimSpace(u.EscapedPath()); p != "" && p != "/dns-query" { + out["path"] = p + } + case "h3": + out["type"] = "h3" + if p := strings.TrimSpace(u.EscapedPath()); p != "" && p != "/dns-query" { + out["path"] = p + } + default: + return nil, "unsupported address scheme: " + scheme, false + } + out["server"] = host + if port > 0 { + out["server_port"] = port + } + return out, "", true + } + + // Plain host/ip, optional :port => UDP. + host, port := splitHostPortLoose(a) + if host == "" { + return nil, "invalid host", false + } + out := map[string]any{ + "type": "udp", + "server": host, + } + if port > 0 { + out["server_port"] = port + } + return out, "", true +} + +func splitHostPortLoose(raw string) (string, int) { + s := strings.TrimSpace(strings.Trim(raw, "[]")) + if s == "" { + return "", 0 + } + // IPv6 literal without brackets can't carry optional port unambiguously here. + if strings.Count(s, ":") > 1 && !strings.Contains(raw, "]") { + addr, err := netip.ParseAddr(s) + if err != nil || !addr.Is6() { + return "", 0 + } + return s, 0 + } + if h, p, err := net.SplitHostPort(raw); err == nil { + return strings.TrimSpace(strings.Trim(h, "[]")), parsePort(p) + } + if i := strings.LastIndex(s, ":"); i > 0 && strings.Count(s, ":") == 1 { + host := strings.TrimSpace(s[:i]) + port := parsePort(s[i+1:]) + if host != "" && port > 0 { + return host, port + } + } + return s, 0 +} diff --git a/selective-vpn-api/app/transport_singbox_dns_migration_map.go b/selective-vpn-api/app/transport_singbox_dns_migration_map.go new file mode 100644 index 0000000..1a2db15 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_dns_migration_map.go @@ -0,0 +1,125 @@ +package app + +import ( + "fmt" + "strings" +) + +func transportMigrateSingBoxDNSConfigMap(root map[string]any) (bool, []string) { + if root == nil { + return false, nil + } + rawDNS, ok := root["dns"].(map[string]any) + if !ok || rawDNS == nil { + return false, nil + } + rawServers, ok := rawDNS["servers"].([]any) + if !ok || len(rawServers) == 0 { + return false, nil + } + + changed := false + warnings := make([]string, 0) + migratedFakeIP := false + fakeIPCfg, _ := rawDNS["fakeip"].(map[string]any) + + for i, raw := range rawServers { + srv, ok := raw.(map[string]any) + if !ok || srv == nil { + continue + } + if strings.EqualFold(strings.TrimSpace(asString(srv["detour"])), "direct") { + delete(srv, "detour") + rawServers[i] = srv + changed = true + } + + if strings.TrimSpace(asString(srv["type"])) != "" { + continue + } + addr := strings.TrimSpace(asString(srv["address"])) + if addr == "" { + continue + } + converted, warn, ok := convertLegacyDNSServer(addr) + if !ok { + warnings = append(warnings, fmt.Sprintf("dns.servers[%d]: %s", i, warn)) + continue + } + + legacyAddrResolver := strings.TrimSpace(asString(srv["address_resolver"])) + legacyAddrStrategy := strings.TrimSpace(asString(srv["address_strategy"])) + legacyStrategy := strings.TrimSpace(asString(srv["strategy"])) + legacySubnet := strings.TrimSpace(asString(srv["client_subnet"])) + tag := strings.TrimSpace(asString(srv["tag"])) + + // Required new fields for typed DNS server. + for k, v := range converted { + srv[k] = v + } + delete(srv, "address") + + if legacyAddrResolver != "" && strings.TrimSpace(asString(srv["domain_resolver"])) == "" { + srv["domain_resolver"] = legacyAddrResolver + } + delete(srv, "address_resolver") + if legacyAddrStrategy != "" && strings.TrimSpace(asString(srv["domain_strategy"])) == "" { + srv["domain_strategy"] = legacyAddrStrategy + } + delete(srv, "address_strategy") + + if legacyStrategy != "" { + if strings.TrimSpace(asString(rawDNS["strategy"])) == "" { + rawDNS["strategy"] = legacyStrategy + } else if tag != "" { + transportAppendDNSRule(rawDNS, map[string]any{ + "server": tag, + "strategy": legacyStrategy, + }) + } else { + warnings = append(warnings, fmt.Sprintf("dns.servers[%d]: strategy moved partially (missing tag)", i)) + } + delete(srv, "strategy") + } + + if legacySubnet != "" { + if tag != "" { + transportAppendDNSRule(rawDNS, map[string]any{ + "server": tag, + "client_subnet": legacySubnet, + }) + } else if strings.TrimSpace(asString(rawDNS["client_subnet"])) == "" { + rawDNS["client_subnet"] = legacySubnet + } else { + warnings = append(warnings, fmt.Sprintf("dns.servers[%d]: client_subnet moved partially (missing tag)", i)) + } + delete(srv, "client_subnet") + } + + if strings.EqualFold(asString(srv["type"]), "fakeip") && fakeIPCfg != nil { + if _, ok := srv["inet4_range"]; !ok { + if v, ok := fakeIPCfg["inet4_range"]; ok { + srv["inet4_range"] = v + } + } + if _, ok := srv["inet6_range"]; !ok { + if v, ok := fakeIPCfg["inet6_range"]; ok { + srv["inet6_range"] = v + } + } + migratedFakeIP = true + } + + rawServers[i] = srv + changed = true + } + + if changed { + rawDNS["servers"] = rawServers + if migratedFakeIP { + delete(rawDNS, "fakeip") + } + root["dns"] = rawDNS + } + return changed, dedupeStrings(warnings) +} diff --git a/selective-vpn-api/app/transport_singbox_dns_migration_rules.go b/selective-vpn-api/app/transport_singbox_dns_migration_rules.go new file mode 100644 index 0000000..ee5ff7e --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_dns_migration_rules.go @@ -0,0 +1,35 @@ +package app + +func transportAppendDNSRule(rawDNS map[string]any, rule map[string]any) { + if rawDNS == nil || len(rule) == 0 { + return + } + rawRules, _ := rawDNS["rules"].([]any) + for _, it := range rawRules { + existing, ok := it.(map[string]any) + if !ok || existing == nil { + continue + } + if mapsEqual(existing, rule) { + return + } + } + rawRules = append(rawRules, rule) + rawDNS["rules"] = rawRules +} + +func mapsEqual(a, b map[string]any) bool { + if len(a) != len(b) { + return false + } + for k, av := range a { + bv, ok := b[k] + if !ok { + return false + } + if asString(av) != asString(bv) { + return false + } + } + return true +} diff --git a/selective-vpn-api/app/transport_singbox_dns_migration_test.go b/selective-vpn-api/app/transport_singbox_dns_migration_test.go new file mode 100644 index 0000000..1111d5a --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_dns_migration_test.go @@ -0,0 +1,226 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestTransportMigrateSingBoxDNSConfigMapLegacyUDP(t *testing.T) { + root := map[string]any{ + "dns": map[string]any{ + "servers": []any{ + map[string]any{ + "tag": "dns-direct", + "address": "1.1.1.1", + "address_resolver": "dns-local", + "address_strategy": "prefer_ipv4", + }, + }, + }, + } + changed, warns := transportMigrateSingBoxDNSConfigMap(root) + if !changed { + t.Fatalf("expected changed=true") + } + if len(warns) != 0 { + t.Fatalf("unexpected warnings: %#v", warns) + } + + dns := root["dns"].(map[string]any) + servers := dns["servers"].([]any) + srv := servers[0].(map[string]any) + if strings.TrimSpace(asString(srv["type"])) != "udp" { + t.Fatalf("expected type=udp, got %#v", srv["type"]) + } + if strings.TrimSpace(asString(srv["server"])) != "1.1.1.1" { + t.Fatalf("expected server=1.1.1.1, got %#v", srv["server"]) + } + if _, ok := srv["address"]; ok { + t.Fatalf("legacy address key must be removed") + } + if strings.TrimSpace(asString(srv["domain_resolver"])) != "dns-local" { + t.Fatalf("expected domain_resolver migration, got %#v", srv["domain_resolver"]) + } + if strings.TrimSpace(asString(srv["domain_strategy"])) != "prefer_ipv4" { + t.Fatalf("expected domain_strategy migration, got %#v", srv["domain_strategy"]) + } + if _, ok := srv["detour"]; ok { + t.Fatalf("detour=direct should be removed in migrated config") + } +} + +func TestTransportMigrateSingBoxDNSConfigMapHTTPS(t *testing.T) { + root := map[string]any{ + "dns": map[string]any{ + "servers": []any{ + map[string]any{ + "tag": "dns-doh", + "address": "https://1.1.1.1/dns-query", + }, + }, + }, + } + changed, warns := transportMigrateSingBoxDNSConfigMap(root) + if !changed { + t.Fatalf("expected changed=true") + } + if len(warns) != 0 { + t.Fatalf("unexpected warnings: %#v", warns) + } + dns := root["dns"].(map[string]any) + srv := dns["servers"].([]any)[0].(map[string]any) + if strings.TrimSpace(asString(srv["type"])) != "https" { + t.Fatalf("expected type=https, got %#v", srv["type"]) + } + if strings.TrimSpace(asString(srv["server"])) != "1.1.1.1" { + t.Fatalf("expected server=1.1.1.1, got %#v", srv["server"]) + } + if _, ok := srv["path"]; ok { + t.Fatalf("default /dns-query path should not be forced into config") + } +} + +func TestTransportMigrateSingBoxDNSConfigMapTypedServerDetourDirectRemoved(t *testing.T) { + root := map[string]any{ + "dns": map[string]any{ + "servers": []any{ + map[string]any{ + "tag": "dns-direct", + "type": "udp", + "server": "1.1.1.1", + "detour": "direct", + }, + }, + }, + } + changed, warns := transportMigrateSingBoxDNSConfigMap(root) + if !changed { + t.Fatalf("expected changed=true when removing direct detour") + } + if len(warns) != 0 { + t.Fatalf("unexpected warnings: %#v", warns) + } + dns := root["dns"].(map[string]any) + srv := dns["servers"].([]any)[0].(map[string]any) + if _, ok := srv["detour"]; ok { + t.Fatalf("detour should be removed for typed DNS server") + } +} + +func TestTransportSystemdBackendProvisionSingBoxDNSMigrationStrictFail(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + transportSystemdUnitsDir = t.TempDir() + transportRunCommand = func(_ time.Duration, _ string, _ ...string) (string, string, int, error) { + return "", "", 0, nil + } + + cfgDir := t.TempDir() + cfg := filepath.Join(cfgDir, "singbox.json") + if err := os.WriteFile(cfg, []byte("{broken-json"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + client := TransportClient{ + ID: "sg-mig-strict", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "sg-mig-strict.service", + "bin": "/usr/bin/sing-box", + "singbox_config_path": cfg, + "singbox_dns_migrate_legacy": true, + "singbox_dns_migrate_strict": true, + }, + } + res := selectTransportBackend(client).Provision(client) + if res.OK { + t.Fatalf("expected strict migration failure, got %#v", res) + } + if res.Code != "TRANSPORT_BACKEND_SINGBOX_DNS_MIGRATE_FAILED" { + t.Fatalf("unexpected code: %#v", res) + } +} + +func TestTransportSystemdBackendProvisionSingBoxDNSMigrationWritesBackup(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + if cmd == "systemctl daemon-reload" { + return "", "", 0, nil + } + return "", "", 0, nil + } + + cfgDir := t.TempDir() + cfg := filepath.Join(cfgDir, "singbox.json") + legacy := map[string]any{ + "dns": map[string]any{ + "servers": []any{ + map[string]any{ + "tag": "dns-direct", + "address": "1.1.1.1", + }, + }, + }, + } + raw, _ := json.Marshal(legacy) + if err := os.WriteFile(cfg, raw, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + client := TransportClient{ + ID: "sg-mig-ok", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "sg-mig-ok.service", + "bin": "/usr/bin/sing-box", + "singbox_config_path": cfg, + "singbox_dns_migrate_legacy": true, + }, + } + res := selectTransportBackend(client).Provision(client) + if !res.OK { + t.Fatalf("expected provision success, got %#v", res) + } + if !strings.Contains(strings.ToLower(res.Stdout), "dns-migrate:") { + t.Fatalf("expected migration note in stdout, got %#v", res.Stdout) + } + if _, err := os.Stat(cfg + ".legacy-dns.bak"); err != nil { + t.Fatalf("expected backup file, stat err=%v", err) + } + updatedRaw, err := os.ReadFile(cfg) + if err != nil { + t.Fatalf("read updated config: %v", err) + } + var updated map[string]any + if err := json.Unmarshal(updatedRaw, &updated); err != nil { + t.Fatalf("parse updated config: %v", err) + } + dns := updated["dns"].(map[string]any) + srv := dns["servers"].([]any)[0].(map[string]any) + if strings.TrimSpace(asString(srv["type"])) != "udp" { + t.Fatalf("expected migrated type=udp, got %#v", srv["type"]) + } + if _, ok := srv["address"]; ok { + t.Fatalf("legacy address key should be removed after migration") + } +} diff --git a/selective-vpn-api/app/transport_singbox_profiles.go b/selective-vpn-api/app/transport_singbox_profiles.go new file mode 100644 index 0000000..d84e313 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles.go @@ -0,0 +1,41 @@ +package app + +import "sync" + +const ( + singBoxProfilesStateVersion = 1 +) + +const ( + singBoxProfileCodeNotFound = "SINGBOX_PROFILE_NOT_FOUND" + singBoxProfileCodeIDConflict = "SINGBOX_PROFILE_ID_CONFLICT" + singBoxProfileCodeBadID = "SINGBOX_PROFILE_ID_INVALID" + singBoxProfileCodeBadMode = "SINGBOX_PROFILE_MODE_INVALID" + singBoxProfileCodeBadBaseRevision = "SINGBOX_PROFILE_BASE_REVISION_INVALID" + singBoxProfileCodeRevisionMismatch = "SINGBOX_PROFILE_REVISION_MISMATCH" + singBoxProfileCodeSaveFailed = "SINGBOX_PROFILE_SAVE_FAILED" + singBoxProfileCodeSecretsFailed = "SINGBOX_PROFILE_SECRETS_FAILED" + singBoxProfileCodeValidationFailed = "SINGBOX_PROFILE_VALIDATION_FAILED" + singBoxProfileCodeRenderFailed = "SINGBOX_PROFILE_RENDER_FAILED" + singBoxProfileCodeApplyFailed = "SINGBOX_PROFILE_APPLY_FAILED" + singBoxProfileCodeRollbackFailed = "SINGBOX_PROFILE_ROLLBACK_FAILED" + singBoxProfileCodeHistoryMissing = "SINGBOX_PROFILE_HISTORY_NOT_FOUND" + singBoxProfileCodeNoClient = "SINGBOX_PROFILE_CLIENT_NOT_FOUND" + singBoxProfileCodeClientKind = "SINGBOX_PROFILE_CLIENT_KIND_MISMATCH" + singBoxProfileCodeRuntimeFailed = "SINGBOX_PROFILE_RUNTIME_FAILED" +) + +var ( + singBoxProfilesMu sync.Mutex + + singBoxProfilesStatePath = stateDir + "/transport/singbox-profiles.json" + singBoxSecretsRootDir = stateDir + "/transport/secrets/singbox" +) + +type singBoxProfilesState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + Revision int64 `json:"revision,omitempty"` + ActiveProfileID string `json:"active_profile_id,omitempty"` + Items []SingBoxProfile `json:"items,omitempty"` +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_card.go b/selective-vpn-api/app/transport_singbox_profiles_card.go new file mode 100644 index 0000000..5fcbf63 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_card.go @@ -0,0 +1,52 @@ +package app + +import ( + "net/http" + "strings" +) + +func handleTransportSingBoxProfileByID(w http.ResponseWriter, r *http.Request) { + const prefix = "/api/v1/transport/singbox/profiles/" + rest := strings.TrimPrefix(r.URL.Path, prefix) + if rest == "" || rest == r.URL.Path { + http.NotFound(w, r) + return + } + parts := strings.Split(strings.Trim(rest, "/"), "/") + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { + http.NotFound(w, r) + return + } + id := sanitizeID(parts[0]) + if id == "" { + writeJSON(w, http.StatusBadRequest, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeBadID, + Message: "invalid profile id", + }) + return + } + if len(parts) == 1 { + handleTransportSingBoxProfileCard(w, r, id) + return + } + if len(parts) > 2 { + http.NotFound(w, r) + return + } + action := strings.ToLower(strings.TrimSpace(parts[1])) + handleTransportSingBoxProfileAction(w, r, id, action) +} + +func handleTransportSingBoxProfileCard(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + handleTransportSingBoxProfileCardGet(w, r, id) + case http.MethodPatch: + handleTransportSingBoxProfileCardPatch(w, r, id) + case http.MethodDelete: + handleTransportSingBoxProfileCardDelete(w, r, id) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_card_delete.go b/selective-vpn-api/app/transport_singbox_profiles_card_delete.go new file mode 100644 index 0000000..d60b5a2 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_card_delete.go @@ -0,0 +1,63 @@ +package app + +import ( + "net/http" + "strings" +) + +func handleTransportSingBoxProfileCardDelete(w http.ResponseWriter, r *http.Request, id string) { + baseRevision, err := parseOptionalInt64(strings.TrimSpace(r.URL.Query().Get("base_revision"))) + if err != nil { + writeJSON(w, http.StatusBadRequest, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeBadBaseRevision, + Message: "invalid base_revision", + }) + return + } + + singBoxProfilesMu.Lock() + defer singBoxProfilesMu.Unlock() + st := loadSingBoxProfilesState() + idx := findSingBoxProfileIndex(st.Items, id) + if idx < 0 { + writeJSON(w, http.StatusNotFound, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeNotFound, + Message: "not found", + }) + return + } + cur := st.Items[idx] + if baseRevision > 0 && baseRevision != cur.ProfileRevision { + writeJSON(w, http.StatusConflict, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeRevisionMismatch, + Message: "base_revision mismatch", + Item: &cur, + }) + return + } + + st.Items = append(st.Items[:idx], st.Items[idx+1:]...) + ensureSingBoxProfilesActiveID(&st) + st.Revision++ + if err := saveSingBoxProfilesState(st); err != nil { + writeJSON(w, http.StatusInternalServerError, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeSaveFailed, + Message: "save failed: " + err.Error(), + }) + return + } + _ = writeSingBoxSecrets(id, nil) + events.push("singbox_profile_saved", map[string]any{ + "id": id, + "deleted": true, + }) + writeJSON(w, http.StatusOK, SingBoxProfilesResponse{ + OK: true, + Message: "deleted", + ActiveProfileID: st.ActiveProfileID, + }) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_card_get.go b/selective-vpn-api/app/transport_singbox_profiles_card_get.go new file mode 100644 index 0000000..ad7bd26 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_card_get.go @@ -0,0 +1,26 @@ +package app + +import "net/http" + +func handleTransportSingBoxProfileCardGet(w http.ResponseWriter, _ *http.Request, id string) { + singBoxProfilesMu.Lock() + st := loadSingBoxProfilesState() + singBoxProfilesMu.Unlock() + + idx := findSingBoxProfileIndex(st.Items, id) + if idx < 0 { + writeJSON(w, http.StatusNotFound, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeNotFound, + Message: "not found", + }) + return + } + item := st.Items[idx] + writeJSON(w, http.StatusOK, SingBoxProfilesResponse{ + OK: true, + Message: "ok", + ActiveProfileID: st.ActiveProfileID, + Item: &item, + }) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_card_patch.go b/selective-vpn-api/app/transport_singbox_profiles_card_patch.go new file mode 100644 index 0000000..1d5607d --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_card_patch.go @@ -0,0 +1,92 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" +) + +func handleTransportSingBoxProfileCardPatch(w http.ResponseWriter, r *http.Request, id string) { + var body SingBoxProfilePatchRequest + 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 + } + } + + singBoxProfilesMu.Lock() + defer singBoxProfilesMu.Unlock() + + st := loadSingBoxProfilesState() + idx := findSingBoxProfileIndex(st.Items, id) + if idx < 0 { + writeJSON(w, http.StatusNotFound, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeNotFound, + Message: "not found", + }) + return + } + cur := st.Items[idx] + if body.BaseRevision > 0 && body.BaseRevision != cur.ProfileRevision { + writeJSON(w, http.StatusConflict, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeRevisionMismatch, + Message: "base_revision mismatch", + Item: &cur, + }) + return + } + + next, secretRes, changed, code, err := patchSingBoxProfile(cur, body) + if err != nil { + status := http.StatusBadRequest + if code == singBoxProfileCodeSecretsFailed { + status = http.StatusInternalServerError + } + writeJSON(w, status, SingBoxProfilesResponse{ + OK: false, + Code: code, + Message: err.Error(), + Item: &cur, + }) + return + } + if !changed { + writeJSON(w, http.StatusOK, SingBoxProfilesResponse{ + OK: true, + Message: "noop", + ActiveProfileID: st.ActiveProfileID, + Item: &cur, + }) + return + } + + st.Items[idx] = next + ensureSingBoxProfilesActiveID(&st) + st.Revision++ + if err := saveSingBoxProfilesState(st); err != nil { + if secretRes.Rollback != nil { + secretRes.Rollback() + } + writeJSON(w, http.StatusInternalServerError, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeSaveFailed, + Message: "save failed: " + err.Error(), + Item: &cur, + }) + return + } + events.push("singbox_profile_saved", map[string]any{ + "id": next.ID, + "revision": next.ProfileRevision, + }) + writeJSON(w, http.StatusOK, SingBoxProfilesResponse{ + OK: true, + Message: "updated", + ActiveProfileID: st.ActiveProfileID, + Item: &next, + }) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_errors.go b/selective-vpn-api/app/transport_singbox_profiles_errors.go new file mode 100644 index 0000000..449e482 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_errors.go @@ -0,0 +1,26 @@ +package app + +import "strings" + +func errSingBoxProfileMode() error { + return &singBoxProfileError{text: "mode must be typed|raw"} +} + +func errSingBoxProfileID() error { + return &singBoxProfileError{text: "invalid profile id"} +} + +func errSingBoxProfileExists() error { + return &singBoxProfileError{text: "profile already exists"} +} + +type singBoxProfileError struct { + text string +} + +func (e *singBoxProfileError) Error() string { + if e == nil { + return "singbox profile error" + } + return strings.TrimSpace(e.text) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_eval.go b/selective-vpn-api/app/transport_singbox_profiles_eval.go new file mode 100644 index 0000000..d6e62ea --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_eval.go @@ -0,0 +1,138 @@ +package app + +import ( + "time" + + transportcfgpkg "selective-vpn-api/app/transportcfg" +) + +type singBoxProfileEvalResult struct { + Valid bool + Config map[string]any + Digest string + Diff SingBoxProfileRenderDiff + Changed bool + Errors []SingBoxProfileIssue + Warnings []SingBoxProfileIssue +} + +func evaluateSingBoxProfile(profile SingBoxProfile, checkBinary bool) singBoxProfileEvalResult { + deps := transportcfgpkg.SingBoxProfileEvalDeps{ + NormalizeMode: func(mode string) (string, bool) { + nm, ok := normalizeSingBoxProfileMode(SingBoxProfileMode(mode)) + return string(nm), ok + }, + CloneMapDeep: cloneMapDeep, + AsString: asString, + ProtocolSupported: transportcfgpkg.SingBoxTypedProtocolSupported, + ParsePort: transportcfgpkg.ParsePort, + } + in := transportcfgpkg.SingBoxProfileInput{ + ID: profile.ID, + Mode: string(profile.Mode), + Protocol: profile.Protocol, + RawConfig: profile.RawConfig, + Typed: profile.Typed, + } + + pkgErrs, pkgWarns := transportcfgpkg.ValidateSingBoxProfile(in, deps) + errs := singBoxIssuesFromPkg(pkgErrs) + warns := singBoxIssuesFromPkg(pkgWarns) + rendered, renderErr := transportcfgpkg.RenderSingBoxProfileConfig(in, deps) + if renderErr == nil { + rendered = transportcfgpkg.NormalizeSingBoxRenderedConfig(rendered, asString) + } + if renderErr != nil { + errs = append(errs, SingBoxProfileIssue{ + Field: "profile", + Severity: "error", + Code: "SINGBOX_RENDER_INVALID", + Message: renderErr.Error(), + }) + } + if checkBinary && renderErr == nil { + if issue := validateSingBoxWithBinary(rendered); issue != nil { + if issue.Severity == "warning" { + warns = append(warns, *issue) + } else { + errs = append(errs, *issue) + } + } + } + valid := len(errs) == 0 + diff := SingBoxProfileRenderDiff{} + digest := "" + changed := false + if rendered != nil { + digest = transportcfgpkg.DigestJSONMap(rendered) + prev := transportcfgpkg.ReadJSONMapFile(singBoxRenderedPath(profile.ID)) + pkgDiff, cfgChanged := transportcfgpkg.DiffConfigMaps(prev, rendered) + diff = SingBoxProfileRenderDiff{ + Added: pkgDiff.Added, + Removed: pkgDiff.Removed, + Changed: pkgDiff.Changed, + } + changed = cfgChanged + } + return singBoxProfileEvalResult{ + Valid: valid, + Config: rendered, + Digest: digest, + Diff: diff, + Changed: changed, + Errors: errs, + Warnings: warns, + } +} + +func singBoxIssuesFromPkg(in []transportcfgpkg.SingBoxIssue) []SingBoxProfileIssue { + if len(in) == 0 { + return nil + } + out := make([]SingBoxProfileIssue, 0, len(in)) + for _, it := range in { + out = append(out, SingBoxProfileIssue{ + Field: it.Field, + Severity: it.Severity, + Code: it.Code, + Message: it.Message, + }) + } + return out +} + +func validateSingBoxWithBinary(config map[string]any) *SingBoxProfileIssue { + issue := transportcfgpkg.ValidateRenderedConfigWithBinary( + config, + runCommandTimeout, + 5*time.Second, + "/usr/local/bin/sing-box", + "/usr/bin/sing-box", + "sing-box", + ) + if issue == nil { + return nil + } + return &SingBoxProfileIssue{ + Field: issue.Field, + Severity: issue.Severity, + Code: issue.Code, + Message: issue.Message, + } +} + +func writeSingBoxRenderedConfig(profileID string, config map[string]any) (string, error) { + path := singBoxRenderedPath(profileID) + if err := transportcfgpkg.WriteJSONConfigFile(path, config); err != nil { + return path, err + } + return path, nil +} + +func singBoxRenderedPath(profileID string) string { + return transportcfgpkg.ProfileConfigPath(singBoxRenderedRootDir, profileID, sanitizeID) +} + +func defaultSingBoxAppliedConfigPath(profileID string) string { + return transportcfgpkg.ProfileConfigPath(singBoxAppliedRootDir, profileID, sanitizeID) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_features.go b/selective-vpn-api/app/transport_singbox_profiles_features.go new file mode 100644 index 0000000..db7fa3b --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_features.go @@ -0,0 +1,74 @@ +package app + +import ( + "net/http" + "strings" + "time" +) + +func handleTransportSingBoxFeatures(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + bin := "" + for _, cand := range []string{"/usr/local/bin/sing-box", "/usr/bin/sing-box", "sing-box"} { + if p, ok := findBinaryPath(cand); ok { + bin = p + break + } + } + version := "" + if bin != "" { + stdout, _, code, _ := runCommandTimeout(2*time.Second, bin, "version") + if code == 0 { + for _, line := range strings.Split(stdout, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + version = line + break + } + } + } + + writeJSON(w, http.StatusOK, SingBoxFeaturesResponse{ + OK: true, + Message: "ok", + Binary: bin, + Version: version, + ProfileModes: map[string]bool{ + string(SingBoxProfileModeTyped): true, + string(SingBoxProfileModeRaw): true, + }, + TypedProtocols: []string{"vless", "trojan", "shadowsocks", "wireguard", "hysteria2", "tuic"}, + DNSFormats: map[string]bool{ + "typed_servers": true, + "typed_rules": true, + "legacy_migration": true, + "system_resolver": true, + "rule_based_routing": true, + }, + ErrorCodes: []string{ + singBoxProfileCodeNotFound, + singBoxProfileCodeIDConflict, + singBoxProfileCodeBadID, + singBoxProfileCodeBadMode, + singBoxProfileCodeBadBaseRevision, + singBoxProfileCodeRevisionMismatch, + singBoxProfileCodeSaveFailed, + singBoxProfileCodeSecretsFailed, + singBoxProfileCodeValidationFailed, + singBoxProfileCodeRenderFailed, + singBoxProfileCodeApplyFailed, + singBoxProfileCodeRollbackFailed, + singBoxProfileCodeHistoryMissing, + singBoxProfileCodeNoClient, + singBoxProfileCodeClientKind, + singBoxProfileCodeRuntimeFailed, + }, + }) + +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow.go b/selective-vpn-api/app/transport_singbox_profiles_flow.go new file mode 100644 index 0000000..063bd56 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow.go @@ -0,0 +1,64 @@ +package app + +import "net/http" + +var ( + singBoxRenderedRootDir = stateDir + "/transport/singbox-rendered" + singBoxHistoryRootDir = stateDir + "/transport/singbox-history" + singBoxAppliedRootDir = stateDir + "/transport/singbox-applied" +) + +type singBoxProfileHistoryRecord struct { + ID string `json:"id"` + At string `json:"at"` + ProfileID string `json:"profile_id"` + Action string `json:"action"` + Status string `json:"status"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + ProfileRevision int64 `json:"profile_revision,omitempty"` + RenderRevision int64 `json:"render_revision,omitempty"` + RenderDigest string `json:"render_digest,omitempty"` + RenderPath string `json:"render_path,omitempty"` + ClientID string `json:"client_id,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + PrevConfigB64 string `json:"prev_config_b64,omitempty"` + PrevConfigExist bool `json:"prev_config_exists,omitempty"` + Diff SingBoxProfileRenderDiff `json:"diff"` + Errors []SingBoxProfileIssue `json:"errors,omitempty"` + Warnings []SingBoxProfileIssue `json:"warnings,omitempty"` +} + +func (r singBoxProfileHistoryRecord) Public() SingBoxProfileHistoryEntry { + return SingBoxProfileHistoryEntry{ + ID: r.ID, + At: r.At, + ProfileID: r.ProfileID, + Action: r.Action, + Status: r.Status, + Code: r.Code, + Message: r.Message, + ProfileRevision: r.ProfileRevision, + RenderRevision: r.RenderRevision, + RenderDigest: r.RenderDigest, + RenderPath: r.RenderPath, + ClientID: r.ClientID, + } +} + +func handleTransportSingBoxProfileAction(w http.ResponseWriter, r *http.Request, id, action string) { + switch action { + case "validate": + handleTransportSingBoxProfileValidate(w, r, id) + case "render": + handleTransportSingBoxProfileRender(w, r, id) + case "apply": + handleTransportSingBoxProfileApply(w, r, id) + case "rollback": + handleTransportSingBoxProfileRollback(w, r, id) + case "history": + handleTransportSingBoxProfileHistory(w, r, id) + default: + http.NotFound(w, r) + } +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_apply.go b/selective-vpn-api/app/transport_singbox_profiles_flow_apply.go new file mode 100644 index 0000000..d8e2ca6 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_apply.go @@ -0,0 +1,45 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" +) + +func handleTransportSingBoxProfileApply(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, checkBinary, restart, err := decodeTransportSingBoxProfileApplyRequest(r) + if err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + singBoxProfilesMu.Lock() + defer singBoxProfilesMu.Unlock() + + status, resp := executeTransportSingBoxProfileApplyLocked(id, body, checkBinary, restart) + writeJSON(w, status, resp) +} + +func decodeTransportSingBoxProfileApplyRequest(r *http.Request) (SingBoxProfileApplyRequest, bool, bool, error) { + var body SingBoxProfileApplyRequest + 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 { + return SingBoxProfileApplyRequest{}, false, false, err + } + } + checkBinary := true + if body.CheckBinary != nil { + checkBinary = *body.CheckBinary + } + restart := true + if body.Restart != nil { + restart = *body.Restart + } + return body, checkBinary, restart, nil +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_apply_exec.go b/selective-vpn-api/app/transport_singbox_profiles_flow_apply_exec.go new file mode 100644 index 0000000..f7a645e --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_apply_exec.go @@ -0,0 +1,90 @@ +package app + +import ( + "net/http" + "time" +) + +func executeTransportSingBoxProfileApplyLocked( + id string, + body SingBoxProfileApplyRequest, + checkBinary bool, + restart bool, +) (int, SingBoxProfileApplyResponse) { + st := loadSingBoxProfilesState() + idx := findSingBoxProfileIndex(st.Items, id) + if idx < 0 { + return http.StatusNotFound, SingBoxProfileApplyResponse{ + OK: false, + Code: singBoxProfileCodeNotFound, + Message: "not found", + } + } + cur := st.Items[idx] + if body.BaseRevision > 0 && body.BaseRevision != cur.ProfileRevision { + return http.StatusConflict, SingBoxProfileApplyResponse{ + OK: false, + Code: singBoxProfileCodeRevisionMismatch, + Message: "base_revision mismatch", + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + } + } + + eval := evaluateSingBoxProfile(cur, checkBinary) + now := time.Now().UTC().Format(time.RFC3339Nano) + if !eval.Valid { + cur.LastValidatedAt = now + cur.LastError = joinSingBoxIssueMessages(eval.Errors) + cur.UpdatedAt = now + st.Items[idx] = cur + _ = saveSingBoxProfilesState(st) + _ = appendSingBoxHistory(singBoxProfileHistoryRecord{ + At: now, + ProfileID: cur.ID, + Action: "apply", + Status: "failed", + Code: singBoxProfileCodeValidationFailed, + Message: "apply blocked by validation errors", + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + Errors: eval.Errors, + Warnings: eval.Warnings, + Diff: eval.Diff, + }) + events.push("singbox_profile_failed", map[string]any{ + "id": cur.ID, + "step": "apply-validate", + }) + return http.StatusOK, SingBoxProfileApplyResponse{ + OK: false, + Message: "apply blocked by validation errors", + Code: singBoxProfileCodeValidationFailed, + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + Valid: false, + Errors: eval.Errors, + Warnings: eval.Warnings, + Diff: eval.Diff, + } + } + + renderPath, err := writeSingBoxRenderedConfig(cur.ID, eval.Config) + if err != nil { + return http.StatusInternalServerError, SingBoxProfileApplyResponse{ + OK: false, + Code: singBoxProfileCodeRenderFailed, + Message: "write rendered config failed: " + err.Error(), + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + Valid: true, + Warnings: eval.Warnings, + Diff: eval.Diff, + } + } + + return executeTransportSingBoxProfileApplyRenderedLocked(st, idx, cur, body, eval, now, renderPath, restart) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_apply_exec_rendered.go b/selective-vpn-api/app/transport_singbox_profiles_flow_apply_exec_rendered.go new file mode 100644 index 0000000..1c22225 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_apply_exec_rendered.go @@ -0,0 +1,195 @@ +package app + +import ( + "encoding/base64" + "net/http" + transportcfgpkg "selective-vpn-api/app/transportcfg" + "strings" +) + +func executeTransportSingBoxProfileApplyRenderedLocked( + st singBoxProfilesState, + idx int, + cur SingBoxProfile, + body SingBoxProfileApplyRequest, + eval singBoxProfileEvalResult, + now string, + renderPath string, + restart bool, +) (int, SingBoxProfileApplyResponse) { + metaClientID := "" + if cur.Meta != nil { + metaClientID = strings.TrimSpace(asString(cur.Meta["client_id"])) + } + client, code, msg := resolveSingBoxApplyClient(body.ClientID, metaClientID) + effectiveClientID := "" + if client != nil { + effectiveClientID = client.ID + } + configPath := strings.TrimSpace(body.ConfigPath) + if configPath == "" && client != nil { + configPath = transportSingBoxConfigPath(*client) + } + if configPath == "" { + configPath = defaultSingBoxAppliedConfigPath(cur.ID) + } + if !body.SkipRuntime && client == nil { + return http.StatusOK, SingBoxProfileApplyResponse{ + OK: false, + Message: msg, + Code: code, + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + RenderPath: renderPath, + RenderDigest: eval.Digest, + Valid: true, + Warnings: eval.Warnings, + Diff: eval.Diff, + } + } + + prevCfg, prevExists, err := transportcfgpkg.ReadFileOptional(configPath) + if err != nil { + return http.StatusInternalServerError, SingBoxProfileApplyResponse{ + OK: false, + Code: singBoxProfileCodeApplyFailed, + Message: "read target config failed: " + err.Error(), + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + RenderPath: renderPath, + RenderDigest: eval.Digest, + Valid: true, + Warnings: eval.Warnings, + Diff: eval.Diff, + } + } + if err := transportcfgpkg.WriteJSONConfigFile(configPath, eval.Config); err != nil { + return http.StatusInternalServerError, SingBoxProfileApplyResponse{ + OK: false, + Code: singBoxProfileCodeApplyFailed, + Message: "write target config failed: " + err.Error(), + ProfileID: cur.ID, + ClientID: effectiveClientID, + ConfigPath: configPath, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + RenderPath: renderPath, + RenderDigest: eval.Digest, + Valid: true, + Warnings: eval.Warnings, + Diff: eval.Diff, + } + } + + if !body.SkipRuntime && client != nil { + if runtimeErr := applySingBoxRuntime(*client, restart); runtimeErr != nil { + _ = transportcfgpkg.RestoreFileOptional(configPath, prevCfg, prevExists) + cur.LastError = runtimeErr.Error() + cur.UpdatedAt = now + st.Items[idx] = cur + _ = saveSingBoxProfilesState(st) + _ = appendSingBoxHistory(singBoxProfileHistoryRecord{ + At: now, + ProfileID: cur.ID, + Action: "apply", + Status: "failed", + Code: singBoxProfileCodeRuntimeFailed, + Message: runtimeErr.Error(), + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + RenderDigest: eval.Digest, + RenderPath: renderPath, + ClientID: effectiveClientID, + ConfigPath: configPath, + Diff: eval.Diff, + Warnings: eval.Warnings, + }) + events.push("singbox_profile_failed", map[string]any{ + "id": cur.ID, + "step": "apply-runtime", + }) + return http.StatusOK, SingBoxProfileApplyResponse{ + OK: false, + Message: runtimeErr.Error(), + Code: singBoxProfileCodeRuntimeFailed, + ProfileID: cur.ID, + ClientID: effectiveClientID, + ConfigPath: configPath, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + RenderPath: renderPath, + RenderDigest: eval.Digest, + RollbackAvailable: true, + Valid: true, + Warnings: eval.Warnings, + Diff: eval.Diff, + } + } + } + + cur.LastValidatedAt = now + cur.LastAppliedAt = now + cur.LastError = "" + cur.UpdatedAt = now + cur.RenderRevision = cur.RenderRevision + 1 + st.Items[idx] = cur + if err := saveSingBoxProfilesState(st); err != nil { + return http.StatusInternalServerError, SingBoxProfileApplyResponse{ + OK: false, + Code: singBoxProfileCodeSaveFailed, + Message: "save failed: " + err.Error(), + ProfileID: cur.ID, + ClientID: effectiveClientID, + ConfigPath: configPath, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + RenderPath: renderPath, + RenderDigest: eval.Digest, + Valid: true, + Warnings: eval.Warnings, + Diff: eval.Diff, + } + } + + _ = appendSingBoxHistory(singBoxProfileHistoryRecord{ + At: now, + ProfileID: cur.ID, + Action: "apply", + Status: "success", + Message: "applied", + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + RenderDigest: eval.Digest, + RenderPath: renderPath, + ClientID: effectiveClientID, + ConfigPath: configPath, + PrevConfigB64: base64.StdEncoding.EncodeToString(prevCfg), + PrevConfigExist: prevExists, + Diff: eval.Diff, + Warnings: eval.Warnings, + }) + events.push("singbox_profile_applied", map[string]any{ + "id": cur.ID, + "client_id": effectiveClientID, + "render_revision": cur.RenderRevision, + }) + + return http.StatusOK, SingBoxProfileApplyResponse{ + OK: true, + Message: "applied", + ProfileID: cur.ID, + ClientID: effectiveClientID, + ConfigPath: configPath, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + LastAppliedAt: cur.LastAppliedAt, + RenderPath: renderPath, + RenderDigest: eval.Digest, + RollbackAvailable: true, + Valid: true, + Warnings: eval.Warnings, + Diff: eval.Diff, + } +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_history.go b/selective-vpn-api/app/transport_singbox_profiles_flow_history.go new file mode 100644 index 0000000..bed0477 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_history.go @@ -0,0 +1,46 @@ +package app + +import ( + "net/http" + "strconv" + "strings" +) + +func handleTransportSingBoxProfileHistory(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + limit := 50 + if s := strings.TrimSpace(r.URL.Query().Get("limit")); s != "" { + if n, err := strconv.Atoi(s); err == nil && n > 0 { + if n > 500 { + n = 500 + } + limit = n + } + } + + records, err := loadSingBoxHistoryRecords(id, limit) + if err != nil { + writeJSON(w, http.StatusInternalServerError, SingBoxProfileHistoryResponse{ + OK: false, + Code: singBoxProfileCodeRollbackFailed, + Message: "history read failed: " + err.Error(), + ProfileID: id, + }) + return + } + items := make([]SingBoxProfileHistoryEntry, 0, len(records)) + for _, it := range records { + items = append(items, it.Public()) + } + writeJSON(w, http.StatusOK, SingBoxProfileHistoryResponse{ + OK: true, + Message: "ok", + ProfileID: id, + Count: len(items), + Items: items, + }) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_render.go b/selective-vpn-api/app/transport_singbox_profiles_flow_render.go new file mode 100644 index 0000000..7af07f9 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_render.go @@ -0,0 +1,45 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" +) + +func handleTransportSingBoxProfileRender(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, checkBinary, persist, err := decodeTransportSingBoxProfileRenderRequest(r) + if err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + singBoxProfilesMu.Lock() + defer singBoxProfilesMu.Unlock() + + status, resp := executeTransportSingBoxProfileRenderLocked(id, body, checkBinary, persist) + writeJSON(w, status, resp) +} + +func decodeTransportSingBoxProfileRenderRequest(r *http.Request) (SingBoxProfileRenderRequest, bool, bool, error) { + var body SingBoxProfileRenderRequest + 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 { + return SingBoxProfileRenderRequest{}, false, false, err + } + } + checkBinary := true + if body.CheckBinary != nil { + checkBinary = *body.CheckBinary + } + persist := true + if body.Persist != nil { + persist = *body.Persist + } + return body, checkBinary, persist, nil +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_render_exec.go b/selective-vpn-api/app/transport_singbox_profiles_flow_render_exec.go new file mode 100644 index 0000000..eef8dee --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_render_exec.go @@ -0,0 +1,145 @@ +package app + +import ( + "net/http" + "time" +) + +func executeTransportSingBoxProfileRenderLocked( + id string, + body SingBoxProfileRenderRequest, + checkBinary bool, + persist bool, +) (int, SingBoxProfileRenderResponse) { + st := loadSingBoxProfilesState() + idx := findSingBoxProfileIndex(st.Items, id) + if idx < 0 { + return http.StatusNotFound, SingBoxProfileRenderResponse{ + OK: false, + Code: singBoxProfileCodeNotFound, + Message: "not found", + } + } + cur := st.Items[idx] + if body.BaseRevision > 0 && body.BaseRevision != cur.ProfileRevision { + return http.StatusConflict, SingBoxProfileRenderResponse{ + OK: false, + Code: singBoxProfileCodeRevisionMismatch, + Message: "base_revision mismatch", + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + } + } + + eval := evaluateSingBoxProfile(cur, checkBinary) + if !eval.Valid { + now := time.Now().UTC().Format(time.RFC3339Nano) + cur.LastError = joinSingBoxIssueMessages(eval.Errors) + cur.UpdatedAt = now + st.Items[idx] = cur + _ = saveSingBoxProfilesState(st) + _ = appendSingBoxHistory(singBoxProfileHistoryRecord{ + At: now, + ProfileID: cur.ID, + Action: "render", + Status: "failed", + Code: singBoxProfileCodeRenderFailed, + Message: "render validation failed", + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + Errors: eval.Errors, + Warnings: eval.Warnings, + Diff: eval.Diff, + }) + events.push("singbox_profile_failed", map[string]any{ + "id": cur.ID, + "step": "render", + }) + return http.StatusOK, SingBoxProfileRenderResponse{ + OK: false, + Message: "render validation failed", + Code: singBoxProfileCodeRenderFailed, + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + Valid: false, + Errors: eval.Errors, + Warnings: eval.Warnings, + Diff: eval.Diff, + } + } + + renderPath, err := writeSingBoxRenderedConfig(cur.ID, eval.Config) + if err != nil { + return http.StatusInternalServerError, SingBoxProfileRenderResponse{ + OK: false, + Code: singBoxProfileCodeRenderFailed, + Message: "write rendered config failed: " + err.Error(), + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + Valid: true, + Warnings: eval.Warnings, + Diff: eval.Diff, + } + } + + renderRevision := cur.RenderRevision + now := time.Now().UTC().Format(time.RFC3339Nano) + if persist { + renderRevision = cur.RenderRevision + 1 + cur.RenderRevision = renderRevision + cur.LastError = "" + cur.UpdatedAt = now + st.Items[idx] = cur + if err := saveSingBoxProfilesState(st); err != nil { + return http.StatusInternalServerError, SingBoxProfileRenderResponse{ + OK: false, + Code: singBoxProfileCodeSaveFailed, + Message: "save failed: " + err.Error(), + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + Valid: true, + Warnings: eval.Warnings, + Diff: eval.Diff, + } + } + } else { + renderRevision++ + } + + _ = appendSingBoxHistory(singBoxProfileHistoryRecord{ + At: now, + ProfileID: cur.ID, + Action: "render", + Status: "success", + Message: "rendered", + ProfileRevision: cur.ProfileRevision, + RenderRevision: renderRevision, + RenderDigest: eval.Digest, + RenderPath: renderPath, + Warnings: eval.Warnings, + Diff: eval.Diff, + }) + events.push("singbox_profile_rendered", map[string]any{ + "id": cur.ID, + "render_revision": renderRevision, + }) + + return http.StatusOK, SingBoxProfileRenderResponse{ + OK: true, + Message: "rendered", + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + RenderRevision: renderRevision, + RenderPath: renderPath, + RenderDigest: eval.Digest, + Changed: eval.Changed, + Valid: true, + Warnings: eval.Warnings, + Diff: eval.Diff, + Config: eval.Config, + } +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_rollback.go b/selective-vpn-api/app/transport_singbox_profiles_flow_rollback.go new file mode 100644 index 0000000..9917be9 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_rollback.go @@ -0,0 +1,41 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" +) + +func handleTransportSingBoxProfileRollback(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, restart, err := decodeTransportSingBoxProfileRollbackRequest(r) + if err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + singBoxProfilesMu.Lock() + defer singBoxProfilesMu.Unlock() + + status, resp := executeTransportSingBoxProfileRollbackLocked(id, body, restart) + writeJSON(w, status, resp) +} + +func decodeTransportSingBoxProfileRollbackRequest(r *http.Request) (SingBoxProfileRollbackRequest, bool, error) { + var body SingBoxProfileRollbackRequest + 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 { + return SingBoxProfileRollbackRequest{}, false, err + } + } + restart := true + if body.Restart != nil { + restart = *body.Restart + } + return body, restart, nil +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_rollback_exec.go b/selective-vpn-api/app/transport_singbox_profiles_flow_rollback_exec.go new file mode 100644 index 0000000..76a055e --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_rollback_exec.go @@ -0,0 +1,124 @@ +package app + +import ( + "net/http" + transportcfgpkg "selective-vpn-api/app/transportcfg" + "strings" +) + +func executeTransportSingBoxProfileRollbackLocked( + id string, + body SingBoxProfileRollbackRequest, + restart bool, +) (int, SingBoxProfileRollbackResponse) { + st := loadSingBoxProfilesState() + idx := findSingBoxProfileIndex(st.Items, id) + if idx < 0 { + return http.StatusNotFound, SingBoxProfileRollbackResponse{ + OK: false, + Code: singBoxProfileCodeNotFound, + Message: "not found", + } + } + cur := st.Items[idx] + if body.BaseRevision > 0 && body.BaseRevision != cur.ProfileRevision { + return http.StatusConflict, SingBoxProfileRollbackResponse{ + OK: false, + Code: singBoxProfileCodeRevisionMismatch, + Message: "base_revision mismatch", + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + } + } + + records, err := loadSingBoxHistoryRecords(cur.ID, 0) + if err != nil { + return http.StatusInternalServerError, SingBoxProfileRollbackResponse{ + OK: false, + Code: singBoxProfileCodeRollbackFailed, + Message: "read history failed: " + err.Error(), + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + } + } + cand, ok := selectSingBoxRollbackCandidate(records, strings.TrimSpace(body.HistoryID)) + if !ok { + return http.StatusOK, SingBoxProfileRollbackResponse{ + OK: false, + Code: singBoxProfileCodeHistoryMissing, + Message: "rollback snapshot not found", + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + } + } + + clientID := strings.TrimSpace(body.ClientID) + if clientID == "" { + clientID = strings.TrimSpace(cand.ClientID) + } + configPath := strings.TrimSpace(body.ConfigPath) + if configPath == "" { + configPath = strings.TrimSpace(cand.ConfigPath) + } + + client, code, msg := resolveSingBoxApplyClient(clientID, "") + if !body.SkipRuntime { + if client == nil { + return http.StatusOK, SingBoxProfileRollbackResponse{ + OK: false, + Code: code, + Message: msg, + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + HistoryID: cand.ID, + } + } + if configPath == "" { + configPath = transportSingBoxConfigPath(*client) + } + } + if configPath == "" { + configPath = defaultSingBoxAppliedConfigPath(cur.ID) + } + + prevData, prevExists, err := decodeHistoryPrevConfig(cand) + if err != nil { + return http.StatusInternalServerError, SingBoxProfileRollbackResponse{ + OK: false, + Code: singBoxProfileCodeRollbackFailed, + Message: "decode rollback snapshot failed: " + err.Error(), + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + HistoryID: cand.ID, + ClientID: clientID, + ConfigPath: configPath, + } + } + + currentData, currentExists, _ := transportcfgpkg.ReadFileOptional(configPath) + if err := transportcfgpkg.RestoreFileOptional(configPath, prevData, prevExists); err != nil { + return http.StatusInternalServerError, SingBoxProfileRollbackResponse{ + OK: false, + Code: singBoxProfileCodeRollbackFailed, + Message: "restore config failed: " + err.Error(), + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + HistoryID: cand.ID, + ClientID: clientID, + ConfigPath: configPath, + } + } + return executeTransportSingBoxProfileRollbackRestoredLocked( + st, + idx, + cur, + body, + cand, + clientID, + configPath, + client, + currentData, + currentExists, + restart, + ) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_rollback_exec_restored.go b/selective-vpn-api/app/transport_singbox_profiles_flow_rollback_exec_restored.go new file mode 100644 index 0000000..9d9530f --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_rollback_exec_restored.go @@ -0,0 +1,110 @@ +package app + +import ( + "encoding/base64" + "net/http" + transportcfgpkg "selective-vpn-api/app/transportcfg" + "time" +) + +func executeTransportSingBoxProfileRollbackRestoredLocked( + st singBoxProfilesState, + idx int, + cur SingBoxProfile, + body SingBoxProfileRollbackRequest, + cand singBoxProfileHistoryRecord, + clientID string, + configPath string, + client *TransportClient, + currentData []byte, + currentExists bool, + restart bool, +) (int, SingBoxProfileRollbackResponse) { + now := time.Now().UTC().Format(time.RFC3339Nano) + if !body.SkipRuntime && client != nil { + if runtimeErr := applySingBoxRuntime(*client, restart); runtimeErr != nil { + _ = transportcfgpkg.RestoreFileOptional(configPath, currentData, currentExists) + cur.LastError = runtimeErr.Error() + cur.UpdatedAt = now + st.Items[idx] = cur + _ = saveSingBoxProfilesState(st) + _ = appendSingBoxHistory(singBoxProfileHistoryRecord{ + At: now, + ProfileID: cur.ID, + Action: "rollback", + Status: "failed", + Code: singBoxProfileCodeRuntimeFailed, + Message: runtimeErr.Error(), + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + ClientID: client.ID, + ConfigPath: configPath, + }) + events.push("singbox_profile_failed", map[string]any{ + "id": cur.ID, + "step": "rollback-runtime", + }) + return http.StatusOK, SingBoxProfileRollbackResponse{ + OK: false, + Code: singBoxProfileCodeRuntimeFailed, + Message: runtimeErr.Error(), + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + HistoryID: cand.ID, + ClientID: client.ID, + ConfigPath: configPath, + } + } + } + + cur.LastAppliedAt = now + cur.LastError = "" + cur.UpdatedAt = now + st.Items[idx] = cur + if err := saveSingBoxProfilesState(st); err != nil { + return http.StatusInternalServerError, SingBoxProfileRollbackResponse{ + OK: false, + Code: singBoxProfileCodeSaveFailed, + Message: "save failed: " + err.Error(), + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + HistoryID: cand.ID, + ClientID: clientID, + ConfigPath: configPath, + } + } + + effectiveClientID := clientID + if client != nil { + effectiveClientID = client.ID + } + _ = appendSingBoxHistory(singBoxProfileHistoryRecord{ + At: now, + ProfileID: cur.ID, + Action: "rollback", + Status: "success", + Message: "rollback applied", + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + ClientID: effectiveClientID, + ConfigPath: configPath, + PrevConfigB64: base64.StdEncoding.EncodeToString(currentData), + PrevConfigExist: currentExists, + }) + events.push("singbox_profile_rollback", map[string]any{ + "id": cur.ID, + "history_id": cand.ID, + "client_id": effectiveClientID, + }) + + return http.StatusOK, SingBoxProfileRollbackResponse{ + OK: true, + Message: "rollback applied", + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + LastAppliedAt: cur.LastAppliedAt, + HistoryID: cand.ID, + ClientID: effectiveClientID, + ConfigPath: configPath, + } +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_test.go b/selective-vpn-api/app/transport_singbox_profiles_flow_test.go new file mode 100644 index 0000000..1f87b6f --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_test.go @@ -0,0 +1,433 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + transportcfgpkg "selective-vpn-api/app/transportcfg" + "strconv" + "strings" + "testing" + "time" +) + +func TestSingBoxProfileFlowRenderApplyRollbackHistory(t *testing.T) { + tmp := t.TempDir() + oldStatePath := singBoxProfilesStatePath + oldSecretsDir := singBoxSecretsRootDir + oldRenderedDir := singBoxRenderedRootDir + oldHistoryDir := singBoxHistoryRootDir + oldAppliedDir := singBoxAppliedRootDir + singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json") + singBoxSecretsRootDir = filepath.Join(tmp, "transport", "secrets", "singbox") + singBoxRenderedRootDir = filepath.Join(tmp, "transport", "singbox-rendered") + singBoxHistoryRootDir = filepath.Join(tmp, "transport", "singbox-history") + singBoxAppliedRootDir = filepath.Join(tmp, "transport", "singbox-applied") + t.Cleanup(func() { + singBoxProfilesStatePath = oldStatePath + singBoxSecretsRootDir = oldSecretsDir + singBoxRenderedRootDir = oldRenderedDir + singBoxHistoryRootDir = oldHistoryDir + singBoxAppliedRootDir = oldAppliedDir + }) + + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles", strings.NewReader(`{ + "id":"e5-flow", + "name":"E5 Flow", + "mode":"typed", + "protocol":"vless", + "typed":{"server":"a.example.org","port":443} + }`)) + createRec := httptest.NewRecorder() + handleTransportSingBoxProfiles(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create status=%d body=%s", createRec.Code, createRec.Body.String()) + } + var createResp SingBoxProfilesResponse + if err := json.Unmarshal(createRec.Body.Bytes(), &createResp); err != nil { + t.Fatalf("decode create response: %v", err) + } + if !createResp.OK || createResp.Item == nil { + t.Fatalf("create failed: %#v", createResp) + } + profileID := createResp.Item.ID + baseRev := createResp.Item.ProfileRevision + + validateReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/validate", strings.NewReader(`{"check_binary":false}`)) + validateRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(validateRec, validateReq) + if validateRec.Code != http.StatusOK { + t.Fatalf("validate status=%d body=%s", validateRec.Code, validateRec.Body.String()) + } + var validateResp SingBoxProfileValidateResponse + if err := json.Unmarshal(validateRec.Body.Bytes(), &validateResp); err != nil { + t.Fatalf("decode validate response: %v", err) + } + if !validateResp.OK || !validateResp.Valid { + t.Fatalf("validate must be valid: %#v", validateResp) + } + + renderReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/render", strings.NewReader(`{"check_binary":false}`)) + renderRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(renderRec, renderReq) + if renderRec.Code != http.StatusOK { + t.Fatalf("render status=%d body=%s", renderRec.Code, renderRec.Body.String()) + } + var renderResp SingBoxProfileRenderResponse + if err := json.Unmarshal(renderRec.Body.Bytes(), &renderResp); err != nil { + t.Fatalf("decode render response: %v", err) + } + if !renderResp.OK || !renderResp.Valid { + t.Fatalf("render must be valid: %#v", renderResp) + } + if _, err := os.Stat(renderResp.RenderPath); err != nil { + t.Fatalf("rendered file missing: %v", err) + } + + targetCfg := filepath.Join(tmp, "runtime", "singbox.json") + applyReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/apply", strings.NewReader(`{ + "config_path":"`+targetCfg+`", + "skip_runtime":true, + "check_binary":false + }`)) + applyRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(applyRec, applyReq) + if applyRec.Code != http.StatusOK { + t.Fatalf("apply status=%d body=%s", applyRec.Code, applyRec.Body.String()) + } + var applyResp SingBoxProfileApplyResponse + if err := json.Unmarshal(applyRec.Body.Bytes(), &applyResp); err != nil { + t.Fatalf("decode apply response: %v", err) + } + if !applyResp.OK { + t.Fatalf("apply failed: %#v", applyResp) + } + cfgA, err := os.ReadFile(targetCfg) + if err != nil { + t.Fatalf("read applied config A: %v", err) + } + if !strings.Contains(string(cfgA), `"a.example.org"`) { + t.Fatalf("applied config must contain first server") + } + + patchReq := httptest.NewRequest(http.MethodPatch, "/api/v1/transport/singbox/profiles/"+profileID, strings.NewReader(`{ + "base_revision":`+itoa(baseRev)+`, + "typed":{"server":"b.example.org","port":443} + }`)) + patchRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(patchRec, patchReq) + if patchRec.Code != http.StatusOK { + t.Fatalf("patch status=%d body=%s", patchRec.Code, patchRec.Body.String()) + } + var patchResp SingBoxProfilesResponse + if err := json.Unmarshal(patchRec.Body.Bytes(), &patchResp); err != nil { + t.Fatalf("decode patch response: %v", err) + } + if !patchResp.OK || patchResp.Item == nil { + t.Fatalf("patch failed: %#v", patchResp) + } + + apply2Req := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/apply", strings.NewReader(`{ + "config_path":"`+targetCfg+`", + "skip_runtime":true, + "check_binary":false + }`)) + apply2Rec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(apply2Rec, apply2Req) + if apply2Rec.Code != http.StatusOK { + t.Fatalf("apply2 status=%d body=%s", apply2Rec.Code, apply2Rec.Body.String()) + } + var apply2Resp SingBoxProfileApplyResponse + if err := json.Unmarshal(apply2Rec.Body.Bytes(), &apply2Resp); err != nil { + t.Fatalf("decode apply2 response: %v", err) + } + if !apply2Resp.OK { + t.Fatalf("apply2 failed: %#v", apply2Resp) + } + cfgB, err := os.ReadFile(targetCfg) + if err != nil { + t.Fatalf("read applied config B: %v", err) + } + if !strings.Contains(string(cfgB), `"b.example.org"`) { + t.Fatalf("applied config must contain second server") + } + + rollbackReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/rollback", strings.NewReader(`{ + "config_path":"`+targetCfg+`", + "skip_runtime":true + }`)) + rollbackRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(rollbackRec, rollbackReq) + if rollbackRec.Code != http.StatusOK { + t.Fatalf("rollback status=%d body=%s", rollbackRec.Code, rollbackRec.Body.String()) + } + var rollbackResp SingBoxProfileRollbackResponse + if err := json.Unmarshal(rollbackRec.Body.Bytes(), &rollbackResp); err != nil { + t.Fatalf("decode rollback response: %v", err) + } + if !rollbackResp.OK { + t.Fatalf("rollback failed: %#v", rollbackResp) + } + cfgAfterRollback, err := os.ReadFile(targetCfg) + if err != nil { + t.Fatalf("read config after rollback: %v", err) + } + if string(cfgAfterRollback) != string(cfgA) { + t.Fatalf("rollback must restore first config") + } + + historyReq := httptest.NewRequest(http.MethodGet, "/api/v1/transport/singbox/profiles/"+profileID+"/history?limit=20", nil) + historyRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(historyRec, historyReq) + if historyRec.Code != http.StatusOK { + t.Fatalf("history status=%d body=%s", historyRec.Code, historyRec.Body.String()) + } + var historyResp SingBoxProfileHistoryResponse + if err := json.Unmarshal(historyRec.Body.Bytes(), &historyResp); err != nil { + t.Fatalf("decode history response: %v", err) + } + if !historyResp.OK || historyResp.Count == 0 { + t.Fatalf("history must return items: %#v", historyResp) + } + hasRollback := false + for _, it := range historyResp.Items { + if it.Action == "rollback" && it.Status == "success" { + hasRollback = true + break + } + } + if !hasRollback { + t.Fatalf("history must contain successful rollback action") + } +} + +func TestSingBoxProfileValidateFail(t *testing.T) { + tmp := t.TempDir() + oldStatePath := singBoxProfilesStatePath + oldSecretsDir := singBoxSecretsRootDir + oldRenderedDir := singBoxRenderedRootDir + oldHistoryDir := singBoxHistoryRootDir + oldAppliedDir := singBoxAppliedRootDir + singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json") + singBoxSecretsRootDir = filepath.Join(tmp, "transport", "secrets", "singbox") + singBoxRenderedRootDir = filepath.Join(tmp, "transport", "singbox-rendered") + singBoxHistoryRootDir = filepath.Join(tmp, "transport", "singbox-history") + singBoxAppliedRootDir = filepath.Join(tmp, "transport", "singbox-applied") + t.Cleanup(func() { + singBoxProfilesStatePath = oldStatePath + singBoxSecretsRootDir = oldSecretsDir + singBoxRenderedRootDir = oldRenderedDir + singBoxHistoryRootDir = oldHistoryDir + singBoxAppliedRootDir = oldAppliedDir + }) + + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles", strings.NewReader(`{ + "id":"invalid-typed", + "mode":"typed", + "typed":{"port":443} + }`)) + createRec := httptest.NewRecorder() + handleTransportSingBoxProfiles(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create status=%d body=%s", createRec.Code, createRec.Body.String()) + } + + validateReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/invalid-typed/validate", strings.NewReader(`{"check_binary":false}`)) + validateRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(validateRec, validateReq) + if validateRec.Code != http.StatusOK { + t.Fatalf("validate status=%d body=%s", validateRec.Code, validateRec.Body.String()) + } + var resp SingBoxProfileValidateResponse + if err := json.Unmarshal(validateRec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.OK || resp.Valid { + t.Fatalf("validate must fail for incomplete typed profile: %#v", resp) + } + if len(resp.Errors) == 0 { + t.Fatalf("validation errors expected") + } +} + +func TestNormalizeSingBoxRenderedConfigDropsLegacyPacketEncodingNone(t *testing.T) { + cfg := map[string]any{ + "outbounds": []any{ + map[string]any{ + "type": "vless", + "packet_encoding": "none", + }, + map[string]any{ + "type": "vless", + "packet_encoding": "xudp", + }, + map[string]any{ + "type": "trojan", + "packet_encoding": "none", + }, + }, + } + + out := transportcfgpkg.NormalizeSingBoxRenderedConfig(cfg, asString) + rows, _ := out["outbounds"].([]any) + if len(rows) != 3 { + t.Fatalf("unexpected outbounds len: %d", len(rows)) + } + vlessLegacy, _ := rows[0].(map[string]any) + if _, ok := vlessLegacy["packet_encoding"]; ok { + t.Fatalf("legacy vless packet_encoding=none must be removed: %#v", vlessLegacy) + } + vlessXUDP, _ := rows[1].(map[string]any) + if got := strings.TrimSpace(asString(vlessXUDP["packet_encoding"])); got != "xudp" { + t.Fatalf("xudp must be preserved, got=%q", got) + } + trojan, _ := rows[2].(map[string]any) + if got := strings.TrimSpace(asString(trojan["packet_encoding"])); got != "none" { + t.Fatalf("non-vless packet_encoding must be unchanged, got=%q", got) + } +} + +func TestNormalizeSingBoxRenderedConfigDropsVLESSFlowNone(t *testing.T) { + cfg := map[string]any{ + "outbounds": []any{ + map[string]any{ + "type": "vless", + "flow": "none", + }, + map[string]any{ + "type": "vless", + "flow": "xtls-rprx-vision", + }, + map[string]any{ + "type": "trojan", + "flow": "none", + }, + }, + } + + out := transportcfgpkg.NormalizeSingBoxRenderedConfig(cfg, asString) + rows, _ := out["outbounds"].([]any) + if len(rows) != 3 { + t.Fatalf("unexpected outbounds len: %d", len(rows)) + } + vlessNone, _ := rows[0].(map[string]any) + if _, ok := vlessNone["flow"]; ok { + t.Fatalf("legacy vless flow=none must be removed: %#v", vlessNone) + } + vlessVision, _ := rows[1].(map[string]any) + if got := strings.TrimSpace(asString(vlessVision["flow"])); got != "xtls-rprx-vision" { + t.Fatalf("vless valid flow must be preserved, got=%q", got) + } + trojan, _ := rows[2].(map[string]any) + if got := strings.TrimSpace(asString(trojan["flow"])); got != "none" { + t.Fatalf("non-vless flow must be unchanged, got=%q", got) + } +} + +func TestPrepareSingBoxClientProfileWritesConfigForLinkedClient(t *testing.T) { + tmp := t.TempDir() + oldStatePath := singBoxProfilesStatePath + oldRenderedDir := singBoxRenderedRootDir + oldAppliedDir := singBoxAppliedRootDir + singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json") + singBoxRenderedRootDir = filepath.Join(tmp, "transport", "singbox-rendered") + singBoxAppliedRootDir = filepath.Join(tmp, "transport", "singbox-applied") + t.Cleanup(func() { + singBoxProfilesStatePath = oldStatePath + singBoxRenderedRootDir = oldRenderedDir + singBoxAppliedRootDir = oldAppliedDir + }) + + profile := SingBoxProfile{ + ID: "c-sg", + Name: "Linked SG", + Mode: SingBoxProfileModeRaw, + Protocol: "vless", + Enabled: true, + SchemaVersion: 1, + RawConfig: map[string]any{ + "inbounds": []any{ + map[string]any{ + "type": "socks", + "tag": "socks-in", + "listen": "127.0.0.1", + "listen_port": 10808, + }, + }, + "outbounds": []any{ + map[string]any{ + "type": "vless", + "tag": "proxy", + "server": "example.com", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111", + "packet_encoding": "none", + }, + }, + }, + Meta: map[string]any{ + "client_id": "c-sg", + }, + ProfileRevision: 1, + } + state := singBoxProfilesState{ + Version: singBoxProfilesStateVersion, + Items: []SingBoxProfile{profile}, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Revision: 1, + } + if err := saveSingBoxProfilesState(state); err != nil { + t.Fatalf("save profile state: %v", err) + } + + configPath := filepath.Join(tmp, "runtime", "c-sg.json") + client := TransportClient{ + ID: "c-sg", + Kind: TransportClientSingBox, + Config: map[string]any{ + "config_path": configPath, + }, + } + + res := prepareSingBoxClientProfile(client, false) + if !res.OK { + t.Fatalf("prepare failed: %#v", res) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read prepared config: %v", err) + } + text := string(data) + if strings.Contains(text, "\"packet_encoding\": \"none\"") { + t.Fatalf("prepared config must not contain legacy packet_encoding=none: %s", text) + } + if !strings.Contains(text, "\"server\": \"example.com\"") { + t.Fatalf("prepared config must contain outbound server: %s", text) + } +} + +func TestFindSingBoxProfileIndexForClient(t *testing.T) { + st := singBoxProfilesState{ + Items: []SingBoxProfile{ + {ID: "p-1", ProfileRevision: 1, Meta: map[string]any{"client_id": "c-1"}}, + {ID: "p-2", ProfileRevision: 3, Meta: map[string]any{"client_id": "c-1"}}, + {ID: "c-2", ProfileRevision: 1}, + }, + } + if idx := findSingBoxProfileIndexForClient(st, "c-1"); idx != 1 { + t.Fatalf("expected highest revision linked profile idx=1, got=%d", idx) + } + if idx := findSingBoxProfileIndexForClient(st, "c-2"); idx != 2 { + t.Fatalf("expected id fallback idx=2, got=%d", idx) + } + if idx := findSingBoxProfileIndexForClient(st, "missing"); idx != -1 { + t.Fatalf("expected missing=-1, got=%d", idx) + } +} + +func itoa(n int64) string { + return strconv.FormatInt(n, 10) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_flow_validate.go b/selective-vpn-api/app/transport_singbox_profiles_flow_validate.go new file mode 100644 index 0000000..c47b413 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_flow_validate.go @@ -0,0 +1,119 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "time" +) + +func handleTransportSingBoxProfileValidate(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var body SingBoxProfileValidateRequest + 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 + } + } + + checkBinary := true + if body.CheckBinary != nil { + checkBinary = *body.CheckBinary + } + + singBoxProfilesMu.Lock() + defer singBoxProfilesMu.Unlock() + + st := loadSingBoxProfilesState() + idx := findSingBoxProfileIndex(st.Items, id) + if idx < 0 { + writeJSON(w, http.StatusNotFound, SingBoxProfileValidateResponse{ + OK: false, + Code: singBoxProfileCodeNotFound, + Message: "not found", + }) + return + } + cur := st.Items[idx] + if body.BaseRevision > 0 && body.BaseRevision != cur.ProfileRevision { + writeJSON(w, http.StatusConflict, SingBoxProfileValidateResponse{ + OK: false, + Code: singBoxProfileCodeRevisionMismatch, + Message: "base_revision mismatch", + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + }) + return + } + + eval := evaluateSingBoxProfile(cur, checkBinary) + now := time.Now().UTC().Format(time.RFC3339Nano) + cur.LastValidatedAt = now + cur.UpdatedAt = now + if eval.Valid { + cur.LastError = "" + } else { + cur.LastError = joinSingBoxIssueMessages(eval.Errors) + } + st.Items[idx] = cur + if err := saveSingBoxProfilesState(st); err != nil { + writeJSON(w, http.StatusInternalServerError, SingBoxProfileValidateResponse{ + OK: false, + Code: singBoxProfileCodeSaveFailed, + Message: "save failed: " + err.Error(), + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + }) + return + } + + status := "success" + eventKind := "singbox_profile_validated" + respCode := "" + respMsg := "valid" + if !eval.Valid { + status = "failed" + eventKind = "singbox_profile_failed" + respCode = singBoxProfileCodeValidationFailed + respMsg = "validation failed" + } + _ = appendSingBoxHistory(singBoxProfileHistoryRecord{ + At: now, + ProfileID: cur.ID, + Action: "validate", + Status: status, + Code: respCode, + Message: respMsg, + ProfileRevision: cur.ProfileRevision, + RenderRevision: cur.RenderRevision, + RenderDigest: eval.Digest, + Diff: eval.Diff, + Errors: eval.Errors, + Warnings: eval.Warnings, + }) + events.push(eventKind, map[string]any{ + "id": cur.ID, + "valid": eval.Valid, + "errors": len(eval.Errors), + "warning": len(eval.Warnings), + }) + + writeJSON(w, http.StatusOK, SingBoxProfileValidateResponse{ + OK: eval.Valid, + Message: respMsg, + Code: respCode, + ProfileID: cur.ID, + ProfileRevision: cur.ProfileRevision, + Valid: eval.Valid, + Errors: eval.Errors, + Warnings: eval.Warnings, + RenderDigest: eval.Digest, + Diff: eval.Diff, + }) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_history.go b/selective-vpn-api/app/transport_singbox_profiles_history.go new file mode 100644 index 0000000..c9a8158 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_history.go @@ -0,0 +1,84 @@ +package app + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + transportcfgpkg "selective-vpn-api/app/transportcfg" +) + +func appendSingBoxHistory(rec singBoxProfileHistoryRecord) error { + profileID := sanitizeID(rec.ProfileID) + if profileID == "" { + return fmt.Errorf("empty profile id") + } + rec.ProfileID = profileID + if strings.TrimSpace(rec.At) == "" { + rec.At = time.Now().UTC().Format(time.RFC3339Nano) + } + if strings.TrimSpace(rec.ID) == "" { + rec.ID = "h-" + newTransportToken(8) + } + if rec.Action == "" { + rec.Action = "unknown" + } + if rec.Status == "" { + rec.Status = "success" + } + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + stamp := transportcfgpkg.SanitizeHistoryStamp(rec.At, time.Now().UTC()) + fileName := fmt.Sprintf("%s-%s-%s-%s.json", stamp, rec.ProfileID, rec.Action, rec.ID) + return transportcfgpkg.WriteFileAtomic(filepath.Join(singBoxHistoryRootDir, fileName), append(data, '\n'), 0o644) +} + +func loadSingBoxHistoryRecords(profileID string, limit int) ([]singBoxProfileHistoryRecord, error) { + rawRecords, err := transportcfgpkg.ReadJSONFiles(singBoxHistoryRootDir) + if err != nil { + return nil, err + } + id := sanitizeID(profileID) + out := make([]singBoxProfileHistoryRecord, 0, len(rawRecords)) + for _, data := range rawRecords { + var rec singBoxProfileHistoryRecord + if err := json.Unmarshal(data, &rec); err != nil { + continue + } + if sanitizeID(rec.ProfileID) != id { + continue + } + out = append(out, rec) + } + transportcfgpkg.SortRecordsDescByAt(out, func(rec singBoxProfileHistoryRecord) string { return rec.At }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func selectSingBoxRollbackCandidate(records []singBoxProfileHistoryRecord, historyID string) (singBoxProfileHistoryRecord, bool) { + return transportcfgpkg.SelectRecordCandidate( + records, + historyID, + func(rec singBoxProfileHistoryRecord) string { return rec.ID }, + func(rec singBoxProfileHistoryRecord) bool { return rec.Action == "apply" && rec.Status == "success" }, + ) +} + +func decodeHistoryPrevConfig(rec singBoxProfileHistoryRecord) ([]byte, bool, error) { + return transportcfgpkg.DecodeBase64Optional(rec.PrevConfigB64, rec.PrevConfigExist) +} + +func joinSingBoxIssueMessages(issues []SingBoxProfileIssue) string { + parts := make([]string, 0, len(issues)) + for _, it := range issues { + parts = append(parts, it.Message) + } + return transportcfgpkg.JoinMessages(parts) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_list.go b/selective-vpn-api/app/transport_singbox_profiles_list.go new file mode 100644 index 0000000..2b2da18 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_list.go @@ -0,0 +1,108 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" +) + +func handleTransportSingBoxProfiles(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + enabledOnly := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("enabled_only")), "true") + modeRaw := strings.TrimSpace(r.URL.Query().Get("mode")) + modeFilter := SingBoxProfileMode("") + if modeRaw != "" { + parsed, ok := normalizeSingBoxProfileMode(SingBoxProfileMode(modeRaw)) + if !ok { + writeJSON(w, http.StatusBadRequest, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeBadMode, + Message: "mode must be typed|raw", + }) + return + } + modeFilter = parsed + } + protocolFilter := normalizeSingBoxProtocol(r.URL.Query().Get("protocol")) + + singBoxProfilesMu.Lock() + st := loadSingBoxProfilesState() + singBoxProfilesMu.Unlock() + + items := make([]SingBoxProfile, 0, len(st.Items)) + for _, it := range st.Items { + if enabledOnly && !it.Enabled { + continue + } + if modeFilter != "" && it.Mode != modeFilter { + continue + } + if protocolFilter != "" && it.Protocol != protocolFilter { + continue + } + items = append(items, it) + } + writeJSON(w, http.StatusOK, SingBoxProfilesResponse{ + OK: true, + Message: "ok", + Count: len(items), + ActiveProfileID: st.ActiveProfileID, + Items: items, + }) + case http.MethodPost: + var body SingBoxProfileCreateRequest + 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 + } + } + + singBoxProfilesMu.Lock() + defer singBoxProfilesMu.Unlock() + + st := loadSingBoxProfilesState() + item, code, err := createSingBoxProfileLocked(&st, body) + if err != nil { + status := http.StatusBadRequest + switch code { + case singBoxProfileCodeIDConflict: + status = http.StatusConflict + case singBoxProfileCodeSecretsFailed: + status = http.StatusInternalServerError + } + writeJSON(w, status, SingBoxProfilesResponse{ + OK: false, + Code: code, + Message: err.Error(), + }) + return + } + if err := saveSingBoxProfilesState(st); err != nil { + if strings.TrimSpace(item.ID) != "" { + _ = writeSingBoxSecrets(item.ID, nil) + } + writeJSON(w, http.StatusInternalServerError, SingBoxProfilesResponse{ + OK: false, + Code: singBoxProfileCodeSaveFailed, + Message: "save failed: " + err.Error(), + }) + return + } + events.push("singbox_profile_saved", map[string]any{ + "id": item.ID, + "revision": item.ProfileRevision, + }) + writeJSON(w, http.StatusOK, SingBoxProfilesResponse{ + OK: true, + Message: "created", + ActiveProfileID: st.ActiveProfileID, + Item: &item, + }) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_mutate.go b/selective-vpn-api/app/transport_singbox_profiles_mutate.go new file mode 100644 index 0000000..de05b10 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_mutate.go @@ -0,0 +1,146 @@ +package app + +import ( + transportcfgpkg "selective-vpn-api/app/transportcfg" + "strings" + "time" +) + +func createSingBoxProfileLocked(st *singBoxProfilesState, req SingBoxProfileCreateRequest) (SingBoxProfile, string, error) { + mode := SingBoxProfileModeTyped + if strings.TrimSpace(string(req.Mode)) != "" { + parsed, ok := normalizeSingBoxProfileMode(req.Mode) + if !ok { + return SingBoxProfile{}, singBoxProfileCodeBadMode, errSingBoxProfileMode() + } + mode = parsed + } + + id := sanitizeID(req.ID) + if id == "" { + id = deriveSingBoxProfileID(req.Name, req.Protocol, st.Items) + } + if id == "" { + return SingBoxProfile{}, singBoxProfileCodeBadID, errSingBoxProfileID() + } + if findSingBoxProfileIndex(st.Items, id) >= 0 { + return SingBoxProfile{}, singBoxProfileCodeIDConflict, errSingBoxProfileExists() + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + now := time.Now().UTC().Format(time.RFC3339) + item := SingBoxProfile{ + ID: id, + Name: strings.TrimSpace(req.Name), + Mode: mode, + Protocol: normalizeSingBoxProtocol(req.Protocol), + Enabled: enabled, + SchemaVersion: normalizeSingBoxSchemaVersion(req.SchemaVersion), + ProfileRevision: 1, + RenderRevision: 0, + Typed: cloneMapDeep(req.Typed), + RawConfig: cloneMapDeep(req.RawConfig), + Meta: cloneMapDeep(req.Meta), + CreatedAt: now, + UpdatedAt: now, + } + if item.Name == "" { + item.Name = id + } + + secretRes, err := applySingBoxSecretsPatch(id, false, req.Secrets) + if err != nil { + return SingBoxProfile{}, singBoxProfileCodeSecretsFailed, err + } + item.HasSecrets = secretRes.HasSecrets + item.SecretsMasked = secretRes.Masked + + st.Items = append(st.Items, item) + ensureSingBoxProfilesActiveID(st) + st.Revision++ + return item, "", nil +} + +func patchSingBoxProfile(cur SingBoxProfile, req SingBoxProfilePatchRequest) (SingBoxProfile, singBoxSecretsPatchResult, bool, string, error) { + next := cur + changed := false + now := time.Now().UTC().Format(time.RFC3339) + + if req.Name != nil { + v := strings.TrimSpace(*req.Name) + if v == "" { + v = next.ID + } + if next.Name != v { + next.Name = v + changed = true + } + } + if req.Mode != nil { + mode, ok := normalizeSingBoxProfileMode(*req.Mode) + if !ok { + return cur, singBoxSecretsPatchResult{}, false, singBoxProfileCodeBadMode, errSingBoxProfileMode() + } + if next.Mode != mode { + next.Mode = mode + changed = true + } + } + if req.Protocol != nil { + v := normalizeSingBoxProtocol(*req.Protocol) + if next.Protocol != v { + next.Protocol = v + changed = true + } + } + if req.Enabled != nil && next.Enabled != *req.Enabled { + next.Enabled = *req.Enabled + changed = true + } + if req.SchemaVersion != nil { + v := normalizeSingBoxSchemaVersion(*req.SchemaVersion) + if next.SchemaVersion != v { + next.SchemaVersion = v + changed = true + } + } + if req.Typed != nil { + next.Typed = cloneMapDeep(req.Typed) + changed = true + } + if req.RawConfig != nil { + next.RawConfig = cloneMapDeep(req.RawConfig) + changed = true + } + if req.Meta != nil { + next.Meta = cloneMapDeep(req.Meta) + changed = true + } + + secretRes := singBoxSecretsPatchResult{ + HasSecrets: next.HasSecrets, + Masked: transportcfgpkg.CloneStringMap(next.SecretsMasked), + } + if req.ClearSecrets || req.Secrets != nil { + var err error + secretRes, err = applySingBoxSecretsPatch(next.ID, req.ClearSecrets, req.Secrets) + if err != nil { + return cur, singBoxSecretsPatchResult{}, false, singBoxProfileCodeSecretsFailed, err + } + next.HasSecrets = secretRes.HasSecrets + next.SecretsMasked = secretRes.Masked + if secretRes.Changed { + changed = true + } + } + + if !changed { + return cur, secretRes, false, "", nil + } + next.ProfileRevision = cur.ProfileRevision + 1 + next.UpdatedAt = now + return next, secretRes, true, "", nil +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_runtime.go b/selective-vpn-api/app/transport_singbox_profiles_runtime.go new file mode 100644 index 0000000..a2e6dba --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_runtime.go @@ -0,0 +1,6 @@ +package app + +// Runtime helpers for SingBox profiles are split by role: +// - profile/client selection: transport_singbox_profiles_runtime_select.go +// - preflight/render/write path: transport_singbox_profiles_runtime_prepare.go +// - backend provision/start/restart/apply flow: transport_singbox_profiles_runtime_apply.go diff --git a/selective-vpn-api/app/transport_singbox_profiles_runtime_apply.go b/selective-vpn-api/app/transport_singbox_profiles_runtime_apply.go new file mode 100644 index 0000000..114951e --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_runtime_apply.go @@ -0,0 +1,73 @@ +package app + +import ( + "errors" + "strings" + "time" +) + +func applySingBoxRuntime(client TransportClient, restart bool) error { + backend := selectTransportBackend(client) + now := time.Now().UTC() + + prov := backend.Provision(client) + if !prov.OK { + transportMu.Lock() + st := loadTransportClientsState() + if idx := findTransportClientIndex(st.Items, client.ID); idx >= 0 { + it := st.Items[idx] + applyTransportProvisionResult(&it, now, backend.ID(), prov) + st.Items[idx] = it + _ = saveTransportClientsState(st) + } + transportMu.Unlock() + msg := strings.TrimSpace(prov.Message) + if msg == "" { + msg = "transport provision failed" + } + return errors.New(msg) + } + + action := "restart" + if !restart { + action = "start" + } + act := backend.Action(client, action) + if !act.OK { + transportMu.Lock() + st := loadTransportClientsState() + if idx := findTransportClientIndex(st.Items, client.ID); idx >= 0 { + it := st.Items[idx] + applyTransportLifecycleFailure(&it, action, now, backend.ID(), act) + st.Items[idx] = it + _ = saveTransportClientsState(st) + } + transportMu.Unlock() + msg := strings.TrimSpace(act.Message) + if msg == "" { + msg = "transport action failed" + } + return errors.New(msg) + } + + probe := backend.Health(client) + transportMu.Lock() + st := loadTransportClientsState() + if idx := findTransportClientIndex(st.Items, client.ID); idx >= 0 { + it := st.Items[idx] + applyTransportLifecycleAction(&it, action, now) + it = applyTransportHealthProbeSnapshot(it, backend.ID(), probe, now) + st.Items[idx] = it + _ = saveTransportClientsState(st) + } + transportMu.Unlock() + + if !probe.OK && normalizeTransportStatus(probe.Status) == TransportClientDown { + msg := strings.TrimSpace(probe.Message) + if msg == "" { + msg = "transport health check failed" + } + return errors.New(msg) + } + return nil +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_runtime_prepare.go b/selective-vpn-api/app/transport_singbox_profiles_runtime_prepare.go new file mode 100644 index 0000000..416e906 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_runtime_prepare.go @@ -0,0 +1,98 @@ +package app + +import ( + "strings" + "time" + + transportcfgpkg "selective-vpn-api/app/transportcfg" +) + +func prepareSingBoxClientProfile(client TransportClient, checkBinary bool) transportBackendActionResult { + if client.Kind != TransportClientSingBox { + return transportBackendActionResult{ + OK: true, + Message: "singbox preflight skipped", + } + } + + singBoxProfilesMu.Lock() + defer singBoxProfilesMu.Unlock() + + st := loadSingBoxProfilesState() + idx := findSingBoxProfileIndexForClient(st, client.ID) + if idx < 0 { + return transportBackendActionResult{ + OK: false, + Code: singBoxProfileCodeNotFound, + Message: "singbox profile not linked to client " + client.ID, + ExitCode: -1, + } + } + cur := st.Items[idx] + now := time.Now().UTC().Format(time.RFC3339Nano) + + eval := evaluateSingBoxProfile(cur, checkBinary) + cur.LastValidatedAt = now + cur.UpdatedAt = now + if !eval.Valid { + cur.LastError = joinSingBoxIssueMessages(eval.Errors) + st.Items[idx] = cur + _ = saveSingBoxProfilesState(st) + return transportBackendActionResult{ + OK: false, + Code: singBoxProfileCodeValidationFailed, + Message: "profile preflight failed: " + strings.TrimSpace(cur.LastError), + ExitCode: -1, + } + } + + renderPath, err := writeSingBoxRenderedConfig(cur.ID, eval.Config) + if err != nil { + cur.LastError = err.Error() + st.Items[idx] = cur + _ = saveSingBoxProfilesState(st) + return transportBackendActionResult{ + OK: false, + Code: singBoxProfileCodeRenderFailed, + Message: "profile preflight render failed: " + err.Error(), + ExitCode: -1, + } + } + + configPath := transportSingBoxConfigPath(client) + if strings.TrimSpace(configPath) == "" { + configPath = defaultSingBoxAppliedConfigPath(cur.ID) + } + if err := transportcfgpkg.WriteJSONConfigFile(configPath, eval.Config); err != nil { + cur.LastError = err.Error() + st.Items[idx] = cur + _ = saveSingBoxProfilesState(st) + return transportBackendActionResult{ + OK: false, + Code: singBoxProfileCodeApplyFailed, + Message: "profile preflight write failed: " + err.Error(), + ExitCode: -1, + Stdout: renderPath, + } + } + + cur.LastError = "" + st.Items[idx] = cur + if err := saveSingBoxProfilesState(st); err != nil { + return transportBackendActionResult{ + OK: false, + Code: singBoxProfileCodeSaveFailed, + Message: "profile preflight save failed: " + err.Error(), + ExitCode: -1, + Stdout: renderPath, + } + } + + return transportBackendActionResult{ + OK: true, + ExitCode: 0, + Message: "profile preflight ready: " + cur.ID, + Stdout: strings.TrimSpace(configPath), + Stderr: strings.TrimSpace(renderPath), + } +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_runtime_select.go b/selective-vpn-api/app/transport_singbox_profiles_runtime_select.go new file mode 100644 index 0000000..cf9e8cf --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_runtime_select.go @@ -0,0 +1,85 @@ +package app + +import ( + "sort" + "strings" +) + +func findSingBoxProfileIndexForClient(st singBoxProfilesState, clientID string) int { + cid := sanitizeID(clientID) + if cid == "" { + return -1 + } + + best := -1 + for i := range st.Items { + meta := st.Items[i].Meta + if meta == nil { + continue + } + if sanitizeID(asString(meta["client_id"])) != cid { + continue + } + if best < 0 || st.Items[i].ProfileRevision > st.Items[best].ProfileRevision { + best = i + } + } + if best >= 0 { + return best + } + + for i := range st.Items { + if sanitizeID(st.Items[i].ID) == cid { + return i + } + } + + if len(st.Items) == 1 { + return 0 + } + return -1 +} + +func resolveSingBoxApplyClient(reqClientID, fallbackClientID string) (*TransportClient, string, string) { + target := strings.TrimSpace(reqClientID) + if target == "" { + target = strings.TrimSpace(fallbackClientID) + } + + transportMu.Lock() + st := loadTransportClientsState() + transportMu.Unlock() + + if len(st.Items) == 0 { + return nil, singBoxProfileCodeNoClient, "transport client not found" + } + if target != "" { + idx := findTransportClientIndex(st.Items, target) + if idx < 0 { + return nil, singBoxProfileCodeNoClient, "transport client not found" + } + cl := st.Items[idx] + if cl.Kind != TransportClientSingBox { + return nil, singBoxProfileCodeClientKind, "target client is not singbox" + } + return &cl, "", "" + } + + candidates := make([]TransportClient, 0) + for _, it := range st.Items { + if it.Kind == TransportClientSingBox { + candidates = append(candidates, it) + } + } + if len(candidates) == 0 { + return nil, singBoxProfileCodeNoClient, "singbox client not found" + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].Enabled != candidates[j].Enabled { + return candidates[i].Enabled + } + return candidates[i].ID < candidates[j].ID + }) + cl := candidates[0] + return &cl, "", "" +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_secrets.go b/selective-vpn-api/app/transport_singbox_profiles_secrets.go new file mode 100644 index 0000000..55105b5 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_secrets.go @@ -0,0 +1,86 @@ +package app + +import ( + "path/filepath" + transportcfgpkg "selective-vpn-api/app/transportcfg" +) + +type singBoxSecretsPatchResult struct { + HasSecrets bool + Masked map[string]string + Changed bool + Rollback func() +} + +func applySingBoxSecretsPatch(profileID string, clear bool, updates map[string]string) (singBoxSecretsPatchResult, error) { + id := sanitizeID(profileID) + if id == "" { + return singBoxSecretsPatchResult{}, errSingBoxProfileID() + } + + prev, err := readSingBoxSecrets(id) + if err != nil { + return singBoxSecretsPatchResult{}, err + } + next := map[string]string{} + if !clear { + next = transportcfgpkg.CloneStringMap(prev) + if next == nil { + next = map[string]string{} + } + } + for key, val := range transportcfgpkg.NormalizeSecretUpdates(updates) { + if val == "" { + delete(next, key) + continue + } + next[key] = val + } + changed := !transportcfgpkg.EqualStringMap(prev, next) + if changed { + if err := writeSingBoxSecrets(id, next); err != nil { + return singBoxSecretsPatchResult{}, err + } + } + res := singBoxSecretsPatchResult{ + HasSecrets: len(next) > 0, + Masked: transportcfgpkg.MaskStringMap(next, "******"), + Changed: changed, + } + if changed { + rollbackPrev := transportcfgpkg.CloneStringMap(prev) + res.Rollback = func() { + _ = writeSingBoxSecrets(id, rollbackPrev) + } + } + if !res.HasSecrets { + res.Masked = nil + } + return res, nil +} + +func readSingBoxSecrets(profileID string) (map[string]string, error) { + id := sanitizeID(profileID) + if id == "" { + return nil, errSingBoxProfileID() + } + path := singBoxSecretsPath(id) + return transportcfgpkg.ReadStringMapJSON(path) +} + +func writeSingBoxSecrets(profileID string, secrets map[string]string) error { + id := sanitizeID(profileID) + if id == "" { + return errSingBoxProfileID() + } + path := singBoxSecretsPath(id) + return transportcfgpkg.WriteStringMapJSON(path, secrets, 0o700, 0o600) +} + +func singBoxSecretsPath(profileID string) string { + id := sanitizeID(profileID) + if id == "" { + return filepath.Join(singBoxSecretsRootDir, "invalid.json") + } + return filepath.Join(singBoxSecretsRootDir, id+".json") +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_state.go b/selective-vpn-api/app/transport_singbox_profiles_state.go new file mode 100644 index 0000000..be5171a --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_state.go @@ -0,0 +1,52 @@ +package app + +import ( + "encoding/json" + "strconv" + "strings" +) + +func normalizeSingBoxProfileMode(mode SingBoxProfileMode) (SingBoxProfileMode, bool) { + switch strings.ToLower(strings.TrimSpace(string(mode))) { + case "typed": + return SingBoxProfileModeTyped, true + case "raw": + return SingBoxProfileModeRaw, true + default: + return "", false + } +} + +func normalizeSingBoxProtocol(v string) string { + return strings.ToLower(strings.TrimSpace(v)) +} + +func normalizeSingBoxSchemaVersion(v int) int { + if v <= 0 { + return 1 + } + return v +} + +func parseOptionalInt64(v string) (int64, error) { + s := strings.TrimSpace(v) + if s == "" { + return 0, nil + } + return strconv.ParseInt(s, 10, 64) +} + +func cloneMapDeep(in map[string]any) map[string]any { + if in == nil { + return nil + } + b, err := json.Marshal(in) + if err != nil { + return cloneMap(in) + } + out := map[string]any{} + if err := json.Unmarshal(b, &out); err != nil { + return cloneMap(in) + } + return out +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_state_normalize.go b/selective-vpn-api/app/transport_singbox_profiles_state_normalize.go new file mode 100644 index 0000000..2504ece --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_state_normalize.go @@ -0,0 +1,166 @@ +package app + +import ( + transportcfgpkg "selective-vpn-api/app/transportcfg" + "sort" + "strconv" + "strings" +) + +func deriveSingBoxProfileID(name, protocol string, existing []SingBoxProfile) string { + base := sanitizeID(name) + if base == "" { + base = sanitizeID(protocol) + } + if base == "" { + base = "singbox-profile" + } + cand := base + + used := make(map[string]struct{}, len(existing)) + for _, it := range existing { + used[it.ID] = struct{}{} + } + if _, ok := used[cand]; !ok { + return cand + } + for i := 2; i < 1000; i++ { + v := base + "-" + strconv.Itoa(i) + if _, ok := used[v]; !ok { + return v + } + } + return "" +} + +func findSingBoxProfileIndex(items []SingBoxProfile, id string) int { + for i := range items { + if strings.TrimSpace(items[i].ID) == id { + return i + } + } + return -1 +} + +func ensureSingBoxProfilesActiveID(st *singBoxProfilesState) { + if st == nil { + return + } + if len(st.Items) == 0 { + st.ActiveProfileID = "" + return + } + active := strings.TrimSpace(st.ActiveProfileID) + if active != "" { + for _, it := range st.Items { + if it.ID == active && it.Enabled { + return + } + } + } + for _, it := range st.Items { + if it.Enabled { + st.ActiveProfileID = it.ID + return + } + } + st.ActiveProfileID = st.Items[0].ID +} + +func normalizeSingBoxProfilesState(in singBoxProfilesState) singBoxProfilesState { + out := in + if out.Version == 0 { + out.Version = singBoxProfilesStateVersion + } + if out.Revision < 0 { + out.Revision = 0 + } + + byID := map[string]SingBoxProfile{} + for _, raw := range in.Items { + it := normalizeSingBoxProfile(raw) + if it.ID == "" { + continue + } + cur, ok := byID[it.ID] + if !ok || preferSingBoxProfile(it, cur) { + byID[it.ID] = it + } + } + keys := make([]string, 0, len(byID)) + for id := range byID { + keys = append(keys, id) + } + sort.Strings(keys) + + out.Items = make([]SingBoxProfile, 0, len(keys)) + for _, id := range keys { + out.Items = append(out.Items, byID[id]) + } + ensureSingBoxProfilesActiveID(&out) + return out +} + +func normalizeSingBoxProfile(in SingBoxProfile) SingBoxProfile { + out := in + out.ID = sanitizeID(in.ID) + if out.ID == "" { + return SingBoxProfile{} + } + out.Name = strings.TrimSpace(in.Name) + if out.Name == "" { + out.Name = out.ID + } + if mode, ok := normalizeSingBoxProfileMode(in.Mode); ok { + out.Mode = mode + } else { + out.Mode = SingBoxProfileModeTyped + } + out.Protocol = normalizeSingBoxProtocol(in.Protocol) + out.SchemaVersion = normalizeSingBoxSchemaVersion(in.SchemaVersion) + if out.ProfileRevision <= 0 { + out.ProfileRevision = 1 + } + if out.RenderRevision < 0 { + out.RenderRevision = 0 + } + out.LastValidatedAt = strings.TrimSpace(in.LastValidatedAt) + out.LastAppliedAt = strings.TrimSpace(in.LastAppliedAt) + out.LastError = strings.TrimSpace(in.LastError) + out.CreatedAt = strings.TrimSpace(in.CreatedAt) + out.UpdatedAt = strings.TrimSpace(in.UpdatedAt) + out.Typed = cloneMapDeep(in.Typed) + out.RawConfig = cloneMapDeep(in.RawConfig) + out.Meta = cloneMapDeep(in.Meta) + + secretMap, err := readSingBoxSecrets(out.ID) + if err == nil { + out.HasSecrets = len(secretMap) > 0 + out.SecretsMasked = transportcfgpkg.MaskStringMap(secretMap, "******") + } else { + out.HasSecrets = in.HasSecrets + out.SecretsMasked = transportcfgpkg.CloneStringMap(in.SecretsMasked) + } + if !out.HasSecrets { + out.SecretsMasked = nil + } + return out +} + +func preferSingBoxProfile(cand, cur SingBoxProfile) bool { + if cand.ProfileRevision != cur.ProfileRevision { + return cand.ProfileRevision > cur.ProfileRevision + } + cu := strings.TrimSpace(cand.UpdatedAt) + ou := strings.TrimSpace(cur.UpdatedAt) + if cu != ou { + if cu == "" { + return false + } + if ou == "" { + return true + } + return cu > ou + } + return strings.TrimSpace(cand.CreatedAt) > strings.TrimSpace(cur.CreatedAt) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_state_store.go b/selective-vpn-api/app/transport_singbox_profiles_state_store.go new file mode 100644 index 0000000..8906bc3 --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_state_store.go @@ -0,0 +1,42 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +func loadSingBoxProfilesState() singBoxProfilesState { + st := singBoxProfilesState{ + Version: singBoxProfilesStateVersion, + Revision: 0, + } + data, err := os.ReadFile(singBoxProfilesStatePath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return singBoxProfilesState{Version: singBoxProfilesStateVersion, Revision: 0} + } + return normalizeSingBoxProfilesState(st) +} + +func saveSingBoxProfilesState(st singBoxProfilesState) error { + st = normalizeSingBoxProfilesState(st) + st.Version = singBoxProfilesStateVersion + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(singBoxProfilesStatePath), 0o755); err != nil { + return err + } + tmp := singBoxProfilesStatePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, singBoxProfilesStatePath) +} diff --git a/selective-vpn-api/app/transport_singbox_profiles_test.go b/selective-vpn-api/app/transport_singbox_profiles_test.go new file mode 100644 index 0000000..17d37fa --- /dev/null +++ b/selective-vpn-api/app/transport_singbox_profiles_test.go @@ -0,0 +1,166 @@ +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSingBoxProfilesCRUDSecretsAndRevision(t *testing.T) { + tmp := t.TempDir() + oldStatePath := singBoxProfilesStatePath + oldSecretsDir := singBoxSecretsRootDir + singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json") + singBoxSecretsRootDir = filepath.Join(tmp, "transport", "secrets", "singbox") + t.Cleanup(func() { + singBoxProfilesStatePath = oldStatePath + singBoxSecretsRootDir = oldSecretsDir + }) + + createBody := `{ + "name":"Main DE", + "mode":"typed", + "protocol":"vless", + "typed":{"server":"example.org","port":443}, + "secrets":{"uuid":"abc-123-token","password":"secret-pass"} + }` + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles", strings.NewReader(createBody)) + createRec := httptest.NewRecorder() + handleTransportSingBoxProfiles(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("unexpected create status: %d body=%s", createRec.Code, createRec.Body.String()) + } + + var createResp SingBoxProfilesResponse + if err := json.Unmarshal(createRec.Body.Bytes(), &createResp); err != nil { + t.Fatalf("decode create response: %v", err) + } + if !createResp.OK || createResp.Item == nil { + t.Fatalf("create failed: %#v", createResp) + } + item := *createResp.Item + if item.ID == "" { + t.Fatalf("empty profile id") + } + if !item.HasSecrets { + t.Fatalf("profile must report has_secrets=true") + } + if got := item.SecretsMasked["uuid"]; got == "" || strings.Contains(got, "abc-123-token") { + t.Fatalf("secrets must be masked, got=%q", got) + } + + stateData, err := os.ReadFile(singBoxProfilesStatePath) + if err != nil { + t.Fatalf("read state: %v", err) + } + if bytes.Contains(stateData, []byte("abc-123-token")) || bytes.Contains(stateData, []byte("secret-pass")) { + t.Fatalf("state file must not contain plain secrets") + } + + secretPath := filepath.Join(singBoxSecretsRootDir, item.ID+".json") + secretData, err := os.ReadFile(secretPath) + if err != nil { + t.Fatalf("read secrets file: %v", err) + } + if !bytes.Contains(secretData, []byte("abc-123-token")) { + t.Fatalf("secrets file must contain original value") + } + + patchBadReq := httptest.NewRequest( + http.MethodPatch, + "/api/v1/transport/singbox/profiles/"+item.ID, + strings.NewReader(`{"base_revision":9999,"name":"Main FR"}`), + ) + patchBadRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(patchBadRec, patchBadReq) + if patchBadRec.Code != http.StatusConflict { + t.Fatalf("unexpected patch conflict status: %d body=%s", patchBadRec.Code, patchBadRec.Body.String()) + } + + patchGoodBody := fmt.Sprintf(`{ + "base_revision":%d, + "name":"Main FR", + "secrets":{"uuid":"new-token","password":""} + }`, item.ProfileRevision) + patchGoodReq := httptest.NewRequest( + http.MethodPatch, + "/api/v1/transport/singbox/profiles/"+item.ID, + strings.NewReader(patchGoodBody), + ) + patchGoodRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(patchGoodRec, patchGoodReq) + if patchGoodRec.Code != http.StatusOK { + t.Fatalf("unexpected patch status: %d body=%s", patchGoodRec.Code, patchGoodRec.Body.String()) + } + + var patchResp SingBoxProfilesResponse + if err := json.Unmarshal(patchGoodRec.Body.Bytes(), &patchResp); err != nil { + t.Fatalf("decode patch response: %v", err) + } + if !patchResp.OK || patchResp.Item == nil { + t.Fatalf("patch failed: %#v", patchResp) + } + if patchResp.Item.ProfileRevision != item.ProfileRevision+1 { + t.Fatalf("revision must be incremented: got=%d want=%d", patchResp.Item.ProfileRevision, item.ProfileRevision+1) + } + secretDataAfterPatch, err := os.ReadFile(secretPath) + if err != nil { + t.Fatalf("read secrets after patch: %v", err) + } + if !bytes.Contains(secretDataAfterPatch, []byte("new-token")) { + t.Fatalf("secrets update not persisted") + } + if bytes.Contains(secretDataAfterPatch, []byte("secret-pass")) { + t.Fatalf("secret key with empty value must be removed") + } + + deleteBadReq := httptest.NewRequest( + http.MethodDelete, + fmt.Sprintf("/api/v1/transport/singbox/profiles/%s?base_revision=%d", item.ID, item.ProfileRevision), + nil, + ) + deleteBadRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(deleteBadRec, deleteBadReq) + if deleteBadRec.Code != http.StatusConflict { + t.Fatalf("unexpected delete conflict status: %d body=%s", deleteBadRec.Code, deleteBadRec.Body.String()) + } + + deleteReq := httptest.NewRequest( + http.MethodDelete, + fmt.Sprintf("/api/v1/transport/singbox/profiles/%s?base_revision=%d", item.ID, patchResp.Item.ProfileRevision), + nil, + ) + deleteRec := httptest.NewRecorder() + handleTransportSingBoxProfileByID(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("unexpected delete status: %d body=%s", deleteRec.Code, deleteRec.Body.String()) + } + if _, err := os.Stat(secretPath); !os.IsNotExist(err) { + t.Fatalf("secrets file must be deleted, err=%v", err) + } +} + +func TestSingBoxFeaturesEndpoint(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/transport/singbox/features", nil) + rec := httptest.NewRecorder() + handleTransportSingBoxFeatures(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", rec.Code) + } + var resp SingBoxFeaturesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK { + t.Fatalf("response must be ok: %#v", resp) + } + if !resp.ProfileModes[string(SingBoxProfileModeTyped)] || !resp.ProfileModes[string(SingBoxProfileModeRaw)] { + t.Fatalf("profile modes not advertised: %#v", resp.ProfileModes) + } +} diff --git a/selective-vpn-api/app/transport_snapshot_commit.go b/selective-vpn-api/app/transport_snapshot_commit.go new file mode 100644 index 0000000..635ba5e --- /dev/null +++ b/selective-vpn-api/app/transport_snapshot_commit.go @@ -0,0 +1,162 @@ +package app + +import "strings" + +type transportInterfacesStateSnapshot struct { + ClientsUpdatedAt string + InterfacesUpdatedAt string +} + +type transportInterfacesOnlySnapshot struct { + InterfacesUpdatedAt string +} + +type transportPolicyPlanStateSnapshot struct { + PolicyRevision int64 + PlanPolicyRevision int64 +} + +type transportOwnershipStateSnapshot struct { + PolicyRevision int64 + OwnershipPolicyRevision int64 + OwnershipUpdatedAt string + OwnershipPlanDigest string +} + +func captureTransportInterfacesStateSnapshot(clients transportClientsState, ifaces transportInterfacesState) transportInterfacesStateSnapshot { + return transportInterfacesStateSnapshot{ + ClientsUpdatedAt: clients.UpdatedAt, + InterfacesUpdatedAt: ifaces.UpdatedAt, + } +} + +func captureTransportInterfacesOnlySnapshot(ifaces transportInterfacesState) transportInterfacesOnlySnapshot { + return transportInterfacesOnlySnapshot{InterfacesUpdatedAt: ifaces.UpdatedAt} +} + +func captureTransportPolicyPlanStateSnapshot(policy TransportPolicyState, plan TransportPolicyCompilePlan) transportPolicyPlanStateSnapshot { + return transportPolicyPlanStateSnapshot{ + PolicyRevision: policy.Revision, + PlanPolicyRevision: plan.PolicyRevision, + } +} + +func captureTransportOwnershipStateSnapshot(policy TransportPolicyState, ownership TransportOwnershipState) transportOwnershipStateSnapshot { + return transportOwnershipStateSnapshot{ + PolicyRevision: policy.Revision, + OwnershipPolicyRevision: ownership.PolicyRevision, + OwnershipUpdatedAt: ownership.UpdatedAt, + OwnershipPlanDigest: ownership.PlanDigest, + } +} + +func compileTransportPolicyPlanForSnapshot( + policy TransportPolicyState, + clients []TransportClient, + plan TransportPolicyCompilePlan, +) (TransportPolicyCompilePlan, bool) { + if !transportPolicyPlanNeedsRecompile(policy, plan) { + return plan, false + } + compiled, _ := compileTransportPolicyPlan(policy.Intents, clients, policy.Revision) + return compiled, true +} + +func transportPolicyPlanNeedsRecompile(policy TransportPolicyState, plan TransportPolicyCompilePlan) bool { + if plan.PolicyRevision != policy.Revision { + return true + } + for _, iface := range plan.Interfaces { + ifaceID := normalizeTransportIfaceID(iface.IfaceID) + for _, set := range iface.Sets { + ownerScope := strings.TrimSpace(set.OwnerScope) + if ownerScope == "" { + return true + } + expected := transportPolicyNftSetName(ownerScope, set.SelectorType) + if strings.TrimSpace(set.Name) != expected { + return true + } + } + for _, rule := range iface.Rules { + ownerScope := strings.TrimSpace(rule.OwnerScope) + expectedScope := transportPolicyNftOwnerScope(ifaceID, rule.ClientID) + if ownerScope == "" || ownerScope != expectedScope { + return true + } + expectedSet := transportPolicyNftSetName(ownerScope, rule.SelectorType) + if strings.TrimSpace(rule.NftSet) != expectedSet { + return true + } + } + } + return false +} + +func saveTransportInterfacesIfSnapshotCurrent(snapshot transportInterfacesStateSnapshot, next transportInterfacesState) error { + transportMu.Lock() + defer transportMu.Unlock() + + currentClients := loadTransportClientsState() + currentIfaces := loadTransportInterfacesState() + if snapshot.ClientsUpdatedAt != currentClients.UpdatedAt { + return nil + } + if snapshot.InterfacesUpdatedAt != currentIfaces.UpdatedAt { + return nil + } + return saveTransportInterfacesState(next) +} + +func saveTransportInterfacesIfUnchanged(snapshot transportInterfacesOnlySnapshot, next transportInterfacesState) error { + transportMu.Lock() + defer transportMu.Unlock() + + currentIfaces := loadTransportInterfacesState() + if snapshot.InterfacesUpdatedAt != currentIfaces.UpdatedAt { + return nil + } + return saveTransportInterfacesState(next) +} + +func saveTransportPlanIfSnapshotCurrent(snapshot transportPolicyPlanStateSnapshot, next TransportPolicyCompilePlan) error { + transportMu.Lock() + defer transportMu.Unlock() + + currentPolicy := loadTransportPolicyState() + if currentPolicy.Revision != snapshot.PolicyRevision { + return nil + } + currentPlan := loadTransportPolicyCompilePlan() + if currentPlan.PolicyRevision != snapshot.PlanPolicyRevision { + return nil + } + if next.PolicyRevision != currentPolicy.Revision { + return nil + } + return saveTransportPolicyCompilePlan(next) +} + +func saveTransportOwnershipIfSnapshotCurrent(snapshot transportOwnershipStateSnapshot, next TransportOwnershipState) error { + transportMu.Lock() + defer transportMu.Unlock() + + currentPolicy := loadTransportPolicyState() + if currentPolicy.Revision != snapshot.PolicyRevision { + return nil + } + currentOwnership := loadTransportOwnershipState() + if currentOwnership.PolicyRevision != snapshot.OwnershipPolicyRevision { + return nil + } + if currentOwnership.UpdatedAt != snapshot.OwnershipUpdatedAt { + return nil + } + if currentOwnership.PlanDigest != snapshot.OwnershipPlanDigest { + return nil + } + if next.PolicyRevision != currentPolicy.Revision { + return nil + } + return saveTransportOwnershipState(next) +} diff --git a/selective-vpn-api/app/transport_snapshot_commit_test.go b/selective-vpn-api/app/transport_snapshot_commit_test.go new file mode 100644 index 0000000..73ee000 --- /dev/null +++ b/selective-vpn-api/app/transport_snapshot_commit_test.go @@ -0,0 +1,76 @@ +package app + +import "testing" + +func TestCompileTransportPolicyPlanForSnapshotRebuildsLegacyNftScopePlan(t *testing.T) { + policy := TransportPolicyState{ + Revision: 5, + Intents: []TransportPolicyIntent{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c1"}, + }, + } + clients := []TransportClient{{ + ID: "c1", + Kind: TransportClientSingBox, + IfaceID: "shared", + MarkHex: "0x120", + PriorityBase: 13300, + }} + legacyPlan := TransportPolicyCompilePlan{ + PolicyRevision: 5, + Interfaces: []TransportPolicyCompileInterface{ + { + IfaceID: "shared", + Sets: []TransportPolicyCompileSet{ + {SelectorType: "domain", Name: "agvpn_pi_shared_domain"}, + }, + Rules: []TransportPolicyCompileRule{ + {SelectorType: "domain", SelectorValue: "example.com", ClientID: "c1", NftSet: "agvpn_pi_shared_domain"}, + }, + }, + }, + } + + next, changed := compileTransportPolicyPlanForSnapshot(policy, clients, legacyPlan) + if !changed { + t.Fatalf("expected recompile for legacy plan") + } + if len(next.Interfaces) != 1 || len(next.Interfaces[0].Rules) != 1 { + t.Fatalf("unexpected compiled plan shape: %#v", next) + } + r := next.Interfaces[0].Rules[0] + if r.OwnerScope == "" { + t.Fatalf("expected owner_scope in compiled rule: %#v", r) + } + if r.NftSet == "agvpn_pi_shared_domain" { + t.Fatalf("expected owner-scoped nft set, got legacy name: %#v", r) + } +} + +func TestCompileTransportPolicyPlanForSnapshotKeepsFreshPlan(t *testing.T) { + policy := TransportPolicyState{ + Revision: 7, + Intents: []TransportPolicyIntent{ + {SelectorType: "cidr", SelectorValue: "10.1.0.0/24", ClientID: "c1"}, + }, + } + clients := []TransportClient{{ + ID: "c1", + Kind: TransportClientSingBox, + IfaceID: "edge-a", + MarkHex: "0x120", + PriorityBase: 13300, + }} + fresh, conflicts := compileTransportPolicyPlan(policy.Intents, clients, policy.Revision) + if len(conflicts) != 0 { + t.Fatalf("unexpected compile conflicts: %#v", conflicts) + } + + next, changed := compileTransportPolicyPlanForSnapshot(policy, clients, fresh) + if changed { + t.Fatalf("did not expect recompile for fresh owner-scoped plan") + } + if next.PolicyRevision != fresh.PolicyRevision || next.RuleCount != fresh.RuleCount { + t.Fatalf("unexpected plan mismatch: next=%#v fresh=%#v", next, fresh) + } +} diff --git a/selective-vpn-api/app/transport_systemd_singbox_template_test.go b/selective-vpn-api/app/transport_systemd_singbox_template_test.go new file mode 100644 index 0000000..c418987 --- /dev/null +++ b/selective-vpn-api/app/transport_systemd_singbox_template_test.go @@ -0,0 +1,500 @@ +package app + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestTransportSystemdProvisionWritesSingBoxTemplateAndDropIn(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + switch cmd { + case "systemctl daemon-reload": + return "", "", 0, nil + default: + return "", "", 0, nil + } + } + + client := TransportClient{ + ID: "sg-template", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@sg-template.service", + "exec_start": "/usr/bin/sing-box run -c /tmp/sg-template.json", + }, + } + backend := selectTransportBackend(client) + res := backend.Provision(client) + if !res.OK { + t.Fatalf("expected provision success, got %#v", res) + } + + templatePath := filepath.Join(tmpDir, "singbox@.service") + templateData, err := os.ReadFile(templatePath) + if err != nil { + t.Fatalf("failed to read template unit: %v", err) + } + templateText := string(templateData) + if !strings.Contains(templateText, transportSingBoxTemplateMarker) { + t.Fatalf("template marker missing: %s", templateText) + } + + dropInPath := filepath.Join(tmpDir, "singbox@sg-template.service.d", transportSingBoxInstanceDropIn) + dropInData, err := os.ReadFile(dropInPath) + if err != nil { + t.Fatalf("failed to read drop-in unit: %v", err) + } + dropInText := string(dropInData) + if !strings.Contains(dropInText, "Environment=SVPN_TRANSPORT_ID=sg-template") { + t.Fatalf("drop-in ownership marker missing: %s", dropInText) + } + if !strings.Contains(dropInText, "ExecStart=/bin/sh -lc") { + t.Fatalf("drop-in exec start missing: %s", dropInText) + } +} + +func TestTransportSystemdBackendStartAutoProvisionOnMissingTemplateInstance(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + startCalls := 0 + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + switch cmd { + case "systemctl reset-failed singbox@sg-auto.service": + return "", "", 0, nil + case "systemctl daemon-reload": + return "", "", 0, nil + case "systemctl start singbox@sg-auto.service": + startCalls++ + if startCalls == 1 { + return "", "Failed to start singbox@sg-auto.service: Unit singbox@sg-auto.service not found.", 5, fmt.Errorf("exit status 5") + } + return "", "", 0, nil + default: + return "", "", 0, nil + } + } + + client := TransportClient{ + ID: "sg-auto", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@sg-auto.service", + "exec_start": "/usr/bin/sing-box run -c /tmp/sg-auto.json", + }, + } + backend := selectTransportBackend(client) + res := backend.Action(client, "start") + if !res.OK { + t.Fatalf("expected start success after auto-provision, got %#v", res) + } + if startCalls != 2 { + t.Fatalf("expected start retried after auto-provision, got calls=%d", startCalls) + } + if _, err := os.Stat(filepath.Join(tmpDir, "singbox@.service")); err != nil { + t.Fatalf("expected template unit file, stat error: %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "singbox@sg-auto.service.d", transportSingBoxInstanceDropIn)); err != nil { + t.Fatalf("expected instance drop-in file, stat error: %v", err) + } +} + +func TestTransportSystemdCleanupRemovesSingBoxDropInKeepsTemplate(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + templatePath := filepath.Join(tmpDir, "singbox@.service") + if err := os.WriteFile(templatePath, []byte(transportSingBoxTemplateMarker+"\n[Service]\nExecStart=/bin/true\n"), 0o644); err != nil { + t.Fatalf("write template unit file: %v", err) + } + dropInDir := filepath.Join(tmpDir, "singbox@sg-clean.service.d") + if err := os.MkdirAll(dropInDir, 0o755); err != nil { + t.Fatalf("mkdir drop-in dir: %v", err) + } + dropInPath := filepath.Join(dropInDir, transportSingBoxInstanceDropIn) + if err := os.WriteFile(dropInPath, []byte("[Service]\nEnvironment=SVPN_TRANSPORT_ID=sg-clean\n"), 0o644); err != nil { + t.Fatalf("write drop-in file: %v", err) + } + + calls := make([]string, 0, 8) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-clean", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@sg-clean.service", + }, + } + backend := selectTransportBackend(client) + res := backend.Cleanup(client) + if !res.OK { + t.Fatalf("expected cleanup success, got %#v", res) + } + if _, err := os.Stat(templatePath); err != nil { + t.Fatalf("template should stay intact, stat error: %v", err) + } + if _, err := os.Stat(dropInPath); !os.IsNotExist(err) { + t.Fatalf("drop-in file was not removed: %v", err) + } + + expected := []string{ + "systemctl stop singbox@sg-clean.service", + "systemctl disable singbox@sg-clean.service", + "systemctl daemon-reload", + "systemctl reset-failed singbox@sg-clean.service", + } + for _, want := range expected { + found := false + for _, got := range calls { + if got == want { + found = true + break + } + } + if !found { + t.Fatalf("missing cleanup call %q in %#v", want, calls) + } + } +} + +func TestTransportSystemdProvisionSingBoxTemplateIncludesNetnsEnv(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + switch cmd { + case "systemctl daemon-reload": + return "", "", 0, nil + default: + return "", "", 0, nil + } + } + + client := TransportClient{ + ID: "sg-netns", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@sg-netns.service", + "exec_start": "/usr/bin/sing-box run -c /tmp/sg-netns.json", + "netns_enabled": true, + "netns_name": "svpn-sg-netns", + }, + } + backend := selectTransportBackend(client) + res := backend.Provision(client) + if !res.OK { + t.Fatalf("expected provision success, got %#v", res) + } + + dropInPath := filepath.Join(tmpDir, "singbox@sg-netns.service.d", transportSingBoxInstanceDropIn) + dropInData, err := os.ReadFile(dropInPath) + if err != nil { + t.Fatalf("failed to read drop-in unit: %v", err) + } + dropInText := string(dropInData) + for _, want := range []string{ + "Environment=SVPN_NETNS_ENABLED=1", + "Environment=SVPN_NETNS_NAME=svpn-sg-netns", + } { + if !strings.Contains(dropInText, want) { + t.Fatalf("drop-in is missing %q: %s", want, dropInText) + } + } +} + +func TestTransportSystemdPreActionMigratesOwnedLegacyUnitToTemplateInstance(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + legacyUnit := "singbox-sg-migrate.service" + legacyPath := filepath.Join(tmpDir, legacyUnit) + legacyBody := "[Service]\nEnvironment=SVPN_TRANSPORT_ID=sg-migrate\nExecStart=/bin/true\n" + if err := os.WriteFile(legacyPath, []byte(legacyBody), 0o644); err != nil { + t.Fatalf("write legacy unit: %v", err) + } + + calls := make([]string, 0, 10) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-migrate", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + "bootstrap_bypass": false, + }, + } + backend := selectTransportBackend(client) + res := backend.Action(client, "start") + if !res.OK { + t.Fatalf("expected start success, got %#v", res) + } + if !strings.Contains(res.Stdout, "legacy-migrate: singbox-sg-migrate.service -> singbox@sg-migrate.service") { + t.Fatalf("expected migrate message in stdout, got: %s", res.Stdout) + } + if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { + t.Fatalf("legacy unit must be removed after migration, stat=%v", err) + } + + mustContain := []string{ + "systemctl stop singbox-sg-migrate.service", + "systemctl disable singbox-sg-migrate.service", + "systemctl daemon-reload", + "systemctl reset-failed singbox-sg-migrate.service", + "systemctl reset-failed singbox@sg-migrate.service", + "systemctl start singbox@sg-migrate.service", + } + got := strings.Join(calls, " | ") + for _, want := range mustContain { + if !strings.Contains(got, want) { + t.Fatalf("missing call %q in %s", want, got) + } + } +} + +func TestTransportSystemdPreActionLegacyMigrationDryRun(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + legacyUnit := "singbox-sg-dry.service" + legacyPath := filepath.Join(tmpDir, legacyUnit) + legacyBody := "[Service]\nEnvironment=SVPN_TRANSPORT_ID=sg-dry\nExecStart=/bin/true\n" + if err := os.WriteFile(legacyPath, []byte(legacyBody), 0o644); err != nil { + t.Fatalf("write legacy unit: %v", err) + } + + calls := make([]string, 0, 10) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-dry", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + "bootstrap_bypass": false, + transportSingBoxLegacyMigrateDryRunConfigKey: true, + }, + } + backend := selectTransportBackend(client) + res := backend.Action(client, "start") + if !res.OK { + t.Fatalf("expected start success, got %#v", res) + } + if !strings.Contains(res.Stdout, "legacy-migrate dry-run: singbox-sg-dry.service -> singbox@sg-dry.service") { + t.Fatalf("expected dry-run message in stdout, got: %s", res.Stdout) + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy unit must stay on dry-run, stat=%v", err) + } + got := strings.Join(calls, " | ") + if strings.Contains(got, "systemctl stop singbox-sg-dry.service") { + t.Fatalf("dry-run must not stop legacy unit, calls=%s", got) + } + if strings.Contains(got, "systemctl disable singbox-sg-dry.service") { + t.Fatalf("dry-run must not disable legacy unit, calls=%s", got) + } + if !strings.Contains(got, "systemctl start singbox@sg-dry.service") { + t.Fatalf("expected template start call, got=%s", got) + } +} + +func TestTransportSystemdPreActionLegacyMigrationSkipsForeignOwnership(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + legacyUnit := "singbox-sg-foreign.service" + legacyPath := filepath.Join(tmpDir, legacyUnit) + legacyBody := "[Service]\nEnvironment=SVPN_TRANSPORT_ID=other-client\nExecStart=/bin/true\n" + if err := os.WriteFile(legacyPath, []byte(legacyBody), 0o644); err != nil { + t.Fatalf("write legacy unit: %v", err) + } + + calls := make([]string, 0, 10) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-foreign", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + "bootstrap_bypass": false, + }, + } + backend := selectTransportBackend(client) + res := backend.Action(client, "start") + if !res.OK { + t.Fatalf("expected start success, got %#v", res) + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("foreign legacy unit must stay untouched, stat=%v", err) + } + + got := strings.Join(calls, " | ") + if strings.Contains(got, "systemctl stop singbox-sg-foreign.service") { + t.Fatalf("must not stop foreign legacy unit, calls=%s", got) + } + if strings.Contains(got, "systemctl disable singbox-sg-foreign.service") { + t.Fatalf("must not disable foreign legacy unit, calls=%s", got) + } + if !strings.Contains(got, "systemctl start singbox@sg-foreign.service") { + t.Fatalf("expected template start call, got=%s", got) + } +} + +func TestTransportSystemdCleanupTemplateRemovesOwnedLegacyUnits(t *testing.T) { + origRunner := transportRunCommand + origUnitsDir := transportSystemdUnitsDir + defer func() { + transportRunCommand = origRunner + transportSystemdUnitsDir = origUnitsDir + }() + + tmpDir := t.TempDir() + transportSystemdUnitsDir = tmpDir + + templatePath := filepath.Join(tmpDir, "singbox@.service") + if err := os.WriteFile(templatePath, []byte(transportSingBoxTemplateMarker+"\n[Service]\nExecStart=/bin/true\n"), 0o644); err != nil { + t.Fatalf("write template unit: %v", err) + } + dropInDir := filepath.Join(tmpDir, "singbox@sg-clean-mixed.service.d") + if err := os.MkdirAll(dropInDir, 0o755); err != nil { + t.Fatalf("mkdir drop-in dir: %v", err) + } + dropInPath := filepath.Join(dropInDir, transportSingBoxInstanceDropIn) + if err := os.WriteFile(dropInPath, []byte("[Service]\nEnvironment=SVPN_TRANSPORT_ID=sg-clean-mixed\n"), 0o644); err != nil { + t.Fatalf("write drop-in: %v", err) + } + legacyPath := filepath.Join(tmpDir, "singbox-sg-clean-mixed.service") + if err := os.WriteFile(legacyPath, []byte("[Service]\nEnvironment=SVPN_TRANSPORT_ID=sg-clean-mixed\nExecStart=/bin/true\n"), 0o644); err != nil { + t.Fatalf("write legacy unit: %v", err) + } + + calls := make([]string, 0, 16) + transportRunCommand = func(_ time.Duration, name string, args ...string) (string, string, int, error) { + cmd := name + " " + strings.Join(args, " ") + calls = append(calls, cmd) + return "", "", 0, nil + } + + client := TransportClient{ + ID: "sg-clean-mixed", + Kind: TransportClientSingBox, + Config: map[string]any{ + "runner": "systemd", + "unit": "singbox@.service", + }, + } + backend := selectTransportBackend(client) + res := backend.Cleanup(client) + if !res.OK { + t.Fatalf("expected cleanup success, got %#v", res) + } + if _, err := os.Stat(templatePath); err != nil { + t.Fatalf("template must stay intact, stat err=%v", err) + } + if _, err := os.Stat(dropInPath); !os.IsNotExist(err) { + t.Fatalf("drop-in must be removed, stat=%v", err) + } + if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { + t.Fatalf("legacy unit must be removed, stat=%v", err) + } + + got := strings.Join(calls, " | ") + mustContain := []string{ + "systemctl stop singbox@sg-clean-mixed.service", + "systemctl disable singbox@sg-clean-mixed.service", + "systemctl stop singbox-sg-clean-mixed.service", + "systemctl disable singbox-sg-clean-mixed.service", + "systemctl daemon-reload", + "systemctl reset-failed singbox@sg-clean-mixed.service", + "systemctl reset-failed singbox-sg-clean-mixed.service", + } + for _, want := range mustContain { + if !strings.Contains(got, want) { + t.Fatalf("missing cleanup call %q in %s", want, got) + } + } +} diff --git a/selective-vpn-api/app/transport_tokens_state.go b/selective-vpn-api/app/transport_tokens_state.go new file mode 100644 index 0000000..6e1b97c --- /dev/null +++ b/selective-vpn-api/app/transport_tokens_state.go @@ -0,0 +1,40 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + transporttoken "selective-vpn-api/app/transporttoken" +) + +func digestTransportIntents(intents []TransportPolicyIntent) string { + b, _ := json.Marshal(intents) + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func digestTransportPolicyCompilePlan(plan TransportPolicyCompilePlan) string { + b, _ := json.Marshal(plan) + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func issueTransportConfirmToken(baseRevision int64, digest string) string { + return transportConfirmStore.Issue("cnf-", baseRevision, digest) +} + +func consumeTransportConfirmToken(token string, baseRevision int64, digest string) bool { + return transportConfirmStore.Consume(token, baseRevision, digest) +} + +func issueTransportOwnerLocksClearToken(baseRevision int64, digest string) string { + return transportConfirmStore.Issue("clr-", baseRevision, digest) +} + +func consumeTransportOwnerLocksClearToken(token string, baseRevision int64, digest string) bool { + return transportConfirmStore.Consume(token, baseRevision, digest) +} + +func newTransportToken(n int) string { + return transporttoken.NewTokenHex(n) +} diff --git a/selective-vpn-api/app/transport_tokens_state_clients.go b/selective-vpn-api/app/transport_tokens_state_clients.go new file mode 100644 index 0000000..d5e4504 --- /dev/null +++ b/selective-vpn-api/app/transport_tokens_state_clients.go @@ -0,0 +1,50 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +func loadTransportClientsState() transportClientsState { + st := transportClientsState{Version: transportStateVersion} + data, err := os.ReadFile(transportClientsStatePath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return transportClientsState{Version: transportStateVersion} + } + if st.Version == 0 { + st.Version = transportStateVersion + } + if st.Items == nil { + st.Items = nil + } + norm, changed := normalizeTransportClientsState(st, false) + if changed { + _ = saveTransportClientsState(norm) + } + return norm +} + +func saveTransportClientsState(st transportClientsState) error { + norm, _ := normalizeTransportClientsState(st, false) + st = norm + st.Version = transportStateVersion + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportClientsStatePath), 0o755); err != nil { + return err + } + tmp := transportClientsStatePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportClientsStatePath) +} diff --git a/selective-vpn-api/app/transport_tokens_state_conflicts.go b/selective-vpn-api/app/transport_tokens_state_conflicts.go new file mode 100644 index 0000000..f93fd10 --- /dev/null +++ b/selective-vpn-api/app/transport_tokens_state_conflicts.go @@ -0,0 +1,44 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +func loadTransportConflictsState() TransportConflictState { + st := TransportConflictState{Version: transportStateVersion} + data, err := os.ReadFile(transportConflictsStatePath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return TransportConflictState{Version: transportStateVersion} + } + if st.Version == 0 { + st.Version = transportStateVersion + } + if st.Items == nil { + st.Items = nil + } + return st +} + +func saveTransportConflictsState(st TransportConflictState) error { + st.Version = transportStateVersion + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportConflictsStatePath), 0o755); err != nil { + return err + } + tmp := transportConflictsStatePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportConflictsStatePath) +} diff --git a/selective-vpn-api/app/transport_tokens_state_interfaces.go b/selective-vpn-api/app/transport_tokens_state_interfaces.go new file mode 100644 index 0000000..fe48a7e --- /dev/null +++ b/selective-vpn-api/app/transport_tokens_state_interfaces.go @@ -0,0 +1,52 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +func loadTransportInterfacesState() transportInterfacesState { + st := transportInterfacesState{Version: transportStateVersion} + data, err := os.ReadFile(transportInterfacesStatePath) + if err != nil { + norm, _ := normalizeTransportInterfacesState(st, nil) + _ = saveTransportInterfacesState(norm) + return norm + } + if err := json.Unmarshal(data, &st); err != nil { + st = transportInterfacesState{Version: transportStateVersion} + } + if st.Version == 0 { + st.Version = transportStateVersion + } + if st.Items == nil { + st.Items = nil + } + norm, changed := normalizeTransportInterfacesState(st, nil) + if changed { + _ = saveTransportInterfacesState(norm) + } + return norm +} + +func saveTransportInterfacesState(st transportInterfacesState) error { + norm, _ := normalizeTransportInterfacesState(st, nil) + st = norm + st.Version = transportStateVersion + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportInterfacesStatePath), 0o755); err != nil { + return err + } + tmp := transportInterfacesStatePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportInterfacesStatePath) +} diff --git a/selective-vpn-api/app/transport_tokens_state_owner_locks.go b/selective-vpn-api/app/transport_tokens_state_owner_locks.go new file mode 100644 index 0000000..a7e643e --- /dev/null +++ b/selective-vpn-api/app/transport_tokens_state_owner_locks.go @@ -0,0 +1,46 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +func loadTransportOwnerLocksState() TransportOwnerLockState { + st := TransportOwnerLockState{Version: transportStateVersion} + data, err := os.ReadFile(transportOwnerLocksPath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return TransportOwnerLockState{Version: transportStateVersion} + } + if st.Version == 0 { + st.Version = transportStateVersion + } + if st.Items == nil { + st.Items = nil + } + st.Count = len(st.Items) + return st +} + +func saveTransportOwnerLocksState(st TransportOwnerLockState) error { + st.Version = transportStateVersion + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + st.Count = len(st.Items) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportOwnerLocksPath), 0o755); err != nil { + return err + } + tmp := transportOwnerLocksPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportOwnerLocksPath) +} diff --git a/selective-vpn-api/app/transport_tokens_state_ownership.go b/selective-vpn-api/app/transport_tokens_state_ownership.go new file mode 100644 index 0000000..1936c49 --- /dev/null +++ b/selective-vpn-api/app/transport_tokens_state_ownership.go @@ -0,0 +1,46 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +func loadTransportOwnershipState() TransportOwnershipState { + st := TransportOwnershipState{Version: transportStateVersion} + data, err := os.ReadFile(transportOwnershipStatePath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return TransportOwnershipState{Version: transportStateVersion} + } + if st.Version == 0 { + st.Version = transportStateVersion + } + if st.Items == nil { + st.Items = nil + } + st.Count = len(st.Items) + return st +} + +func saveTransportOwnershipState(st TransportOwnershipState) error { + st.Version = transportStateVersion + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + st.Count = len(st.Items) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportOwnershipStatePath), 0o755); err != nil { + return err + } + tmp := transportOwnershipStatePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportOwnershipStatePath) +} diff --git a/selective-vpn-api/app/transport_tokens_state_paths.go b/selective-vpn-api/app/transport_tokens_state_paths.go new file mode 100644 index 0000000..ddef6c6 --- /dev/null +++ b/selective-vpn-api/app/transport_tokens_state_paths.go @@ -0,0 +1,12 @@ +package app + +var ( + transportClientsStatePath = transportClientsPath + transportInterfacesStatePath = transportInterfacesPath + transportPolicyStatePath = transportPolicyPath + transportPolicySnapshotPath = transportPolicySnap + transportPolicyPlanStatePath = transportPolicyPlanPath + transportOwnershipStatePath = transportOwnershipPath + transportConflictsStatePath = transportConflictsPath + transportPolicyIdempotencyStatePath = stateDir + "/transport-policy-idempotency.json" +) diff --git a/selective-vpn-api/app/transport_tokens_state_policy.go b/selective-vpn-api/app/transport_tokens_state_policy.go new file mode 100644 index 0000000..5476ae6 --- /dev/null +++ b/selective-vpn-api/app/transport_tokens_state_policy.go @@ -0,0 +1,74 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +func loadTransportPolicyState() TransportPolicyState { + st := TransportPolicyState{Version: transportStateVersion, Revision: 0} + data, err := os.ReadFile(transportPolicyStatePath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return TransportPolicyState{Version: transportStateVersion, Revision: 0} + } + if st.Version == 0 { + st.Version = transportStateVersion + } + if st.Intents == nil { + st.Intents = nil + } + return st +} + +func saveTransportPolicyState(st TransportPolicyState) error { + st.Version = transportStateVersion + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportPolicyStatePath), 0o755); err != nil { + return err + } + tmp := transportPolicyStatePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportPolicyStatePath) +} + +func saveTransportPolicySnapshot(st TransportPolicyState) error { + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportPolicySnapshotPath), 0o755); err != nil { + return err + } + tmp := transportPolicySnapshotPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportPolicySnapshotPath) +} + +func loadTransportPolicySnapshot() (TransportPolicyState, bool) { + data, err := os.ReadFile(transportPolicySnapshotPath) + if err != nil { + return TransportPolicyState{}, false + } + var st TransportPolicyState + if err := json.Unmarshal(data, &st); err != nil { + return TransportPolicyState{}, false + } + if st.Version == 0 { + st.Version = transportStateVersion + } + return st, true +} diff --git a/selective-vpn-api/app/transport_tokens_state_policy_plan.go b/selective-vpn-api/app/transport_tokens_state_policy_plan.go new file mode 100644 index 0000000..2f538af --- /dev/null +++ b/selective-vpn-api/app/transport_tokens_state_policy_plan.go @@ -0,0 +1,42 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "time" +) + +func loadTransportPolicyCompilePlan() TransportPolicyCompilePlan { + var plan TransportPolicyCompilePlan + data, err := os.ReadFile(transportPolicyPlanStatePath) + if err != nil { + return plan + } + if err := json.Unmarshal(data, &plan); err != nil { + return TransportPolicyCompilePlan{} + } + if plan.Interfaces == nil { + plan.Interfaces = nil + } + return plan +} + +func saveTransportPolicyCompilePlan(plan TransportPolicyCompilePlan) error { + if strings.TrimSpace(plan.GeneratedAt) == "" { + plan.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + } + data, err := json.MarshalIndent(plan, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(transportPolicyPlanStatePath), 0o755); err != nil { + return err + } + tmp := transportPolicyPlanStatePath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, transportPolicyPlanStatePath) +} diff --git a/selective-vpn-api/app/transport_virtual_adapter_test.go b/selective-vpn-api/app/transport_virtual_adapter_test.go new file mode 100644 index 0000000..6c8f690 --- /dev/null +++ b/selective-vpn-api/app/transport_virtual_adapter_test.go @@ -0,0 +1,66 @@ +package app + +import ( + "testing" + "time" +) + +func TestBuildTransportPolicyAdGuardTargetFromObservation(t *testing.T) { + now := time.Date(2026, time.March, 23, 19, 0, 0, 0, time.UTC) + item := buildTransportPolicyAdGuardTargetFromObservation( + "active", + "CONNECTED", + "after connect: CONNECTED; raw: Connected to HELSINKI in TUN mode, running on tun0", + now, + ) + + if item.ID != transportPolicyTargetAdGuardID { + t.Fatalf("unexpected id: %#v", item) + } + if item.Kind != TransportClientKind(transportPolicyTargetAdGuardID) { + t.Fatalf("unexpected kind: %#v", item) + } + if item.Status != TransportClientUp { + t.Fatalf("unexpected status: %#v", item) + } + if item.Iface != "tun0" { + t.Fatalf("unexpected iface: %#v", item) + } + if item.RoutingTable != transportPolicyTargetAdGuardTable { + t.Fatalf("unexpected routing table: %#v", item) + } + if len(item.Runtime.AllowedActions) != 3 || item.Runtime.AllowedActions[0] != "start" { + t.Fatalf("unexpected allowed actions: %#v", item.Runtime) + } +} + +func TestTransportClientMatchesKindFilter(t *testing.T) { + it := TransportClient{Kind: TransportClientKind("adguardvpn")} + if !transportClientMatchesKindFilter(it, "adguardvpn", "") { + t.Fatalf("expected raw kind match for adguard virtual client") + } + if transportClientMatchesKindFilter(it, "singbox", TransportClientSingBox) { + t.Fatalf("unexpected known kind match") + } +} + +func TestTransportVirtualAdGuardSystemdAction(t *testing.T) { + if got, ok := transportVirtualAdGuardSystemdAction("start"); !ok || got != "start" { + t.Fatalf("unexpected start mapping: %q %v", got, ok) + } + if got, ok := transportVirtualAdGuardSystemdAction("ReStArT"); !ok || got != "restart" { + t.Fatalf("unexpected restart mapping: %q %v", got, ok) + } + if _, ok := transportVirtualAdGuardSystemdAction("provision"); ok { + t.Fatalf("unexpected mapping for unsupported action") + } +} + +func TestTransportRuntimeObservabilityScopeForClientID(t *testing.T) { + if got := transportRuntimeObservabilityScopeForClientID("adguardvpn"); got != "adguardvpn" { + t.Fatalf("unexpected adguard scope: %q", got) + } + if got := transportRuntimeObservabilityScopeForClientID("sb-one"); got != "transport:sb-one" { + t.Fatalf("unexpected transport scope: %q", got) + } +} diff --git a/selective-vpn-api/app/transportcfg/exec_helpers.go b/selective-vpn-api/app/transportcfg/exec_helpers.go new file mode 100644 index 0000000..358f7ef --- /dev/null +++ b/selective-vpn-api/app/transportcfg/exec_helpers.go @@ -0,0 +1,410 @@ +package transportcfg + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +func ResolvePrimaryExecStart(client Client, singBoxConfigPath, phoenixDefaultConfigPath string) (string, string, error) { + if manual := strings.TrimSpace(ConfigString(client.Config, "exec_start")); manual != "" { + return manual, "manual", nil + } + switch strings.ToLower(strings.TrimSpace(client.Kind)) { + case KindSingBox: + cmd, err := BuildSingBoxCommand(client, singBoxConfigPath) + if err != nil { + return "", "", err + } + return cmd, "template:singbox", nil + case KindDNSTT: + cmd, err := BuildDNSTTClientCommand(client) + if err != nil { + return "", "", err + } + return cmd, "template:dnstt", nil + case KindPhoenix: + cmd, err := BuildPhoenixClientCommand(client, phoenixDefaultConfigPath) + if err != nil { + return "", "", err + } + return cmd, "template:phoenix", nil + default: + return "", "", fmt.Errorf("no command template for transport kind %q", client.Kind) + } +} + +func BuildSingBoxCommand(client Client, configPath string) (string, error) { + bin := strings.TrimSpace(ConfigString(client.Config, "singbox_bin")) + if bin == "" { + bin = strings.TrimSpace(ConfigString(client.Config, "bin")) + } + if bin == "" { + var err error + bin, err = ResolveBinary(client.Config, "singbox", "/usr/bin/sing-box", "/usr/local/bin/sing-box", "sing-box") + if err != nil { + return "", err + } + } else if err := ValidateRequiredBinary(client.Config, "singbox", bin); err != nil { + return "", err + } + + args := []string{bin, "run", "-c", strings.TrimSpace(configPath)} + if extra := strings.TrimSpace(ConfigString(client.Config, "singbox_extra_args")); extra != "" { + args = append(args, strings.Fields(extra)...) + } + return ShellJoinArgs(args, ShellQuoteArg), nil +} + +func BuildPhoenixClientCommand(client Client, defaultConfigPath string) (string, error) { + bin := strings.TrimSpace(ConfigString(client.Config, "phoenix_bin")) + if bin == "" { + bin = strings.TrimSpace(ConfigString(client.Config, "bin")) + } + if bin == "" { + var err error + bin, err = ResolveBinary(client.Config, "phoenix", "/usr/local/bin/phoenix-client", "/usr/bin/phoenix-client", "phoenix-client") + if err != nil { + return "", err + } + } else if err := ValidateRequiredBinary(client.Config, "phoenix", bin); err != nil { + return "", err + } + + configPath := strings.TrimSpace(ConfigString(client.Config, "phoenix_config_path")) + if configPath == "" { + configPath = strings.TrimSpace(ConfigString(client.Config, "config_path")) + } + if configPath == "" { + configPath = strings.TrimSpace(defaultConfigPath) + } + + args := []string{bin, "-config", configPath} + if extra := strings.TrimSpace(ConfigString(client.Config, "phoenix_extra_args")); extra != "" { + args = append(args, strings.Fields(extra)...) + } + return ShellJoinArgs(args, ShellQuoteArg), nil +} + +func BuildDNSTTClientCommand(client Client) (string, error) { + bin := strings.TrimSpace(ConfigString(client.Config, "dnstt_bin")) + if bin == "" { + bin = strings.TrimSpace(ConfigString(client.Config, "bin")) + } + if bin == "" { + var err error + bin, err = ResolveBinary(client.Config, "dnstt", "/usr/local/bin/dnstt-client", "/usr/bin/dnstt-client", "dnstt-client") + if err != nil { + return "", err + } + } else if err := ValidateRequiredBinary(client.Config, "dnstt", bin); err != nil { + return "", err + } + if rawArgs := strings.TrimSpace(ConfigString(client.Config, "dnstt_args")); rawArgs != "" { + args := append([]string{bin}, strings.Fields(rawArgs)...) + return ShellJoinArgs(args, ShellQuoteArg), nil + } + + resolverMode := strings.ToLower(strings.TrimSpace(ConfigString(client.Config, "resolver_mode"))) + dohURL := strings.TrimSpace(ConfigString(client.Config, "doh_url")) + dotAddr := strings.TrimSpace(ConfigString(client.Config, "dot_addr")) + udpAddr := strings.TrimSpace(ConfigString(client.Config, "udp_addr")) + if dohURL == "" { + dohURL = strings.TrimSpace(ConfigString(client.Config, "resolver_url")) + } + if dotAddr == "" { + dotAddr = strings.TrimSpace(ConfigString(client.Config, "resolver_addr")) + } + if udpAddr == "" { + udpAddr = strings.TrimSpace(ConfigString(client.Config, "resolver_addr")) + } + + if resolverMode == "" { + switch { + case dohURL != "": + resolverMode = "doh" + case dotAddr != "": + resolverMode = "dot" + case udpAddr != "": + resolverMode = "udp" + default: + resolverMode = "doh" + } + } + args := []string{bin} + switch resolverMode { + case "doh": + if dohURL == "" { + return "", fmt.Errorf("dnstt template requires config.doh_url for resolver_mode=doh") + } + args = append(args, "-doh", dohURL) + case "dot": + if dotAddr == "" { + return "", fmt.Errorf("dnstt template requires config.dot_addr or config.resolver_addr for resolver_mode=dot") + } + args = append(args, "-dot", dotAddr) + case "udp": + if udpAddr == "" { + return "", fmt.Errorf("dnstt template requires config.udp_addr or config.resolver_addr for resolver_mode=udp") + } + args = append(args, "-udp", udpAddr) + default: + return "", fmt.Errorf("dnstt template resolver_mode must be doh|dot|udp") + } + + pubkey := strings.TrimSpace(ConfigString(client.Config, "pubkey")) + if pubkey == "" { + pubkey = strings.TrimSpace(ConfigString(client.Config, "pubkey_hex")) + } + pubkeyFile := strings.TrimSpace(ConfigString(client.Config, "pubkey_file")) + if pubkey == "" && pubkeyFile == "" { + return "", fmt.Errorf("dnstt template requires config.pubkey or config.pubkey_file") + } + if pubkeyFile != "" { + args = append(args, "-pubkey-file", pubkeyFile) + } else { + args = append(args, "-pubkey", pubkey) + } + + utls := strings.TrimSpace(ConfigString(client.Config, "utls")) + if utls != "" { + args = append(args, "-utls", utls) + } + + if extra := strings.TrimSpace(ConfigString(client.Config, "dnstt_extra_args")); extra != "" { + args = append(args, strings.Fields(extra)...) + } + + domain := strings.TrimSpace(ConfigString(client.Config, "domain")) + if domain == "" { + return "", fmt.Errorf("dnstt template requires config.domain") + } + localAddr := strings.TrimSpace(ConfigString(client.Config, "local_addr")) + if localAddr == "" { + localAddr = "127.0.0.1:7000" + } + args = append(args, domain, localAddr) + return ShellJoinArgs(args, ShellQuoteArg), nil +} + +func DefaultConfigPath(clientID, fileName string, sanitizeID func(string) string) string { + id := strings.TrimSpace(clientID) + if sanitizeID != nil { + id = sanitizeID(id) + } + if strings.TrimSpace(id) == "" { + id = "client" + } + return filepath.Join("/etc/selective-vpn/transports", id, fileName) +} + +func ResolveBinary(cfg map[string]any, kind string, systemCandidates ...string) (string, error) { + candidates := make([]string, 0, len(systemCandidates)+2) + profile := PackagingProfile(cfg) + switch profile { + case "bundled": + root := strings.TrimSpace(ConfigString(cfg, "bin_root")) + if root == "" { + root = "/opt/selective-vpn/bin" + } + if name := BinaryName(kind); name != "" { + candidates = append(candidates, filepath.Join(root, name)) + } + if ConfigBool(cfg, "packaging_system_fallback") || !ConfigHasKey(cfg, "packaging_system_fallback") { + candidates = append(candidates, systemCandidates...) + } + default: + candidates = append(candidates, systemCandidates...) + } + bin, found := FirstExistingBinaryCandidate(candidates...) + if bin == "" { + return "", fmt.Errorf("no binary candidates configured for transport kind %q", kind) + } + if ConfigBool(cfg, "require_binary") && !found { + return "", fmt.Errorf("required %s binary not found (profile=%s)", kind, profile) + } + return bin, nil +} + +func ValidateRequiredBinary(cfg map[string]any, kind, bin string) error { + if !ConfigBool(cfg, "require_binary") { + return nil + } + if BinaryExists(bin) { + return nil + } + return fmt.Errorf("required %s binary not found: %s", kind, strings.TrimSpace(bin)) +} + +func PackagingProfile(cfg map[string]any) string { + profile := strings.ToLower(strings.TrimSpace(ConfigString(cfg, "packaging_profile"))) + switch profile { + case "", "system": + return "system" + case "bundled", "bundle": + return "bundled" + default: + return "system" + } +} + +func BinaryName(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "singbox": + return "sing-box" + case "dnstt": + return "dnstt-client" + case "phoenix": + return "phoenix-client" + default: + return "" + } +} + +func FirstExistingBinaryCandidate(candidates ...string) (string, bool) { + firstNonEmpty := "" + for _, candidate := range candidates { + c := strings.TrimSpace(candidate) + if c == "" { + continue + } + if firstNonEmpty == "" { + firstNonEmpty = c + } + if resolved, ok := FindBinaryPath(c); ok { + return resolved, true + } + } + return firstNonEmpty, false +} + +func FindBinaryPath(candidate string) (string, bool) { + c := strings.TrimSpace(candidate) + if c == "" { + return "", false + } + if strings.ContainsRune(c, '/') { + if _, err := os.Stat(c); err == nil { + return c, true + } + return "", false + } + path, err := exec.LookPath(c) + if err != nil { + return "", false + } + return path, true +} + +func BinaryExists(candidate string) bool { + _, ok := FindBinaryPath(candidate) + return ok +} + +func ShellJoinArgs(args []string, quoteArg func(string) string) string { + q := quoteArg + if q == nil { + q = ShellQuoteArg + } + out := make([]string, 0, len(args)) + for _, arg := range args { + arg = strings.TrimSpace(arg) + if arg == "" { + continue + } + out = append(out, q(arg)) + } + return strings.Join(out, " ") +} + +func ShellQuoteArg(in string) string { + s := strings.ReplaceAll(in, "'", "'\"'\"'") + return "'" + s + "'" +} + +func BuildSSHOverlayCommand(cfg map[string]any, configInt func(map[string]any, string, int) int, quoteArg func(string) string) (string, error) { + intGetter := configInt + if intGetter == nil { + intGetter = ConfigInt + } + q := quoteArg + if q == nil { + q = ShellQuoteArg + } + + host := strings.TrimSpace(ConfigString(cfg, "ssh_host")) + if host == "" { + return "", fmt.Errorf("config.ssh_host is required for ssh overlay") + } + user := strings.TrimSpace(ConfigString(cfg, "ssh_user")) + if user == "" { + user = "root" + } + socksHost := strings.TrimSpace(ConfigString(cfg, "socks_host")) + if socksHost == "" { + socksHost = "127.0.0.1" + } + socksPort := intGetter(cfg, "socks_port", 1080) + if socksPort <= 0 || socksPort > 65535 { + return "", fmt.Errorf("config.socks_port must be in 1..65535") + } + sshPort := intGetter(cfg, "ssh_port", 22) + if sshPort <= 0 || sshPort > 65535 { + return "", fmt.Errorf("config.ssh_port must be in 1..65535") + } + sshBin := strings.TrimSpace(ConfigString(cfg, "ssh_bin")) + if sshBin == "" { + sshBin = "/usr/bin/ssh" + } + args := []string{ + sshBin, + "-N", + "-o", "ExitOnForwardFailure=yes", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "-D", fmt.Sprintf("%s:%d", socksHost, socksPort), + "-p", strconv.Itoa(sshPort), + } + sshKey := strings.TrimSpace(ConfigString(cfg, "ssh_key")) + if sshKey != "" { + args = append(args, "-i", sshKey) + } + extra := strings.TrimSpace(ConfigString(cfg, "ssh_extra_args")) + if extra != "" { + args = append(args, strings.Fields(extra)...) + } + args = append(args, user+"@"+host) + + quoted := make([]string, 0, len(args)) + for _, arg := range args { + quoted = append(quoted, q(arg)) + } + return strings.Join(quoted, " "), nil +} + +func ConfigInt(cfg map[string]any, key string, defaultVal int) int { + if cfg == nil { + return defaultVal + } + raw, ok := cfg[key] + if !ok || raw == nil { + return defaultVal + } + switch v := raw.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err == nil { + return n + } + } + return defaultVal +} diff --git a/selective-vpn-api/app/transportcfg/history_helpers.go b/selective-vpn-api/app/transportcfg/history_helpers.go new file mode 100644 index 0000000..810b844 --- /dev/null +++ b/selective-vpn-api/app/transportcfg/history_helpers.go @@ -0,0 +1,86 @@ +package transportcfg + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" +) + +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, perm); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func ReadJSONFiles(rootDir string) ([][]byte, error) { + entries, err := os.ReadDir(rootDir) + if err != nil { + if os.IsNotExist(err) { + return [][]byte{}, nil + } + return nil, err + } + out := make([][]byte, 0, len(entries)) + for _, ent := range entries { + if ent.IsDir() { + continue + } + name := strings.ToLower(strings.TrimSpace(ent.Name())) + if !strings.HasSuffix(name, ".json") { + continue + } + path := filepath.Join(rootDir, ent.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + out = append(out, data) + } + return out, nil +} + +func SelectRecordCandidate[T any]( + records []T, + historyID string, + recordID func(T) string, + eligible func(T) bool, +) (T, bool) { + id := strings.TrimSpace(historyID) + if id != "" { + for _, rec := range records { + if strings.TrimSpace(recordID(rec)) == id { + return rec, eligible(rec) + } + } + var zero T + return zero, false + } + for _, rec := range records { + if eligible(rec) { + return rec, true + } + } + var zero T + return zero, false +} + +func DecodeBase64Optional(raw string, exists bool) ([]byte, bool, error) { + if !exists { + return nil, false, nil + } + s := strings.TrimSpace(raw) + if s == "" { + return nil, true, nil + } + data, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, false, err + } + return data, true, nil +} diff --git a/selective-vpn-api/app/transportcfg/probe_helpers.go b/selective-vpn-api/app/transportcfg/probe_helpers.go new file mode 100644 index 0000000..6e9eb52 --- /dev/null +++ b/selective-vpn-api/app/transportcfg/probe_helpers.go @@ -0,0 +1,402 @@ +package transportcfg + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/netip" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +const ( + DefaultHealthTimeout = 5 * time.Second + DefaultProbeTimeout = 900 * time.Millisecond +) + +type DialRunner func(ctx context.Context, network, address string) (net.Conn, error) + +type Endpoint struct { + Host string + Port int +} + +func (ep Endpoint) Address() string { + return net.JoinHostPort(ep.Host, strconv.Itoa(ep.Port)) +} + +type ProbeDeps struct { + Dial DialRunner + HealthTimeout time.Duration + ProbeTimeout time.Duration + NetnsEnabled func(Client) bool + NetnsName func(Client) string + NetnsExecCommand func(Client, string, ...string) (string, []string, error) + RunCommand func(time.Duration, string, ...string) (string, string, int, error) + CommandError func(string, string, string, int, error) error + ShellJoinArgs func([]string) string + ReadFile func(string) ([]byte, error) + ConfigInt func(map[string]any, string, int) int +} + +func ProbeClientLatency(client Client, deps ProbeDeps) (int, error) { + healthTimeout := deps.HealthTimeout + if healthTimeout <= 0 { + healthTimeout = DefaultHealthTimeout + } + probeTimeout := deps.ProbeTimeout + if probeTimeout <= 0 { + probeTimeout = DefaultProbeTimeout + } + + endpoints := CollectProbeEndpoints(client, deps.ReadFile, deps.ConfigInt) + if len(endpoints) == 0 { + return 0, nil + } + + deadline := time.Now().Add(healthTimeout) + var firstErr error + for _, ep := range endpoints { + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + if remaining > probeTimeout { + remaining = probeTimeout + } + ms, err := ProbeDialEndpoint(client, ep, remaining, deps) + if err == nil && ms >= 0 { + return ms, nil + } + if firstErr == nil && err != nil { + firstErr = err + } + } + if firstErr != nil { + return 0, firstErr + } + return 0, nil +} + +func ProbeDialEndpoint(client Client, ep Endpoint, timeout time.Duration, deps ProbeDeps) (int, error) { + probeTimeout := deps.ProbeTimeout + if probeTimeout <= 0 { + probeTimeout = DefaultProbeTimeout + } + if timeout <= 0 { + timeout = probeTimeout + } + if deps.NetnsEnabled != nil && deps.NetnsEnabled(client) { + if ms, err := ProbeDialEndpointInNetns(client, ep, timeout, deps); err == nil { + return ms, nil + } + // Fall back to host probe for compatibility. + } + return ProbeDialEndpointHost(ep, timeout, deps.Dial) +} + +func ProbeDialEndpointHost(ep Endpoint, timeout time.Duration, dial DialRunner) (int, error) { + host := strings.TrimSpace(ep.Host) + if addr, err := netip.ParseAddr(strings.TrimSpace(ep.Host)); err == nil { + host = addr.Unmap().String() + } + if host == "" || ep.Port <= 0 || ep.Port > 65535 { + return 0, fmt.Errorf("invalid endpoint") + } + if timeout <= 0 { + timeout = DefaultProbeTimeout + } + d := dial + if d == nil { + d = func(ctx context.Context, network, address string) (net.Conn, error) { + var nd net.Dialer + return nd.DialContext(ctx, network, address) + } + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + start := time.Now() + conn, err := d(ctx, "tcp4", net.JoinHostPort(host, strconv.Itoa(ep.Port))) + if err != nil { + return 0, err + } + _ = conn.Close() + ms := int(time.Since(start).Milliseconds()) + if ms < 1 { + ms = 1 + } + return ms, nil +} + +func ProbeDialEndpointInNetns(client Client, ep Endpoint, timeout time.Duration, deps ProbeDeps) (int, error) { + probeTimeout := deps.ProbeTimeout + if probeTimeout <= 0 { + probeTimeout = DefaultProbeTimeout + } + if timeout <= 0 { + timeout = probeTimeout + } + if deps.NetnsName == nil || deps.NetnsExecCommand == nil || deps.RunCommand == nil { + return 0, fmt.Errorf("netns probe dependencies are not configured") + } + ns := strings.TrimSpace(deps.NetnsName(client)) + if ns == "" { + return 0, fmt.Errorf("netns name is empty") + } + script := fmt.Sprintf( + "set -e; t0=$(date +%%s%%3N); exec 3<>/dev/tcp/%s/%d; exec 3>&-; t1=$(date +%%s%%3N); echo $((t1-t0))", + strings.TrimSpace(ep.Host), + ep.Port, + ) + name, args, err := deps.NetnsExecCommand(client, ns, "bash", "-lc", script) + if err != nil { + return 0, err + } + start := time.Now() + stdout, stderr, code, runErr := deps.RunCommand(timeout+500*time.Millisecond, name, args...) + if runErr != nil || code != 0 { + cmdErr := deps.CommandError + if cmdErr == nil { + cmdErr = defaultCommandError + } + join := deps.ShellJoinArgs + if join == nil { + join = func(in []string) string { return ShellJoinArgs(in, ShellQuoteArg) } + } + return 0, cmdErr(join(append([]string{name}, args...)), stdout, stderr, code, runErr) + } + val := strings.TrimSpace(stdout) + ms, err := strconv.Atoi(val) + if err != nil || ms <= 0 { + ms = int(time.Since(start).Milliseconds()) + } + if ms < 1 { + ms = 1 + } + return ms, nil +} + +func CollectProbeEndpoints(client Client, readFile func(string) ([]byte, error), configInt func(map[string]any, string, int) int) []Endpoint { + combined := make([]Endpoint, 0, 12) + if strings.ToLower(strings.TrimSpace(client.Kind)) == KindSingBox { + combined = append(combined, CollectSingBoxConfigProbeEndpoints(client, readFile)...) + } + combined = append(combined, CollectConfigProbeEndpoints(client.Config, configInt)...) + return DedupeProbeEndpoints(combined) +} + +func CollectConfigProbeEndpoints(cfg map[string]any, configInt func(map[string]any, string, int) int) []Endpoint { + intGetter := configInt + if intGetter == nil { + intGetter = ConfigInt + } + if cfg == nil { + return nil + } + rawHosts := SplitCSV(ConfigString(cfg, "probe_endpoints")) + if len(rawHosts) == 0 { + host := strings.TrimSpace(ConfigString(cfg, "endpoint_host")) + port := intGetter(cfg, "endpoint_port", 443) + if host != "" && port > 0 { + rawHosts = []string{fmt.Sprintf("%s:%d", host, port)} + } + } + fallbackPort := intGetter(cfg, "probe_port", 443) + out := make([]Endpoint, 0, len(rawHosts)) + for _, raw := range rawHosts { + if ep, ok := ParseDialEndpoint(raw, fallbackPort); ok { + out = append(out, ep) + } + } + return out +} + +func CollectSingBoxConfigProbeEndpoints(client Client, readFile func(string) ([]byte, error)) []Endpoint { + reader := readFile + if reader == nil { + reader = os.ReadFile + } + path := strings.TrimSpace(ConfigString(client.Config, "config_path")) + if path == "" { + path = strings.TrimSpace(ConfigString(client.Config, "singbox_config_path")) + } + if path == "" { + return nil + } + data, err := reader(path) + if err != nil { + return nil + } + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + return nil + } + out := make([]Endpoint, 0, 8) + if raw != nil { + CollectProbeEndpointsRecursive(raw, &out) + } + return out +} + +func CollectProbeEndpointsRecursive(node any, out *[]Endpoint) { + switch v := node.(type) { + case map[string]any: + fallbackPort := 443 + for _, key := range []string{"server_port", "port", "listen_port"} { + if p, ok := ParseInt(v[key]); ok && p > 0 && p <= 65535 { + fallbackPort = p + break + } + } + for _, key := range []string{"server", "address", "host"} { + raw, ok := v[key] + if !ok || raw == nil { + continue + } + if vv, ok := raw.(string); ok { + if ep, ok := ParseDialEndpoint(vv, fallbackPort); ok { + *out = append(*out, ep) + } + } + } + for _, child := range v { + CollectProbeEndpointsRecursive(child, out) + } + case []any: + for _, child := range v { + CollectProbeEndpointsRecursive(child, out) + } + } +} + +func ParseDialEndpoint(raw string, fallbackPort int) (Endpoint, bool) { + s := strings.TrimSpace(raw) + if s == "" { + return Endpoint{}, false + } + if strings.Contains(s, "://") { + if u, err := url.Parse(s); err == nil { + host := strings.TrimSpace(u.Hostname()) + if host != "" { + port := fallbackPort + if p := u.Port(); p != "" { + if parsed, err := strconv.Atoi(p); err == nil { + port = parsed + } + } + if port <= 0 { + port = fallbackPort + } + if port > 0 && port <= 65535 { + return Endpoint{Host: strings.ToLower(host), Port: port}, true + } + } + } + return Endpoint{}, false + } + host := s + port := fallbackPort + if h, p, err := net.SplitHostPort(s); err == nil { + host = strings.TrimSpace(h) + if parsed, err := strconv.Atoi(strings.TrimSpace(p)); err == nil { + port = parsed + } + } else if idx := strings.LastIndex(s, ":"); idx > 0 && idx+1 < len(s) && !strings.Contains(s[idx+1:], ":") { + candidateHost := strings.TrimSpace(s[:idx]) + candidatePort := strings.TrimSpace(s[idx+1:]) + if parsed, err := strconv.Atoi(candidatePort); err == nil { + host = candidateHost + port = parsed + } + } + host = strings.TrimSpace(host) + if host == "" { + return Endpoint{}, false + } + if addr, err := netip.ParseAddr(host); err == nil { + host = addr.Unmap().String() + } + if port <= 0 || port > 65535 { + return Endpoint{}, false + } + return Endpoint{Host: strings.ToLower(host), Port: port}, true +} + +func DedupeProbeEndpoints(in []Endpoint) []Endpoint { + if len(in) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]Endpoint, 0, len(in)) + for _, ep := range in { + host := strings.ToLower(strings.TrimSpace(ep.Host)) + if host == "" || ep.Port <= 0 || ep.Port > 65535 { + continue + } + key := fmt.Sprintf("%s:%d", host, ep.Port) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, Endpoint{Host: host, Port: ep.Port}) + } + return out +} + +func ParseInt(raw any) (int, bool) { + switch v := raw.(type) { + case int: + return v, true + case int32: + return int(v), true + case int64: + return int(v), true + case float64: + return int(v), true + case string: + s := strings.TrimSpace(v) + if s == "" { + return 0, false + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + return n, true + default: + return 0, false + } +} + +func SplitCSV(raw string) []string { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + v := strings.TrimSpace(p) + if v == "" { + continue + } + out = append(out, v) + } + return out +} + +func defaultCommandError(cmd, stdout, stderr string, code int, err error) error { + if err == nil { + err = fmt.Errorf("exit code %d", code) + } + cmd = strings.TrimSpace(cmd) + stderr = strings.TrimSpace(stderr) + if stderr != "" { + return fmt.Errorf("%s: %w: %s", cmd, err, stderr) + } + stdout = strings.TrimSpace(stdout) + if stdout != "" { + return fmt.Errorf("%s: %w: %s", cmd, err, stdout) + } + return fmt.Errorf("%s: %w", cmd, err) +} diff --git a/selective-vpn-api/app/transportcfg/runtime_helpers.go b/selective-vpn-api/app/transportcfg/runtime_helpers.go new file mode 100644 index 0000000..f2b37d9 --- /dev/null +++ b/selective-vpn-api/app/transportcfg/runtime_helpers.go @@ -0,0 +1,210 @@ +package transportcfg + +import ( + "fmt" + "strings" +) + +const ( + KindSingBox = "singbox" + KindDNSTT = "dnstt" + KindPhoenix = "phoenix" + + defaultSingBoxUnitTemplate = "singbox@.service" +) + +type Client struct { + ID string + Kind string + Config map[string]any +} + +func BackendUnit(client Client) string { + kind := strings.ToLower(strings.TrimSpace(client.Kind)) + unit := strings.TrimSpace(ConfigString(client.Config, "unit")) + if kind == KindSingBox { + return resolveSingBoxBackendUnit(client.ID, unit) + } + if unit != "" { + return unit + } + return DefaultBackendUnit(kind) +} + +func DefaultBackendUnit(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case KindSingBox: + return defaultSingBoxUnitTemplate + case KindDNSTT: + return "dnstt-client.service" + case KindPhoenix: + return "phoenix.service" + default: + return "" + } +} + +func resolveSingBoxBackendUnit(clientID, configuredUnit string) string { + unit := strings.TrimSpace(configuredUnit) + if unit == "" { + unit = defaultSingBoxUnitTemplate + } + if strings.HasSuffix(unit, "@.service") { + instance := sanitizeSystemdInstanceID(clientID) + if instance == "" { + instance = "client" + } + return strings.TrimSuffix(unit, "@.service") + "@" + instance + ".service" + } + return unit +} + +func sanitizeSystemdInstanceID(in string) string { + s := strings.ToLower(strings.TrimSpace(in)) + if s == "" { + return "" + } + var b strings.Builder + b.Grow(len(s)) + lastDash := false + for i := 0; i < len(s); i++ { + ch := s[i] + if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.' { + b.WriteByte(ch) + lastDash = false + continue + } + if ch == '-' { + if lastDash { + continue + } + b.WriteByte('-') + lastDash = true + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + return strings.Trim(b.String(), "-") +} + +func DNSTTSSHTunnelEnabled(client Client) bool { + if strings.ToLower(strings.TrimSpace(client.Kind)) != KindDNSTT { + return false + } + return ConfigBool(client.Config, "ssh_tunnel") || ConfigBool(client.Config, "ssh_overlay") +} + +func DNSTTSSHUnit(client Client) string { + unit := strings.TrimSpace(ConfigString(client.Config, "ssh_unit")) + if unit != "" { + return unit + } + return "dnstt-ssh-tunnel.service" +} + +func RuntimeMode(cfg map[string]any) string { + mode := strings.ToLower(strings.TrimSpace(ConfigString(cfg, "runtime_mode"))) + switch mode { + case "", "exec", "external", "companion": + return "exec" + case "embedded": + return "embedded" + case "sidecar": + return "sidecar" + default: + return mode + } +} + +func SystemdActionUnits(client Client, action string) ([]string, string, string) { + unit := BackendUnit(client) + if unit == "" { + return nil, "TRANSPORT_BACKEND_UNIT_REQUIRED", "systemd unit is required" + } + if !DNSTTSSHTunnelEnabled(client) { + return []string{unit}, "", "" + } + sshUnit := DNSTTSSHUnit(client) + if strings.TrimSpace(sshUnit) == "" { + return nil, "TRANSPORT_BACKEND_UNIT_REQUIRED", "dnstt ssh tunnel unit is required" + } + switch action { + case "stop": + return []string{unit, sshUnit}, "", "" + default: + return []string{sshUnit, unit}, "", "" + } +} + +func SystemdHealthUnits(client Client) ([]string, string, string) { + unit := BackendUnit(client) + if unit == "" { + return nil, "TRANSPORT_BACKEND_UNIT_REQUIRED", "systemd unit is required" + } + units := []string{unit} + if DNSTTSSHTunnelEnabled(client) { + sshUnit := DNSTTSSHUnit(client) + if strings.TrimSpace(sshUnit) == "" { + return nil, "TRANSPORT_BACKEND_UNIT_REQUIRED", "dnstt ssh tunnel unit is required" + } + units = append(units, sshUnit) + } + return units, "", "" +} + +func ConfigString(cfg map[string]any, key string) string { + if cfg == nil { + return "" + } + raw, ok := cfg[key] + if !ok || raw == nil { + return "" + } + switch v := raw.(type) { + case string: + return strings.TrimSpace(v) + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} + +func ConfigBool(cfg map[string]any, key string) bool { + if cfg == nil { + return false + } + raw, ok := cfg[key] + if !ok || raw == nil { + return false + } + switch v := raw.(type) { + case bool: + return v + case string: + s := strings.ToLower(strings.TrimSpace(v)) + return s == "1" || s == "true" || s == "yes" || s == "on" + case float64: + return v != 0 + case int: + return v != 0 + default: + s := strings.ToLower(strings.TrimSpace(fmt.Sprint(v))) + return s == "1" || s == "true" || s == "yes" || s == "on" + } +} + +func ConfigHasKey(cfg map[string]any, key string) bool { + if cfg == nil { + return false + } + raw, ok := cfg[key] + if !ok || raw == nil { + return false + } + if s, ok := raw.(string); ok { + return strings.TrimSpace(s) != "" + } + return true +} diff --git a/selective-vpn-api/app/transportcfg/runtime_helpers_test.go b/selective-vpn-api/app/transportcfg/runtime_helpers_test.go new file mode 100644 index 0000000..56f85a4 --- /dev/null +++ b/selective-vpn-api/app/transportcfg/runtime_helpers_test.go @@ -0,0 +1,67 @@ +package transportcfg + +import "testing" + +func TestBackendUnitSingBoxUsesInstanceTemplateByDefault(t *testing.T) { + client := Client{ + ID: "sg-realnetns", + Kind: KindSingBox, + } + got := BackendUnit(client) + if got != "singbox@sg-realnetns.service" { + t.Fatalf("unexpected singbox unit: %q", got) + } +} + +func TestBackendUnitSingBoxTemplateConfigExpandsInstance(t *testing.T) { + client := Client{ + ID: "sg-1", + Kind: KindSingBox, + Config: map[string]any{ + "unit": "singbox@.service", + }, + } + got := BackendUnit(client) + if got != "singbox@sg-1.service" { + t.Fatalf("unexpected template unit: %q", got) + } +} + +func TestBackendUnitSingBoxKeepsExplicitNonTemplateUnit(t *testing.T) { + client := Client{ + ID: "sg-realnetns", + Kind: KindSingBox, + Config: map[string]any{ + "unit": "custom-singbox.service", + }, + } + got := BackendUnit(client) + if got != "custom-singbox.service" { + t.Fatalf("unexpected custom unit: %q", got) + } +} + +func TestBackendUnitNonSingBoxUnchanged(t *testing.T) { + client := Client{ + ID: "dn-1", + Kind: KindDNSTT, + Config: map[string]any{ + "unit": "dnstt-custom.service", + }, + } + got := BackendUnit(client) + if got != "dnstt-custom.service" { + t.Fatalf("unexpected non-singbox unit: %q", got) + } +} + +func TestBackendUnitSingBoxSanitizesInstanceID(t *testing.T) { + client := Client{ + ID: " SG Real/NetNS ", + Kind: KindSingBox, + } + got := BackendUnit(client) + if got != "singbox@sg-real-netns.service" { + t.Fatalf("unexpected sanitized singbox unit: %q", got) + } +} diff --git a/selective-vpn-api/app/transportcfg/secrets_helpers.go b/selective-vpn-api/app/transportcfg/secrets_helpers.go new file mode 100644 index 0000000..ed961a2 --- /dev/null +++ b/selective-vpn-api/app/transportcfg/secrets_helpers.go @@ -0,0 +1,100 @@ +package transportcfg + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" +) + +func NormalizeSecretUpdates(in map[string]string) map[string]string { + if in == nil { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + key := strings.TrimSpace(k) + if key == "" { + continue + } + out[key] = v + } + return out +} + +func CloneStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func EqualStringMap(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, av := range a { + if bv, ok := b[k]; !ok || av != bv { + return false + } + } + return true +} + +func MaskStringMap(in map[string]string, mask string) map[string]string { + if len(in) == 0 { + return nil + } + keys := make([]string, 0, len(in)) + for k := range in { + keys = append(keys, k) + } + sort.Strings(keys) + out := make(map[string]string, len(keys)) + for _, k := range keys { + out[k] = mask + } + return out +} + +func ReadStringMapJSON(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, err + } + var raw map[string]string + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + return NormalizeSecretUpdates(raw), nil +} + +func WriteStringMapJSON(path string, values map[string]string, dirPerm, filePerm os.FileMode) error { + normalized := NormalizeSecretUpdates(values) + if len(normalized) == 0 { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return err + } + data, err := json.MarshalIndent(normalized, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, filePerm); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/selective-vpn-api/app/transportcfg/singbox_binary_check.go b/selective-vpn-api/app/transportcfg/singbox_binary_check.go new file mode 100644 index 0000000..8d7bba5 --- /dev/null +++ b/selective-vpn-api/app/transportcfg/singbox_binary_check.go @@ -0,0 +1,82 @@ +package transportcfg + +import ( + "os" + "strings" + "time" +) + +type RunCommandTimeoutFunc func(timeout time.Duration, name string, args ...string) (stdout string, stderr string, exitCode int, err error) + +func ValidateRenderedConfigWithBinary( + config map[string]any, + run RunCommandTimeoutFunc, + timeout time.Duration, + binaryCandidates ...string, +) *SingBoxIssue { + if len(config) == 0 { + return &SingBoxIssue{ + Field: "config", + Severity: "error", + Code: "SINGBOX_RENDER_EMPTY", + Message: "rendered config is empty", + } + } + bin, _ := FirstExistingBinaryCandidate(binaryCandidates...) + if strings.TrimSpace(bin) == "" { + return &SingBoxIssue{ + Field: "binary", + Severity: "warning", + Code: "SINGBOX_BINARY_MISSING", + Message: "sing-box binary not found; binary check skipped", + } + } + if run == nil { + return &SingBoxIssue{ + Field: "binary", + Severity: "warning", + Code: "SINGBOX_CHECK_RUNNER_MISSING", + Message: "command runner is not configured; binary check skipped", + } + } + + tmp, err := os.CreateTemp("", "svpn-singbox-check-*.json") + if err != nil { + return &SingBoxIssue{ + Field: "binary", + Severity: "warning", + Code: "SINGBOX_CHECK_TEMPFILE_FAILED", + Message: err.Error(), + } + } + tmpPath := tmp.Name() + _ = tmp.Close() + defer os.Remove(tmpPath) + + if err := WriteJSONConfigFile(tmpPath, config); err != nil { + return &SingBoxIssue{ + Field: "binary", + Severity: "warning", + Code: "SINGBOX_CHECK_TEMPFILE_FAILED", + Message: err.Error(), + } + } + + stdout, _, code, runErr := run(timeout, bin, "check", "-c", tmpPath) + if runErr == nil && code == 0 { + return nil + } + msg := strings.TrimSpace(stdout) + if msg == "" && runErr != nil { + msg = runErr.Error() + } + if msg == "" { + msg = "sing-box check failed" + } + return &SingBoxIssue{ + Field: "config", + Severity: "error", + Code: "SINGBOX_CHECK_FAILED", + Message: msg, + } +} diff --git a/selective-vpn-api/app/transportcfg/singbox_helpers.go b/selective-vpn-api/app/transportcfg/singbox_helpers.go new file mode 100644 index 0000000..d05d8bd --- /dev/null +++ b/selective-vpn-api/app/transportcfg/singbox_helpers.go @@ -0,0 +1,199 @@ +package transportcfg + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + "time" +) + +type ConfigDiff struct { + Added int + Removed int + Changed int +} + +func SingBoxTypedProtocolSupported(proto string) bool { + switch strings.ToLower(strings.TrimSpace(proto)) { + case "vless", "trojan", "shadowsocks", "wireguard", "hysteria2", "tuic": + return true + default: + return false + } +} + +func ParsePort(v any) (int, bool) { + switch x := v.(type) { + case int: + return x, true + case int64: + return int(x), true + case float64: + return int(x), true + case string: + s := strings.TrimSpace(x) + if s == "" { + return 0, false + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + return n, true + default: + return 0, false + } +} + +func DigestJSONMap(config map[string]any) string { + if config == nil { + return "" + } + b, err := json.Marshal(config) + if err != nil { + return "" + } + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func DiffConfigMaps(prev, next map[string]any) (ConfigDiff, bool) { + diff := ConfigDiff{} + changed := false + if prev == nil { + diff.Added = len(next) + return diff, diff.Added > 0 + } + seen := map[string]struct{}{} + for k, pv := range prev { + seen[k] = struct{}{} + nv, ok := next[k] + if !ok { + diff.Removed++ + changed = true + continue + } + if !reflect.DeepEqual(pv, nv) { + diff.Changed++ + changed = true + } + } + for k := range next { + if _, ok := seen[k]; ok { + continue + } + diff.Added++ + changed = true + } + return diff, changed +} + +func ProfileConfigPath(rootDir, profileID string, sanitizeID func(string) string) string { + id := profileID + if sanitizeID != nil { + id = sanitizeID(id) + } + if strings.TrimSpace(id) == "" { + id = "profile" + } + return filepath.Join(strings.TrimSpace(rootDir), id+".json") +} + +func WriteJSONConfigFile(path string, config map[string]any) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func ReadJSONMapFile(path string) map[string]any { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return nil + } + return out +} + +func ReadFileOptional(path string) ([]byte, bool, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + return data, true, nil +} + +func RestoreFileOptional(path string, data []byte, exists bool) error { + if !exists { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func SanitizeHistoryStamp(ts string, now time.Time) string { + s := strings.TrimSpace(ts) + if s == "" { + s = now.UTC().Format(time.RFC3339Nano) + } + repl := strings.NewReplacer(":", "", "-", "", "T", "_", "Z", "", ".", "") + out := repl.Replace(s) + out = strings.Trim(out, "_") + if out == "" { + out = strconv.FormatInt(now.UTC().UnixNano(), 10) + } + return out +} + +func JoinMessages(messages []string) string { + if len(messages) == 0 { + return "" + } + parts := make([]string, 0, len(messages)) + for _, msg := range messages { + v := strings.TrimSpace(msg) + if v == "" { + continue + } + parts = append(parts, v) + } + return strings.Join(parts, "; ") +} + +func SortRecordsDescByAt[T any](items []T, extractAt func(T) string) { + if len(items) < 2 || extractAt == nil { + return + } + sort.Slice(items, func(i, j int) bool { + return extractAt(items[i]) > extractAt(items[j]) + }) +} diff --git a/selective-vpn-api/app/transportcfg/singbox_profile_eval.go b/selective-vpn-api/app/transportcfg/singbox_profile_eval.go new file mode 100644 index 0000000..3359d42 --- /dev/null +++ b/selective-vpn-api/app/transportcfg/singbox_profile_eval.go @@ -0,0 +1,280 @@ +package transportcfg + +import ( + "fmt" + "strings" +) + +type SingBoxProfileInput struct { + ID string + Mode string + Protocol string + RawConfig map[string]any + Typed map[string]any +} + +type SingBoxIssue struct { + Field string + Severity string + Code string + Message string +} + +type SingBoxProfileEvalDeps struct { + NormalizeMode func(mode string) (normalized string, ok bool) + CloneMapDeep func(in map[string]any) map[string]any + AsString func(v any) string + ProtocolSupported func(proto string) bool + ParsePort func(v any) (int, bool) +} + +func ValidateSingBoxProfile(profile SingBoxProfileInput, deps SingBoxProfileEvalDeps) ([]SingBoxIssue, []SingBoxIssue) { + errs := make([]SingBoxIssue, 0) + warns := make([]SingBoxIssue, 0) + + if strings.TrimSpace(profile.ID) == "" { + errs = append(errs, SingBoxIssue{ + Field: "id", + Severity: "error", + Code: "SINGBOX_PROFILE_ID_EMPTY", + Message: "profile id is required", + }) + } + + mode, ok := normalizeProfileMode(profile.Mode, deps) + if !ok { + errs = append(errs, SingBoxIssue{ + Field: "mode", + Severity: "error", + Code: "SINGBOX_PROFILE_MODE_INVALID", + Message: "mode must be typed|raw", + }) + return errs, warns + } + + if mode == "raw" { + if len(profile.RawConfig) == 0 { + errs = append(errs, SingBoxIssue{ + Field: "raw_config", + Severity: "error", + Code: "SINGBOX_PROFILE_RAW_EMPTY", + Message: "raw_config is required for raw mode", + }) + } + if len(profile.Typed) > 0 { + warns = append(warns, SingBoxIssue{ + Field: "typed", + Severity: "warning", + Code: "SINGBOX_PROFILE_TYPED_IGNORED", + Message: "typed fields are ignored in raw mode", + }) + } + return errs, warns + } + + proto := strings.ToLower(strings.TrimSpace(profile.Protocol)) + if !isProtocolSupported(proto, deps) { + errs = append(errs, SingBoxIssue{ + Field: "protocol", + Severity: "error", + Code: "SINGBOX_PROFILE_PROTOCOL_UNSUPPORTED", + Message: "typed mode supports: vless,trojan,shadowsocks,wireguard,hysteria2,tuic", + }) + } + if len(profile.Typed) == 0 { + errs = append(errs, SingBoxIssue{ + Field: "typed", + Severity: "error", + Code: "SINGBOX_PROFILE_TYPED_EMPTY", + Message: "typed config is required in typed mode", + }) + return errs, warns + } + + server := strings.TrimSpace(asString(deps, profile.Typed["server"])) + if server == "" { + addr := strings.TrimSpace(asString(deps, profile.Typed["address"])) + if addr == "" && profile.Typed["config"] == nil { + errs = append(errs, SingBoxIssue{ + Field: "typed.server", + Severity: "error", + Code: "SINGBOX_PROFILE_SERVER_REQUIRED", + Message: "typed.server (or typed.address) is required", + }) + } + } + + if p, ok := parsePort(deps, profile.Typed["port"]); ok { + if p <= 0 || p > 65535 { + errs = append(errs, SingBoxIssue{ + Field: "typed.port", + Severity: "error", + Code: "SINGBOX_PROFILE_PORT_INVALID", + Message: "typed.port must be in range 1..65535", + }) + } + } + + if len(profile.RawConfig) > 0 { + warns = append(warns, SingBoxIssue{ + Field: "raw_config", + Severity: "warning", + Code: "SINGBOX_PROFILE_RAW_IGNORED", + Message: "raw_config is ignored in typed mode", + }) + } + return errs, warns +} + +func RenderSingBoxProfileConfig(profile SingBoxProfileInput, deps SingBoxProfileEvalDeps) (map[string]any, error) { + mode, ok := normalizeProfileMode(profile.Mode, deps) + if !ok { + return nil, fmt.Errorf("unsupported profile mode %q", profile.Mode) + } + if mode == "raw" { + cfg := cloneMap(deps, profile.RawConfig) + if len(cfg) == 0 { + return nil, fmt.Errorf("raw_config is empty") + } + return cfg, nil + } + + typed := cloneMap(deps, profile.Typed) + if typed == nil { + return nil, fmt.Errorf("typed config is empty") + } + if embedded, ok := typed["config"].(map[string]any); ok && len(embedded) > 0 { + return cloneMap(deps, embedded), nil + } + + outbound := cloneMap(deps, typed) + if outbound == nil { + outbound = map[string]any{} + } + proto := strings.ToLower(strings.TrimSpace(profile.Protocol)) + if proto == "" { + return nil, fmt.Errorf("protocol is required in typed mode") + } + outbound["type"] = proto + tag := strings.TrimSpace(asString(deps, outbound["tag"])) + if tag == "" { + tag = "proxy" + outbound["tag"] = tag + } + + cfg := map[string]any{ + "log": map[string]any{ + "level": "warn", + }, + "outbounds": []any{ + outbound, + map[string]any{"type": "direct", "tag": "direct"}, + }, + "route": map[string]any{ + "final": tag, + "auto_detect_interface": true, + }, + "dns": map[string]any{ + "servers": []any{ + map[string]any{"type": "local", "tag": "local"}, + }, + "final": "local", + }, + } + + if v, ok := typed["dns"]; ok { + if dnsMap, ok := v.(map[string]any); ok && len(dnsMap) > 0 { + cfg["dns"] = cloneMap(deps, dnsMap) + } + } + if v, ok := typed["route"]; ok { + if routeMap, ok := v.(map[string]any); ok && len(routeMap) > 0 { + cfg["route"] = cloneMap(deps, routeMap) + } + } + if v, ok := typed["inbounds"]; ok { + switch vv := v.(type) { + case []any: + cfg["inbounds"] = vv + case map[string]any: + cfg["inbounds"] = []any{vv} + } + } + return cfg, nil +} + +func NormalizeSingBoxRenderedConfig(config map[string]any, asStringFn func(v any) string) map[string]any { + if config == nil { + return nil + } + outboundsRaw, ok := config["outbounds"].([]any) + if !ok || len(outboundsRaw) == 0 { + return config + } + asString := asStringFn + if asString == nil { + asString = func(v any) string { return strings.TrimSpace(fmt.Sprint(v)) } + } + for i := range outboundsRaw { + outbound, ok := outboundsRaw[i].(map[string]any) + if !ok { + continue + } + if strings.ToLower(strings.TrimSpace(asString(outbound["type"]))) != "vless" { + continue + } + packetEncoding := strings.ToLower(strings.TrimSpace(asString(outbound["packet_encoding"]))) + if packetEncoding == "" || packetEncoding == "none" { + delete(outbound, "packet_encoding") + } + flow := strings.ToLower(strings.TrimSpace(asString(outbound["flow"]))) + if flow == "" || flow == "none" { + delete(outbound, "flow") + } + } + config["outbounds"] = outboundsRaw + return config +} + +func normalizeProfileMode(mode string, deps SingBoxProfileEvalDeps) (string, bool) { + if deps.NormalizeMode == nil { + return "", false + } + out, ok := deps.NormalizeMode(mode) + return strings.ToLower(strings.TrimSpace(out)), ok +} + +func asString(deps SingBoxProfileEvalDeps, v any) string { + if deps.AsString == nil { + return "" + } + return deps.AsString(v) +} + +func cloneMap(deps SingBoxProfileEvalDeps, in map[string]any) map[string]any { + if deps.CloneMapDeep == nil { + if in == nil { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out + } + return deps.CloneMapDeep(in) +} + +func isProtocolSupported(proto string, deps SingBoxProfileEvalDeps) bool { + if deps.ProtocolSupported == nil { + return false + } + return deps.ProtocolSupported(proto) +} + +func parsePort(deps SingBoxProfileEvalDeps, v any) (int, bool) { + if deps.ParsePort == nil { + return 0, false + } + return deps.ParsePort(v) +} diff --git a/selective-vpn-api/app/transportcfg/systemd_helpers.go b/selective-vpn-api/app/transportcfg/systemd_helpers.go new file mode 100644 index 0000000..2c53b8d --- /dev/null +++ b/selective-vpn-api/app/transportcfg/systemd_helpers.go @@ -0,0 +1,412 @@ +package transportcfg + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +type SystemdServiceTuning struct { + RestartPolicy string + RestartSec int + StartLimitIntervalSec int + StartLimitBurst int + TimeoutStartSec int + TimeoutStopSec int + WatchdogSec int +} + +type SystemdHardening struct { + Enabled bool + NoNewPrivileges bool + PrivateTmp bool + ProtectSystem string + ProtectHome string + ProtectControlGroups bool + ProtectKernelModules bool + ProtectKernelTunables bool + RestrictSUIDSGID bool + LockPersonality bool + PrivateDevices bool + UMask string +} + +func ValidSystemdUnitName(unit string) bool { + u := strings.TrimSpace(unit) + if u == "" || !strings.HasSuffix(u, ".service") { + return false + } + if strings.Contains(u, "/") || strings.Contains(u, "\\") || strings.Contains(u, "..") { + return false + } + for _, ch := range u { + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') { + continue + } + if ch == '-' || ch == '_' || ch == '.' || ch == '@' { + continue + } + return false + } + return true +} + +func SystemdUnitPath(unitsDir, unit string) string { + return filepath.Join(unitsDir, unit) +} + +func SystemdUnitOwnedByClient(path, clientID string) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + marker := "Environment=SVPN_TRANSPORT_ID=" + strings.TrimSpace(clientID) + return strings.Contains(string(data), marker), nil +} + +func WriteSystemdUnitFile(unitsDir, unit, content string) error { + path := SystemdUnitPath(unitsDir, unit) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func RenderSystemdUnit( + client Client, + stateDir string, + unit string, + execStart string, + requiresSSH bool, + sshUnit string, + tuning SystemdServiceTuning, + hardening SystemdHardening, + shellQuoteArg func(string) string, +) string { + desc := fmt.Sprintf("Selective VPN transport %s (%s)", client.ID, client.Kind) + b := strings.Builder{} + b.WriteString("[Unit]\n") + b.WriteString("Description=" + desc + "\n") + b.WriteString("After=network-online.target\n") + b.WriteString("Wants=network-online.target\n") + b.WriteString("StartLimitIntervalSec=" + strconv.Itoa(tuning.StartLimitIntervalSec) + "\n") + b.WriteString("StartLimitBurst=" + strconv.Itoa(tuning.StartLimitBurst) + "\n") + if requiresSSH && strings.TrimSpace(sshUnit) != "" { + b.WriteString("Requires=" + sshUnit + "\n") + b.WriteString("After=" + sshUnit + "\n") + } + b.WriteString("\n[Service]\n") + b.WriteString("Type=simple\n") + b.WriteString("WorkingDirectory=" + stateDir + "\n") + b.WriteString("Restart=" + tuning.RestartPolicy + "\n") + b.WriteString("RestartSec=" + strconv.Itoa(tuning.RestartSec) + "\n") + b.WriteString("Environment=SVPN_TRANSPORT_ID=" + client.ID + "\n") + b.WriteString("Environment=SVPN_TRANSPORT_KIND=" + client.Kind + "\n") + b.WriteString("ExecStart=" + SystemdShellExec(execStart, shellQuoteArg) + "\n") + b.WriteString("ExecStop=/bin/kill -TERM $MAINPID\n") + b.WriteString("TimeoutStartSec=" + strconv.Itoa(tuning.TimeoutStartSec) + "\n") + b.WriteString("TimeoutStopSec=" + strconv.Itoa(tuning.TimeoutStopSec) + "\n") + if tuning.WatchdogSec > 0 { + b.WriteString("WatchdogSec=" + strconv.Itoa(tuning.WatchdogSec) + "\n") + b.WriteString("NotifyAccess=main\n") + } + renderSystemdHardening(&b, hardening) + b.WriteString("\n[Install]\n") + b.WriteString("WantedBy=multi-user.target\n") + return b.String() +} + +func RenderSSHOverlayUnit( + client Client, + stateDir string, + unit string, + execStart string, + tuning SystemdServiceTuning, + hardening SystemdHardening, + shellQuoteArg func(string) string, +) string { + desc := fmt.Sprintf("Selective VPN DNSTT SSH overlay (%s)", client.ID) + b := strings.Builder{} + b.WriteString("[Unit]\n") + b.WriteString("Description=" + desc + "\n") + b.WriteString("After=network-online.target\n") + b.WriteString("Wants=network-online.target\n") + b.WriteString("StartLimitIntervalSec=" + strconv.Itoa(tuning.StartLimitIntervalSec) + "\n") + b.WriteString("StartLimitBurst=" + strconv.Itoa(tuning.StartLimitBurst) + "\n") + b.WriteString("\n[Service]\n") + b.WriteString("Type=simple\n") + b.WriteString("WorkingDirectory=" + stateDir + "\n") + b.WriteString("Restart=" + tuning.RestartPolicy + "\n") + b.WriteString("RestartSec=" + strconv.Itoa(tuning.RestartSec) + "\n") + b.WriteString("Environment=SVPN_TRANSPORT_ID=" + client.ID + "\n") + b.WriteString("Environment=SVPN_TRANSPORT_KIND=" + client.Kind + "\n") + b.WriteString("ExecStart=" + SystemdShellExec(execStart, shellQuoteArg) + "\n") + b.WriteString("ExecStop=/bin/kill -TERM $MAINPID\n") + b.WriteString("TimeoutStartSec=" + strconv.Itoa(tuning.TimeoutStartSec) + "\n") + b.WriteString("TimeoutStopSec=" + strconv.Itoa(tuning.TimeoutStopSec) + "\n") + if tuning.WatchdogSec > 0 { + b.WriteString("WatchdogSec=" + strconv.Itoa(tuning.WatchdogSec) + "\n") + b.WriteString("NotifyAccess=main\n") + } + renderSystemdHardening(&b, hardening) + b.WriteString("\n[Install]\n") + b.WriteString("WantedBy=multi-user.target\n") + return b.String() +} + +func SystemdServiceTuningFromConfig(cfg map[string]any, prefix string, configInt func(map[string]any, string, int) int) SystemdServiceTuning { + intGetter := configInt + if intGetter == nil { + intGetter = defaultConfigInt + } + t := SystemdServiceTuning{ + RestartPolicy: "always", + RestartSec: 2, + StartLimitIntervalSec: 300, + StartLimitBurst: 30, + TimeoutStartSec: 90, + TimeoutStopSec: 20, + WatchdogSec: 0, + } + + if policy := configStringWithPrefixFallback(cfg, prefix, "restart_policy"); policy != "" { + t.RestartPolicy = normalizeSystemdRestartPolicy(policy) + } + t.RestartSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "restart_sec", t.RestartSec, intGetter), 0, 3600) + t.StartLimitIntervalSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "start_limit_interval_sec", t.StartLimitIntervalSec, intGetter), 0, 86400) + t.StartLimitBurst = clampInt(configIntWithPrefixFallback(cfg, prefix, "start_limit_burst", t.StartLimitBurst, intGetter), 1, 1000) + t.TimeoutStartSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "timeout_start_sec", t.TimeoutStartSec, intGetter), 1, 3600) + t.TimeoutStopSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "timeout_stop_sec", t.TimeoutStopSec, intGetter), 1, 3600) + t.WatchdogSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "watchdog_sec", t.WatchdogSec, intGetter), 0, 3600) + return t +} + +func SystemdHardeningFromConfig(cfg map[string]any, prefix string) SystemdHardening { + h := SystemdHardening{ + Enabled: true, + NoNewPrivileges: true, + PrivateTmp: true, + ProtectSystem: "full", + ProtectHome: "read-only", + ProtectControlGroups: true, + ProtectKernelModules: true, + ProtectKernelTunables: true, + RestrictSUIDSGID: true, + LockPersonality: true, + PrivateDevices: false, + UMask: "0077", + } + + profile := strings.ToLower(strings.TrimSpace(configStringWithPrefixFallback(cfg, prefix, "hardening_profile"))) + switch profile { + case "off", "none", "disabled", "false", "0": + h.Enabled = false + case "strict": + h.ProtectSystem = "strict" + h.PrivateDevices = true + case "", "baseline", "default": + // baseline defaults + default: + // unknown profile -> keep baseline defaults + } + if ConfigHasKey(cfg, "hardening_enabled") { + h.Enabled = ConfigBool(cfg, "hardening_enabled") + } + if prefix != "" && ConfigHasKey(cfg, prefix+"hardening_enabled") { + h.Enabled = ConfigBool(cfg, prefix+"hardening_enabled") + } + if !h.Enabled { + return h + } + + h.NoNewPrivileges = configBoolWithPrefixFallback(cfg, prefix, "no_new_privileges", h.NoNewPrivileges) + h.PrivateTmp = configBoolWithPrefixFallback(cfg, prefix, "private_tmp", h.PrivateTmp) + h.ProtectControlGroups = configBoolWithPrefixFallback(cfg, prefix, "protect_control_groups", h.ProtectControlGroups) + h.ProtectKernelModules = configBoolWithPrefixFallback(cfg, prefix, "protect_kernel_modules", h.ProtectKernelModules) + h.ProtectKernelTunables = configBoolWithPrefixFallback(cfg, prefix, "protect_kernel_tunables", h.ProtectKernelTunables) + h.RestrictSUIDSGID = configBoolWithPrefixFallback(cfg, prefix, "restrict_suid_sgid", h.RestrictSUIDSGID) + h.LockPersonality = configBoolWithPrefixFallback(cfg, prefix, "lock_personality", h.LockPersonality) + h.PrivateDevices = configBoolWithPrefixFallback(cfg, prefix, "private_devices", h.PrivateDevices) + h.ProtectSystem = normalizeSystemdProtectSystem(configStringWithPrefixFallback(cfg, prefix, "protect_system"), h.ProtectSystem) + h.ProtectHome = normalizeSystemdProtectHome(configStringWithPrefixFallback(cfg, prefix, "protect_home"), h.ProtectHome) + h.UMask = normalizeSystemdUMask(configStringWithPrefixFallback(cfg, prefix, "umask"), h.UMask) + return h +} + +func SystemdShellExec(command string, shellQuoteArg func(string) string) string { + cmd := strings.TrimSpace(command) + if cmd == "" { + return "/bin/true" + } + quote := shellQuoteArg + if quote == nil { + quote = defaultShellQuoteArg + } + return "/bin/sh -lc " + quote(cmd) +} + +func configStringWithPrefixFallback(cfg map[string]any, prefix, key string) string { + if prefix != "" { + if v := strings.TrimSpace(ConfigString(cfg, prefix+key)); v != "" { + return v + } + } + return strings.TrimSpace(ConfigString(cfg, key)) +} + +func configBoolWithPrefixFallback(cfg map[string]any, prefix, key string, defaultVal bool) bool { + base := defaultVal + if ConfigHasKey(cfg, key) { + base = ConfigBool(cfg, key) + } + if prefix == "" { + return base + } + if !ConfigHasKey(cfg, prefix+key) { + return base + } + return ConfigBool(cfg, prefix+key) +} + +func configIntWithPrefixFallback(cfg map[string]any, prefix, key string, defaultVal int, configInt func(map[string]any, string, int) int) int { + base := configInt(cfg, key, defaultVal) + if prefix == "" { + return base + } + if !ConfigHasKey(cfg, prefix+key) { + return base + } + return configInt(cfg, prefix+key, base) +} + +func systemdBool(v bool) string { + if v { + return "yes" + } + return "no" +} + +func normalizeSystemdProtectSystem(raw, defaultVal string) string { + v := strings.ToLower(strings.TrimSpace(raw)) + switch v { + case "": + return defaultVal + case "yes", "true", "1": + return "yes" + case "no", "false", "0": + return "no" + case "full", "strict": + return v + default: + return defaultVal + } +} + +func normalizeSystemdProtectHome(raw, defaultVal string) string { + v := strings.ToLower(strings.TrimSpace(raw)) + switch v { + case "": + return defaultVal + case "yes", "true", "1": + return "yes" + case "no", "false", "0": + return "no" + case "read-only", "tmpfs": + return v + default: + return defaultVal + } +} + +func normalizeSystemdUMask(raw, defaultVal string) string { + v := strings.TrimSpace(raw) + if v == "" { + return defaultVal + } + if len(v) == 3 { + v = "0" + v + } + if len(v) != 4 { + return defaultVal + } + for _, ch := range v { + if ch < '0' || ch > '7' { + return defaultVal + } + } + return v +} + +func normalizeSystemdRestartPolicy(v string) string { + s := strings.ToLower(strings.TrimSpace(v)) + switch s { + case "no", "on-success", "on-failure", "on-abnormal", "on-watchdog", "on-abort", "always": + return s + default: + return "always" + } +} + +func clampInt(v, minV, maxV int) int { + if v < minV { + return minV + } + if v > maxV { + return maxV + } + return v +} + +func defaultConfigInt(cfg map[string]any, key string, defaultVal int) int { + if cfg == nil { + return defaultVal + } + raw, ok := cfg[key] + if !ok || raw == nil { + return defaultVal + } + switch v := raw.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err == nil { + return n + } + } + return defaultVal +} + +func defaultShellQuoteArg(in string) string { + s := strings.ReplaceAll(in, "'", "'\"'\"'") + return "'" + s + "'" +} + +func renderSystemdHardening(b *strings.Builder, h SystemdHardening) { + if !h.Enabled { + return + } + b.WriteString("NoNewPrivileges=" + systemdBool(h.NoNewPrivileges) + "\n") + b.WriteString("PrivateTmp=" + systemdBool(h.PrivateTmp) + "\n") + b.WriteString("ProtectSystem=" + h.ProtectSystem + "\n") + b.WriteString("ProtectHome=" + h.ProtectHome + "\n") + b.WriteString("ProtectControlGroups=" + systemdBool(h.ProtectControlGroups) + "\n") + b.WriteString("ProtectKernelModules=" + systemdBool(h.ProtectKernelModules) + "\n") + b.WriteString("ProtectKernelTunables=" + systemdBool(h.ProtectKernelTunables) + "\n") + b.WriteString("RestrictSUIDSGID=" + systemdBool(h.RestrictSUIDSGID) + "\n") + b.WriteString("LockPersonality=" + systemdBool(h.LockPersonality) + "\n") + if h.PrivateDevices { + b.WriteString("PrivateDevices=yes\n") + } + b.WriteString("UMask=" + h.UMask + "\n") +} diff --git a/selective-vpn-api/app/transporttoken/store.go b/selective-vpn-api/app/transporttoken/store.go new file mode 100644 index 0000000..f926581 --- /dev/null +++ b/selective-vpn-api/app/transporttoken/store.go @@ -0,0 +1,91 @@ +package transporttoken + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "sync" + "time" +) + +type record struct { + baseRevision int64 + digest string + expiresAt time.Time +} + +type Store struct { + mu sync.Mutex + ttl time.Duration + records map[string]record +} + +func NewStore(ttl time.Duration) *Store { + if ttl <= 0 { + ttl = 10 * time.Minute + } + return &Store{ + ttl: ttl, + records: map[string]record{}, + } +} + +func (s *Store) Issue(prefix string, baseRevision int64, digest string) string { + token := strings.TrimSpace(prefix) + NewTokenHex(10) + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + s.cleanupLocked(now) + s.records[token] = record{ + baseRevision: baseRevision, + digest: digest, + expiresAt: now.Add(s.ttl), + } + return token +} + +func (s *Store) Consume(token string, baseRevision int64, digest string) bool { + token = strings.TrimSpace(token) + if token == "" { + return false + } + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + s.cleanupLocked(now) + rec, ok := s.records[token] + if !ok { + return false + } + delete(s.records, token) + if rec.baseRevision != baseRevision { + return false + } + if rec.digest != digest { + return false + } + if now.After(rec.expiresAt) { + return false + } + return true +} + +func (s *Store) cleanupLocked(now time.Time) { + for k, rec := range s.records { + if now.After(rec.expiresAt) { + delete(s.records, k) + } + } +} + +func NewTokenHex(n int) string { + if n <= 0 { + n = 8 + } + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(buf) +} diff --git a/selective-vpn-api/app/types.go b/selective-vpn-api/app/types.go index 72bba2c..5c76c71 100644 --- a/selective-vpn-api/app/types.go +++ b/selective-vpn-api/app/types.go @@ -39,302 +39,6 @@ type VPNLoginState struct { Color string `json:"color,omitempty"` } -type DNSUpstreams struct { - Default1 string `json:"default1"` - Default2 string `json:"default2"` - Meta1 string `json:"meta1"` - Meta2 string `json:"meta2"` -} - -type DNSUpstreamPoolItem struct { - Addr string `json:"addr"` - Enabled bool `json:"enabled"` -} - -type DNSUpstreamPoolState struct { - Items []DNSUpstreamPoolItem `json:"items"` -} - -type DNSResolverMode string - -const ( - DNSModeDirect DNSResolverMode = "direct" - DNSModeSmartDNS DNSResolverMode = "smartdns" - DNSModeHybridWildcard DNSResolverMode = "hybrid_wildcard" -) - -type DNSMode struct { - ViaSmartDNS bool `json:"via_smartdns"` - SmartDNSAddr string `json:"smartdns_addr"` - Mode DNSResolverMode `json:"mode"` -} - -type DNSStatusResponse struct { - ViaSmartDNS bool `json:"via_smartdns"` - SmartDNSAddr string `json:"smartdns_addr"` - Mode DNSResolverMode `json:"mode"` - UnitState string `json:"unit_state"` - RuntimeNftset bool `json:"runtime_nftset"` - WildcardSource string `json:"wildcard_source"` - RuntimeCfgPath string `json:"runtime_config_path,omitempty"` - RuntimeCfgError string `json:"runtime_config_error,omitempty"` -} - -type DNSModeRequest struct { - ViaSmartDNS bool `json:"via_smartdns"` - SmartDNSAddr string `json:"smartdns_addr"` - Mode DNSResolverMode `json:"mode"` -} - -type DNSBenchmarkUpstream struct { - Addr string `json:"addr"` - Enabled bool `json:"enabled"` -} - -type DNSBenchmarkRequest struct { - Upstreams []DNSBenchmarkUpstream `json:"upstreams"` - Domains []string `json:"domains"` - TimeoutMS int `json:"timeout_ms"` - Attempts int `json:"attempts"` - Concurrency int `json:"concurrency"` -} - -type DNSBenchmarkResult struct { - Upstream string `json:"upstream"` - Attempts int `json:"attempts"` - OK int `json:"ok"` - Fail int `json:"fail"` - NXDomain int `json:"nxdomain"` - Timeout int `json:"timeout"` - Temporary int `json:"temporary"` - Other int `json:"other"` - AvgMS int `json:"avg_ms"` - P95MS int `json:"p95_ms"` - Score float64 `json:"score"` - Color string `json:"color"` -} - -type DNSBenchmarkResponse struct { - Results []DNSBenchmarkResult `json:"results"` - DomainsUsed []string `json:"domains_used"` - TimeoutMS int `json:"timeout_ms"` - AttemptsPerDomain int `json:"attempts_per_domain"` - RecommendedDefault []string `json:"recommended_default"` - RecommendedMeta []string `json:"recommended_meta"` -} - -type SmartDNSRuntimeStatusResponse struct { - Enabled bool `json:"enabled"` - AppliedEnable bool `json:"applied_enabled"` - WildcardSource string `json:"wildcard_source"` - UnitState string `json:"unit_state"` - ConfigPath string `json:"config_path"` - Changed bool `json:"changed,omitempty"` - Restarted bool `json:"restarted,omitempty"` - Message string `json:"message,omitempty"` -} - -type SmartDNSRuntimeRequest struct { - Enabled *bool `json:"enabled"` - Restart *bool `json:"restart,omitempty"` -} - -type TrafficMode string - -const ( - TrafficModeSelective TrafficMode = "selective" - TrafficModeFullTunnel TrafficMode = "full_tunnel" - TrafficModeDirect TrafficMode = "direct" -) - -type TrafficModeState struct { - Mode TrafficMode `json:"mode"` - PreferredIface string `json:"preferred_iface,omitempty"` - AutoLocalBypass bool `json:"auto_local_bypass"` - IngressReplyBypass bool `json:"ingress_reply_bypass"` - ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"` - ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"` - ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"` - ForceDirectSubnets []string `json:"force_direct_subnets,omitempty"` - ForceDirectUIDs []string `json:"force_direct_uids,omitempty"` - ForceDirectCGroups []string `json:"force_direct_cgroups,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` -} - -type TrafficModeRequest struct { - Mode TrafficMode `json:"mode"` - PreferredIface *string `json:"preferred_iface,omitempty"` - AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"` - IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"` - ForceVPNSubnets *[]string `json:"force_vpn_subnets,omitempty"` - ForceVPNUIDs *[]string `json:"force_vpn_uids,omitempty"` - ForceVPNCGroups *[]string `json:"force_vpn_cgroups,omitempty"` - ForceDirectSubnets *[]string `json:"force_direct_subnets,omitempty"` - ForceDirectUIDs *[]string `json:"force_direct_uids,omitempty"` - ForceDirectCGroups *[]string `json:"force_direct_cgroups,omitempty"` -} - -type TrafficModeStatusResponse struct { - Mode TrafficMode `json:"mode"` - DesiredMode TrafficMode `json:"desired_mode"` - AppliedMode TrafficMode `json:"applied_mode"` - PreferredIface string `json:"preferred_iface,omitempty"` - AdvancedActive bool `json:"advanced_active"` - AutoLocalBypass bool `json:"auto_local_bypass"` - AutoLocalActive bool `json:"auto_local_active"` - IngressReplyBypass bool `json:"ingress_reply_bypass"` - IngressReplyActive bool `json:"ingress_reply_active"` - BypassCandidates int `json:"bypass_candidates"` - ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"` - ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"` - ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"` - ForceDirectSubnets []string `json:"force_direct_subnets,omitempty"` - ForceDirectUIDs []string `json:"force_direct_uids,omitempty"` - ForceDirectCGroups []string `json:"force_direct_cgroups,omitempty"` - OverridesApplied int `json:"overrides_applied"` - CgroupResolvedUIDs int `json:"cgroup_resolved_uids"` - CgroupWarning string `json:"cgroup_warning,omitempty"` - ActiveIface string `json:"active_iface,omitempty"` - IfaceReason string `json:"iface_reason,omitempty"` - RuleMark bool `json:"rule_mark"` - RuleFull bool `json:"rule_full"` - IngressRulePresent bool `json:"ingress_rule_present"` - IngressNftActive bool `json:"ingress_nft_active"` - TableDefault bool `json:"table_default"` - ProbeOK bool `json:"probe_ok"` - ProbeMessage string `json:"probe_message,omitempty"` - Healthy bool `json:"healthy"` - Message string `json:"message,omitempty"` -} - -type TrafficCandidateSubnet struct { - CIDR string `json:"cidr"` - Dev string `json:"dev,omitempty"` - Kind string `json:"kind,omitempty"` // lan|docker|link - LinkDown bool `json:"linkdown,omitempty"` -} - -type TrafficCandidateUnit struct { - Unit string `json:"unit"` - Description string `json:"description,omitempty"` - Cgroup string `json:"cgroup,omitempty"` -} - -type TrafficCandidateUID struct { - UID int `json:"uid"` - User string `json:"user,omitempty"` - Examples []string `json:"examples,omitempty"` -} - -type TrafficCandidatesResponse struct { - GeneratedAt string `json:"generated_at"` - Subnets []TrafficCandidateSubnet `json:"subnets,omitempty"` - Units []TrafficCandidateUnit `json:"units,omitempty"` - UIDs []TrafficCandidateUID `json:"uids,omitempty"` -} -type TrafficInterfacesResponse struct { - Interfaces []string `json:"interfaces"` - PreferredIface string `json:"preferred_iface,omitempty"` - ActiveIface string `json:"active_iface,omitempty"` - IfaceReason string `json:"iface_reason,omitempty"` -} - -// --------------------------------------------------------------------- -// traffic app marks (per-app routing via cgroup -> fwmark) -// --------------------------------------------------------------------- - -type TrafficAppMarksOp string - -const ( - TrafficAppMarksAdd TrafficAppMarksOp = "add" - TrafficAppMarksDel TrafficAppMarksOp = "del" - TrafficAppMarksClear TrafficAppMarksOp = "clear" -) - -// EN: Runtime app marking request. Used by per-app launcher wrappers. -// RU: Runtime app marking запрос. Используется wrapper-лаунчером per-app. -type TrafficAppMarksRequest struct { - Op TrafficAppMarksOp `json:"op"` - Target string `json:"target"` // vpn|direct - Cgroup string `json:"cgroup,omitempty"` - // EN: Optional metadata to deduplicate marks per-app across restarts / re-runs. - // RU: Опциональные метаданные, чтобы не плодить метки на одно и то же приложение. - Unit string `json:"unit,omitempty"` - Command string `json:"command,omitempty"` - AppKey string `json:"app_key,omitempty"` - TimeoutSec int `json:"timeout_sec,omitempty"` // only for add; 0 = persistent -} - -type TrafficAppMarksResponse struct { - OK bool `json:"ok"` - Message string `json:"message,omitempty"` - Op string `json:"op,omitempty"` - Target string `json:"target,omitempty"` - Cgroup string `json:"cgroup,omitempty"` - CgroupID uint64 `json:"cgroup_id,omitempty"` - TimeoutSec int `json:"timeout_sec,omitempty"` -} - -type TrafficAppMarksStatusResponse struct { - VPNCount int `json:"vpn_count"` - DirectCount int `json:"direct_count"` - Message string `json:"message,omitempty"` -} - -// EN: Detailed list item for runtime per-app marks (for UI). -// RU: Детальный элемент списка runtime per-app меток (для UI). -type TrafficAppMarkItemView struct { - ID uint64 `json:"id"` - Target string `json:"target"` // vpn|direct - Cgroup string `json:"cgroup,omitempty"` - CgroupRel string `json:"cgroup_rel,omitempty"` - Level int `json:"level,omitempty"` - Unit string `json:"unit,omitempty"` - Command string `json:"command,omitempty"` - AppKey string `json:"app_key,omitempty"` - AddedAt string `json:"added_at,omitempty"` - ExpiresAt string `json:"expires_at,omitempty"` - RemainingSec int `json:"remaining_sec,omitempty"` // -1 = persistent -} - -type TrafficAppMarksItemsResponse struct { - Items []TrafficAppMarkItemView `json:"items"` - Message string `json:"message,omitempty"` -} - -// --------------------------------------------------------------------- -// traffic app profiles (persistent app launcher configs) -// --------------------------------------------------------------------- - -// EN: Persistent per-app launcher profile (separate from runtime marks). -// RU: Постоянный профиль запуска приложения (отдельно от runtime marks). -type TrafficAppProfile struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - AppKey string `json:"app_key,omitempty"` - Command string `json:"command,omitempty"` - Target string `json:"target,omitempty"` // vpn|direct - TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent - VPNProfile string `json:"vpn_profile,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` -} - -type TrafficAppProfilesResponse struct { - Profiles []TrafficAppProfile `json:"profiles"` - Message string `json:"message,omitempty"` -} - -type TrafficAppProfileUpsertRequest struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - AppKey string `json:"app_key,omitempty"` - Command string `json:"command,omitempty"` - Target string `json:"target,omitempty"` // vpn|direct - TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent - VPNProfile string `json:"vpn_profile,omitempty"` -} - type SystemdState struct { State string `json:"state"` } diff --git a/selective-vpn-api/app/types_dns.go b/selective-vpn-api/app/types_dns.go new file mode 100644 index 0000000..7774797 --- /dev/null +++ b/selective-vpn-api/app/types_dns.go @@ -0,0 +1,103 @@ +package app + +type DNSUpstreams struct { + Default1 string `json:"default1"` + Default2 string `json:"default2"` + Meta1 string `json:"meta1"` + Meta2 string `json:"meta2"` +} + +type DNSUpstreamPoolItem struct { + Addr string `json:"addr"` + Enabled bool `json:"enabled"` +} + +type DNSUpstreamPoolState struct { + Items []DNSUpstreamPoolItem `json:"items"` +} + +type DNSResolverMode string + +const ( + DNSModeDirect DNSResolverMode = "direct" + DNSModeSmartDNS DNSResolverMode = "smartdns" + DNSModeHybridWildcard DNSResolverMode = "hybrid_wildcard" +) + +type DNSMode struct { + ViaSmartDNS bool `json:"via_smartdns"` + SmartDNSAddr string `json:"smartdns_addr"` + Mode DNSResolverMode `json:"mode"` +} + +type DNSStatusResponse struct { + ViaSmartDNS bool `json:"via_smartdns"` + SmartDNSAddr string `json:"smartdns_addr"` + Mode DNSResolverMode `json:"mode"` + UnitState string `json:"unit_state"` + RuntimeNftset bool `json:"runtime_nftset"` + WildcardSource string `json:"wildcard_source"` + RuntimeCfgPath string `json:"runtime_config_path,omitempty"` + RuntimeCfgError string `json:"runtime_config_error,omitempty"` +} + +type DNSModeRequest struct { + ViaSmartDNS bool `json:"via_smartdns"` + SmartDNSAddr string `json:"smartdns_addr"` + Mode DNSResolverMode `json:"mode"` +} + +type DNSBenchmarkUpstream struct { + Addr string `json:"addr"` + Enabled bool `json:"enabled"` +} + +type DNSBenchmarkRequest struct { + Upstreams []DNSBenchmarkUpstream `json:"upstreams"` + Domains []string `json:"domains"` + TimeoutMS int `json:"timeout_ms"` + Attempts int `json:"attempts"` + Concurrency int `json:"concurrency"` + Profile string `json:"profile,omitempty"` // quick | load +} + +type DNSBenchmarkResult struct { + Upstream string `json:"upstream"` + Attempts int `json:"attempts"` + OK int `json:"ok"` + Fail int `json:"fail"` + NXDomain int `json:"nxdomain"` + Timeout int `json:"timeout"` + Temporary int `json:"temporary"` + Other int `json:"other"` + AvgMS int `json:"avg_ms"` + P95MS int `json:"p95_ms"` + Score float64 `json:"score"` + Color string `json:"color"` +} + +type DNSBenchmarkResponse struct { + Results []DNSBenchmarkResult `json:"results"` + DomainsUsed []string `json:"domains_used"` + TimeoutMS int `json:"timeout_ms"` + AttemptsPerDomain int `json:"attempts_per_domain"` + Profile string `json:"profile,omitempty"` + RecommendedDefault []string `json:"recommended_default"` + RecommendedMeta []string `json:"recommended_meta"` +} + +type SmartDNSRuntimeStatusResponse struct { + Enabled bool `json:"enabled"` + AppliedEnable bool `json:"applied_enabled"` + WildcardSource string `json:"wildcard_source"` + UnitState string `json:"unit_state"` + ConfigPath string `json:"config_path"` + Changed bool `json:"changed,omitempty"` + Restarted bool `json:"restarted,omitempty"` + Message string `json:"message,omitempty"` +} + +type SmartDNSRuntimeRequest struct { + Enabled *bool `json:"enabled"` + Restart *bool `json:"restart,omitempty"` +} diff --git a/selective-vpn-api/app/types_egress.go b/selective-vpn-api/app/types_egress.go new file mode 100644 index 0000000..170db64 --- /dev/null +++ b/selective-vpn-api/app/types_egress.go @@ -0,0 +1,46 @@ +package app + +// --------------------------------------------------------------------- +// egress identity (scope-aware IP + geo snapshot) +// --------------------------------------------------------------------- + +type EgressIdentity struct { + Scope string `json:"scope"` + Source string `json:"source"` + SourceID string `json:"source_id,omitempty"` + IP string `json:"ip,omitempty"` + CountryCode string `json:"country_code,omitempty"` + CountryName string `json:"country_name,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Stale bool `json:"stale"` + RefreshInProgress bool `json:"refresh_in_progress"` + LastError string `json:"last_error,omitempty"` + NextRetryAt string `json:"next_retry_at,omitempty"` +} + +type EgressIdentityResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Item EgressIdentity `json:"item"` +} + +type EgressIdentityRefreshRequest struct { + Scopes []string `json:"scopes,omitempty"` + Force bool `json:"force,omitempty"` +} + +type EgressIdentityRefreshItem struct { + Scope string `json:"scope"` + Status string `json:"status"` // queued|skipped + Queued bool `json:"queued"` + Reason string `json:"reason,omitempty"` +} + +type EgressIdentityRefreshResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Count int `json:"count"` + Queued int `json:"queued"` + Skipped int `json:"skipped"` + Items []EgressIdentityRefreshItem `json:"items,omitempty"` +} diff --git a/selective-vpn-api/app/types_singbox.go b/selective-vpn-api/app/types_singbox.go new file mode 100644 index 0000000..8d8aafe --- /dev/null +++ b/selective-vpn-api/app/types_singbox.go @@ -0,0 +1,6 @@ +package app + +// SingBox API types are split by role: +// - profile CRUD + capability responses: types_singbox_profiles.go +// - validate/render/apply/rollback flow DTO: types_singbox_flow.go +// - history DTO: types_singbox_history.go diff --git a/selective-vpn-api/app/types_singbox_flow.go b/selective-vpn-api/app/types_singbox_flow.go new file mode 100644 index 0000000..1571f63 --- /dev/null +++ b/selective-vpn-api/app/types_singbox_flow.go @@ -0,0 +1,104 @@ +package app + +type SingBoxProfileIssue struct { + Field string `json:"field,omitempty"` + Severity string `json:"severity,omitempty"` // error|warning + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type SingBoxProfileRenderDiff struct { + Added int `json:"added"` + Changed int `json:"changed"` + Removed int `json:"removed"` +} + +type SingBoxProfileValidateRequest struct { + BaseRevision int64 `json:"base_revision,omitempty"` + CheckBinary *bool `json:"check_binary,omitempty"` +} + +type SingBoxProfileValidateResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + ProfileRevision int64 `json:"profile_revision,omitempty"` + Valid bool `json:"valid"` + Errors []SingBoxProfileIssue `json:"errors,omitempty"` + Warnings []SingBoxProfileIssue `json:"warnings,omitempty"` + RenderDigest string `json:"render_digest,omitempty"` + Diff SingBoxProfileRenderDiff `json:"diff"` +} + +type SingBoxProfileRenderRequest struct { + BaseRevision int64 `json:"base_revision,omitempty"` + CheckBinary *bool `json:"check_binary,omitempty"` + Persist *bool `json:"persist,omitempty"` +} + +type SingBoxProfileRenderResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + ProfileRevision int64 `json:"profile_revision,omitempty"` + RenderRevision int64 `json:"render_revision,omitempty"` + RenderPath string `json:"render_path,omitempty"` + RenderDigest string `json:"render_digest,omitempty"` + Changed bool `json:"changed"` + Valid bool `json:"valid"` + Errors []SingBoxProfileIssue `json:"errors,omitempty"` + Warnings []SingBoxProfileIssue `json:"warnings,omitempty"` + Diff SingBoxProfileRenderDiff `json:"diff"` + Config map[string]any `json:"config,omitempty"` +} + +type SingBoxProfileApplyRequest struct { + BaseRevision int64 `json:"base_revision,omitempty"` + ClientID string `json:"client_id,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + Restart *bool `json:"restart,omitempty"` + SkipRuntime bool `json:"skip_runtime,omitempty"` + CheckBinary *bool `json:"check_binary,omitempty"` +} + +type SingBoxProfileApplyResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + ClientID string `json:"client_id,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + ProfileRevision int64 `json:"profile_revision,omitempty"` + RenderRevision int64 `json:"render_revision,omitempty"` + LastAppliedAt string `json:"last_applied_at,omitempty"` + RenderPath string `json:"render_path,omitempty"` + RenderDigest string `json:"render_digest,omitempty"` + RollbackAvailable bool `json:"rollback_available,omitempty"` + Valid bool `json:"valid"` + Errors []SingBoxProfileIssue `json:"errors,omitempty"` + Warnings []SingBoxProfileIssue `json:"warnings,omitempty"` + Diff SingBoxProfileRenderDiff `json:"diff"` +} + +type SingBoxProfileRollbackRequest struct { + BaseRevision int64 `json:"base_revision,omitempty"` + ClientID string `json:"client_id,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + HistoryID string `json:"history_id,omitempty"` + Restart *bool `json:"restart,omitempty"` + SkipRuntime bool `json:"skip_runtime,omitempty"` +} + +type SingBoxProfileRollbackResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + ClientID string `json:"client_id,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + HistoryID string `json:"history_id,omitempty"` + ProfileRevision int64 `json:"profile_revision,omitempty"` + LastAppliedAt string `json:"last_applied_at,omitempty"` +} diff --git a/selective-vpn-api/app/types_singbox_history.go b/selective-vpn-api/app/types_singbox_history.go new file mode 100644 index 0000000..b96fd7a --- /dev/null +++ b/selective-vpn-api/app/types_singbox_history.go @@ -0,0 +1,25 @@ +package app + +type SingBoxProfileHistoryEntry struct { + ID string `json:"id"` + At string `json:"at"` + ProfileID string `json:"profile_id"` + Action string `json:"action"` // validate|render|apply|rollback + Status string `json:"status"` // success|failed + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + ProfileRevision int64 `json:"profile_revision,omitempty"` + RenderRevision int64 `json:"render_revision,omitempty"` + RenderDigest string `json:"render_digest,omitempty"` + RenderPath string `json:"render_path,omitempty"` + ClientID string `json:"client_id,omitempty"` +} + +type SingBoxProfileHistoryResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + Count int `json:"count,omitempty"` + Items []SingBoxProfileHistoryEntry `json:"items,omitempty"` +} diff --git a/selective-vpn-api/app/types_singbox_profiles.go b/selective-vpn-api/app/types_singbox_profiles.go new file mode 100644 index 0000000..66dddf2 --- /dev/null +++ b/selective-vpn-api/app/types_singbox_profiles.go @@ -0,0 +1,78 @@ +package app + +type SingBoxProfileMode string + +const ( + SingBoxProfileModeTyped SingBoxProfileMode = "typed" + SingBoxProfileModeRaw SingBoxProfileMode = "raw" +) + +type SingBoxProfile struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Mode SingBoxProfileMode `json:"mode"` + Protocol string `json:"protocol,omitempty"` + Enabled bool `json:"enabled"` + SchemaVersion int `json:"schema_version,omitempty"` + ProfileRevision int64 `json:"profile_revision,omitempty"` + RenderRevision int64 `json:"render_revision,omitempty"` + LastValidatedAt string `json:"last_validated_at,omitempty"` + LastAppliedAt string `json:"last_applied_at,omitempty"` + LastError string `json:"last_error,omitempty"` + Typed map[string]any `json:"typed,omitempty"` + RawConfig map[string]any `json:"raw_config,omitempty"` + Meta map[string]any `json:"meta,omitempty"` + HasSecrets bool `json:"has_secrets,omitempty"` + SecretsMasked map[string]string `json:"secrets_masked,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type SingBoxProfilesResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Count int `json:"count,omitempty"` + ActiveProfileID string `json:"active_profile_id,omitempty"` + Items []SingBoxProfile `json:"items,omitempty"` + Item *SingBoxProfile `json:"item,omitempty"` +} + +type SingBoxProfileCreateRequest struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Mode SingBoxProfileMode `json:"mode"` + Protocol string `json:"protocol,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + SchemaVersion int `json:"schema_version,omitempty"` + Typed map[string]any `json:"typed,omitempty"` + RawConfig map[string]any `json:"raw_config,omitempty"` + Meta map[string]any `json:"meta,omitempty"` + Secrets map[string]string `json:"secrets,omitempty"` +} + +type SingBoxProfilePatchRequest struct { + BaseRevision int64 `json:"base_revision,omitempty"` + Name *string `json:"name,omitempty"` + Mode *SingBoxProfileMode `json:"mode,omitempty"` + Protocol *string `json:"protocol,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + SchemaVersion *int `json:"schema_version,omitempty"` + Typed map[string]any `json:"typed,omitempty"` + RawConfig map[string]any `json:"raw_config,omitempty"` + Meta map[string]any `json:"meta,omitempty"` + Secrets map[string]string `json:"secrets,omitempty"` + ClearSecrets bool `json:"clear_secrets,omitempty"` +} + +type SingBoxFeaturesResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Binary string `json:"binary,omitempty"` + Version string `json:"version,omitempty"` + ProfileModes map[string]bool `json:"profile_modes,omitempty"` + TypedProtocols []string `json:"typed_protocols,omitempty"` + DNSFormats map[string]bool `json:"dns_formats,omitempty"` + ErrorCodes []string `json:"error_codes,omitempty"` +} diff --git a/selective-vpn-api/app/types_traffic_apps.go b/selective-vpn-api/app/types_traffic_apps.go new file mode 100644 index 0000000..6419b11 --- /dev/null +++ b/selective-vpn-api/app/types_traffic_apps.go @@ -0,0 +1,143 @@ +package app + +// --------------------------------------------------------------------- +// traffic app marks (per-app routing via cgroup -> fwmark) +// --------------------------------------------------------------------- + +type TrafficAppMarksOp string + +const ( + TrafficAppMarksAdd TrafficAppMarksOp = "add" + TrafficAppMarksDel TrafficAppMarksOp = "del" + TrafficAppMarksClear TrafficAppMarksOp = "clear" +) + +// EN: Runtime app marking request. Used by per-app launcher wrappers. +// RU: Runtime app marking запрос. Используется wrapper-лаунчером per-app. +type TrafficAppMarksRequest struct { + Op TrafficAppMarksOp `json:"op"` + Target string `json:"target"` // vpn|direct + Cgroup string `json:"cgroup,omitempty"` + + // Current runtime fields. + Unit string `json:"unit,omitempty"` + Command string `json:"command,omitempty"` + AppKey string `json:"app_key,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` + + // Compatibility fields (legacy/experimental clients). + UIDs []int `json:"uids,omitempty"` + SelectorType string `json:"selector_type,omitempty"` // app|uid|cgroup + SelectorValue string `json:"selector_value,omitempty"` // app key / uid / cgroup path + CommandLine string `json:"command_line,omitempty"` + UnitName string `json:"unit_name,omitempty"` + CgroupPath string `json:"cgroup_path,omitempty"` + TTLSeconds int `json:"ttl_seconds,omitempty"` + Key string `json:"key,omitempty"` +} + +type TrafficAppMarksResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + + Op string `json:"op,omitempty"` + Target string `json:"target,omitempty"` + Cgroup string `json:"cgroup,omitempty"` + CgroupID uint64 `json:"cgroup_id,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` + + // Compatibility fields (older UI/runtime bits). + Count int `json:"count,omitempty"` + CgroupMark string `json:"cgroup_mark,omitempty"` + RuleAdded bool `json:"rule_added,omitempty"` + RuleDeleted bool `json:"rule_deleted,omitempty"` + Key string `json:"key,omitempty"` + RuleMark string `json:"rule_mark,omitempty"` + RulePref int `json:"rule_pref,omitempty"` + RuleTable string `json:"rule_table,omitempty"` +} + +type TrafficAppMarksStatusResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Enabled bool `json:"enabled"` + RuleTable string `json:"rule_table,omitempty"` + RuleTableNum string `json:"rule_table_num,omitempty"` + RuleCount int `json:"rule_count"` + MarkBase string `json:"mark_base,omitempty"` + PrefBase int `json:"pref_base,omitempty"` + LastReconcile string `json:"last_reconcile,omitempty"` + LastError string `json:"last_error,omitempty"` + OwnedItems int `json:"owned_items,omitempty"` + LegacyRules int `json:"legacy_rules,omitempty"` + + VPNCount int `json:"vpn_count"` + DirectCount int `json:"direct_count"` + Total int `json:"total"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type TrafficAppMarkItemView struct { + ID uint64 `json:"id"` + Target string `json:"target"` // vpn|direct + Cgroup string `json:"cgroup,omitempty"` + CgroupRel string `json:"cgroup_rel,omitempty"` + Level int `json:"level,omitempty"` + Unit string `json:"unit,omitempty"` + Command string `json:"command,omitempty"` + AppKey string `json:"app_key,omitempty"` + AddedAt string `json:"added_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + RemainingSec int `json:"remaining_sec,omitempty"` + + // Compatibility fields for older viewers. + Key string `json:"key,omitempty"` + CgroupPath string `json:"cgroup_path,omitempty"` + CgroupInode uint64 `json:"cgroup_inode,omitempty"` + RuleMarkHex string `json:"rule_mark_hex,omitempty"` + RuleMarkValue uint64 `json:"rule_mark_value,omitempty"` + UIDMap string `json:"uid_map,omitempty"` + AppliedRule bool `json:"applied_rule,omitempty"` + LastSeen string `json:"last_seen,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + TTLExpiresAt string `json:"ttl_expires_at,omitempty"` + Expired bool `json:"expired,omitempty"` +} + +type TrafficAppMarksItemsResponse struct { + Items []TrafficAppMarkItemView `json:"items"` + Message string `json:"message,omitempty"` +} + +// --------------------------------------------------------------------- +// traffic app profiles (named routing profiles for app selectors) +// --------------------------------------------------------------------- + +type TrafficAppProfile struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + AppKey string `json:"app_key,omitempty"` + Command string `json:"command,omitempty"` + Target string `json:"target,omitempty"` // vpn|direct + TTLSec int `json:"ttl_sec,omitempty"` + VPNProfile string `json:"vpn_profile,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type TrafficAppProfilesResponse struct { + Profiles []TrafficAppProfile `json:"profiles"` + Message string `json:"message,omitempty"` +} + +type TrafficAppProfileUpsertRequest struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + AppKey string `json:"app_key,omitempty"` + Command string `json:"command,omitempty"` + Target string `json:"target,omitempty"` // vpn|direct + TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent + VPNProfile string `json:"vpn_profile,omitempty"` +} diff --git a/selective-vpn-api/app/types_traffic_mode.go b/selective-vpn-api/app/types_traffic_mode.go new file mode 100644 index 0000000..51bbbff --- /dev/null +++ b/selective-vpn-api/app/types_traffic_mode.go @@ -0,0 +1,102 @@ +package app + +type TrafficMode string + +const ( + TrafficModeSelective TrafficMode = "selective" + TrafficModeFullTunnel TrafficMode = "full_tunnel" + TrafficModeDirect TrafficMode = "direct" +) + +type TrafficModeState struct { + Mode TrafficMode `json:"mode"` + PreferredIface string `json:"preferred_iface,omitempty"` + AutoLocalBypass bool `json:"auto_local_bypass"` + IngressReplyBypass bool `json:"ingress_reply_bypass"` + ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"` + ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"` + ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"` + ForceDirectSubnets []string `json:"force_direct_subnets,omitempty"` + ForceDirectUIDs []string `json:"force_direct_uids,omitempty"` + ForceDirectCGroups []string `json:"force_direct_cgroups,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type TrafficModeRequest struct { + Mode TrafficMode `json:"mode"` + PreferredIface *string `json:"preferred_iface,omitempty"` + AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"` + IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"` + ForceVPNSubnets *[]string `json:"force_vpn_subnets,omitempty"` + ForceVPNUIDs *[]string `json:"force_vpn_uids,omitempty"` + ForceVPNCGroups *[]string `json:"force_vpn_cgroups,omitempty"` + ForceDirectSubnets *[]string `json:"force_direct_subnets,omitempty"` + ForceDirectUIDs *[]string `json:"force_direct_uids,omitempty"` + ForceDirectCGroups *[]string `json:"force_direct_cgroups,omitempty"` +} + +type TrafficModeStatusResponse struct { + Mode TrafficMode `json:"mode"` + DesiredMode TrafficMode `json:"desired_mode"` + AppliedMode TrafficMode `json:"applied_mode"` + PreferredIface string `json:"preferred_iface,omitempty"` + AdvancedActive bool `json:"advanced_active"` + AutoLocalBypass bool `json:"auto_local_bypass"` + AutoLocalActive bool `json:"auto_local_active"` + IngressReplyBypass bool `json:"ingress_reply_bypass"` + IngressReplyActive bool `json:"ingress_reply_active"` + BypassCandidates int `json:"bypass_candidates"` + ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"` + ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"` + ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"` + ForceDirectSubnets []string `json:"force_direct_subnets,omitempty"` + ForceDirectUIDs []string `json:"force_direct_uids,omitempty"` + ForceDirectCGroups []string `json:"force_direct_cgroups,omitempty"` + OverridesApplied int `json:"overrides_applied"` + CgroupResolvedUIDs int `json:"cgroup_resolved_uids"` + CgroupWarning string `json:"cgroup_warning,omitempty"` + ActiveIface string `json:"active_iface,omitempty"` + IfaceReason string `json:"iface_reason,omitempty"` + RuleMark bool `json:"rule_mark"` + RuleFull bool `json:"rule_full"` + IngressRulePresent bool `json:"ingress_rule_present"` + IngressNftActive bool `json:"ingress_nft_active"` + TableDefault bool `json:"table_default"` + ProbeOK bool `json:"probe_ok"` + ProbeMessage string `json:"probe_message,omitempty"` + Healthy bool `json:"healthy"` + Message string `json:"message,omitempty"` +} + +type TrafficCandidateSubnet struct { + CIDR string `json:"cidr"` + Dev string `json:"dev,omitempty"` + Kind string `json:"kind,omitempty"` // lan|docker|link + LinkDown bool `json:"linkdown,omitempty"` +} + +type TrafficCandidateUnit struct { + Unit string `json:"unit"` + Description string `json:"description,omitempty"` + Cgroup string `json:"cgroup,omitempty"` +} + +type TrafficCandidateUID struct { + UID int `json:"uid"` + User string `json:"user,omitempty"` + Examples []string `json:"examples,omitempty"` +} + +type TrafficCandidatesResponse struct { + GeneratedAt string `json:"generated_at"` + Subnets []TrafficCandidateSubnet `json:"subnets,omitempty"` + Units []TrafficCandidateUnit `json:"units,omitempty"` + UIDs []TrafficCandidateUID `json:"uids,omitempty"` +} + +type TrafficInterfacesResponse struct { + Interfaces []string `json:"interfaces"` + PreferredIface string `json:"preferred_iface,omitempty"` + ActiveIface string `json:"active_iface,omitempty"` + IfaceReason string `json:"iface_reason,omitempty"` +} diff --git a/selective-vpn-api/app/types_transport_core.go b/selective-vpn-api/app/types_transport_core.go new file mode 100644 index 0000000..1f181aa --- /dev/null +++ b/selective-vpn-api/app/types_transport_core.go @@ -0,0 +1,108 @@ +package app + +// --------------------------------------------------------------------- +// transport control-plane (core client models) +// --------------------------------------------------------------------- + +type TransportClientKind string + +const ( + TransportClientSingBox TransportClientKind = "singbox" + TransportClientDNSTT TransportClientKind = "dnstt" + TransportClientPhoenix TransportClientKind = "phoenix" +) + +type TransportClientStatus string + +const ( + TransportClientStarting TransportClientStatus = "starting" + TransportClientUp TransportClientStatus = "up" + TransportClientDegraded TransportClientStatus = "degraded" + TransportClientDown TransportClientStatus = "down" +) + +type TransportClientHealth struct { + LastCheck string `json:"last_check,omitempty"` + LatencyMS int `json:"latency_ms,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +type TransportClientError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Retryable bool `json:"retryable,omitempty"` + At string `json:"at,omitempty"` +} + +type TransportClientMetrics struct { + Restarts int `json:"restarts,omitempty"` + StateChanges int `json:"state_changes,omitempty"` + UptimeSec int64 `json:"uptime_sec,omitempty"` + LastTransitionAt string `json:"last_transition_at,omitempty"` +} + +type TransportClientRuntime struct { + Backend string `json:"backend,omitempty"` + AllowedActions []string `json:"allowed_actions,omitempty"` + LastAction string `json:"last_action,omitempty"` + LastActionAt string `json:"last_action_at,omitempty"` + LastExitCode int `json:"last_exit_code,omitempty"` + StartedAt string `json:"started_at,omitempty"` + StoppedAt string `json:"stopped_at,omitempty"` + Metrics TransportClientMetrics `json:"metrics,omitempty"` + LastError TransportClientError `json:"last_error,omitempty"` +} + +type TransportClient struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Kind TransportClientKind `json:"kind"` + Enabled bool `json:"enabled"` + Status TransportClientStatus `json:"status,omitempty"` + IfaceID string `json:"iface_id,omitempty"` + Iface string `json:"iface,omitempty"` + RoutingTable string `json:"routing_table,omitempty"` + MarkHex string `json:"mark_hex,omitempty"` + PriorityBase int `json:"priority_base,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Health TransportClientHealth `json:"health,omitempty"` + Runtime TransportClientRuntime `json:"runtime,omitempty"` + Config map[string]any `json:"config,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type TransportClientsResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Count int `json:"count,omitempty"` + Items []TransportClient `json:"items,omitempty"` + Item *TransportClient `json:"item,omitempty"` +} + +type TransportClientCreateRequest struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Kind TransportClientKind `json:"kind"` + IfaceID string `json:"iface_id,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Config map[string]any `json:"config,omitempty"` +} + +type TransportClientPatchRequest struct { + Name *string `json:"name,omitempty"` + IfaceID *string `json:"iface_id,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Config map[string]any `json:"config,omitempty"` +} + +type TransportCapabilitiesResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Clients map[string]map[string]bool `json:"clients"` + RuntimeModes map[string]bool `json:"runtime_modes,omitempty"` + PackagingProfiles map[string]bool `json:"packaging_profiles,omitempty"` + Lifecycle []string `json:"lifecycle,omitempty"` + HealthFields []string `json:"health_fields,omitempty"` + MetricsFields []string `json:"metrics_fields,omitempty"` + ErrorCodes []string `json:"error_codes,omitempty"` +} diff --git a/selective-vpn-api/app/types_transport_interfaces.go b/selective-vpn-api/app/types_transport_interfaces.go new file mode 100644 index 0000000..abb6654 --- /dev/null +++ b/selective-vpn-api/app/types_transport_interfaces.go @@ -0,0 +1,52 @@ +package app + +// --------------------------------------------------------------------- +// transport interfaces (foundation for multi-interface orchestrator) +// --------------------------------------------------------------------- + +type TransportInterfaceMode string + +const ( + TransportInterfaceModeShared TransportInterfaceMode = "shared" + TransportInterfaceModeDedicated TransportInterfaceMode = "dedicated" +) + +type TransportInterface struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Mode TransportInterfaceMode `json:"mode,omitempty"` + RuntimeIface string `json:"runtime_iface,omitempty"` + NetnsName string `json:"netns_name,omitempty"` + RoutingTable string `json:"routing_table,omitempty"` + Config map[string]any `json:"config,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type transportInterfacesState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + Items []TransportInterface `json:"items,omitempty"` +} + +type TransportInterfaceItem struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Mode TransportInterfaceMode `json:"mode,omitempty"` + RuntimeIface string `json:"runtime_iface,omitempty"` + NetnsName string `json:"netns_name,omitempty"` + RoutingTable string `json:"routing_table,omitempty"` + Config map[string]any `json:"config,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ClientIDs []string `json:"client_ids,omitempty"` + ClientCount int `json:"client_count,omitempty"` + UpCount int `json:"up_count,omitempty"` +} + +type TransportInterfacesResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Count int `json:"count,omitempty"` + Items []TransportInterfaceItem `json:"items,omitempty"` + Item *TransportInterfaceItem `json:"item,omitempty"` +} diff --git a/selective-vpn-api/app/types_transport_observability.go b/selective-vpn-api/app/types_transport_observability.go new file mode 100644 index 0000000..96b6d0e --- /dev/null +++ b/selective-vpn-api/app/types_transport_observability.go @@ -0,0 +1,61 @@ +package app + +// --------------------------------------------------------------------- +// transport control-plane (multi-interface runtime observability) +// --------------------------------------------------------------------- + +type TransportRuntimeObservabilityCounters struct { + ClientCount int `json:"client_count,omitempty"` + EnabledCount int `json:"enabled_count,omitempty"` + UpCount int `json:"up_count,omitempty"` + StartingCount int `json:"starting_count,omitempty"` + DegradedCount int `json:"degraded_count,omitempty"` + DownCount int `json:"down_count,omitempty"` + RuleCount int `json:"rule_count,omitempty"` +} + +type TransportRuntimeObservabilityEngineCounter struct { + Kind string `json:"kind"` + Count int `json:"count"` + UpCount int `json:"up_count,omitempty"` + StartingCount int `json:"starting_count,omitempty"` + DegradedCount int `json:"degraded_count,omitempty"` + DownCount int `json:"down_count,omitempty"` +} + +type TransportRuntimeObservabilityItem struct { + IfaceID string `json:"iface_id"` + Name string `json:"name,omitempty"` + Mode TransportInterfaceMode `json:"mode,omitempty"` + RuntimeIface string `json:"runtime_iface,omitempty"` + ActiveIface string `json:"active_iface,omitempty"` + NetnsName string `json:"netns_name,omitempty"` + RoutingTable string `json:"routing_table,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientIDs []string `json:"client_ids,omitempty"` + Status string `json:"status,omitempty"` + LatencyMS int `json:"latency_ms,omitempty"` + LastError string `json:"last_error,omitempty"` + LastCheck string `json:"last_check,omitempty"` + Egress EgressIdentity `json:"egress"` + Counters TransportRuntimeObservabilityCounters `json:"counters"` + EngineCounts []TransportRuntimeObservabilityEngineCounter `json:"engine_counts,omitempty"` +} + +type TransportRuntimeObservabilityResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + GeneratedAt string `json:"generated_at,omitempty"` + Count int `json:"count,omitempty"` + Items []TransportRuntimeObservabilityItem `json:"items,omitempty"` +} + +type TransportRuntimeObservabilityChangedEvent struct { + Reason string `json:"reason,omitempty"` + GeneratedAt string `json:"generated_at,omitempty"` + Count int `json:"count,omitempty"` + IfaceIDs []string `json:"iface_ids,omitempty"` + ClientIDs []string `json:"client_ids,omitempty"` + Items []TransportRuntimeObservabilityItem `json:"items,omitempty"` +} diff --git a/selective-vpn-api/app/types_transport_policy.go b/selective-vpn-api/app/types_transport_policy.go new file mode 100644 index 0000000..740d67c --- /dev/null +++ b/selective-vpn-api/app/types_transport_policy.go @@ -0,0 +1,274 @@ +package app + +// --------------------------------------------------------------------- +// transport control-plane (policy/conflicts) +// --------------------------------------------------------------------- + +type TransportPolicyIntent struct { + SelectorType string `json:"selector_type"` + SelectorValue string `json:"selector_value"` + ClientID string `json:"client_id"` + Priority int `json:"priority,omitempty"` + Mode string `json:"mode,omitempty"` +} + +type TransportPolicyState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + Revision int64 `json:"revision"` + Intents []TransportPolicyIntent `json:"intents,omitempty"` +} + +type TransportConflictRecord struct { + Key string `json:"key"` + Type string `json:"type,omitempty"` + Severity string `json:"severity"` // warn|block + Owners []string `json:"owners,omitempty"` + Reason string `json:"reason,omitempty"` + SuggestedResolution string `json:"suggested_resolution,omitempty"` +} + +type TransportConflictState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + HasBlocking bool `json:"has_blocking"` + Items []TransportConflictRecord `json:"items,omitempty"` +} + +type TransportPolicyValidateOptions struct { + AllowWarnings bool `json:"allow_warnings,omitempty"` + ForceOverride bool `json:"force_override,omitempty"` +} + +type TransportPolicyValidateRequest struct { + BaseRevision int64 `json:"base_revision,omitempty"` + Intents []TransportPolicyIntent `json:"intents"` + Options TransportPolicyValidateOptions `json:"options,omitempty"` +} + +type TransportPolicyValidateSummary struct { + BlockCount int `json:"block_count"` + WarnCount int `json:"warn_count"` +} + +type TransportPolicyDiff struct { + Added int `json:"added"` + Changed int `json:"changed"` + Removed int `json:"removed"` +} + +type TransportPolicyValidateResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Valid bool `json:"valid"` + BaseRevision int64 `json:"base_revision,omitempty"` + ConfirmToken string `json:"confirm_token,omitempty"` + Summary TransportPolicyValidateSummary `json:"summary"` + Conflicts []TransportConflictRecord `json:"conflicts,omitempty"` + Diff TransportPolicyDiff `json:"diff"` + Plan *TransportPolicyCompilePlan `json:"plan,omitempty"` +} + +type TransportPolicyApplyOptions struct { + ForceOverride bool `json:"force_override,omitempty"` + ConfirmToken string `json:"confirm_token,omitempty"` +} + +type TransportPolicyApplyRequest struct { + BaseRevision int64 `json:"base_revision"` + Intents []TransportPolicyIntent `json:"intents"` + Options TransportPolicyApplyOptions `json:"options,omitempty"` +} + +type TransportPolicyRollbackRequest struct { + BaseRevision int64 `json:"base_revision,omitempty"` +} + +type TransportPolicyResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + PolicyRevision int64 `json:"policy_revision,omitempty"` + CurrentRevision int64 `json:"current_revision,omitempty"` + ApplyID string `json:"apply_id,omitempty"` + RollbackAvailable bool `json:"rollback_available,omitempty"` + Intents []TransportPolicyIntent `json:"intents,omitempty"` + Conflicts []TransportConflictRecord `json:"conflicts,omitempty"` + Plan *TransportPolicyCompilePlan `json:"plan,omitempty"` + HealthCheck *TransportPolicyHealthCheck `json:"health_check,omitempty"` +} + +type TransportPolicyHealthCheckItem struct { + ClientID string `json:"client_id"` + Kind string `json:"kind,omitempty"` + Required bool `json:"required,omitempty"` + OK bool `json:"ok"` + Status string `json:"status,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type TransportPolicyHealthCheckInterface struct { + IfaceID string `json:"iface_id"` + Mode string `json:"mode,omitempty"` + RuntimeIface string `json:"runtime_iface,omitempty"` + NetnsName string `json:"netns_name,omitempty"` + RoutingTable string `json:"routing_table,omitempty"` + ClientIDs []string `json:"client_ids,omitempty"` + ClientCount int `json:"client_count,omitempty"` + CheckedCount int `json:"checked_count,omitempty"` + FailedCount int `json:"failed_count,omitempty"` + SkippedCount int `json:"skipped_count,omitempty"` + Status string `json:"status,omitempty"` + LatencyMS int `json:"latency_ms,omitempty"` + LastError string `json:"last_error,omitempty"` + ActiveClientID string `json:"active_client_id,omitempty"` + OK bool `json:"ok"` + Message string `json:"message,omitempty"` +} + +type TransportPolicyHealthCheck struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + CheckedCount int `json:"checked_count,omitempty"` + FailedCount int `json:"failed_count,omitempty"` + InterfaceCount int `json:"interface_count,omitempty"` + Interfaces []TransportPolicyHealthCheckInterface `json:"interfaces,omitempty"` + Items []TransportPolicyHealthCheckItem `json:"items,omitempty"` +} + +type TransportConflictsResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + HasBlocking bool `json:"has_blocking"` + Items []TransportConflictRecord `json:"items,omitempty"` +} + +type TransportOwnershipRecord struct { + Key string `json:"key"` + SelectorType string `json:"selector_type"` + SelectorValue string `json:"selector_value"` + ClientID string `json:"client_id"` + ClientKind string `json:"client_kind,omitempty"` + OwnerScope string `json:"owner_scope,omitempty"` + OwnerStatus string `json:"owner_status,omitempty"` + LockActive bool `json:"lock_active,omitempty"` + IfaceID string `json:"iface_id,omitempty"` + RoutingTable string `json:"routing_table,omitempty"` + MarkHex string `json:"mark_hex,omitempty"` + PriorityBase int `json:"priority_base,omitempty"` + Mode string `json:"mode,omitempty"` + Priority int `json:"priority,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type TransportOwnershipState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + PolicyRevision int64 `json:"policy_revision,omitempty"` + PlanDigest string `json:"plan_digest,omitempty"` + Count int `json:"count,omitempty"` + Items []TransportOwnershipRecord `json:"items,omitempty"` +} + +type TransportOwnershipResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + PolicyRevision int64 `json:"policy_revision,omitempty"` + PlanDigest string `json:"plan_digest,omitempty"` + Count int `json:"count,omitempty"` + LockCount int `json:"lock_count,omitempty"` + Items []TransportOwnershipRecord `json:"items,omitempty"` +} + +type TransportOwnerLockRecord struct { + DestinationIP string `json:"destination_ip"` + ClientID string `json:"client_id"` + ClientKind string `json:"client_kind,omitempty"` + IfaceID string `json:"iface_id,omitempty"` + MarkHex string `json:"mark_hex,omitempty"` + Proto string `json:"proto,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type TransportOwnerLockState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at,omitempty"` + PolicyRevision int64 `json:"policy_revision,omitempty"` + Count int `json:"count,omitempty"` + Items []TransportOwnerLockRecord `json:"items,omitempty"` +} + +type TransportOwnerLocksResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + PolicyRevision int64 `json:"policy_revision,omitempty"` + Count int `json:"count,omitempty"` + Items []TransportOwnerLockRecord `json:"items,omitempty"` +} + +type TransportOwnerLocksClearRequest struct { + BaseRevision int64 `json:"base_revision,omitempty"` + ClientID string `json:"client_id,omitempty"` + DestinationIP string `json:"destination_ip,omitempty"` + DestinationIPs []string `json:"destination_ips,omitempty"` + ConfirmToken string `json:"confirm_token,omitempty"` +} + +type TransportOwnerLocksClearResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + BaseRevision int64 `json:"base_revision,omitempty"` + ConfirmRequired bool `json:"confirm_required,omitempty"` + ConfirmToken string `json:"confirm_token,omitempty"` + MatchCount int `json:"match_count,omitempty"` + ClearedCount int `json:"cleared_count,omitempty"` + RemainingCount int `json:"remaining_count,omitempty"` + Items []TransportOwnerLockRecord `json:"items,omitempty"` +} + +type TransportPolicyCompileRule struct { + SelectorType string `json:"selector_type"` + SelectorValue string `json:"selector_value"` + ClientID string `json:"client_id"` + ClientKind string `json:"client_kind,omitempty"` + OwnerScope string `json:"owner_scope,omitempty"` + Mode string `json:"mode,omitempty"` + Priority int `json:"priority,omitempty"` + MarkHex string `json:"mark_hex,omitempty"` + PriorityBase int `json:"priority_base,omitempty"` + NftSet string `json:"nft_set,omitempty"` +} + +type TransportPolicyCompileSet struct { + SelectorType string `json:"selector_type"` + OwnerScope string `json:"owner_scope,omitempty"` + Name string `json:"name"` + RuleCount int `json:"rule_count"` +} + +type TransportPolicyCompileInterface struct { + IfaceID string `json:"iface_id"` + Mode string `json:"mode,omitempty"` + RuntimeIface string `json:"runtime_iface,omitempty"` + NetnsName string `json:"netns_name,omitempty"` + RoutingTable string `json:"routing_table,omitempty"` + ClientIDs []string `json:"client_ids,omitempty"` + MarkHexes []string `json:"mark_hexes,omitempty"` + PriorityBase []int `json:"priority_base,omitempty"` + RuleCount int `json:"rule_count"` + Sets []TransportPolicyCompileSet `json:"sets,omitempty"` + Rules []TransportPolicyCompileRule `json:"rules,omitempty"` +} + +type TransportPolicyCompilePlan struct { + GeneratedAt string `json:"generated_at,omitempty"` + PolicyRevision int64 `json:"policy_revision,omitempty"` + InterfaceCount int `json:"interface_count"` + RuleCount int `json:"rule_count"` + Interfaces []TransportPolicyCompileInterface `json:"interfaces,omitempty"` +} diff --git a/selective-vpn-api/app/types_transport_runtime.go b/selective-vpn-api/app/types_transport_runtime.go new file mode 100644 index 0000000..3e1bc34 --- /dev/null +++ b/selective-vpn-api/app/types_transport_runtime.go @@ -0,0 +1,101 @@ +package app + +// --------------------------------------------------------------------- +// transport control-plane (runtime actions/refresh/netns) +// --------------------------------------------------------------------- + +type TransportClientHealthResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + ClientID string `json:"client_id,omitempty"` + Kind TransportClientKind `json:"kind,omitempty"` + Status TransportClientStatus `json:"status,omitempty"` + Latency int `json:"latency_ms,omitempty"` + LastErr string `json:"last_error,omitempty"` + Health TransportClientHealth `json:"health"` + Runtime TransportClientRuntime `json:"runtime"` +} + +type TransportClientMetricsResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + ClientID string `json:"client_id,omitempty"` + Kind TransportClientKind `json:"kind,omitempty"` + Status TransportClientStatus `json:"status,omitempty"` + Metrics TransportClientMetrics `json:"metrics"` + Runtime TransportClientRuntime `json:"runtime"` +} + +type TransportClientLifecycleResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + ExitCode int `json:"exitCode,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + ClientID string `json:"client_id,omitempty"` + Kind TransportClientKind `json:"kind,omitempty"` + Action string `json:"action,omitempty"` + StatusBefore TransportClientStatus `json:"status_before,omitempty"` + StatusAfter TransportClientStatus `json:"status_after,omitempty"` + Health TransportClientHealth `json:"health"` + Runtime TransportClientRuntime `json:"runtime"` +} + +type TransportHealthRefreshRequest struct { + ClientIDs []string `json:"client_ids,omitempty"` + Force bool `json:"force,omitempty"` +} + +type TransportHealthRefreshItem struct { + ClientID string `json:"client_id,omitempty"` + Status TransportClientStatus `json:"status,omitempty"` + Queued bool `json:"queued"` + Reason string `json:"reason,omitempty"` +} + +type TransportHealthRefreshResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Count int `json:"count"` + Queued int `json:"queued"` + Skipped int `json:"skipped"` + Items []TransportHealthRefreshItem `json:"items,omitempty"` +} + +type TransportNetnsToggleRequest struct { + Enabled *bool `json:"enabled,omitempty"` + ClientIDs []string `json:"client_ids,omitempty"` + Provision *bool `json:"provision,omitempty"` + RestartRunning *bool `json:"restart_running,omitempty"` +} + +type TransportNetnsToggleItem struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + ClientID string `json:"client_id,omitempty"` + Kind TransportClientKind `json:"kind,omitempty"` + StatusBefore TransportClientStatus `json:"status_before,omitempty"` + StatusAfter TransportClientStatus `json:"status_after,omitempty"` + NetnsEnabled bool `json:"netns_enabled"` + ConfigUpdated bool `json:"config_updated,omitempty"` + Provisioned bool `json:"provisioned,omitempty"` + Restarted bool `json:"restarted,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` +} + +type TransportNetnsToggleResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Enabled bool `json:"enabled"` + Count int `json:"count"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + Items []TransportNetnsToggleItem `json:"items,omitempty"` +} diff --git a/selective-vpn-api/app/vpn_handlers.go b/selective-vpn-api/app/vpn_handlers.go index ec23d3d..4b652e8 100644 --- a/selective-vpn-api/app/vpn_handlers.go +++ b/selective-vpn-api/app/vpn_handlers.go @@ -1,371 +1,10 @@ package app -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "strings" - "time" -) - // --------------------------------------------------------------------- // VPN handlers / status / locations // --------------------------------------------------------------------- - +// // EN: VPN-facing HTTP handlers for login state, logout, service/unit control, // EN: autoloop status, locations, and location switching. // RU: VPN-ориентированные HTTP-обработчики для login state, logout, // RU: управления unit/service, статуса autoloop, списка локаций и смены локации. - -func handleVPNLoginState(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - state := VPNLoginState{ - State: "no_login", - Msg: "login state file not found", - Text: "AdGuard VPN: (no login data)", - Color: "gray30", - } - - data, err := os.ReadFile(loginStatePath) - if err == nil { - var fileState VPNLoginState - if err := json.Unmarshal(data, &fileState); err == nil { - if fileState.State != "" { - state.State = fileState.State - } - if fileState.Email != "" { - state.Email = fileState.Email - } - if fileState.Msg != "" { - state.Msg = fileState.Msg - } - } else { - state.State = "error" - state.Msg = "invalid adguard-login.json: " + err.Error() - } - } else if !os.IsNotExist(err) { - state.State = "error" - state.Msg = err.Error() - } - - // text/color для GUI - switch state.State { - case "ok": - if state.Email != "" { - state.Text = fmt.Sprintf("AdGuard VPN: logged in as %s", state.Email) - } else { - state.Text = "AdGuard VPN: logged in" - } - state.Color = "green4" - case "no_login": - state.Text = "AdGuard VPN: (no login data)" - state.Color = "gray30" - default: - state.Text = "AdGuard VPN: " + state.State - state.Color = "orange3" - } - - writeJSON(w, http.StatusOK, state) -} - -// --------------------------------------------------------------------- -// logout -// --------------------------------------------------------------------- - -func handleVPNLogout(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - appendTraceLine("login", "logout") - stdout, stderr, exitCode, err := runCommand(adgvpnCLI, "logout") - res := cmdResult{ - OK: err == nil && exitCode == 0, - ExitCode: exitCode, - Stdout: stdout, - Stderr: stderr, - } - if err != nil { - res.Message = err.Error() - } else { - res.Message = "logout done" - } - - // refresh login state - _, _, _, _ = runCommand("systemctl", "restart", adgvpnUnit) - - writeJSON(w, http.StatusOK, res) -} - -// --------------------------------------------------------------------- -// systemd state -// --------------------------------------------------------------------- - -func handleSystemdState(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - unit := strings.TrimSpace(r.URL.Query().Get("unit")) - if unit == "" { - http.Error(w, "unit required", http.StatusBadRequest) - return - } - stdout, _, _, err := runCommand("systemctl", "is-active", unit) - st := strings.TrimSpace(stdout) - if err != nil || st == "" { - st = "unknown" - } - writeJSON(w, http.StatusOK, SystemdState{State: st}) -} - -// --------------------------------------------------------------------- -// AdGuard autoloop / status parse -// --------------------------------------------------------------------- - -// аккуратный разбор лога autoloop: игнорим "route:", смотрим status -func parseAutoloopStatus(lines []string) (word, raw string) { - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" { - continue - } - if idx := strings.Index(line, "autoloop:"); idx >= 0 { - line = strings.TrimSpace(line[idx+len("autoloop:"):]) - } - lower := strings.ToLower(line) - - // route: default dev ... - нам неинтересно - if strings.HasPrefix(lower, "route: ") { - continue - } - - switch { - case strings.Contains(lower, "status: connected"), - strings.Contains(lower, "after connect: connected"): - return "CONNECTED", line - case strings.Contains(lower, "status: reconnecting"): - return "RECONNECTING", line - case strings.Contains(lower, "status: disconnected"), - strings.Contains(lower, "still disconnected"): - return "DISCONNECTED", line - case strings.Contains(lower, "timeout"), - strings.Contains(lower, "failed"): - return "ERROR", line - } - } - return "unknown", "" -} - -// --------------------------------------------------------------------- -// /api/v1/vpn/autoloop-status -// --------------------------------------------------------------------- - -func handleVPNAutoloopStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - lines := tailFile(autoloopLogPath, 200) - word, raw := parseAutoloopStatus(lines) - writeJSON(w, http.StatusOK, map[string]any{ - "raw_text": raw, - "status_word": word, - }) -} - -// --------------------------------------------------------------------- -// /api/v1/vpn/status -// --------------------------------------------------------------------- - -func handleVPNStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // desired location - loc := "" - if data, err := os.ReadFile(desiredLocation); err == nil { - loc = strings.TrimSpace(string(data)) - } - - // unit state - stdout, _, _, err := runCommand("systemctl", "is-active", adgvpnUnit) - unitState := strings.TrimSpace(stdout) - if err != nil || unitState == "" { - unitState = "unknown" - } - - // автолуп - lines := tailFile(autoloopLogPath, 200) - word, raw := parseAutoloopStatus(lines) - - writeJSON(w, http.StatusOK, map[string]any{ - "desired_location": loc, - "status_word": word, - "raw_text": raw, - "unit_state": unitState, - }) -} - -// --------------------------------------------------------------------- -// /api/v1/vpn/autoconnect -// --------------------------------------------------------------------- - -func handleVPNAutoconnect(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)) - var cmd []string - switch action { - case "start": - cmd = []string{"systemctl", "start", adgvpnUnit} - case "stop": - cmd = []string{"systemctl", "stop", adgvpnUnit} - default: - http.Error(w, "unknown action", http.StatusBadRequest) - return - } - stdout, stderr, exitCode, err := runCommand(cmd[0], cmd[1:]...) - res := cmdResult{ - OK: err == nil && exitCode == 0, - ExitCode: exitCode, - Stdout: stdout, - Stderr: stderr, - } - if err != nil { - res.Message = err.Error() - } - writeJSON(w, http.StatusOK, res) -} - -// --------------------------------------------------------------------- -// /api/v1/vpn/locations -// --------------------------------------------------------------------- - -func handleVPNListLocations(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // Жесткий таймаут на list-locations, чтобы не клинить HTTP - const locationsTimeout = 7 * time.Second - - start := time.Now() - stdout, _, exitCode, err := runCommandTimeout(locationsTimeout, adgvpnCLI, "list-locations") - log.Printf("list-locations took %s (exit=%d, err=%v)", time.Since(start), exitCode, err) - if err != nil || exitCode != 0 { - writeJSON(w, http.StatusOK, map[string]any{ - "locations": []any{}, - "error": fmt.Sprintf("list-locations failed: %v (exit=%d)", err, exitCode), - }) - return - } - - stdout = stripANSI(stdout) - - var locations []map[string]string - - for _, ln := range strings.Split(stdout, "\n") { - line := strings.TrimSpace(ln) - if line == "" { - continue - } - if strings.HasPrefix(line, "ISO ") { - continue - } - if strings.HasPrefix(line, "VPN ") || strings.HasPrefix(line, "You can connect") { - continue - } - - parts := strings.Fields(line) - if len(parts) < 4 { - continue - } - iso := parts[0] - ping := parts[len(parts)-1] - - if len(iso) != 2 { - continue - } - okPing := true - for _, ch := range ping { - if ch < '0' || ch > '9' { - okPing = false - break - } - } - if !okPing { - continue - } - - name := strings.Join(parts[1:len(parts)-1], " ") - label := fmt.Sprintf("%s %s (%s ms)", iso, name, ping) - - locations = append(locations, map[string]string{ - "label": label, - "iso": iso, - }) - } - - writeJSON(w, http.StatusOK, map[string]any{ - "locations": locations, - }) -} - -// --------------------------------------------------------------------- -// /api/v1/vpn/location -// --------------------------------------------------------------------- - -func handleVPNSetLocation(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var body struct { - ISO string `json:"iso"` - } - 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 - } - } - val := strings.TrimSpace(body.ISO) - if val == "" { - http.Error(w, "iso is required", http.StatusBadRequest) - return - } - _ = os.MkdirAll(stateDir, 0o755) - if err := os.WriteFile(desiredLocation, []byte(val+"\n"), 0o644); err != nil { - http.Error(w, "write error", http.StatusInternalServerError) - return - } - - // как старый GUI: сразу рестартуем автоконнект - _, _, _, _ = runCommand("systemctl", "restart", adgvpnUnit) - - writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "iso": val, - }) -} diff --git a/selective-vpn-api/app/vpn_handlers_auth.go b/selective-vpn-api/app/vpn_handlers_auth.go new file mode 100644 index 0000000..287e3cd --- /dev/null +++ b/selective-vpn-api/app/vpn_handlers_auth.go @@ -0,0 +1,107 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" +) + +func handleVPNLoginState(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + state := VPNLoginState{ + State: "no_login", + Msg: "login state file not found", + Text: "AdGuard VPN: (no login data)", + Color: "gray30", + } + + data, err := os.ReadFile(loginStatePath) + if err == nil { + var fileState VPNLoginState + if err := json.Unmarshal(data, &fileState); err == nil { + if fileState.State != "" { + state.State = fileState.State + } + if fileState.Email != "" { + state.Email = fileState.Email + } + if fileState.Msg != "" { + state.Msg = fileState.Msg + } + } else { + state.State = "error" + state.Msg = "invalid adguard-login.json: " + err.Error() + } + } else if !os.IsNotExist(err) { + state.State = "error" + state.Msg = err.Error() + } + + // text/color для GUI + switch state.State { + case "ok": + if state.Email != "" { + state.Text = fmt.Sprintf("AdGuard VPN: logged in as %s", state.Email) + } else { + state.Text = "AdGuard VPN: logged in" + } + state.Color = "green4" + case "no_login": + state.Text = "AdGuard VPN: (no login data)" + state.Color = "gray30" + default: + state.Text = "AdGuard VPN: " + state.State + state.Color = "orange3" + } + + writeJSON(w, http.StatusOK, state) +} + +func handleVPNLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + appendTraceLine("login", "logout") + stdout, stderr, exitCode, err := runCommand(adgvpnCLI, "logout") + res := cmdResult{ + OK: err == nil && exitCode == 0, + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + } + if err != nil { + res.Message = err.Error() + } else { + res.Message = "logout done" + } + + // refresh login state + _, _, _, _ = runCommand("systemctl", "restart", adgvpnUnit) + + writeJSON(w, http.StatusOK, res) +} + +func handleSystemdState(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + unit := strings.TrimSpace(r.URL.Query().Get("unit")) + if unit == "" { + http.Error(w, "unit required", http.StatusBadRequest) + return + } + stdout, _, _, err := runCommand("systemctl", "is-active", unit) + st := strings.TrimSpace(stdout) + if err != nil || st == "" { + st = "unknown" + } + writeJSON(w, http.StatusOK, SystemdState{State: st}) +} diff --git a/selective-vpn-api/app/vpn_handlers_location_resolve_test.go b/selective-vpn-api/app/vpn_handlers_location_resolve_test.go new file mode 100644 index 0000000..5700807 --- /dev/null +++ b/selective-vpn-api/app/vpn_handlers_location_resolve_test.go @@ -0,0 +1,87 @@ +package app + +import "testing" + +func TestResolveVPNLocationSelection_MultiCityISO(t *testing.T) { + locs := []vpnLocationItem{ + {ISO: "US", Label: "US United States Boston (10 ms)", Target: "United States Boston"}, + {ISO: "US", Label: "US United States Los Angeles (20 ms)", Target: "United States Los Angeles"}, + {ISO: "SE", Label: "SE Sweden Stockholm (30 ms)", Target: "SE"}, + } + + target, iso, label, validated, err := resolveVPNLocationSelection( + "United States Los Angeles", + "US", + "", + locs, + ) + if err != nil { + t.Fatalf("resolve returned error: %v", err) + } + if iso != "US" { + t.Fatalf("expected iso=US, got %q", iso) + } + if target != "Los Angeles" { + t.Fatalf("expected city connect arg, got %q", target) + } + if label != "US United States Los Angeles (20 ms)" { + t.Fatalf("unexpected label: %q", label) + } + if !validated { + t.Fatal("expected validated=true") + } +} + +func TestResolveVPNLocationSelection_SingleCityFallbackToISO(t *testing.T) { + locs := []vpnLocationItem{ + {ISO: "SE", Label: "SE Sweden Stockholm (30 ms)", Target: "Sweden Stockholm"}, + } + + target, iso, _, validated, err := resolveVPNLocationSelection( + "Sweden Stockholm", + "SE", + "", + locs, + ) + if err != nil { + t.Fatalf("resolve returned error: %v", err) + } + if iso != "SE" || target != "SE" { + t.Fatalf("expected SE->SE fallback, got iso=%q target=%q", iso, target) + } + if !validated { + t.Fatal("expected validated=true") + } +} + +func TestResolveVPNLocationSelection_CacheUnavailableISOOnly(t *testing.T) { + target, iso, _, validated, err := resolveVPNLocationSelection( + "", + "AU", + "", + nil, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if target != "AU" || iso != "AU" { + t.Fatalf("expected AU/AU, got target=%q iso=%q", target, iso) + } + if validated { + t.Fatal("expected validated=false when catalog is unavailable") + } +} + +func TestResolveVPNLocationSelection_UnknownTargetRejected(t *testing.T) { + locs := []vpnLocationItem{ + {ISO: "SE", Label: "SE Sweden Stockholm (30 ms)", Target: "SE"}, + } + if _, _, _, _, err := resolveVPNLocationSelection( + "__invalid__", + "", + "", + locs, + ); err == nil { + t.Fatal("expected error for unknown target") + } +} diff --git a/selective-vpn-api/app/vpn_handlers_locations.go b/selective-vpn-api/app/vpn_handlers_locations.go new file mode 100644 index 0000000..52895be --- /dev/null +++ b/selective-vpn-api/app/vpn_handlers_locations.go @@ -0,0 +1,139 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "os" + "strings" + "time" +) + +func handleVPNAutoconnect(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)) + switch action { + case "start", "stop", "restart": + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return + } + + _, lifecycle := runTransportVirtualClientLifecycleAction(transportPolicyTargetAdGuardID, action) + res := cmdResult{ + OK: lifecycle.OK, + Message: lifecycle.Message, + ExitCode: lifecycle.ExitCode, + Stdout: lifecycle.Stdout, + Stderr: lifecycle.Stderr, + } + writeJSON(w, http.StatusOK, res) +} + +func handleVPNListLocations(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + force := false + switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) { + case "1", "true", "yes", "on": + force = true + } + writeJSON(w, http.StatusOK, getVPNLocationsSnapshot(force)) +} + +func handleVPNSetLocation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + ISO string `json:"iso"` + Target string `json:"target"` + Label string `json:"label"` + } + 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 + } + } + reqTarget := strings.TrimSpace(body.Target) + reqISO := strings.ToUpper(strings.TrimSpace(body.ISO)) + reqLabel := strings.TrimSpace(body.Label) + if reqTarget == "" && reqISO == "" { + http.Error(w, "target or iso is required", http.StatusBadRequest) + return + } + + snap := getVPNLocationsSnapshot(false) + val, iso, resolvedLabel, validated, err := resolveVPNLocationSelection( + reqTarget, reqISO, reqLabel, snap.Locations, + ) + if err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "status": "error", + "error": err.Error(), + "requested_target": reqTarget, + "requested_iso": reqISO, + "requested_label": reqLabel, + }) + return + } + + _ = os.MkdirAll(stateDir, 0o755) + stored := val + if isISO2(iso) && !strings.EqualFold(val, iso) { + stored = val + "|" + iso + } + if err := os.WriteFile(desiredLocation, []byte(stored+"\n"), 0o644); err != nil { + http.Error(w, "write error", http.StatusInternalServerError) + return + } + + // Force location switch for already connected tunnels: + // disconnect first so autoloop reconnects using the new desired location. + _, _, _, _ = runCommandTimeout(8*time.Second, adgvpnCLI, "disconnect") + + // как старый GUI: сразу рестартуем автоконнект + _, _, _, _ = runCommand("systemctl", "restart", adgvpnUnit) + triggerVPNEgressRefreshBurst() + + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "iso": iso, + "target": val, + "label": resolvedLabel, + "validated": validated, + }) +} + +func triggerVPNEgressRefreshBurst() { + go func() { + // Multiple forced refreshes are used because service restart can report + // old egress for a short period before tunnel re-establishes. + delays := []time.Duration{ + 0, + 2500 * time.Millisecond, + 4500 * time.Millisecond, + } + for _, d := range delays { + if d > 0 { + time.Sleep(d) + } + _, _ = egressIdentitySWR.queueRefresh([]string{"adguardvpn"}, true) + } + }() +} diff --git a/selective-vpn-api/app/vpn_handlers_status.go b/selective-vpn-api/app/vpn_handlers_status.go new file mode 100644 index 0000000..fdbf3da --- /dev/null +++ b/selective-vpn-api/app/vpn_handlers_status.go @@ -0,0 +1,96 @@ +package app + +import ( + "net/http" + "os" + "strings" +) + +// аккуратный разбор лога autoloop: игнорим "route:", смотрим status +func parseAutoloopStatus(lines []string) (word, raw string) { + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + if idx := strings.Index(line, "autoloop:"); idx >= 0 { + line = strings.TrimSpace(line[idx+len("autoloop:"):]) + } + lower := strings.ToLower(line) + + // route: default dev ... - нам неинтересно + if strings.HasPrefix(lower, "route: ") { + continue + } + + switch { + case strings.Contains(lower, "status: connected"), + strings.Contains(lower, "after connect: connected"): + return "CONNECTED", line + case strings.Contains(lower, "status: reconnecting"): + return "RECONNECTING", line + case strings.Contains(lower, "status: disconnected"), + strings.Contains(lower, "still disconnected"): + return "DISCONNECTED", line + case strings.Contains(lower, "timeout"), + strings.Contains(lower, "failed"): + return "ERROR", line + } + } + return "unknown", "" +} + +func handleVPNAutoloopStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + lines := tailFile(autoloopLogPath, 200) + word, raw := parseAutoloopStatus(lines) + writeJSON(w, http.StatusOK, map[string]any{ + "raw_text": raw, + "status_word": word, + }) +} + +func handleVPNStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // desired location + loc := "" + if data, err := os.ReadFile(desiredLocation); err == nil { + loc = parseStoredVPNLocationPrimary(string(data)) + } + + // unit state + stdout, _, _, err := runCommand("systemctl", "is-active", adgvpnUnit) + unitState := strings.TrimSpace(stdout) + if err != nil || unitState == "" { + unitState = "unknown" + } + + // автолуп + lines := tailFile(autoloopLogPath, 200) + word, raw := parseAutoloopStatus(lines) + + writeJSON(w, http.StatusOK, map[string]any{ + "desired_location": loc, + "status_word": word, + "raw_text": raw, + "unit_state": unitState, + }) +} + +func parseStoredVPNLocationPrimary(raw string) string { + v := strings.TrimSpace(raw) + if v == "" { + return "" + } + if p := strings.SplitN(v, "|", 2); len(p) == 2 { + return strings.TrimSpace(p[0]) + } + return v +} diff --git a/selective-vpn-api/app/vpn_locations_cache.go b/selective-vpn-api/app/vpn_locations_cache.go new file mode 100644 index 0000000..8ff153a --- /dev/null +++ b/selective-vpn-api/app/vpn_locations_cache.go @@ -0,0 +1,51 @@ +package app + +import ( + "sync" + "time" +) + +const ( + vpnLocationsCommandTimeout = 7 * time.Second + vpnLocationsFreshTTL = 10 * time.Minute + vpnLocationsBackoffMin = 2 * time.Second + vpnLocationsBackoffMax = 60 * time.Second + vpnLocationsCachePath = stateDir + "/vpn-locations-cache.json" +) + +type vpnLocationItem struct { + Label string `json:"label"` + ISO string `json:"iso"` + Target string `json:"target,omitempty"` +} + +type vpnLocationsSnapshot struct { + Locations []vpnLocationItem `json:"locations"` + UpdatedAt string `json:"updated_at,omitempty"` + Stale bool `json:"stale"` + RefreshInProgress bool `json:"refresh_in_progress"` + LastError string `json:"last_error,omitempty"` + NextRetryAt string `json:"next_retry_at,omitempty"` +} + +type vpnLocationsCacheDisk struct { + Locations []vpnLocationItem `json:"locations"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type vpnLocationsStore struct { + mu sync.Mutex + + loaded bool + + locations []vpnLocationItem + swr refreshCoordinator +} + +var vpnLocationCache = &vpnLocationsStore{ + swr: newRefreshCoordinator( + vpnLocationsFreshTTL, + vpnLocationsBackoffMin, + vpnLocationsBackoffMax, + ), +} diff --git a/selective-vpn-api/app/vpn_locations_cache_parse.go b/selective-vpn-api/app/vpn_locations_cache_parse.go new file mode 100644 index 0000000..ea8bec1 --- /dev/null +++ b/selective-vpn-api/app/vpn_locations_cache_parse.go @@ -0,0 +1,136 @@ +package app + +import ( + "fmt" + "strings" +) + +func parseVPNLocationsOutput(raw string) ([]vpnLocationItem, error) { + stdout := stripANSI(raw) + type parsedLocation struct { + iso string + label string + name string + } + var parsed []parsedLocation + + for _, ln := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(ln) + if line == "" { + continue + } + if strings.HasPrefix(line, "ISO ") { + continue + } + if strings.HasPrefix(line, "VPN ") || strings.HasPrefix(line, "You can connect") { + continue + } + + parts := strings.Fields(line) + if len(parts) < 4 { + continue + } + iso := strings.ToUpper(strings.TrimSpace(parts[0])) + ping := strings.TrimSpace(parts[len(parts)-1]) + + if len(iso) != 2 { + continue + } + if !isDigitsOnly(ping) { + continue + } + + name := strings.Join(parts[1:len(parts)-1], " ") + label := fmt.Sprintf("%s %s (%s ms)", iso, name, ping) + parsed = append(parsed, parsedLocation{ + iso: iso, + label: label, + name: name, + }) + } + + if len(parsed) == 0 { + trimmed := strings.TrimSpace(stdout) + if trimmed == "" { + return nil, fmt.Errorf("empty list-locations output") + } + return nil, fmt.Errorf("no locations parsed from output") + } + + namesByISO := map[string][][]string{} + for _, it := range parsed { + namesByISO[it.iso] = append(namesByISO[it.iso], strings.Fields(it.name)) + } + commonPrefixByISO := map[string][]string{} + for iso, names := range namesByISO { + if len(names) < 2 { + continue + } + pfx := commonPrefixTokens(names) + if len(pfx) > 0 { + commonPrefixByISO[iso] = pfx + } + } + + var out []vpnLocationItem + for _, it := range parsed { + target := strings.ToUpper(strings.TrimSpace(it.iso)) + if pfx := commonPrefixByISO[it.iso]; len(pfx) > 0 { + tokens := strings.Fields(it.name) + if len(tokens) > len(pfx) { + city := strings.TrimSpace(strings.Join(tokens[len(pfx):], " ")) + if city != "" { + target = city + } + } + } + item, ok := normalizeVPNLocationItem(vpnLocationItem{ + Label: it.label, + ISO: it.iso, + Target: target, + }) + if !ok { + continue + } + out = append(out, item) + } + return out, nil +} + +func commonPrefixTokens(items [][]string) []string { + if len(items) == 0 { + return nil + } + prefix := append([]string{}, items[0]...) + for i := 1; i < len(items); i++ { + row := items[i] + n := len(prefix) + if len(row) < n { + n = len(row) + } + j := 0 + for j < n { + if !strings.EqualFold(prefix[j], row[j]) { + break + } + j++ + } + prefix = prefix[:j] + if len(prefix) == 0 { + return nil + } + } + return prefix +} + +func isDigitsOnly(s string) bool { + if s == "" { + return false + } + for _, ch := range s { + if ch < '0' || ch > '9' { + return false + } + } + return true +} diff --git a/selective-vpn-api/app/vpn_locations_cache_refresh.go b/selective-vpn-api/app/vpn_locations_cache_refresh.go new file mode 100644 index 0000000..c21cf53 --- /dev/null +++ b/selective-vpn-api/app/vpn_locations_cache_refresh.go @@ -0,0 +1,136 @@ +package app + +import ( + "errors" + "fmt" + "log" + "os" + "time" +) + +func getVPNLocationsSnapshot(force bool) vpnLocationsSnapshot { + vpnLocationCache.ensureLoaded() + vpnLocationCache.maybeStartRefresh(force) + return vpnLocationCache.snapshot(time.Now()) +} + +func (s *vpnLocationsStore) ensureLoaded() { + s.mu.Lock() + if s.loaded { + s.mu.Unlock() + return + } + s.loaded = true + s.mu.Unlock() + + locs, updatedAt, err := loadVPNLocationsCache(vpnLocationsCachePath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.Printf("vpn locations cache load warning: %v", err) + } + return + } + + s.mu.Lock() + if len(locs) > 0 { + s.locations = cloneVPNLocations(locs) + } + if !updatedAt.IsZero() { + s.swr.setUpdatedAt(updatedAt) + } + s.mu.Unlock() +} + +func (s *vpnLocationsStore) maybeStartRefresh(force bool) { + now := time.Now() + + s.mu.Lock() + if !s.swr.beginRefresh(now, force, len(s.locations) > 0) { + s.mu.Unlock() + return + } + s.mu.Unlock() + + go s.refresh() +} + +func (s *vpnLocationsStore) snapshot(now time.Time) vpnLocationsSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + meta := s.swr.snapshot(now) + + out := vpnLocationsSnapshot{ + Locations: cloneVPNLocations(s.locations), + UpdatedAt: meta.UpdatedAt, + Stale: meta.Stale, + RefreshInProgress: meta.RefreshInProgress, + LastError: meta.LastError, + NextRetryAt: meta.NextRetryAt, + } + return out +} + +func (s *vpnLocationsStore) refresh() { + start := time.Now() + stdout, _, exitCode, err := runCommandTimeout(vpnLocationsCommandTimeout, adgvpnCLI, "list-locations") + if err != nil || exitCode != 0 { + s.finishError(fmt.Sprintf("list-locations failed: err=%v exit=%d", err, exitCode), time.Now()) + log.Printf("vpn locations refresh failed in %s: exit=%d err=%v", time.Since(start), exitCode, err) + return + } + + locs, err := parseVPNLocationsOutput(stdout) + if err != nil { + s.finishError(fmt.Sprintf("list-locations parse error: %v", err), time.Now()) + log.Printf("vpn locations parse failed in %s: %v", time.Since(start), err) + return + } + + now := time.Now() + changed, snapshot := s.finishSuccess(locs, now) + if err := saveVPNLocationsCache(vpnLocationsCachePath, snapshot.Locations, now); err != nil { + log.Printf("vpn locations cache save warning: %v", err) + } + + events.push("vpn_locations_changed", map[string]any{ + "ok": true, + "count": len(snapshot.Locations), + "changed": changed, + "updated_at": snapshot.UpdatedAt, + }) + log.Printf("vpn locations refresh ok in %s: count=%d changed=%v", time.Since(start), len(snapshot.Locations), changed) +} + +func (s *vpnLocationsStore) finishError(msg string, now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.swr.finishError(msg, now) + meta := s.swr.snapshot(now) + + events.push("vpn_locations_changed", map[string]any{ + "ok": false, + "error": meta.LastError, + "next_retry_at": meta.NextRetryAt, + "cached_count": len(s.locations), + }) +} + +func (s *vpnLocationsStore) finishSuccess(locs []vpnLocationItem, now time.Time) (bool, vpnLocationsSnapshot) { + s.mu.Lock() + defer s.mu.Unlock() + + changed := !equalVPNLocations(s.locations, locs) + s.locations = cloneVPNLocations(locs) + s.swr.finishSuccess(now) + meta := s.swr.snapshot(now) + + snap := vpnLocationsSnapshot{ + Locations: cloneVPNLocations(s.locations), + UpdatedAt: meta.UpdatedAt, + Stale: meta.Stale, + RefreshInProgress: meta.RefreshInProgress, + LastError: meta.LastError, + NextRetryAt: meta.NextRetryAt, + } + return changed, snap +} diff --git a/selective-vpn-api/app/vpn_locations_cache_store.go b/selective-vpn-api/app/vpn_locations_cache_store.go new file mode 100644 index 0000000..99dbc1e --- /dev/null +++ b/selective-vpn-api/app/vpn_locations_cache_store.go @@ -0,0 +1,124 @@ +package app + +import ( + "encoding/json" + "os" + "strings" + "time" +) + +func cloneVPNLocations(in []vpnLocationItem) []vpnLocationItem { + if len(in) == 0 { + return []vpnLocationItem{} + } + out := make([]vpnLocationItem, 0, len(in)) + for _, row := range in { + item, ok := normalizeVPNLocationItem(row) + if !ok { + continue + } + out = append(out, item) + } + return out +} + +func equalVPNLocations(a, b []vpnLocationItem) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].ISO != b[i].ISO || a[i].Label != b[i].Label || a[i].Target != b[i].Target { + return false + } + } + return true +} + +func loadVPNLocationsCache(path string) ([]vpnLocationItem, time.Time, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, time.Time{}, err + } + + var disk vpnLocationsCacheDisk + if err := json.Unmarshal(data, &disk); err != nil { + return nil, time.Time{}, err + } + + var updatedAt time.Time + if ts := strings.TrimSpace(disk.UpdatedAt); ts != "" { + parsed, parseErr := time.Parse(time.RFC3339, ts) + if parseErr == nil { + updatedAt = parsed + } + } + + return cloneVPNLocations(disk.Locations), updatedAt, nil +} + +func saveVPNLocationsCache(path string, locs []vpnLocationItem, updatedAt time.Time) error { + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return err + } + + disk := vpnLocationsCacheDisk{ + Locations: cloneVPNLocations(locs), + } + if !updatedAt.IsZero() { + disk.UpdatedAt = updatedAt.UTC().Format(time.RFC3339) + } + + data, err := json.MarshalIndent(disk, "", " ") + if err != nil { + return err + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func normalizeVPNLocationItem(in vpnLocationItem) (vpnLocationItem, bool) { + iso := strings.ToUpper(strings.TrimSpace(in.ISO)) + label := strings.TrimSpace(in.Label) + target := strings.TrimSpace(in.Target) + + if iso == "" || label == "" { + return vpnLocationItem{}, false + } + + if target == "" { + target = inferVPNLocationTargetFromLabel(label, iso) + } + if target == "" { + target = iso + } + + return vpnLocationItem{ + Label: label, + ISO: iso, + Target: target, + }, true +} + +func inferVPNLocationTargetFromLabel(label, iso string) string { + trimmed := strings.TrimSpace(label) + if trimmed == "" { + return "" + } + + if idx := strings.LastIndex(trimmed, "("); idx > 0 && strings.HasSuffix(trimmed, " ms)") { + trimmed = strings.TrimSpace(trimmed[:idx]) + } + + prefix := strings.ToUpper(strings.TrimSpace(iso)) + if prefix != "" { + withSpace := prefix + " " + if strings.HasPrefix(strings.ToUpper(trimmed), withSpace) { + trimmed = strings.TrimSpace(trimmed[len(withSpace):]) + } + } + return strings.TrimSpace(trimmed) +} diff --git a/selective-vpn-api/app/vpn_locations_cache_test.go b/selective-vpn-api/app/vpn_locations_cache_test.go new file mode 100644 index 0000000..f988055 --- /dev/null +++ b/selective-vpn-api/app/vpn_locations_cache_test.go @@ -0,0 +1,59 @@ +package app + +import "testing" + +func TestParseVPNLocationsOutput(t *testing.T) { + raw := ` +ISO Country Region Ping +DE Germany Frankfurt 35 +US United States New-York 120 +US United States Los-Angeles 130 +` + + locs, err := parseVPNLocationsOutput(raw) + if err != nil { + t.Fatalf("parseVPNLocationsOutput returned error: %v", err) + } + if len(locs) != 3 { + t.Fatalf("expected 3 locations, got %d", len(locs)) + } + if locs[0].ISO != "DE" || locs[1].ISO != "US" || locs[2].ISO != "US" { + t.Fatalf("unexpected iso order: %+v", locs) + } + if locs[1].Target != "New-York" { + t.Fatalf("unexpected target parse: %+v", locs[1]) + } + if locs[2].Target != "Los-Angeles" { + t.Fatalf("unexpected target parse: %+v", locs[2]) + } +} + +func TestParseVPNLocationsOutputRejectsInvalid(t *testing.T) { + raw := "error: not logged in\ntry: adguardvpn-cli-root login\n" + if _, err := parseVPNLocationsOutput(raw); err == nil { + t.Fatal("expected parse error for invalid output, got nil") + } +} + +func TestInferVPNLocationTargetFromLabel(t *testing.T) { + got := inferVPNLocationTargetFromLabel("US United States Los Angeles (271 ms)", "US") + if got != "United States Los Angeles" { + t.Fatalf("unexpected target for basic label: %q", got) + } + got = inferVPNLocationTargetFromLabel("RU Russia Moscow (Virtual) (29 ms)", "RU") + if got != "Russia Moscow (Virtual)" { + t.Fatalf("unexpected target for virtual label: %q", got) + } +} + +func TestCommonPrefixTokens(t *testing.T) { + in := [][]string{ + {"United", "States", "Los", "Angeles"}, + {"United", "States", "Boston"}, + {"United", "States", "Seattle"}, + } + pfx := commonPrefixTokens(in) + if len(pfx) != 2 || pfx[0] != "United" || pfx[1] != "States" { + t.Fatalf("unexpected common prefix: %#v", pfx) + } +} diff --git a/selective-vpn-api/app/vpn_locations_selection.go b/selective-vpn-api/app/vpn_locations_selection.go new file mode 100644 index 0000000..7fbe731 --- /dev/null +++ b/selective-vpn-api/app/vpn_locations_selection.go @@ -0,0 +1,164 @@ +package app + +import ( + "fmt" + "strings" +) + +type vpnLocationChoice struct { + ISO string + Label string + RawTarget string + Name string + Connect string +} + +func resolveVPNLocationSelection( + reqTarget string, + reqISO string, + reqLabel string, + locs []vpnLocationItem, +) (target string, iso string, label string, validated bool, err error) { + targetReq := strings.TrimSpace(reqTarget) + isoReq := strings.ToUpper(strings.TrimSpace(reqISO)) + labelReq := strings.TrimSpace(reqLabel) + + choices := buildVPNLocationChoices(locs) + if len(choices) == 0 { + if isISO2(isoReq) { + return isoReq, isoReq, "", false, nil + } + if isISO2(targetReq) { + t := strings.ToUpper(strings.TrimSpace(targetReq)) + return t, t, "", false, nil + } + return "", "", "", false, fmt.Errorf("location catalog unavailable; use ISO code") + } + + findBy := func(pred func(vpnLocationChoice) bool) *vpnLocationChoice { + for i := range choices { + if pred(choices[i]) { + return &choices[i] + } + } + return nil + } + + var chosen *vpnLocationChoice + + if labelReq != "" { + chosen = findBy(func(it vpnLocationChoice) bool { + return equalsFoldTrim(labelReq, it.Label) + }) + } + + if chosen == nil && targetReq != "" { + chosen = findBy(func(it vpnLocationChoice) bool { + return equalsFoldTrim(targetReq, it.Connect) || + equalsFoldTrim(targetReq, it.Name) || + equalsFoldTrim(targetReq, it.RawTarget) || + equalsFoldTrim(targetReq, it.Label) + }) + } + + if chosen == nil && isISO2(isoReq) { + chosen = findBy(func(it vpnLocationChoice) bool { + return it.ISO == isoReq + }) + } + + if chosen == nil && isISO2(targetReq) { + isoByTarget := strings.ToUpper(strings.TrimSpace(targetReq)) + chosen = findBy(func(it vpnLocationChoice) bool { + return it.ISO == isoByTarget + }) + } + + if chosen == nil { + return "", "", "", false, fmt.Errorf("location not recognized; current connection kept") + } + + connectArg := strings.TrimSpace(chosen.Connect) + if connectArg == "" { + connectArg = chosen.ISO + } + + isValidated := true + if targetReq != "" && + !equalsFoldTrim(targetReq, connectArg) && + !equalsFoldTrim(targetReq, chosen.ISO) && + !equalsFoldTrim(targetReq, chosen.Name) && + !equalsFoldTrim(targetReq, chosen.RawTarget) && + !equalsFoldTrim(targetReq, chosen.Label) { + isValidated = false + } + + return connectArg, chosen.ISO, chosen.Label, isValidated, nil +} + +func buildVPNLocationChoices(locs []vpnLocationItem) []vpnLocationChoice { + choices := make([]vpnLocationChoice, 0, len(locs)) + namesByISO := map[string][][]string{} + countByISO := map[string]int{} + + for _, it := range locs { + iso := strings.ToUpper(strings.TrimSpace(it.ISO)) + if !isISO2(iso) { + continue + } + label := strings.TrimSpace(it.Label) + rawTarget := strings.TrimSpace(it.Target) + + name := inferVPNLocationTargetFromLabel(label, iso) + if name == "" { + name = rawTarget + } + if name == "" { + name = iso + } + + namesByISO[iso] = append(namesByISO[iso], strings.Fields(name)) + countByISO[iso]++ + choices = append(choices, vpnLocationChoice{ + ISO: iso, + Label: label, + RawTarget: rawTarget, + Name: name, + }) + } + + commonByISO := map[string][]string{} + for iso, names := range namesByISO { + if len(names) < 2 { + continue + } + pfx := commonPrefixTokens(names) + if len(pfx) > 0 { + commonByISO[iso] = pfx + } + } + + for i := range choices { + iso := choices[i].ISO + if countByISO[iso] <= 1 { + choices[i].Connect = iso + continue + } + tokens := strings.Fields(choices[i].Name) + pfx := commonByISO[iso] + if len(pfx) > 0 && len(tokens) > len(pfx) { + choices[i].Connect = strings.TrimSpace(strings.Join(tokens[len(pfx):], " ")) + } else if choices[i].Name != "" { + choices[i].Connect = choices[i].Name + } + if choices[i].Connect == "" { + choices[i].Connect = iso + } + } + + return choices +} + +func equalsFoldTrim(a, b string) bool { + return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) +} diff --git a/selective-vpn-api/app/vpn_login_session.go b/selective-vpn-api/app/vpn_login_session.go index f65735c..9cb96c8 100644 --- a/selective-vpn-api/app/vpn_login_session.go +++ b/selective-vpn-api/app/vpn_login_session.go @@ -1,20 +1,11 @@ package app import ( - "bufio" - "encoding/json" - "fmt" - "io" - "net/http" "os" "os/exec" "regexp" - "strconv" - "strings" "sync" "time" - - "github.com/creack/pty" ) // --------------------------------------------------------------------- @@ -114,426 +105,3 @@ func newLoginSessionManager(max int) *loginSessionManager { reNextCheck: regexp.MustCompile(`(?i)^Next check in \d+s$`), } } - -// --------------------------------------------------------------------- -// EN: `setPhaseLocked` sets phase locked to the requested value. -// RU: `setPhaseLocked` - устанавливает phase locked в требуемое значение. -// --------------------------------------------------------------------- -func (m *loginSessionManager) setPhaseLocked(phase, level string) { - m.phase = phase - m.level = level -} - -// --------------------------------------------------------------------- -// EN: `resetLocked` contains core logic for reset locked. -// RU: `resetLocked` - содержит основную логику для reset locked. -// --------------------------------------------------------------------- -func (m *loginSessionManager) resetLocked() { - m.lines = nil - m.lastN = 0 - m.url = "" - m.email = "" - m.lastAutoCheck = time.Time{} -} - -// --------------------------------------------------------------------- -// EN: `appendLineLocked` appends or adds line locked to an existing state. -// RU: `appendLineLocked` - добавляет line locked в текущее состояние. -// --------------------------------------------------------------------- -func (m *loginSessionManager) appendLineLocked(line string) { - m.lastN++ - m.lines = append(m.lines, loginLine{N: m.lastN, Line: line}) - if len(m.lines) > m.max { - m.lines = m.lines[len(m.lines)-m.max:] - } -} - -// --------------------------------------------------------------------- -// EN: `linesSinceLocked` contains core logic for lines since locked. -// RU: `linesSinceLocked` - содержит основную логику для lines since locked. -// --------------------------------------------------------------------- -func (m *loginSessionManager) linesSinceLocked(since int64) (out []string) { - for _, it := range m.lines { - if it.N > since { - out = append(out, it.Line) - } - } - return out -} - -// --------------------------------------------------------------------- -// EN: `sendKeyLocked` sends key locked to a downstream process. -// RU: `sendKeyLocked` - отправляет key locked в нижележащий процесс. -// --------------------------------------------------------------------- -func (m *loginSessionManager) sendKeyLocked(key string) error { - if !m.alive || m.pty == nil { - return fmt.Errorf("login session not alive") - } - _, err := m.pty.Write([]byte(key + "\n")) - return err -} - -// --------------------------------------------------------------------- -// EN: `stopLocked` stops locked and cleans up resources. -// RU: `stopLocked` - останавливает locked и освобождает ресурсы. -// --------------------------------------------------------------------- -func (m *loginSessionManager) stopLocked(hard bool) { - if m.cmd == nil { - m.setPhaseLocked("idle", "yellow") - m.alive = false - m.url = "" - return - } - - // мягкий cancel - _ = m.sendKeyLocked("x") - - deadline := time.Now().Add(1200 * time.Millisecond) - for time.Now().Before(deadline) { - if m.cmd == nil || m.cmd.Process == nil { - break - } - time.Sleep(80 * time.Millisecond) - } - - if hard && m.cmd != nil && m.cmd.Process != nil { - _ = m.cmd.Process.Signal(os.Interrupt) - time.Sleep(150 * time.Millisecond) - _ = m.cmd.Process.Kill() - } - - if m.pty != nil { - _ = m.pty.Close() - m.pty = nil - } - - m.cmd = nil - m.alive = false - m.setPhaseLocked("idle", "yellow") - m.url = "" -} - -// --------------------------------------------------------------------- -// EN: `setAlreadyLoggedLocked` sets already logged locked to the requested value. -// RU: `setAlreadyLoggedLocked` - устанавливает already logged locked в требуемое значение. -// --------------------------------------------------------------------- -func (m *loginSessionManager) setAlreadyLoggedLocked(email string) { - // без запуска процесса - m.stopLocked(true) - m.resetLocked() - m.email = email - m.alive = false - m.setPhaseLocked("already_logged", "green") - if email != "" { - m.appendLineLocked("Already logged in as " + email) - } else { - m.appendLineLocked("Already logged in") - } -} - -// --------------------------------------------------------------------- -// EN: `startPTY` starts pty and initializes required state. -// RU: `startPTY` - запускает pty и инициализирует нужное состояние. -// --------------------------------------------------------------------- -func (m *loginSessionManager) startPTY() (pid int, err error) { - // caller must hold lock - m.stopLocked(true) - m.resetLocked() - m.setPhaseLocked("starting", "yellow") - - cmd := exec.Command(adgvpnCLI, "login") - ptmx, err := pty.Start(cmd) - if err != nil { - m.setPhaseLocked("failed", "red") - return 0, err - } - - m.cmd = cmd - m.pty = ptmx - m.alive = true - - pid = 0 - if cmd.Process != nil { - pid = cmd.Process.Pid - } - - go m.readerLoop(cmd, ptmx) - - return pid, nil -} - -// --------------------------------------------------------------------- -// EN: `readerLoop` reads er loop from input data. -// RU: `readerLoop` - читает er loop из входных данных. -// --------------------------------------------------------------------- -func (m *loginSessionManager) readerLoop(cmd *exec.Cmd, ptmx *os.File) { - sc := bufio.NewScanner(ptmx) - buf := make([]byte, 0, 64*1024) - sc.Buffer(buf, 1024*1024) - - for sc.Scan() { - line := strings.TrimRight(sc.Text(), "\r\n") - line = strings.TrimSpace(line) - if line == "" { - continue - } - - m.mu.Lock() - low := strings.ToLower(line) - - // URL - if m.url == "" { - if mm := m.reURL.FindStringSubmatch(line); len(mm) > 1 { - m.url = mm[1] - m.setPhaseLocked("waiting_browser", "yellow") - } - } - - // already logged / current user - if strings.Contains(low, "already logged in") || strings.Contains(low, "current user is") { - if em := m.reEmail.FindStringSubmatch(line); len(em) > 0 { - m.email = em[0] - } - m.setPhaseLocked("already_logged", "green") - } - - // success / fail - if strings.Contains(low, "successfully logged in") { - m.setPhaseLocked("success", "green") - if em := m.reEmail.FindStringSubmatch(line); len(em) > 0 { - m.email = em[0] - } - } - if strings.Contains(low, "failed to log in") { - m.setPhaseLocked("failed", "red") - } - - // auto-check trigger - if m.reNextCheck.MatchString(line) { - m.setPhaseLocked("checking", "yellow") - now := time.Now() - if m.lastAutoCheck.IsZero() || now.Sub(m.lastAutoCheck) > 1200*time.Millisecond { - _ = m.sendKeyLocked("s") - m.lastAutoCheck = now - } - m.appendLineLocked(line) - m.mu.Unlock() - continue - } - - m.appendLineLocked(line) - m.mu.Unlock() - } - - _ = ptmx.Close() - err := cmd.Wait() - - m.mu.Lock() - defer m.mu.Unlock() - - m.alive = false - - switch m.phase { - case "success", "failed", "cancelled", "already_logged": - // keep - default: - if err != nil { - m.setPhaseLocked("failed", "red") - } else { - m.setPhaseLocked("exited", "yellow") - } - } - - m.cmd = nil - m.pty = nil -} - -// --------------------------------------------------------------------- -// login state helper -// --------------------------------------------------------------------- - -func loginStateAlreadyLogged() (bool, string) { - data, err := os.ReadFile(loginStatePath) - if err != nil { - return false, "" - } - var st VPNLoginState - if err := json.Unmarshal(data, &st); err != nil { - return false, "" - } - if strings.TrimSpace(st.State) == "ok" { - return true, strings.TrimSpace(st.Email) - } - return false, "" -} - -// --------------------------------------------------------------------- -// login session API -// --------------------------------------------------------------------- - -func handleVPNLoginSessionStart(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // если уже залогинен (по adguard-login.json) — сразу возвращаем green - if ok, email := loginStateAlreadyLogged(); ok { - appendTraceLine("login", fmt.Sprintf("session/start: already_logged email=%s", email)) - loginMgr.mu.Lock() - loginMgr.setAlreadyLoggedLocked(email) - loginMgr.mu.Unlock() - writeJSON(w, http.StatusOK, LoginSessionStartResp{ - OK: true, - Phase: "already_logged", - Level: "green", - Email: email, - }) - return - } - - loginMgr.mu.Lock() - pid, err := loginMgr.startPTY() - phase := loginMgr.phase - level := loginMgr.level - loginMgr.mu.Unlock() - if err == nil { - appendTraceLine("login", fmt.Sprintf("session/start: pid=%d", pid)) - } else { - appendTraceLine("login", fmt.Sprintf("session/start: failed: %v", err)) - } - - if err != nil { - writeJSON(w, http.StatusOK, LoginSessionStartResp{ - OK: false, - Phase: "failed", - Level: "red", - Error: err.Error(), - }) - return - } - - writeJSON(w, http.StatusOK, LoginSessionStartResp{ - OK: true, - Phase: phase, - Level: level, - PID: pid, - }) -} - -// GET /api/v1/vpn/login/session/state -// --------------------------------------------------------------------- -// EN: `handleVPNLoginSessionState` is an HTTP handler for vpn login session state. -// RU: `handleVPNLoginSessionState` - HTTP-обработчик для vpn login session state. -// --------------------------------------------------------------------- -func handleVPNLoginSessionState(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - sinceStr := strings.TrimSpace(r.URL.Query().Get("since")) - var since int64 - if sinceStr != "" { - if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v >= 0 { - since = v - } - } - - loginMgr.mu.Lock() - lines := loginMgr.linesSinceLocked(since) - phase := loginMgr.phase - level := loginMgr.level - alive := loginMgr.alive - url := loginMgr.url - email := loginMgr.email - cursor := loginMgr.lastN - loginMgr.mu.Unlock() - - can := alive && phase != "success" && phase != "already_logged" && phase != "failed" && phase != "cancelled" - writeJSON(w, http.StatusOK, LoginSessionStateResp{ - OK: true, - Phase: phase, - Level: level, - Alive: alive, - URL: url, - Email: email, - Cursor: cursor, - Lines: lines, - CanOpen: can, - CanCheck: can, - CanCancel: can, - }) -} - -// POST /api/v1/vpn/login/session/action -// --------------------------------------------------------------------- -// EN: `handleVPNLoginSessionAction` is an HTTP handler for vpn login session action. -// RU: `handleVPNLoginSessionAction` - HTTP-обработчик для vpn login session action. -// --------------------------------------------------------------------- -func handleVPNLoginSessionAction(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - var body LoginSessionActionReq - 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 == "" { - http.Error(w, "action required", http.StatusBadRequest) - return - } - - loginMgr.mu.Lock() - defer loginMgr.mu.Unlock() - - if !loginMgr.alive { - writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "login session not alive"}) - return - } - - switch action { - case "open": - appendTraceLine("login", "session/action: open") - _ = loginMgr.sendKeyLocked("b") - loginMgr.setPhaseLocked("waiting_browser", "yellow") - case "check": - appendTraceLine("login", "session/action: check") - _ = loginMgr.sendKeyLocked("s") - loginMgr.setPhaseLocked("checking", "yellow") - case "cancel": - appendTraceLine("login", "session/action: cancel") - _ = loginMgr.sendKeyLocked("x") - loginMgr.setPhaseLocked("cancelled", "red") - default: - http.Error(w, "unknown action (open|check|cancel)", http.StatusBadRequest) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "phase": loginMgr.phase, - "level": loginMgr.level, - }) -} - -// POST /api/v1/vpn/login/session/stop -// --------------------------------------------------------------------- -// EN: `handleVPNLoginSessionStop` is an HTTP handler for vpn login session stop. -// RU: `handleVPNLoginSessionStop` - HTTP-обработчик для vpn login session stop. -// --------------------------------------------------------------------- -func handleVPNLoginSessionStop(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - loginMgr.mu.Lock() - appendTraceLine("login", "session/stop") - loginMgr.stopLocked(true) - loginMgr.mu.Unlock() - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} diff --git a/selective-vpn-api/app/vpn_login_session_handlers.go b/selective-vpn-api/app/vpn_login_session_handlers.go new file mode 100644 index 0000000..2a7c1eb --- /dev/null +++ b/selective-vpn-api/app/vpn_login_session_handlers.go @@ -0,0 +1,6 @@ +package app + +// VPN login session handlers are split by role: +// - login state helper: vpn_login_session_handlers_state_helper.go +// - session start/state endpoints: vpn_login_session_handlers_{start,state}.go +// - action/stop endpoints: vpn_login_session_handlers_{action,stop}.go diff --git a/selective-vpn-api/app/vpn_login_session_handlers_action.go b/selective-vpn-api/app/vpn_login_session_handlers_action.go new file mode 100644 index 0000000..3009503 --- /dev/null +++ b/selective-vpn-api/app/vpn_login_session_handlers_action.go @@ -0,0 +1,63 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" +) + +// POST /api/v1/vpn/login/session/action +// --------------------------------------------------------------------- +// EN: `handleVPNLoginSessionAction` is an HTTP handler for vpn login session action. +// RU: `handleVPNLoginSessionAction` - HTTP-обработчик для vpn login session action. +// --------------------------------------------------------------------- +func handleVPNLoginSessionAction(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var body LoginSessionActionReq + 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 == "" { + http.Error(w, "action required", http.StatusBadRequest) + return + } + + loginMgr.mu.Lock() + defer loginMgr.mu.Unlock() + + if !loginMgr.alive { + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "login session not alive"}) + return + } + + switch action { + case "open": + appendTraceLine("login", "session/action: open") + _ = loginMgr.sendKeyLocked("b") + loginMgr.setPhaseLocked("waiting_browser", "yellow") + case "check": + appendTraceLine("login", "session/action: check") + _ = loginMgr.sendKeyLocked("s") + loginMgr.setPhaseLocked("checking", "yellow") + case "cancel": + appendTraceLine("login", "session/action: cancel") + _ = loginMgr.sendKeyLocked("x") + loginMgr.setPhaseLocked("cancelled", "red") + default: + http.Error(w, "unknown action (open|check|cancel)", http.StatusBadRequest) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "phase": loginMgr.phase, + "level": loginMgr.level, + }) +} diff --git a/selective-vpn-api/app/vpn_login_session_handlers_start.go b/selective-vpn-api/app/vpn_login_session_handlers_start.go new file mode 100644 index 0000000..c3e0f8f --- /dev/null +++ b/selective-vpn-api/app/vpn_login_session_handlers_start.go @@ -0,0 +1,55 @@ +package app + +import ( + "fmt" + "net/http" +) + +func handleVPNLoginSessionStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if ok, email := loginStateAlreadyLogged(); ok { + appendTraceLine("login", fmt.Sprintf("session/start: already_logged email=%s", email)) + loginMgr.mu.Lock() + loginMgr.setAlreadyLoggedLocked(email) + loginMgr.mu.Unlock() + writeJSON(w, http.StatusOK, LoginSessionStartResp{ + OK: true, + Phase: "already_logged", + Level: "green", + Email: email, + }) + return + } + + loginMgr.mu.Lock() + pid, err := loginMgr.startPTY() + phase := loginMgr.phase + level := loginMgr.level + loginMgr.mu.Unlock() + if err == nil { + appendTraceLine("login", fmt.Sprintf("session/start: pid=%d", pid)) + } else { + appendTraceLine("login", fmt.Sprintf("session/start: failed: %v", err)) + } + + if err != nil { + writeJSON(w, http.StatusOK, LoginSessionStartResp{ + OK: false, + Phase: "failed", + Level: "red", + Error: err.Error(), + }) + return + } + + writeJSON(w, http.StatusOK, LoginSessionStartResp{ + OK: true, + Phase: phase, + Level: level, + PID: pid, + }) +} diff --git a/selective-vpn-api/app/vpn_login_session_handlers_state.go b/selective-vpn-api/app/vpn_login_session_handlers_state.go new file mode 100644 index 0000000..dc3327e --- /dev/null +++ b/selective-vpn-api/app/vpn_login_session_handlers_state.go @@ -0,0 +1,52 @@ +package app + +import ( + "net/http" + "strconv" + "strings" +) + +// GET /api/v1/vpn/login/session/state +// --------------------------------------------------------------------- +// EN: `handleVPNLoginSessionState` is an HTTP handler for vpn login session state. +// RU: `handleVPNLoginSessionState` - HTTP-обработчик для vpn login session state. +// --------------------------------------------------------------------- +func handleVPNLoginSessionState(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + sinceStr := strings.TrimSpace(r.URL.Query().Get("since")) + var since int64 + if sinceStr != "" { + if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v >= 0 { + since = v + } + } + + loginMgr.mu.Lock() + lines := loginMgr.linesSinceLocked(since) + phase := loginMgr.phase + level := loginMgr.level + alive := loginMgr.alive + url := loginMgr.url + email := loginMgr.email + cursor := loginMgr.lastN + loginMgr.mu.Unlock() + + can := alive && phase != "success" && phase != "already_logged" && phase != "failed" && phase != "cancelled" + writeJSON(w, http.StatusOK, LoginSessionStateResp{ + OK: true, + Phase: phase, + Level: level, + Alive: alive, + URL: url, + Email: email, + Cursor: cursor, + Lines: lines, + CanOpen: can, + CanCheck: can, + CanCancel: can, + }) +} diff --git a/selective-vpn-api/app/vpn_login_session_handlers_state_helper.go b/selective-vpn-api/app/vpn_login_session_handlers_state_helper.go new file mode 100644 index 0000000..3eb0d4b --- /dev/null +++ b/selective-vpn-api/app/vpn_login_session_handlers_state_helper.go @@ -0,0 +1,26 @@ +package app + +import ( + "encoding/json" + "os" + "strings" +) + +// --------------------------------------------------------------------- +// login state helper +// --------------------------------------------------------------------- + +func loginStateAlreadyLogged() (bool, string) { + data, err := os.ReadFile(loginStatePath) + if err != nil { + return false, "" + } + var st VPNLoginState + if err := json.Unmarshal(data, &st); err != nil { + return false, "" + } + if strings.TrimSpace(st.State) == "ok" { + return true, strings.TrimSpace(st.Email) + } + return false, "" +} diff --git a/selective-vpn-api/app/vpn_login_session_handlers_stop.go b/selective-vpn-api/app/vpn_login_session_handlers_stop.go new file mode 100644 index 0000000..73ef146 --- /dev/null +++ b/selective-vpn-api/app/vpn_login_session_handlers_stop.go @@ -0,0 +1,20 @@ +package app + +import "net/http" + +// POST /api/v1/vpn/login/session/stop +// --------------------------------------------------------------------- +// EN: `handleVPNLoginSessionStop` is an HTTP handler for vpn login session stop. +// RU: `handleVPNLoginSessionStop` - HTTP-обработчик для vpn login session stop. +// --------------------------------------------------------------------- +func handleVPNLoginSessionStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + loginMgr.mu.Lock() + appendTraceLine("login", "session/stop") + loginMgr.stopLocked(true) + loginMgr.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/selective-vpn-api/app/vpn_login_session_pty.go b/selective-vpn-api/app/vpn_login_session_pty.go new file mode 100644 index 0000000..1965d0d --- /dev/null +++ b/selective-vpn-api/app/vpn_login_session_pty.go @@ -0,0 +1,128 @@ +package app + +import ( + "bufio" + "os" + "os/exec" + "strings" + "time" + + "github.com/creack/pty" +) + +// --------------------------------------------------------------------- +// EN: `startPTY` starts pty and initializes required state. +// RU: `startPTY` - запускает pty и инициализирует нужное состояние. +// --------------------------------------------------------------------- +func (m *loginSessionManager) startPTY() (pid int, err error) { + // caller must hold lock + m.stopLocked(true) + m.resetLocked() + m.setPhaseLocked("starting", "yellow") + + cmd := exec.Command(adgvpnCLI, "login") + ptmx, err := pty.Start(cmd) + if err != nil { + m.setPhaseLocked("failed", "red") + return 0, err + } + + m.cmd = cmd + m.pty = ptmx + m.alive = true + + pid = 0 + if cmd.Process != nil { + pid = cmd.Process.Pid + } + + go m.readerLoop(cmd, ptmx) + + return pid, nil +} + +// --------------------------------------------------------------------- +// EN: `readerLoop` reads er loop from input data. +// RU: `readerLoop` - читает er loop из входных данных. +// --------------------------------------------------------------------- +func (m *loginSessionManager) readerLoop(cmd *exec.Cmd, ptmx *os.File) { + sc := bufio.NewScanner(ptmx) + buf := make([]byte, 0, 64*1024) + sc.Buffer(buf, 1024*1024) + + for sc.Scan() { + line := strings.TrimRight(sc.Text(), "\r\n") + line = strings.TrimSpace(line) + if line == "" { + continue + } + + m.mu.Lock() + low := strings.ToLower(line) + + // URL + if m.url == "" { + if mm := m.reURL.FindStringSubmatch(line); len(mm) > 1 { + m.url = mm[1] + m.setPhaseLocked("waiting_browser", "yellow") + } + } + + // already logged / current user + if strings.Contains(low, "already logged in") || strings.Contains(low, "current user is") { + if em := m.reEmail.FindStringSubmatch(line); len(em) > 0 { + m.email = em[0] + } + m.setPhaseLocked("already_logged", "green") + } + + // success / fail + if strings.Contains(low, "successfully logged in") { + m.setPhaseLocked("success", "green") + if em := m.reEmail.FindStringSubmatch(line); len(em) > 0 { + m.email = em[0] + } + } + if strings.Contains(low, "failed to log in") { + m.setPhaseLocked("failed", "red") + } + + // auto-check trigger + if m.reNextCheck.MatchString(line) { + m.setPhaseLocked("checking", "yellow") + now := time.Now() + if m.lastAutoCheck.IsZero() || now.Sub(m.lastAutoCheck) > 1200*time.Millisecond { + _ = m.sendKeyLocked("s") + m.lastAutoCheck = now + } + m.appendLineLocked(line) + m.mu.Unlock() + continue + } + + m.appendLineLocked(line) + m.mu.Unlock() + } + + _ = ptmx.Close() + err := cmd.Wait() + + m.mu.Lock() + defer m.mu.Unlock() + + m.alive = false + + switch m.phase { + case "success", "failed", "cancelled", "already_logged": + // keep + default: + if err != nil { + m.setPhaseLocked("failed", "red") + } else { + m.setPhaseLocked("exited", "yellow") + } + } + + m.cmd = nil + m.pty = nil +} diff --git a/selective-vpn-api/app/vpn_login_session_state.go b/selective-vpn-api/app/vpn_login_session_state.go new file mode 100644 index 0000000..ca5d297 --- /dev/null +++ b/selective-vpn-api/app/vpn_login_session_state.go @@ -0,0 +1,123 @@ +package app + +import ( + "fmt" + "os" + "time" +) + +// --------------------------------------------------------------------- +// EN: `setPhaseLocked` sets phase locked to the requested value. +// RU: `setPhaseLocked` - устанавливает phase locked в требуемое значение. +// --------------------------------------------------------------------- +func (m *loginSessionManager) setPhaseLocked(phase, level string) { + m.phase = phase + m.level = level +} + +// --------------------------------------------------------------------- +// EN: `resetLocked` contains core logic for reset locked. +// RU: `resetLocked` - содержит основную логику для reset locked. +// --------------------------------------------------------------------- +func (m *loginSessionManager) resetLocked() { + m.lines = nil + m.lastN = 0 + m.url = "" + m.email = "" + m.lastAutoCheck = time.Time{} +} + +// --------------------------------------------------------------------- +// EN: `appendLineLocked` appends or adds line locked to an existing state. +// RU: `appendLineLocked` - добавляет line locked в текущее состояние. +// --------------------------------------------------------------------- +func (m *loginSessionManager) appendLineLocked(line string) { + m.lastN++ + m.lines = append(m.lines, loginLine{N: m.lastN, Line: line}) + if len(m.lines) > m.max { + m.lines = m.lines[len(m.lines)-m.max:] + } +} + +// --------------------------------------------------------------------- +// EN: `linesSinceLocked` contains core logic for lines since locked. +// RU: `linesSinceLocked` - содержит основную логику для lines since locked. +// --------------------------------------------------------------------- +func (m *loginSessionManager) linesSinceLocked(since int64) (out []string) { + for _, it := range m.lines { + if it.N > since { + out = append(out, it.Line) + } + } + return out +} + +// --------------------------------------------------------------------- +// EN: `sendKeyLocked` sends key locked to a downstream process. +// RU: `sendKeyLocked` - отправляет key locked в нижележащий процесс. +// --------------------------------------------------------------------- +func (m *loginSessionManager) sendKeyLocked(key string) error { + if !m.alive || m.pty == nil { + return fmt.Errorf("login session not alive") + } + _, err := m.pty.Write([]byte(key + "\n")) + return err +} + +// --------------------------------------------------------------------- +// EN: `stopLocked` stops locked and cleans up resources. +// RU: `stopLocked` - останавливает locked и освобождает ресурсы. +// --------------------------------------------------------------------- +func (m *loginSessionManager) stopLocked(hard bool) { + if m.cmd == nil { + m.setPhaseLocked("idle", "yellow") + m.alive = false + m.url = "" + return + } + + // мягкий cancel + _ = m.sendKeyLocked("x") + + deadline := time.Now().Add(1200 * time.Millisecond) + for time.Now().Before(deadline) { + if m.cmd == nil || m.cmd.Process == nil { + break + } + time.Sleep(80 * time.Millisecond) + } + + if hard && m.cmd != nil && m.cmd.Process != nil { + _ = m.cmd.Process.Signal(os.Interrupt) + time.Sleep(150 * time.Millisecond) + _ = m.cmd.Process.Kill() + } + + if m.pty != nil { + _ = m.pty.Close() + m.pty = nil + } + + m.cmd = nil + m.alive = false + m.setPhaseLocked("idle", "yellow") + m.url = "" +} + +// --------------------------------------------------------------------- +// EN: `setAlreadyLoggedLocked` sets already logged locked to the requested value. +// RU: `setAlreadyLoggedLocked` - устанавливает already logged locked в требуемое значение. +// --------------------------------------------------------------------- +func (m *loginSessionManager) setAlreadyLoggedLocked(email string) { + // без запуска процесса + m.stopLocked(true) + m.resetLocked() + m.email = email + m.alive = false + m.setPhaseLocked("already_logged", "green") + if email != "" { + m.appendLineLocked("Already logged in as " + email) + } else { + m.appendLineLocked("Already logged in") + } +} diff --git a/selective-vpn-api/app/watchers.go b/selective-vpn-api/app/watchers.go index ef40a18..87baf30 100644 --- a/selective-vpn-api/app/watchers.go +++ b/selective-vpn-api/app/watchers.go @@ -1,14 +1,5 @@ package app -import ( - "context" - "crypto/sha256" - "encoding/json" - "os" - "strings" - "time" -) - // --------------------------------------------------------------------- // фоновые вотчеры / события // --------------------------------------------------------------------- @@ -17,226 +8,3 @@ import ( // EN: publish normalized events into the in-memory event bus for SSE clients. // RU: Фоновые poll-вотчеры, отслеживающие изменения файлов/сервисов и // RU: публикующие нормализованные события в in-memory event bus для SSE-клиентов. - -func startWatchers(ctx context.Context) { - statusEvery := time.Duration(envInt("SVPN_POLL_STATUS_MS", defaultPollStatusMs)) * time.Millisecond - loginEvery := time.Duration(envInt("SVPN_POLL_LOGIN_MS", defaultPollLoginMs)) * time.Millisecond - autoEvery := time.Duration(envInt("SVPN_POLL_AUTOLOOP_MS", defaultPollAutoloopMs)) * time.Millisecond - systemdEvery := time.Duration(envInt("SVPN_POLL_SYSTEMD_MS", defaultPollSystemdMs)) * time.Millisecond - traceEvery := time.Duration(envInt("SVPN_POLL_TRACE_MS", defaultPollTraceMs)) * time.Millisecond - appMarksEvery := time.Duration(envInt("SVPN_POLL_APPMARKS_MS", defaultPollAppMarksMs)) * time.Millisecond - - go watchStatusFile(ctx, statusEvery) - go watchLoginFile(ctx, loginEvery) - go watchAutoloop(ctx, autoEvery) - go watchFileChange(ctx, traceLogPath, "trace_changed", "full", traceEvery) - go watchFileChange(ctx, smartdnsLogPath, "trace_changed", "smartdns", traceEvery) - go watchTrafficAppMarksTTL(ctx, appMarksEvery) - - go watchSystemdUnitDynamic(ctx, routesServiceUnitName, "routes_service", systemdEvery) - go watchSystemdUnitDynamic(ctx, routesTimerUnitName, "routes_timer", systemdEvery) - go watchSystemdUnit(ctx, adgvpnUnit, "vpn_unit", systemdEvery) - go watchSystemdUnit(ctx, "smartdns-local.service", "smartdns_unit", systemdEvery) -} - -func watchTrafficAppMarksTTL(ctx context.Context, every time.Duration) { - for { - select { - case <-ctx.Done(): - return - case <-time.After(every): - } - _ = pruneExpiredAppMarks() - } -} - -// --------------------------------------------------------------------- -// status file watcher -// --------------------------------------------------------------------- - -func watchStatusFile(ctx context.Context, every time.Duration) { - var last [32]byte - have := false - for { - select { - case <-ctx.Done(): - return - case <-time.After(every): - } - - data, err := os.ReadFile(statusFilePath) - if err != nil { - continue - } - h := sha256.Sum256(data) - if have && h == last { - continue - } - last = h - have = true - - var st Status - if err := json.Unmarshal(data, &st); err != nil { - events.push("status_error", map[string]any{"error": err.Error()}) - continue - } - events.push("status_changed", st) - } -} - -// --------------------------------------------------------------------- -// login file watcher -// --------------------------------------------------------------------- - -func watchLoginFile(ctx context.Context, every time.Duration) { - var last [32]byte - have := false - for { - select { - case <-ctx.Done(): - return - case <-time.After(every): - } - - data, err := os.ReadFile(loginStatePath) - if err != nil { - continue - } - h := sha256.Sum256(data) - if have && h == last { - continue - } - last = h - have = true - - var st VPNLoginState - if err := json.Unmarshal(data, &st); err != nil { - events.push("login_state_error", map[string]any{"error": err.Error()}) - continue - } - events.push("login_state_changed", st) - } -} - -// --------------------------------------------------------------------- -// autoloop watcher -// --------------------------------------------------------------------- - -func watchAutoloop(ctx context.Context, every time.Duration) { - lastWord := "" - lastRaw := "" - for { - select { - case <-ctx.Done(): - return - case <-time.After(every): - } - - lines := tailFile(autoloopLogPath, 200) - word, raw := parseAutoloopStatus(lines) - if word == "" && raw == "" { - continue - } - if word == lastWord && raw == lastRaw { - continue - } - lastWord, lastRaw = word, raw - events.push("autoloop_status_changed", map[string]string{ - "status_word": word, - "raw_text": raw, - }) - } -} - -// --------------------------------------------------------------------- -// systemd unit watcher -// --------------------------------------------------------------------- - -func watchSystemdUnit(ctx context.Context, unit string, kind string, every time.Duration) { - last := "" - for { - select { - case <-ctx.Done(): - return - case <-time.After(every): - } - - stdout, _, _, err := runCommand("systemctl", "is-active", unit) - state := strings.TrimSpace(stdout) - if err != nil || state == "" { - state = "unknown" - } - if state == last { - continue - } - last = state - events.push("unit_state_changed", map[string]string{ - "unit": unit, - "kind": kind, - "state": state, - }) - } -} - -func watchSystemdUnitDynamic(ctx context.Context, resolveUnit func() string, kind string, every time.Duration) { - lastUnit := "" - lastState := "" - for { - select { - case <-ctx.Done(): - return - case <-time.After(every): - } - - unit := strings.TrimSpace(resolveUnit()) - state := "unknown" - if unit != "" { - stdout, _, _, err := runCommand("systemctl", "is-active", unit) - s := strings.TrimSpace(stdout) - if err == nil && s != "" { - state = s - } - } - if unit == lastUnit && state == lastState { - continue - } - lastUnit, lastState = unit, state - events.push("unit_state_changed", map[string]string{ - "unit": unit, - "kind": kind, - "state": state, - }) - } -} - -// --------------------------------------------------------------------- -// generic file watcher -// --------------------------------------------------------------------- - -func watchFileChange(ctx context.Context, path string, kind string, mode string, every time.Duration) { - var lastMod time.Time - var lastSize int64 = -1 - for { - select { - case <-ctx.Done(): - return - case <-time.After(every): - } - - info, err := os.Stat(path) - if err != nil { - continue - } - if info.ModTime() == lastMod && info.Size() == lastSize { - continue - } - lastMod = info.ModTime() - lastSize = info.Size() - events.push(kind, map[string]any{ - "path": path, - "mode": mode, - "size": info.Size(), - "mtime": info.ModTime().UTC().Format(time.RFC3339Nano), - }) - } -} diff --git a/selective-vpn-api/app/watchers_runtime.go b/selective-vpn-api/app/watchers_runtime.go new file mode 100644 index 0000000..61876ec --- /dev/null +++ b/selective-vpn-api/app/watchers_runtime.go @@ -0,0 +1,98 @@ +package app + +import ( + "context" + "strings" + "time" +) + +// --------------------------------------------------------------------- +// autoloop watcher +// --------------------------------------------------------------------- + +func watchAutoloop(ctx context.Context, every time.Duration) { + lastWord := "" + lastRaw := "" + for { + select { + case <-ctx.Done(): + return + case <-time.After(every): + } + + lines := tailFile(autoloopLogPath, 200) + word, raw := parseAutoloopStatus(lines) + if word == "" && raw == "" { + continue + } + if word == lastWord && raw == lastRaw { + continue + } + lastWord, lastRaw = word, raw + events.push("autoloop_status_changed", map[string]string{ + "status_word": word, + "raw_text": raw, + }) + } +} + +// --------------------------------------------------------------------- +// systemd unit watcher +// --------------------------------------------------------------------- + +func watchSystemdUnit(ctx context.Context, unit string, kind string, every time.Duration) { + last := "" + for { + select { + case <-ctx.Done(): + return + case <-time.After(every): + } + + stdout, _, _, err := runCommand("systemctl", "is-active", unit) + state := strings.TrimSpace(stdout) + if err != nil || state == "" { + state = "unknown" + } + if state == last { + continue + } + last = state + events.push("unit_state_changed", map[string]string{ + "unit": unit, + "kind": kind, + "state": state, + }) + } +} + +func watchSystemdUnitDynamic(ctx context.Context, resolveUnit func() string, kind string, every time.Duration) { + lastUnit := "" + lastState := "" + for { + select { + case <-ctx.Done(): + return + case <-time.After(every): + } + + unit := strings.TrimSpace(resolveUnit()) + state := "unknown" + if unit != "" { + stdout, _, _, err := runCommand("systemctl", "is-active", unit) + s := strings.TrimSpace(stdout) + if err == nil && s != "" { + state = s + } + } + if unit == lastUnit && state == lastState { + continue + } + lastUnit, lastState = unit, state + events.push("unit_state_changed", map[string]string{ + "unit": unit, + "kind": kind, + "state": state, + }) + } +} diff --git a/selective-vpn-api/app/watchers_start.go b/selective-vpn-api/app/watchers_start.go new file mode 100644 index 0000000..4eff956 --- /dev/null +++ b/selective-vpn-api/app/watchers_start.go @@ -0,0 +1,38 @@ +package app + +import ( + "context" + "time" +) + +func startWatchers(ctx context.Context) { + statusEvery := time.Duration(envInt("SVPN_POLL_STATUS_MS", defaultPollStatusMs)) * time.Millisecond + loginEvery := time.Duration(envInt("SVPN_POLL_LOGIN_MS", defaultPollLoginMs)) * time.Millisecond + autoEvery := time.Duration(envInt("SVPN_POLL_AUTOLOOP_MS", defaultPollAutoloopMs)) * time.Millisecond + systemdEvery := time.Duration(envInt("SVPN_POLL_SYSTEMD_MS", defaultPollSystemdMs)) * time.Millisecond + traceEvery := time.Duration(envInt("SVPN_POLL_TRACE_MS", defaultPollTraceMs)) * time.Millisecond + appMarksEvery := time.Duration(envInt("SVPN_POLL_APPMARKS_MS", defaultPollAppMarksMs)) * time.Millisecond + + go watchStatusFile(ctx, statusEvery) + go watchLoginFile(ctx, loginEvery) + go watchAutoloop(ctx, autoEvery) + go watchFileChange(ctx, traceLogPath, "trace_changed", "full", traceEvery) + go watchFileChange(ctx, smartdnsLogPath, "trace_changed", "smartdns", traceEvery) + go watchTrafficAppMarksTTL(ctx, appMarksEvery) + + go watchSystemdUnitDynamic(ctx, routesServiceUnitName, "routes_service", systemdEvery) + go watchSystemdUnitDynamic(ctx, routesTimerUnitName, "routes_timer", systemdEvery) + go watchSystemdUnit(ctx, adgvpnUnit, "vpn_unit", systemdEvery) + go watchSystemdUnit(ctx, "smartdns-local.service", "smartdns_unit", systemdEvery) +} + +func watchTrafficAppMarksTTL(ctx context.Context, every time.Duration) { + for { + select { + case <-ctx.Done(): + return + case <-time.After(every): + } + _ = pruneExpiredAppMarks() + } +} diff --git a/selective-vpn-api/app/watchers_state_files.go b/selective-vpn-api/app/watchers_state_files.go new file mode 100644 index 0000000..92f33cc --- /dev/null +++ b/selective-vpn-api/app/watchers_state_files.go @@ -0,0 +1,109 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/json" + "os" + "time" +) + +// --------------------------------------------------------------------- +// status file watcher +// --------------------------------------------------------------------- + +func watchStatusFile(ctx context.Context, every time.Duration) { + var last [32]byte + have := false + for { + select { + case <-ctx.Done(): + return + case <-time.After(every): + } + + data, err := os.ReadFile(statusFilePath) + if err != nil { + continue + } + h := sha256.Sum256(data) + if have && h == last { + continue + } + last = h + have = true + + var st Status + if err := json.Unmarshal(data, &st); err != nil { + events.push("status_error", map[string]any{"error": err.Error()}) + continue + } + events.push("status_changed", st) + } +} + +// --------------------------------------------------------------------- +// login file watcher +// --------------------------------------------------------------------- + +func watchLoginFile(ctx context.Context, every time.Duration) { + var last [32]byte + have := false + for { + select { + case <-ctx.Done(): + return + case <-time.After(every): + } + + data, err := os.ReadFile(loginStatePath) + if err != nil { + continue + } + h := sha256.Sum256(data) + if have && h == last { + continue + } + last = h + have = true + + var st VPNLoginState + if err := json.Unmarshal(data, &st); err != nil { + events.push("login_state_error", map[string]any{"error": err.Error()}) + continue + } + events.push("login_state_changed", st) + } +} + +// --------------------------------------------------------------------- +// generic file watcher +// --------------------------------------------------------------------- + +func watchFileChange(ctx context.Context, path string, kind string, mode string, every time.Duration) { + var lastMod time.Time + var lastSize int64 = -1 + for { + select { + case <-ctx.Done(): + return + case <-time.After(every): + } + + info, err := os.Stat(path) + if err != nil { + continue + } + if info.ModTime() == lastMod && info.Size() == lastSize { + continue + } + lastMod = info.ModTime() + lastSize = info.Size() + events.push(kind, map[string]any{ + "path": path, + "mode": mode, + "size": info.Size(), + "mtime": info.ModTime().UTC().Format(time.RFC3339Nano), + }) + } +} diff --git a/selective-vpn-api/cmd/selective-vpn-api/main.go b/selective-vpn-api/cmd/selective-vpn-api/main.go new file mode 100644 index 0000000..7611384 --- /dev/null +++ b/selective-vpn-api/cmd/selective-vpn-api/main.go @@ -0,0 +1,9 @@ +package main + +import app "selective-vpn-api/app" + +func main() { + // Keep API mode as default, but preserve legacy CLI modes + // (autoloop/routes-*) to avoid breaking existing systemd units. + app.Run() +} diff --git a/selective-vpn-api/cmd/selective-vpn-autoloop/main.go b/selective-vpn-api/cmd/selective-vpn-autoloop/main.go new file mode 100644 index 0000000..eb3e70e --- /dev/null +++ b/selective-vpn-api/cmd/selective-vpn-autoloop/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + app "selective-vpn-api/app" +) + +func main() { + os.Exit(app.RunAutoloopCLI(os.Args[1:])) +} diff --git a/selective-vpn-api/cmd/selective-vpn-routes-clear/main.go b/selective-vpn-api/cmd/selective-vpn-routes-clear/main.go new file mode 100644 index 0000000..9388b71 --- /dev/null +++ b/selective-vpn-api/cmd/selective-vpn-routes-clear/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + app "selective-vpn-api/app" +) + +func main() { + os.Exit(app.RunRoutesClearCLI(os.Args[1:])) +} diff --git a/selective-vpn-api/cmd/selective-vpn-routes-update/main.go b/selective-vpn-api/cmd/selective-vpn-routes-update/main.go new file mode 100644 index 0000000..9da8492 --- /dev/null +++ b/selective-vpn-api/cmd/selective-vpn-routes-update/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + app "selective-vpn-api/app" +) + +func main() { + os.Exit(app.RunRoutesUpdateCLI(os.Args[1:])) +} diff --git a/selective-vpn-api/main.go b/selective-vpn-api/main.go index ec226cd..1c6ebf6 100644 --- a/selective-vpn-api/main.go +++ b/selective-vpn-api/main.go @@ -2,8 +2,12 @@ package main import app "selective-vpn-api/app" -// EN: Thin executable entrypoint that delegates runtime startup to the app package. -// RU: Тонкая точка входа бинаря, делегирующая запуск пакету app. +// EN: Legacy compatibility entrypoint. +// EN: New explicit entrypoints live under cmd/*, this file is kept to avoid +// EN: breaking existing unit/scripts that still call the root binary. +// RU: Legacy-совместимый entrypoint. +// RU: Новые явные точки входа находятся в cmd/*, этот файл сохранён, чтобы +// RU: не ломать существующие unit/скрипты, которые запускают корневой бинарь. func main() { app.Run() } diff --git a/selective-vpn-gui/api/__init__.py b/selective-vpn-gui/api/__init__.py new file mode 100644 index 0000000..8642563 --- /dev/null +++ b/selective-vpn-gui/api/__init__.py @@ -0,0 +1,4 @@ +from .client import ApiClient +from .errors import ApiError +from .models import * +from .utils import strip_ansi diff --git a/selective-vpn-gui/api/client.py b/selective-vpn-gui/api/client.py new file mode 100644 index 0000000..58446b2 --- /dev/null +++ b/selective-vpn-gui/api/client.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Selective-VPN API client (UI-agnostic). + +Design goals: +- The dashboard (GUI) must NOT know any URLs, HTTP methods, JSON keys, or payload shapes. +- All REST details live here. +- Returned values are normalized into dataclasses for clean UI usage. + +Env: +- SELECTIVE_VPN_API (default: http://127.0.0.1:8080) + +This file is meant to be imported by a controller (dashboard_controller.py) and UI. +""" + +from __future__ import annotations + +import json +import os +import time +from typing import Any, Callable, Dict, Iterator, List, Optional + +import requests + +from .errors import ApiError +from .models import * +from .utils import strip_ansi +from .dns import DNSApiMixin +from .domains import DomainsApiMixin +from .routes import RoutesApiMixin +from .status import StatusApiMixin +from .trace import TraceApiMixin +from .traffic import TrafficApiMixin +from .transport import TransportApiMixin +from .vpn import VpnApiMixin + + +class ApiClient( + TransportApiMixin, + StatusApiMixin, + RoutesApiMixin, + TrafficApiMixin, + DNSApiMixin, + DomainsApiMixin, + VpnApiMixin, + TraceApiMixin, +): + """Domain API client. + + Public methods here are the ONLY surface the dashboard/controller should use. + """ + + def __init__( + self, + base_url: str, + *, + timeout: float = 5.0, + session: Optional[requests.Session] = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = float(timeout) + self._s = session or requests.Session() + + @classmethod + def from_env( + cls, + env_var: str = "SELECTIVE_VPN_API", + default: str = "http://127.0.0.1:8080", + *, + timeout: float = 5.0, + ) -> "ApiClient": + base = os.environ.get(env_var, default).rstrip("/") + return cls(base, timeout=timeout) + + # ---- low-level internals (private) ---- + + def _url(self, path: str) -> str: + if not path.startswith("/"): + path = "/" + path + return self.base_url + path + + def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + accept_json: bool = True, + ) -> requests.Response: + url = self._url(path) + headers: Dict[str, str] = {} + if accept_json: + headers["Accept"] = "application/json" + + try: + resp = self._s.request( + method=method.upper(), + url=url, + params=params, + json=json_body, + timeout=self.timeout if timeout is None else float(timeout), + headers=headers, + ) + except requests.RequestException as e: + raise ApiError("API request failed", method.upper(), url, None, str(e)) from e + + if not (200 <= resp.status_code < 300): + txt = resp.text.strip() + raise ApiError("API returned error", method.upper(), url, resp.status_code, txt) + + return resp + + def _json(self, resp: requests.Response) -> Any: + if not resp.content: + return None + try: + return resp.json() + except ValueError: + # Backend should be JSON, but keep safe fallback. + return {"raw": resp.text} + + # ---- event stream (SSE) ---- + + def events_stream(self, since: int = 0, stop: Optional[Callable[[], bool]] = None) -> Iterator[Event]: + """ + Iterate over server-sent events. Reconnects automatically on errors. + + Args: + since: last seen event id (inclusive). Server will replay newer ones. + stop: optional callable returning True to stop streaming. + """ + last = max(0, int(since)) + backoff = 1.0 + while True: + if stop and stop(): + return + try: + for ev in self._sse_once(last, stop): + if stop and stop(): + return + last = ev.id if ev.id else last + yield ev + # normal end -> reconnect + backoff = 1.0 + except ApiError: + # bubble up API errors; caller decides + raise + except Exception: + # transient error, retry with backoff + time.sleep(backoff) + backoff = min(backoff * 2, 10.0) + + def _sse_once(self, since: int, stop: Optional[Callable[[], bool]]) -> Iterator[Event]: + headers = { + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + } + params = {} + if since > 0: + params["since"] = str(since) + + url = self._url("/api/v1/events/stream") + # SSE соединение живёт долго: backend шлёт heartbeat каждые 15s, + # поэтому ставим более длинный read-timeout, иначе стандартные 5s + # приводят к ложным ошибкам чтения. + read_timeout = max(self.timeout * 3, 60.0) + try: + resp = self._s.request( + method="GET", + url=url, + headers=headers, + params=params, + stream=True, + timeout=(self.timeout, read_timeout), + ) + except requests.RequestException as e: + raise ApiError("API request failed", "GET", url, None, str(e)) from e + + if not (200 <= resp.status_code < 300): + txt = resp.text.strip() + raise ApiError("API returned error", "GET", url, resp.status_code, txt) + + ev_id: Optional[int] = None + ev_kind: str = "" + data_lines: List[str] = [] + + for raw in resp.iter_lines(decode_unicode=True): + if stop and stop(): + resp.close() + return + if raw is None: + continue + line = raw.strip("\r") + if line == "": + if data_lines or ev_kind or ev_id is not None: + ev = self._make_event(ev_id, ev_kind, data_lines) + if ev: + yield ev + ev_id = None + ev_kind = "" + data_lines = [] + continue + if line.startswith(":"): + # heartbeat/comment + continue + if line.startswith("id:"): + try: + ev_id = int(line[3:].strip()) + except ValueError: + ev_id = None + continue + if line.startswith("event:"): + ev_kind = line[6:].strip() + continue + if line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + continue + # unknown field -> ignore + + def _make_event(self, ev_id: Optional[int], ev_kind: str, data_lines: List[str]) -> Optional[Event]: + payload: Any = None + if data_lines: + data_str = "\n".join(data_lines) + try: + payload = json.loads(data_str) + except Exception: + payload = data_str + if isinstance(payload, dict): + id_val = ev_id + if id_val is None: + try: + id_val = int(payload.get("id", 0)) + except Exception: + id_val = 0 + kind_val = ev_kind or str(payload.get("kind") or "") + ts_val = str(payload.get("ts") or "") + data_val = payload.get("data", payload) + return Event(id=id_val, kind=kind_val, ts=ts_val, data=data_val) + return Event(id=ev_id or 0, kind=ev_kind, ts="", data=payload) + + # ---- shared helpers ---- + + def _to_int(self, value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return int(default) + + def _parse_cmd_result(self, data: Dict[str, Any]) -> CmdResult: + ok = bool(data.get("ok", False)) + msg = str(data.get("message") or "") + exit_code_val = data.get("exitCode", None) + exit_code: Optional[int] + try: + exit_code = int(exit_code_val) if exit_code_val is not None else None + except (TypeError, ValueError): + exit_code = None + + stdout = strip_ansi(str(data.get("stdout") or "")) + stderr = strip_ansi(str(data.get("stderr") or "")) + return CmdResult(ok=ok, message=msg, exit_code=exit_code, stdout=stdout, stderr=stderr) diff --git a/selective-vpn-gui/api/dns.py b/selective-vpn-gui/api/dns.py new file mode 100644 index 0000000..983f799 --- /dev/null +++ b/selective-vpn-gui/api/dns.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from typing import Any, Dict, List, cast + +from .models import * + + +class DNSApiMixin: + def dns_upstreams_get(self) -> DnsUpstreams: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns-upstreams")) or {}) + return DnsUpstreams( + default1=str(data.get("default1") or ""), + default2=str(data.get("default2") or ""), + meta1=str(data.get("meta1") or ""), + meta2=str(data.get("meta2") or ""), + ) + + def dns_upstreams_set(self, cfg: DnsUpstreams) -> None: + self._request( + "POST", + "/api/v1/dns-upstreams", + json_body={ + "default1": cfg.default1, + "default2": cfg.default2, + "meta1": cfg.meta1, + "meta2": cfg.meta2, + }, + ) + + def dns_upstream_pool_get(self) -> DNSUpstreamPoolState: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/upstream-pool")) or {}) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + items: List[DNSBenchmarkUpstream] = [] + for row in raw: + if not isinstance(row, dict): + continue + addr = str(row.get("addr") or "").strip() + if not addr: + continue + items.append(DNSBenchmarkUpstream(addr=addr, enabled=bool(row.get("enabled", True)))) + return DNSUpstreamPoolState(items=items) + + def dns_upstream_pool_set(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState: + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/dns/upstream-pool", + json_body={ + "items": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (items or [])], + }, + ) + ) + or {}, + ) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + out: List[DNSBenchmarkUpstream] = [] + for row in raw: + if not isinstance(row, dict): + continue + addr = str(row.get("addr") or "").strip() + if not addr: + continue + out.append(DNSBenchmarkUpstream(addr=addr, enabled=bool(row.get("enabled", True)))) + return DNSUpstreamPoolState(items=out) + + def dns_benchmark( + self, + upstreams: List[DNSBenchmarkUpstream], + domains: List[str], + timeout_ms: int = 1800, + attempts: int = 1, + concurrency: int = 6, + profile: str = "load", + ) -> DNSBenchmarkResponse: + # Benchmark can legitimately run much longer than the default 5s API timeout. + # Estimate a safe read-timeout from payload size and keep an upper cap. + upstream_count = len(upstreams or []) + domain_count = len(domains or []) + if domain_count <= 0: + domain_count = 6 # backend default domains + clamped_attempts = max(1, min(int(attempts), 3)) + clamped_concurrency = max(1, min(int(concurrency), 32)) + if upstream_count <= 0: + upstream_count = 1 + waves = (upstream_count + clamped_concurrency - 1) // clamped_concurrency + mode = str(profile or "load").strip().lower() + if mode not in ("quick", "load"): + mode = "load" + # Rough estimator for backend load profile. + load_factor = 1.0 if mode == "quick" else 6.0 + per_wave_sec = domain_count * max(1, clamped_attempts) * (max(300, int(timeout_ms)) / 1000.0) * load_factor + bench_timeout = min(420.0, max(20.0, waves * per_wave_sec * 1.1 + 8.0)) + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/dns/benchmark", + json_body={ + "upstreams": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (upstreams or [])], + "domains": [str(d or "").strip() for d in (domains or []) if str(d or "").strip()], + "timeout_ms": int(timeout_ms), + "attempts": int(attempts), + "concurrency": int(concurrency), + "profile": mode, + }, + timeout=bench_timeout, + ) + ) + or {}, + ) + raw_results = data.get("results") or [] + if not isinstance(raw_results, list): + raw_results = [] + results: List[DNSBenchmarkResult] = [] + for row in raw_results: + if not isinstance(row, dict): + continue + results.append( + DNSBenchmarkResult( + upstream=str(row.get("upstream") or "").strip(), + attempts=int(row.get("attempts", 0) or 0), + ok=int(row.get("ok", 0) or 0), + fail=int(row.get("fail", 0) or 0), + nxdomain=int(row.get("nxdomain", 0) or 0), + timeout=int(row.get("timeout", 0) or 0), + temporary=int(row.get("temporary", 0) or 0), + other=int(row.get("other", 0) or 0), + avg_ms=int(row.get("avg_ms", 0) or 0), + p95_ms=int(row.get("p95_ms", 0) or 0), + score=float(row.get("score", 0.0) or 0.0), + color=str(row.get("color") or "").strip().lower(), + ) + ) + return DNSBenchmarkResponse( + results=results, + domains_used=[str(d or "").strip() for d in (data.get("domains_used") or []) if str(d or "").strip()], + timeout_ms=int(data.get("timeout_ms", 0) or 0), + attempts_per_domain=int(data.get("attempts_per_domain", 0) or 0), + profile=str(data.get("profile") or mode), + recommended_default=[str(d or "").strip() for d in (data.get("recommended_default") or []) if str(d or "").strip()], + recommended_meta=[str(d or "").strip() for d in (data.get("recommended_meta") or []) if str(d or "").strip()], + ) + + def dns_status_get(self) -> DNSStatus: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/status")) or {}) + return self._parse_dns_status(data) + + def dns_mode_set(self, via_smartdns: bool, smartdns_addr: str) -> DNSStatus: + mode = "hybrid_wildcard" if bool(via_smartdns) else "direct" + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/dns/mode", + json_body={ + "via_smartdns": bool(via_smartdns), + "smartdns_addr": str(smartdns_addr or ""), + "mode": mode, + }, + ) + ) + or {}, + ) + return self._parse_dns_status(data) + + def dns_smartdns_service_set(self, action: ServiceAction) -> DNSStatus: + act = action.lower() + if act not in ("start", "stop", "restart"): + raise ValueError(f"Invalid action: {action}") + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/dns/smartdns-service", + json_body={"action": act}, + ) + ) + or {}, + ) + if not bool(data.get("ok", False)): + raise ValueError(str(data.get("message") or f"SmartDNS {act} failed")) + return self._parse_dns_status(data) + + def smartdns_service_get(self) -> SmartdnsServiceState: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/smartdns/service")) or {}) + return SmartdnsServiceState(state=str(data.get("state") or "unknown")) + + def smartdns_service_set(self, action: ServiceAction) -> CmdResult: + act = action.lower() + if act not in ("start", "stop", "restart"): + raise ValueError(f"Invalid action: {action}") + data = cast( + Dict[str, Any], + self._json(self._request("POST", "/api/v1/smartdns/service", json_body={"action": act})) + or {}, + ) + return self._parse_cmd_result(data) + + def smartdns_runtime_get(self) -> SmartdnsRuntimeState: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/smartdns/runtime")) or {}) + return SmartdnsRuntimeState( + enabled=bool(data.get("enabled", False)), + applied_enabled=bool(data.get("applied_enabled", False)), + wildcard_source=str(data.get("wildcard_source") or ("both" if bool(data.get("enabled", False)) else "resolver")), + unit_state=str(data.get("unit_state") or "unknown"), + config_path=str(data.get("config_path") or ""), + changed=bool(data.get("changed", False)), + restarted=bool(data.get("restarted", False)), + message=str(data.get("message") or ""), + ) + + def smartdns_runtime_set(self, enabled: bool, restart: bool = True) -> SmartdnsRuntimeState: + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/smartdns/runtime", + json_body={"enabled": bool(enabled), "restart": bool(restart)}, + ) + ) + or {}, + ) + return SmartdnsRuntimeState( + enabled=bool(data.get("enabled", False)), + applied_enabled=bool(data.get("applied_enabled", False)), + wildcard_source=str(data.get("wildcard_source") or ("both" if bool(data.get("enabled", False)) else "resolver")), + unit_state=str(data.get("unit_state") or "unknown"), + config_path=str(data.get("config_path") or ""), + changed=bool(data.get("changed", False)), + restarted=bool(data.get("restarted", False)), + message=str(data.get("message") or ""), + ) + + def smartdns_prewarm(self, limit: int = 0, aggressive_subs: bool = False) -> CmdResult: + payload: Dict[str, Any] = {} + if int(limit) > 0: + payload["limit"] = int(limit) + if aggressive_subs: + payload["aggressive_subs"] = True + data = cast( + Dict[str, Any], + self._json(self._request("POST", "/api/v1/smartdns/prewarm", json_body=payload)) or {}, + ) + return self._parse_cmd_result(data) + + def _parse_dns_status(self, data: Dict[str, Any]) -> DNSStatus: + via = bool(data.get("via_smartdns", False)) + runtime = bool(data.get("runtime_nftset", True)) + return DNSStatus( + via_smartdns=via, + smartdns_addr=str(data.get("smartdns_addr") or ""), + mode=str(data.get("mode") or ("hybrid_wildcard" if via else "direct")), + unit_state=str(data.get("unit_state") or "unknown"), + runtime_nftset=runtime, + wildcard_source=str(data.get("wildcard_source") or ("both" if runtime else "resolver")), + runtime_config_path=str(data.get("runtime_config_path") or ""), + runtime_config_error=str(data.get("runtime_config_error") or ""), + ) diff --git a/selective-vpn-gui/api/domains.py b/selective-vpn-gui/api/domains.py new file mode 100644 index 0000000..08b808e --- /dev/null +++ b/selective-vpn-gui/api/domains.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Any, Dict, cast + +from .models import * + + +class DomainsApiMixin: + # Domains editor + def domains_table(self) -> DomainsTable: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/domains/table")) or {}) + lines = data.get("lines") or [] + if not isinstance(lines, list): + lines = [] + return DomainsTable(lines=[str(x) for x in lines]) + + def domains_file_get( + self, + name: Literal[ + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ], + ) -> DomainsFile: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/domains/file", params={"name": name})) or {}, + ) + content = str(data.get("content") or "") + source = str(data.get("source") or "") + return DomainsFile(name=name, content=content, source=source) + + def domains_file_set( + self, + name: Literal[ + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ], + content: str, + ) -> None: + self._request("POST", "/api/v1/domains/file", json_body={"name": name, "content": content}) diff --git a/selective-vpn-gui/api/errors.py b/selective-vpn-gui/api/errors.py new file mode 100644 index 0000000..104dfa5 --- /dev/null +++ b/selective-vpn-gui/api/errors.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class ApiError(Exception): + """Raised when API call fails (network or non-2xx).""" + + message: str + method: str + url: str + status_code: Optional[int] = None + response_text: str = "" + + def __str__(self) -> str: + code = f" ({self.status_code})" if self.status_code is not None else "" + tail = f": {self.response_text}" if self.response_text else "" + return f"{self.message}{code} [{self.method} {self.url}]{tail}" diff --git a/selective-vpn-gui/api/models.py b/selective-vpn-gui/api/models.py new file mode 100644 index 0000000..9d1d781 --- /dev/null +++ b/selective-vpn-gui/api/models.py @@ -0,0 +1,788 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Literal, Optional + +# --------------------------- +# Models (UI-friendly) +# --------------------------- + +@dataclass(frozen=True) +class Status: + timestamp: str + ip_count: int + domain_count: int + iface: str + table: str + mark: str + # NOTE: backend uses omitempty for these, so they may be absent. + policy_route_ok: Optional[bool] + route_ok: Optional[bool] + + +@dataclass(frozen=True) +class CmdResult: + ok: bool + message: str + exit_code: Optional[int] = None + stdout: str = "" + stderr: str = "" + + +@dataclass(frozen=True) +class LoginState: + state: str + email: str + msg: str + # backend may also provide UI-ready fields + text: str + color: str + + +@dataclass(frozen=True) +class UnitState: + state: str + + +@dataclass(frozen=True) +class RoutesTimerState: + enabled: bool + + +@dataclass(frozen=True) +class TrafficModeStatus: + mode: str + desired_mode: str + applied_mode: str + preferred_iface: str + advanced_active: bool + auto_local_bypass: bool + auto_local_active: bool + ingress_reply_bypass: bool + ingress_reply_active: bool + bypass_candidates: int + force_vpn_subnets: List[str] + force_vpn_uids: List[str] + force_vpn_cgroups: List[str] + force_direct_subnets: List[str] + force_direct_uids: List[str] + force_direct_cgroups: List[str] + overrides_applied: int + cgroup_resolved_uids: int + cgroup_warning: str + active_iface: str + iface_reason: str + rule_mark: bool + rule_full: bool + ingress_rule_present: bool + ingress_nft_active: bool + table_default: bool + probe_ok: bool + probe_message: str + healthy: bool + message: str + + +@dataclass(frozen=True) +class TrafficInterfaces: + interfaces: List[str] + preferred_iface: str + active_iface: str + iface_reason: str + + +@dataclass(frozen=True) +class TrafficAppMarksStatus: + vpn_count: int + direct_count: int + message: str + + +@dataclass(frozen=True) +class TrafficAppMarksResult: + ok: bool + message: str + op: str = "" + target: str = "" + cgroup: str = "" + cgroup_id: int = 0 + timeout_sec: int = 0 + + +@dataclass(frozen=True) +class TrafficAppMarkItem: + id: int + target: str # vpn|direct + cgroup: str + cgroup_rel: str + level: int + unit: str + command: str + app_key: str + added_at: str + expires_at: str + remaining_sec: int + + +@dataclass(frozen=True) +class TrafficAppProfile: + id: str + name: str + app_key: str + command: str + target: str # vpn|direct + ttl_sec: int + vpn_profile: str + created_at: str + updated_at: str + + +@dataclass(frozen=True) +class TrafficAppProfileSaveResult: + ok: bool + message: str + profile: Optional[TrafficAppProfile] = None + + +@dataclass(frozen=True) +class TrafficAudit: + ok: bool + message: str + now: str + pretty: str + issues: List[str] + + + +@dataclass(frozen=True) +class TransportClientHealth: + last_check: str + latency_ms: int + last_error: str + + +@dataclass(frozen=True) +class TransportClient: + id: str + name: str + kind: str + enabled: bool + status: str + iface: str + routing_table: str + mark_hex: str + priority_base: int + capabilities: List[str] + health: TransportClientHealth + config: Dict[str, Any] + updated_at: str + + +@dataclass(frozen=True) +class TransportHealthRefreshItem: + client_id: str + status: str + queued: bool + reason: str + + +@dataclass(frozen=True) +class TransportHealthRefreshResult: + ok: bool + message: str + code: str + count: int + queued: int + skipped: int + items: List[TransportHealthRefreshItem] + + +@dataclass(frozen=True) +class TransportClientHealthSnapshot: + client_id: str + status: str + latency_ms: int + last_error: str + last_check: str + + +@dataclass(frozen=True) +class TransportPolicyIntent: + selector_type: str + selector_value: str + client_id: str + priority: int = 100 + mode: str = "strict" + + +@dataclass(frozen=True) +class TransportPolicy: + revision: int + intents: List[TransportPolicyIntent] + + +@dataclass(frozen=True) +class TransportConflict: + key: str + type: str + severity: str + owners: List[str] + reason: str + suggested_resolution: str + + +@dataclass(frozen=True) +class TransportPolicyValidateSummary: + block_count: int + warn_count: int + + +@dataclass(frozen=True) +class TransportPolicyDiff: + added: int + changed: int + removed: int + + +@dataclass(frozen=True) +class TransportPolicyValidateResult: + ok: bool + message: str + code: str + valid: bool + base_revision: int + confirm_token: str + summary: TransportPolicyValidateSummary + conflicts: List[TransportConflict] + diff: TransportPolicyDiff + + +@dataclass(frozen=True) +class TransportPolicyApplyResult: + ok: bool + message: str + code: str + policy_revision: int + current_revision: int + apply_id: str + rollback_available: bool + conflicts: List[TransportConflict] + + +@dataclass(frozen=True) +class TransportConflicts: + has_blocking: bool + items: List[TransportConflict] + + +@dataclass(frozen=True) +class TransportCapabilities: + clients: Dict[str, Dict[str, bool]] + + +@dataclass(frozen=True) +class TransportInterfaceItem: + id: str + name: str + mode: str + runtime_iface: str + netns_name: str + routing_table: str + client_ids: List[str] + client_count: int + up_count: int + updated_at: str + config: Dict[str, Any] + + +@dataclass(frozen=True) +class TransportInterfacesSnapshot: + ok: bool + message: str + code: str + count: int + items: List[TransportInterfaceItem] + + +@dataclass(frozen=True) +class TransportOwnershipRecord: + key: str + selector_type: str + selector_value: str + client_id: str + client_kind: str + owner_scope: str + owner_status: str + lock_active: bool + iface_id: str + routing_table: str + mark_hex: str + priority_base: int + mode: str + priority: int + updated_at: str + + +@dataclass(frozen=True) +class TransportOwnershipSnapshot: + ok: bool + message: str + code: str + policy_revision: int + plan_digest: str + count: int + lock_count: int + items: List[TransportOwnershipRecord] + + +@dataclass(frozen=True) +class TransportOwnerLockRecord: + destination_ip: str + client_id: str + client_kind: str + iface_id: str + mark_hex: str + proto: str + updated_at: str + + +@dataclass(frozen=True) +class TransportOwnerLocksSnapshot: + ok: bool + message: str + code: str + policy_revision: int + count: int + items: List[TransportOwnerLockRecord] + + +@dataclass(frozen=True) +class TransportOwnerLocksClearResult: + ok: bool + message: str + code: str + base_revision: int + confirm_required: bool + confirm_token: str + match_count: int + cleared_count: int + remaining_count: int + items: List[TransportOwnerLockRecord] + + +@dataclass(frozen=True) +class TransportClientActionResult: + ok: bool + message: str + code: str + client_id: str + kind: str + action: str + status_before: str + status_after: str + last_error: str + exit_code: Optional[int] = None + stdout: str = "" + stderr: str = "" + + +@dataclass(frozen=True) +class TransportNetnsToggleItem: + ok: bool + message: str + code: str + client_id: str + kind: str + status_before: str + status_after: str + netns_enabled: bool + config_updated: bool + provisioned: bool + restarted: bool + stdout: str = "" + stderr: str = "" + + +@dataclass(frozen=True) +class TransportNetnsToggleResult: + ok: bool + message: str + code: str + enabled: bool + count: int + success_count: int + failure_count: int + items: List[TransportNetnsToggleItem] + + +@dataclass(frozen=True) +class SingBoxProfileIssue: + field: str + severity: str + code: str + message: str + + +@dataclass(frozen=True) +class SingBoxProfileRenderDiff: + added: int + changed: int + removed: int + + +@dataclass(frozen=True) +class SingBoxProfileValidateResult: + ok: bool + message: str + code: str + profile_id: str + profile_revision: int + valid: bool + errors: List[SingBoxProfileIssue] + warnings: List[SingBoxProfileIssue] + render_digest: str + diff: SingBoxProfileRenderDiff + + +@dataclass(frozen=True) +class SingBoxProfileApplyResult: + ok: bool + message: str + code: str + profile_id: str + client_id: str + config_path: str + profile_revision: int + render_revision: int + last_applied_at: str + render_path: str + render_digest: str + rollback_available: bool + valid: bool + errors: List[SingBoxProfileIssue] + warnings: List[SingBoxProfileIssue] + diff: SingBoxProfileRenderDiff + + +@dataclass(frozen=True) +class SingBoxProfile: + id: str + name: str + mode: str + protocol: str + enabled: bool + schema_version: int + profile_revision: int + render_revision: int + last_validated_at: str + last_applied_at: str + last_error: str + typed: Dict[str, Any] + raw_config: Dict[str, Any] + meta: Dict[str, Any] + has_secrets: bool + secrets_masked: Dict[str, str] + created_at: str + updated_at: str + + +@dataclass(frozen=True) +class SingBoxProfilesState: + ok: bool + message: str + code: str + count: int + active_profile_id: str + items: List[SingBoxProfile] + item: Optional[SingBoxProfile] = None + + +@dataclass(frozen=True) +class SingBoxProfileRenderResult: + ok: bool + message: str + code: str + profile_id: str + profile_revision: int + render_revision: int + render_path: str + render_digest: str + changed: bool + valid: bool + errors: List[SingBoxProfileIssue] + warnings: List[SingBoxProfileIssue] + diff: SingBoxProfileRenderDiff + config: Dict[str, Any] + + +@dataclass(frozen=True) +class SingBoxProfileRollbackResult: + ok: bool + message: str + code: str + profile_id: str + client_id: str + config_path: str + history_id: str + profile_revision: int + last_applied_at: str + + +@dataclass(frozen=True) +class SingBoxProfileHistoryEntry: + id: str + at: str + profile_id: str + action: str + status: str + code: str + message: str + profile_revision: int + render_revision: int + render_digest: str + render_path: str + client_id: str + + +@dataclass(frozen=True) +class SingBoxProfileHistoryResult: + ok: bool + message: str + code: str + profile_id: str + count: int + items: List[SingBoxProfileHistoryEntry] + + +@dataclass(frozen=True) +class TrafficCandidateSubnet: + cidr: str + dev: str + kind: str + linkdown: bool + + +@dataclass(frozen=True) +class TrafficCandidateUnit: + unit: str + description: str + cgroup: str + + +@dataclass(frozen=True) +class TrafficCandidateUID: + uid: int + user: str + examples: List[str] + + +@dataclass(frozen=True) +class TrafficCandidates: + generated_at: str + subnets: List[TrafficCandidateSubnet] + units: List[TrafficCandidateUnit] + uids: List[TrafficCandidateUID] + + +@dataclass(frozen=True) +class DnsUpstreams: + default1: str + default2: str + meta1: str + meta2: str + + +@dataclass(frozen=True) +class DNSBenchmarkUpstream: + addr: str + enabled: bool = True + + +@dataclass(frozen=True) +class DNSBenchmarkResult: + upstream: str + attempts: int + ok: int + fail: int + nxdomain: int + timeout: int + temporary: int + other: int + avg_ms: int + p95_ms: int + score: float + color: str + + +@dataclass(frozen=True) +class DNSBenchmarkResponse: + results: List[DNSBenchmarkResult] + domains_used: List[str] + timeout_ms: int + attempts_per_domain: int + profile: str + recommended_default: List[str] + recommended_meta: List[str] + + +@dataclass(frozen=True) +class DNSUpstreamPoolState: + items: List[DNSBenchmarkUpstream] + + +@dataclass(frozen=True) +class SmartdnsServiceState: + state: str + + +@dataclass(frozen=True) +class DNSStatus: + via_smartdns: bool + smartdns_addr: str + mode: str + unit_state: str + runtime_nftset: bool + wildcard_source: str + runtime_config_path: str + runtime_config_error: str + + +@dataclass(frozen=True) +class SmartdnsRuntimeState: + enabled: bool + applied_enabled: bool + wildcard_source: str + unit_state: str + config_path: str + changed: bool = False + restarted: bool = False + message: str = "" + + +@dataclass(frozen=True) +class DomainsTable: + lines: List[str] + + +@dataclass(frozen=True) +class DomainsFile: + name: str + content: str + source: str = "" + + +@dataclass(frozen=True) +class VpnAutoloopStatus: + raw_text: str + status_word: str + + +@dataclass(frozen=True) +class VpnStatus: + desired_location: str + status_word: str + raw_text: str + unit_state: str + + +@dataclass(frozen=True) +class VpnLocation: + label: str + iso: str + target: str + + +@dataclass(frozen=True) +class VpnLocationsState: + locations: List[VpnLocation] + updated_at: str + stale: bool + refresh_in_progress: bool + last_error: str + next_retry_at: str + + +@dataclass(frozen=True) +class EgressIdentity: + scope: str + source: str + source_id: str + ip: str + country_code: str + country_name: str + updated_at: str + stale: bool + refresh_in_progress: bool + last_error: str + next_retry_at: str + + +@dataclass(frozen=True) +class EgressIdentityRefreshItem: + scope: str + status: str + queued: bool + reason: str + + +@dataclass(frozen=True) +class EgressIdentityRefreshResult: + ok: bool + message: str + count: int + queued: int + skipped: int + items: List[EgressIdentityRefreshItem] + + +@dataclass(frozen=True) +class TraceDump: + lines: List[str] + + +@dataclass(frozen=True) +class Event: + id: int + kind: str + ts: str + data: Any + +# --------------------------- +# AdGuard VPN interactive login-session (PTY) +# --------------------------- + +@dataclass(frozen=True) +class LoginSessionStart: + ok: bool + phase: str + level: str + pid: Optional[int] = None + email: str = "" + error: str = "" + + +@dataclass(frozen=True) +class LoginSessionState: + ok: bool + phase: str + level: str + alive: bool + url: str + email: str + cursor: int + lines: List[str] + can_open: bool + can_check: bool + can_cancel: bool + + +@dataclass(frozen=True) +class LoginSessionAction: + ok: bool + phase: str = "" + level: str = "" + error: str = "" + +TraceMode = Literal["full", "gui", "smartdns"] +ServiceAction = Literal["start", "stop", "restart"] +TransportClientAction = Literal["provision", "start", "stop", "restart"] diff --git a/selective-vpn-gui/api/routes.py b/selective-vpn-gui/api/routes.py new file mode 100644 index 0000000..82eafc4 --- /dev/null +++ b/selective-vpn-gui/api/routes.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import Any, Dict, cast + +import requests + +from .errors import ApiError +from .models import * + + +class RoutesApiMixin: + # Routes + def routes_service(self, action: ServiceAction) -> CmdResult: + action_l = action.lower() + if action_l not in ("start", "stop", "restart"): + raise ValueError(f"Invalid action: {action}") + url = self._url("/api/v1/routes/service") + payload = {"action": action_l} + try: + # Короткий read-timeout: если systemctl завис на минуты, выходим, + # но backend продолжает выполнение (не привязан к request context). + resp = self._s.post(url, json=payload, timeout=(self.timeout, 2.0)) + except requests.Timeout: + return CmdResult( + ok=True, + message=f"{action_l} accepted; backend is still running systemctl", + exit_code=None, + ) + except requests.RequestException as e: + raise ApiError("API request failed", "POST", url, None, str(e)) from e + + if not (200 <= resp.status_code < 300): + txt = resp.text.strip() + raise ApiError("API returned error", "POST", url, resp.status_code, txt) + + data = cast(Dict[str, Any], self._json(resp) or {}) + return self._parse_cmd_result(data) + + def routes_clear(self) -> CmdResult: + data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/routes/clear")) or {}) + return self._parse_cmd_result(data) + + def routes_cache_restore(self) -> CmdResult: + data = cast( + Dict[str, Any], + self._json(self._request("POST", "/api/v1/routes/cache/restore")) or {}, + ) + return self._parse_cmd_result(data) + + def routes_precheck_debug(self, run_now: bool = True) -> CmdResult: + url = self._url("/api/v1/routes/precheck/debug") + payload = {"run_now": bool(run_now)} + try: + # Endpoint может запускать routes restart и выходить за 5s timeout. + # Для GUI считаем timeout признаком фонового принятого действия. + resp = self._s.post(url, json=payload, timeout=(self.timeout, 2.0)) + except requests.Timeout: + return CmdResult( + ok=True, + message="precheck debug accepted; backend is still running", + exit_code=None, + ) + except requests.RequestException as e: + raise ApiError("API request failed", "POST", url, None, str(e)) from e + + if not (200 <= resp.status_code < 300): + txt = resp.text.strip() + raise ApiError("API returned error", "POST", url, resp.status_code, txt) + + data = cast(Dict[str, Any], self._json(resp) or {}) + return self._parse_cmd_result(data) + + def routes_fix_policy_route(self) -> CmdResult: + data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/routes/fix-policy-route")) or {}) + return self._parse_cmd_result(data) + + def routes_timer_get(self) -> RoutesTimerState: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/routes/timer")) or {}) + return RoutesTimerState(enabled=bool(data.get("enabled", False))) + + def routes_timer_set(self, enabled: bool) -> CmdResult: + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/routes/timer", + json_body={"enabled": bool(enabled)}, + ) + ) + or {}, + ) + return self._parse_cmd_result(data) diff --git a/selective-vpn-gui/api/status.py b/selective-vpn-gui/api/status.py new file mode 100644 index 0000000..806d5d9 --- /dev/null +++ b/selective-vpn-gui/api/status.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any, Dict, cast + +from .models import * +from .utils import strip_ansi + + +class StatusApiMixin: + # Status / system + def get_status(self) -> Status: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/status")) or {}) + return Status( + timestamp=str(data.get("timestamp") or ""), + ip_count=int(data.get("ip_count") or 0), + domain_count=int(data.get("domain_count") or 0), + iface=str(data.get("iface") or ""), + table=str(data.get("table") or ""), + mark=str(data.get("mark") or ""), + policy_route_ok=cast(Optional[bool], data.get("policy_route_ok", None)), + route_ok=cast(Optional[bool], data.get("route_ok", None)), + ) + + def systemd_state(self, unit: str) -> UnitState: + data = cast( + Dict[str, Any], + self._json( + self._request("GET", "/api/v1/systemd/state", params={"unit": unit}, timeout=2.0) + ) + or {}, + ) + st = str(data.get("state") or "unknown").strip() or "unknown" + return UnitState(state=st) + + def get_login_state(self) -> LoginState: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/vpn/login-state", timeout=2.0)) or {}) + # Normalize and strip ANSI. + state = str(data.get("state") or "unknown").strip() + email = strip_ansi(str(data.get("email") or "").strip()) + msg = strip_ansi(str(data.get("msg") or "").strip()) + text = strip_ansi(str(data.get("text") or "").strip()) + color = str(data.get("color") or "").strip() + + return LoginState( + state=state, + email=email, + msg=msg, + text=text, + color=color, + ) diff --git a/selective-vpn-gui/api/trace.py b/selective-vpn-gui/api/trace.py new file mode 100644 index 0000000..4de1080 --- /dev/null +++ b/selective-vpn-gui/api/trace.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any, Dict, cast + +from .errors import ApiError +from .models import * +from .utils import strip_ansi + + +class TraceApiMixin: + # Trace + def trace_get(self, mode: TraceMode = "full") -> TraceDump: + m = str(mode).lower().strip() + if m not in ("full", "gui", "smartdns"): + m = "full" + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/trace-json", params={"mode": m}, timeout=5.0)) or {}, + ) + lines = data.get("lines") or [] + if not isinstance(lines, list): + lines = [] + return TraceDump(lines=[strip_ansi(str(x)) for x in lines]) + + def trace_append(self, kind: Literal["gui", "smartdns", "info"], line: str) -> None: + try: + self._request( + "POST", + "/api/v1/trace/append", + json_body={"kind": kind, "line": str(line)}, + timeout=2.0, + ) + except ApiError: + # Logging must never crash UI. + pass diff --git a/selective-vpn-gui/api/traffic.py b/selective-vpn-gui/api/traffic.py new file mode 100644 index 0000000..867a53e --- /dev/null +++ b/selective-vpn-gui/api/traffic.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, cast + +from .models import * +from .utils import strip_ansi + + +class TrafficApiMixin: + def traffic_mode_get(self) -> TrafficModeStatus: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/traffic/mode")) or {}) + return self._parse_traffic_mode_status(data, fallback_mode="selective") + + def traffic_mode_set( + self, + mode: str, + preferred_iface: Optional[str] = None, + auto_local_bypass: Optional[bool] = None, + ingress_reply_bypass: Optional[bool] = None, + force_vpn_subnets: Optional[List[str]] = None, + force_vpn_uids: Optional[List[str]] = None, + force_vpn_cgroups: Optional[List[str]] = None, + force_direct_subnets: Optional[List[str]] = None, + force_direct_uids: Optional[List[str]] = None, + force_direct_cgroups: Optional[List[str]] = None, + ) -> TrafficModeStatus: + m = str(mode or "").strip().lower() + if m not in ("selective", "full_tunnel", "direct"): + raise ValueError(f"Invalid traffic mode: {mode}") + payload: Dict[str, Any] = {"mode": m} + if preferred_iface is not None: + payload["preferred_iface"] = str(preferred_iface).strip() + if auto_local_bypass is not None: + payload["auto_local_bypass"] = bool(auto_local_bypass) + if ingress_reply_bypass is not None: + payload["ingress_reply_bypass"] = bool(ingress_reply_bypass) + if force_vpn_subnets is not None: + payload["force_vpn_subnets"] = [str(x) for x in force_vpn_subnets] + if force_vpn_uids is not None: + payload["force_vpn_uids"] = [str(x) for x in force_vpn_uids] + if force_vpn_cgroups is not None: + payload["force_vpn_cgroups"] = [str(x) for x in force_vpn_cgroups] + if force_direct_subnets is not None: + payload["force_direct_subnets"] = [str(x) for x in force_direct_subnets] + if force_direct_uids is not None: + payload["force_direct_uids"] = [str(x) for x in force_direct_uids] + if force_direct_cgroups is not None: + payload["force_direct_cgroups"] = [str(x) for x in force_direct_cgroups] + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/traffic/mode", + json_body=payload, + ) + ) + or {}, + ) + return self._parse_traffic_mode_status(data, fallback_mode=m) + + def traffic_mode_test(self) -> TrafficModeStatus: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/traffic/mode/test")) or {}, + ) + return self._parse_traffic_mode_status(data, fallback_mode="selective") + + def traffic_advanced_reset(self) -> TrafficModeStatus: + data = cast( + Dict[str, Any], + self._json(self._request("POST", "/api/v1/traffic/advanced/reset")) or {}, + ) + return self._parse_traffic_mode_status(data, fallback_mode="selective") + + def traffic_interfaces_get(self) -> TrafficInterfaces: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/traffic/interfaces")) or {}, + ) + raw = data.get("interfaces") or [] + if not isinstance(raw, list): + raw = [] + return TrafficInterfaces( + interfaces=[str(x) for x in raw if str(x).strip()], + preferred_iface=str(data.get("preferred_iface") or ""), + active_iface=str(data.get("active_iface") or ""), + iface_reason=str(data.get("iface_reason") or ""), + ) + + def traffic_candidates_get(self) -> TrafficCandidates: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/traffic/candidates")) or {}, + ) + + subnets: List[TrafficCandidateSubnet] = [] + for it in (data.get("subnets") or []): + if not isinstance(it, dict): + continue + cidr = str(it.get("cidr") or "").strip() + if not cidr: + continue + subnets.append( + TrafficCandidateSubnet( + cidr=cidr, + dev=str(it.get("dev") or "").strip(), + kind=str(it.get("kind") or "").strip(), + linkdown=bool(it.get("linkdown", False)), + ) + ) + + units: List[TrafficCandidateUnit] = [] + for it in (data.get("units") or []): + if not isinstance(it, dict): + continue + unit = str(it.get("unit") or "").strip() + if not unit: + continue + units.append( + TrafficCandidateUnit( + unit=unit, + description=str(it.get("description") or "").strip(), + cgroup=str(it.get("cgroup") or "").strip(), + ) + ) + + uids: List[TrafficCandidateUID] = [] + for it in (data.get("uids") or []): + if not isinstance(it, dict): + continue + try: + uid = int(it.get("uid", 0) or 0) + except Exception: + continue + user = str(it.get("user") or "").strip() + raw_ex = it.get("examples") or [] + if not isinstance(raw_ex, list): + raw_ex = [] + examples = [str(x) for x in raw_ex if str(x).strip()] + uids.append(TrafficCandidateUID(uid=uid, user=user, examples=examples)) + + return TrafficCandidates( + generated_at=str(data.get("generated_at") or ""), + subnets=subnets, + units=units, + uids=uids, + ) + + def traffic_appmarks_status(self) -> TrafficAppMarksStatus: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/traffic/appmarks")) or {}, + ) + return TrafficAppMarksStatus( + vpn_count=int(data.get("vpn_count", 0) or 0), + direct_count=int(data.get("direct_count", 0) or 0), + message=str(data.get("message") or ""), + ) + + def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/traffic/appmarks/items")) or {}, + ) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + + out: List[TrafficAppMarkItem] = [] + for it in raw: + if not isinstance(it, dict): + continue + try: + mid = int(it.get("id", 0) or 0) + except Exception: + mid = 0 + tgt = str(it.get("target") or "").strip().lower() + if mid <= 0 or tgt not in ("vpn", "direct"): + continue + out.append( + TrafficAppMarkItem( + id=mid, + target=tgt, + cgroup=str(it.get("cgroup") or "").strip(), + cgroup_rel=str(it.get("cgroup_rel") or "").strip(), + level=int(it.get("level", 0) or 0), + unit=str(it.get("unit") or "").strip(), + command=str(it.get("command") or "").strip(), + app_key=str(it.get("app_key") or "").strip(), + added_at=str(it.get("added_at") or "").strip(), + expires_at=str(it.get("expires_at") or "").strip(), + remaining_sec=int(it.get("remaining_sec", 0) or 0), + ) + ) + return out + + def traffic_appmarks_apply( + self, + *, + op: str, + target: str, + cgroup: str = "", + unit: str = "", + command: str = "", + app_key: str = "", + timeout_sec: int = 0, + ) -> TrafficAppMarksResult: + payload: Dict[str, Any] = { + "op": str(op or "").strip().lower(), + "target": str(target or "").strip().lower(), + } + if cgroup: + payload["cgroup"] = str(cgroup).strip() + if unit: + payload["unit"] = str(unit).strip() + if command: + payload["command"] = str(command).strip() + if app_key: + payload["app_key"] = str(app_key).strip() + if int(timeout_sec or 0) > 0: + payload["timeout_sec"] = int(timeout_sec) + + data = cast( + Dict[str, Any], + self._json(self._request("POST", "/api/v1/traffic/appmarks", json_body=payload)) + or {}, + ) + return TrafficAppMarksResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or ""), + op=str(data.get("op") or payload["op"]), + target=str(data.get("target") or payload["target"]), + cgroup=str(data.get("cgroup") or payload.get("cgroup") or ""), + cgroup_id=int(data.get("cgroup_id", 0) or 0), + timeout_sec=int(data.get("timeout_sec", 0) or 0), + ) + + def traffic_app_profiles_list(self) -> List[TrafficAppProfile]: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/traffic/app-profiles")) or {}, + ) + raw = data.get("profiles") or [] + if not isinstance(raw, list): + raw = [] + + out: List[TrafficAppProfile] = [] + for it in raw: + if not isinstance(it, dict): + continue + pid = str(it.get("id") or "").strip() + if not pid: + continue + out.append( + TrafficAppProfile( + id=pid, + name=str(it.get("name") or "").strip(), + app_key=str(it.get("app_key") or "").strip(), + command=str(it.get("command") or "").strip(), + target=str(it.get("target") or "").strip().lower(), + ttl_sec=int(it.get("ttl_sec", 0) or 0), + vpn_profile=str(it.get("vpn_profile") or "").strip(), + created_at=str(it.get("created_at") or "").strip(), + updated_at=str(it.get("updated_at") or "").strip(), + ) + ) + return out + + def traffic_app_profile_upsert( + self, + *, + id: str = "", + name: str = "", + app_key: str = "", + command: str, + target: str, + ttl_sec: int = 0, + vpn_profile: str = "", + ) -> TrafficAppProfileSaveResult: + payload: Dict[str, Any] = { + "command": str(command or "").strip(), + "target": str(target or "").strip().lower(), + } + if id: + payload["id"] = str(id).strip() + if name: + payload["name"] = str(name).strip() + if app_key: + payload["app_key"] = str(app_key).strip() + if int(ttl_sec or 0) > 0: + payload["ttl_sec"] = int(ttl_sec) + if vpn_profile: + payload["vpn_profile"] = str(vpn_profile).strip() + + data = cast( + Dict[str, Any], + self._json( + self._request("POST", "/api/v1/traffic/app-profiles", json_body=payload) + ) + or {}, + ) + msg = str(data.get("message") or "") + raw = data.get("profiles") or [] + if not isinstance(raw, list): + raw = [] + prof: Optional[TrafficAppProfile] = None + if raw and isinstance(raw[0], dict): + it = cast(Dict[str, Any], raw[0]) + pid = str(it.get("id") or "").strip() + if pid: + prof = TrafficAppProfile( + id=pid, + name=str(it.get("name") or "").strip(), + app_key=str(it.get("app_key") or "").strip(), + command=str(it.get("command") or "").strip(), + target=str(it.get("target") or "").strip().lower(), + ttl_sec=int(it.get("ttl_sec", 0) or 0), + vpn_profile=str(it.get("vpn_profile") or "").strip(), + created_at=str(it.get("created_at") or "").strip(), + updated_at=str(it.get("updated_at") or "").strip(), + ) + + ok = bool(prof) and (msg.strip().lower() in ("saved", "ok")) + if not msg and ok: + msg = "saved" + return TrafficAppProfileSaveResult(ok=ok, message=msg, profile=prof) + + def traffic_app_profile_delete(self, id: str) -> CmdResult: + pid = str(id or "").strip() + if not pid: + raise ValueError("missing id") + data = cast( + Dict[str, Any], + self._json( + self._request("DELETE", "/api/v1/traffic/app-profiles", params={"id": pid}) + ) + or {}, + ) + return CmdResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or ""), + exit_code=None, + stdout="", + stderr="", + ) + + def traffic_audit_get(self) -> TrafficAudit: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/traffic/audit")) or {}, + ) + raw_issues = data.get("issues") or [] + if not isinstance(raw_issues, list): + raw_issues = [] + return TrafficAudit( + ok=bool(data.get("ok", False)), + message=strip_ansi(str(data.get("message") or "").strip()), + now=str(data.get("now") or "").strip(), + pretty=strip_ansi(str(data.get("pretty") or "").strip()), + issues=[strip_ansi(str(x)).strip() for x in raw_issues if str(x).strip()], + ) + + def _parse_traffic_mode_status(self, data: Dict[str, Any], *, fallback_mode: str) -> TrafficModeStatus: + mode = str(data.get("mode") or fallback_mode or "selective") + return TrafficModeStatus( + mode=mode, + desired_mode=str(data.get("desired_mode") or data.get("mode") or mode), + applied_mode=str(data.get("applied_mode") or "direct"), + preferred_iface=str(data.get("preferred_iface") or ""), + advanced_active=bool(data.get("advanced_active", False)), + auto_local_bypass=bool(data.get("auto_local_bypass", True)), + auto_local_active=bool(data.get("auto_local_active", False)), + ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)), + ingress_reply_active=bool(data.get("ingress_reply_active", False)), + bypass_candidates=int(data.get("bypass_candidates", 0) or 0), + force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()], + force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()], + force_vpn_cgroups=[str(x) for x in (data.get("force_vpn_cgroups") or []) if str(x).strip()], + force_direct_subnets=[str(x) for x in (data.get("force_direct_subnets") or []) if str(x).strip()], + force_direct_uids=[str(x) for x in (data.get("force_direct_uids") or []) if str(x).strip()], + force_direct_cgroups=[str(x) for x in (data.get("force_direct_cgroups") or []) if str(x).strip()], + overrides_applied=int(data.get("overrides_applied", 0) or 0), + cgroup_resolved_uids=int(data.get("cgroup_resolved_uids", 0) or 0), + cgroup_warning=str(data.get("cgroup_warning") or ""), + active_iface=str(data.get("active_iface") or ""), + iface_reason=str(data.get("iface_reason") or ""), + rule_mark=bool(data.get("rule_mark", False)), + rule_full=bool(data.get("rule_full", False)), + ingress_rule_present=bool(data.get("ingress_rule_present", False)), + ingress_nft_active=bool(data.get("ingress_nft_active", False)), + table_default=bool(data.get("table_default", False)), + probe_ok=bool(data.get("probe_ok", False)), + probe_message=str(data.get("probe_message") or ""), + healthy=bool(data.get("healthy", False)), + message=str(data.get("message") or ""), + ) diff --git a/selective-vpn-gui/api/transport.py b/selective-vpn-gui/api/transport.py new file mode 100644 index 0000000..b83b386 --- /dev/null +++ b/selective-vpn-gui/api/transport.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from .transport_clients import TransportClientsApiMixin +from .transport_policy import TransportPolicyApiMixin +from .transport_singbox import TransportSingBoxApiMixin + + +class TransportApiMixin( + TransportClientsApiMixin, + TransportPolicyApiMixin, + TransportSingBoxApiMixin, +): + """Facade mixin for transport domain API. + + Kept for backward compatibility with existing `from api.transport import TransportApiMixin` + imports while implementation is split by subdomain. + """ + + pass diff --git a/selective-vpn-gui/api/transport_clients.py b/selective-vpn-gui/api/transport_clients.py new file mode 100644 index 0000000..6a498d6 --- /dev/null +++ b/selective-vpn-gui/api/transport_clients.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, cast + +from .models import * +from .utils import strip_ansi + + +class TransportClientsApiMixin: + def transport_interfaces_get(self) -> TransportInterfacesSnapshot: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/transport/interfaces")) or {}, + ) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + items: List[TransportInterfaceItem] = [] + for row in raw: + parsed = self._parse_transport_interface_item(row) + if parsed is not None: + items.append(parsed) + return TransportInterfacesSnapshot( + ok=bool(data.get("ok", False)), + message=strip_ansi(str(data.get("message") or "").strip()), + code=str(data.get("code") or "").strip(), + count=self._to_int(data.get("count")), + items=items, + ) + + def transport_clients_get(self, enabled_only: bool = False, kind: str = "", include_virtual: bool = False) -> List[TransportClient]: + params: Dict[str, Any] = {} + if enabled_only: + params["enabled_only"] = "true" + kind_l = str(kind or "").strip().lower() + if kind_l: + params["kind"] = kind_l + if include_virtual: + params["include_virtual"] = "true" + + data = cast( + Dict[str, Any], + self._json( + self._request("GET", "/api/v1/transport/clients", params=(params or None)) + ) + or {}, + ) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + + out: List[TransportClient] = [] + for row in raw: + item = self._parse_transport_client(row) + if item is not None: + out.append(item) + return out + + def transport_health_refresh( + self, + *, + client_ids: Optional[List[str]] = None, + force: bool = False, + ) -> TransportHealthRefreshResult: + payload: Dict[str, Any] = {} + ids: List[str] = [] + for raw in list(client_ids or []): + cid = str(raw or "").strip() + if cid: + ids.append(cid) + if ids: + payload["client_ids"] = ids + if force: + payload["force"] = True + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/transport/health/refresh", + json_body=payload, + ) + ) + or {}, + ) + + raw_items = data.get("items") or [] + if not isinstance(raw_items, list): + raw_items = [] + + items: List[TransportHealthRefreshItem] = [] + for row in raw_items: + if not isinstance(row, dict): + continue + items.append( + TransportHealthRefreshItem( + client_id=str(row.get("client_id") or "").strip(), + status=str(row.get("status") or "").strip().lower(), + queued=bool(row.get("queued", False)), + reason=strip_ansi(str(row.get("reason") or "").strip()), + ) + ) + + return TransportHealthRefreshResult( + ok=bool(data.get("ok", False)), + message=strip_ansi(str(data.get("message") or "").strip()), + code=str(data.get("code") or "").strip(), + count=self._to_int(data.get("count")), + queued=self._to_int(data.get("queued")), + skipped=self._to_int(data.get("skipped")), + items=items, + ) + + def transport_client_health_get(self, client_id: str) -> TransportClientHealthSnapshot: + cid = str(client_id or "").strip() + if not cid: + raise ValueError("missing transport client id") + + data = cast( + Dict[str, Any], + self._json( + self._request( + "GET", + f"/api/v1/transport/clients/{cid}/health", + ) + ) + or {}, + ) + + raw_health = data.get("health") or {} + if not isinstance(raw_health, dict): + raw_health = {} + + latency_raw = raw_health.get("latency_ms") + if latency_raw is None: + latency_raw = data.get("latency_ms") + + last_err = ( + str(raw_health.get("last_error") or "").strip() + or str(data.get("last_error") or "").strip() + ) + last_check = ( + str(raw_health.get("last_check") or "").strip() + or str(data.get("last_check") or "").strip() + ) + + return TransportClientHealthSnapshot( + client_id=str(data.get("client_id") or cid).strip(), + status=str(data.get("status") or "").strip().lower(), + latency_ms=self._to_int(latency_raw), + last_error=strip_ansi(last_err), + last_check=last_check, + ) + + def transport_client_create( + self, + *, + client_id: str, + kind: str, + name: str = "", + enabled: bool = True, + config: Optional[Dict[str, Any]] = None, + ) -> CmdResult: + cid = str(client_id or "").strip() + if not cid: + raise ValueError("missing transport client id") + k = str(kind or "").strip().lower() + if not k: + raise ValueError("missing transport client kind") + payload: Dict[str, Any] = { + "id": cid, + "kind": k, + "enabled": bool(enabled), + } + if str(name or "").strip(): + payload["name"] = str(name).strip() + if config is not None: + payload["config"] = cast(Dict[str, Any], config) + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/transport/clients", + json_body=payload, + ) + ) + or {}, + ) + return CmdResult( + ok=bool(data.get("ok", False)), + message=strip_ansi(str(data.get("message") or "").strip()), + exit_code=None, + stdout="", + stderr="", + ) + + def transport_client_patch( + self, + client_id: str, + *, + name: Optional[str] = None, + enabled: Optional[bool] = None, + config: Optional[Dict[str, Any]] = None, + ) -> CmdResult: + cid = str(client_id or "").strip() + if not cid: + raise ValueError("missing transport client id") + payload: Dict[str, Any] = {} + if name is not None: + payload["name"] = str(name).strip() + if enabled is not None: + payload["enabled"] = bool(enabled) + if config is not None: + payload["config"] = cast(Dict[str, Any], config) + if not payload: + raise ValueError("empty patch payload") + + data = cast( + Dict[str, Any], + self._json( + self._request( + "PATCH", + f"/api/v1/transport/clients/{cid}", + json_body=payload, + ) + ) + or {}, + ) + return CmdResult( + ok=bool(data.get("ok", False)), + message=strip_ansi(str(data.get("message") or "").strip()), + exit_code=None, + stdout="", + stderr="", + ) + + def transport_client_action( + self, + client_id: str, + action: TransportClientAction, + ) -> TransportClientActionResult: + cid = str(client_id or "").strip() + if not cid: + raise ValueError("missing transport client id") + act = str(action or "").strip().lower() + if act not in ("provision", "start", "stop", "restart"): + raise ValueError(f"invalid transport action: {action}") + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + f"/api/v1/transport/clients/{cid}/{act}", + ) + ) + or {}, + ) + + health_raw = data.get("health") or {} + if not isinstance(health_raw, dict): + health_raw = {} + runtime_raw = data.get("runtime") or {} + if not isinstance(runtime_raw, dict): + runtime_raw = {} + runtime_err_raw = runtime_raw.get("last_error") or {} + if not isinstance(runtime_err_raw, dict): + runtime_err_raw = {} + + last_error = ( + str(health_raw.get("last_error") or "").strip() + or str(runtime_err_raw.get("message") or "").strip() + or str(data.get("stderr") or "").strip() + ) + + exit_code_val = data.get("exitCode", None) + exit_code: Optional[int] + try: + exit_code = int(exit_code_val) if exit_code_val is not None else None + except (TypeError, ValueError): + exit_code = None + + return TransportClientActionResult( + ok=bool(data.get("ok", False)), + message=strip_ansi(str(data.get("message") or "").strip()), + code=str(data.get("code") or "").strip(), + client_id=str(data.get("client_id") or cid).strip(), + kind=str(data.get("kind") or "").strip().lower(), + action=str(data.get("action") or act).strip().lower(), + status_before=str(data.get("status_before") or "").strip().lower(), + status_after=str(data.get("status_after") or "").strip().lower(), + last_error=strip_ansi(last_error), + exit_code=exit_code, + stdout=strip_ansi(str(data.get("stdout") or "")), + stderr=strip_ansi(str(data.get("stderr") or "")), + ) + + def transport_client_delete( + self, + client_id: str, + *, + force: bool = False, + cleanup: bool = True, + ) -> CmdResult: + cid = str(client_id or "").strip() + if not cid: + raise ValueError("missing transport client id") + params: Dict[str, Any] = {} + if force: + params["force"] = "true" + if not cleanup: + params["cleanup"] = "false" + data = cast( + Dict[str, Any], + self._json( + self._request( + "DELETE", + f"/api/v1/transport/clients/{cid}", + params=(params or None), + ) + ) + or {}, + ) + return CmdResult( + ok=bool(data.get("ok", False)), + message=strip_ansi(str(data.get("message") or "").strip()), + exit_code=None, + stdout="", + stderr="", + ) + + def transport_netns_toggle( + self, + *, + enabled: Optional[bool] = None, + client_ids: Optional[List[str]] = None, + provision: bool = True, + restart_running: bool = True, + ) -> TransportNetnsToggleResult: + payload: Dict[str, Any] = { + "provision": bool(provision), + "restart_running": bool(restart_running), + } + if enabled is not None: + payload["enabled"] = bool(enabled) + if client_ids is not None: + payload["client_ids"] = [ + str(x).strip() + for x in (client_ids or []) + if str(x).strip() + ] + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/transport/netns/toggle", + json_body=payload, + ) + ) + or {}, + ) + + raw_items = data.get("items") or [] + if not isinstance(raw_items, list): + raw_items = [] + items: List[TransportNetnsToggleItem] = [] + for row in raw_items: + if not isinstance(row, dict): + continue + items.append( + TransportNetnsToggleItem( + ok=bool(row.get("ok", False)), + message=strip_ansi(str(row.get("message") or "").strip()), + code=str(row.get("code") or "").strip(), + client_id=str(row.get("client_id") or "").strip(), + kind=str(row.get("kind") or "").strip().lower(), + status_before=str(row.get("status_before") or "").strip().lower(), + status_after=str(row.get("status_after") or "").strip().lower(), + netns_enabled=bool(row.get("netns_enabled", False)), + config_updated=bool(row.get("config_updated", False)), + provisioned=bool(row.get("provisioned", False)), + restarted=bool(row.get("restarted", False)), + stdout=strip_ansi(str(row.get("stdout") or "")), + stderr=strip_ansi(str(row.get("stderr") or "")), + ) + ) + + return TransportNetnsToggleResult( + ok=bool(data.get("ok", False)), + message=strip_ansi(str(data.get("message") or "").strip()), + code=str(data.get("code") or "").strip(), + enabled=bool(data.get("enabled", False)), + count=self._to_int(data.get("count")), + success_count=self._to_int(data.get("success_count")), + failure_count=self._to_int(data.get("failure_count")), + items=items, + ) + + def _parse_transport_interface_item(self, row: Any) -> Optional[TransportInterfaceItem]: + if not isinstance(row, dict): + return None + iface_id = str(row.get("id") or "").strip() + if not iface_id: + return None + raw_ids = row.get("client_ids") or [] + if not isinstance(raw_ids, list): + raw_ids = [] + client_ids = [str(x).strip() for x in raw_ids if str(x).strip()] + cfg = row.get("config") or {} + if not isinstance(cfg, dict): + cfg = {} + return TransportInterfaceItem( + id=iface_id, + name=str(row.get("name") or "").strip(), + mode=str(row.get("mode") or "").strip().lower(), + runtime_iface=str(row.get("runtime_iface") or "").strip(), + netns_name=str(row.get("netns_name") or "").strip(), + routing_table=str(row.get("routing_table") or "").strip(), + client_ids=client_ids, + client_count=self._to_int(row.get("client_count")), + up_count=self._to_int(row.get("up_count")), + updated_at=str(row.get("updated_at") or "").strip(), + config=cast(Dict[str, Any], cfg), + ) + def _parse_transport_client(self, row: Any) -> Optional[TransportClient]: + if not isinstance(row, dict): + return None + cid = str(row.get("id") or "").strip() + if not cid: + return None + raw_health = row.get("health") or {} + if not isinstance(raw_health, dict): + raw_health = {} + raw_caps = row.get("capabilities") or [] + if not isinstance(raw_caps, list): + raw_caps = [] + raw_cfg = row.get("config") or {} + if not isinstance(raw_cfg, dict): + raw_cfg = {} + return TransportClient( + id=cid, + name=str(row.get("name") or "").strip(), + kind=str(row.get("kind") or "").strip().lower(), + enabled=bool(row.get("enabled", False)), + status=str(row.get("status") or "").strip().lower(), + iface=str(row.get("iface") or "").strip(), + routing_table=str(row.get("routing_table") or "").strip(), + mark_hex=str(row.get("mark_hex") or "").strip(), + priority_base=self._to_int(row.get("priority_base")), + capabilities=[str(x).strip() for x in raw_caps if str(x).strip()], + health=TransportClientHealth( + last_check=str(raw_health.get("last_check") or "").strip(), + latency_ms=self._to_int(raw_health.get("latency_ms")), + last_error=str(raw_health.get("last_error") or "").strip(), + ), + config=cast(Dict[str, Any], raw_cfg), + updated_at=str(row.get("updated_at") or "").strip(), + ) diff --git a/selective-vpn-gui/api/transport_policy.py b/selective-vpn-gui/api/transport_policy.py new file mode 100644 index 0000000..468a1e5 --- /dev/null +++ b/selective-vpn-gui/api/transport_policy.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, cast + +from .models import * + + +class TransportPolicyApiMixin: + def transport_policy_get(self) -> TransportPolicy: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/transport/policies")) or {}, + ) + raw = data.get("intents") or [] + if not isinstance(raw, list): + raw = [] + intents: List[TransportPolicyIntent] = [] + for row in raw: + it = self._parse_transport_intent(row) + if it is not None: + intents.append(it) + return TransportPolicy( + revision=self._to_int(data.get("policy_revision")), + intents=intents, + ) + + def transport_policy_validate( + self, + *, + base_revision: int = 0, + intents: List[TransportPolicyIntent], + allow_warnings: bool = True, + force_override: bool = False, + ) -> TransportPolicyValidateResult: + payload: Dict[str, Any] = { + "intents": [self._transport_intent_payload(it) for it in (intents or [])], + } + if int(base_revision or 0) > 0: + payload["base_revision"] = int(base_revision) + payload["options"] = { + "allow_warnings": bool(allow_warnings), + "force_override": bool(force_override), + } + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/transport/policies/validate", + json_body=payload, + ) + ) + or {}, + ) + + summary_raw = data.get("summary") or {} + if not isinstance(summary_raw, dict): + summary_raw = {} + diff_raw = data.get("diff") or {} + if not isinstance(diff_raw, dict): + diff_raw = {} + + conflicts_raw = data.get("conflicts") or [] + if not isinstance(conflicts_raw, list): + conflicts_raw = [] + conflicts: List[TransportConflict] = [] + for row in conflicts_raw: + c = self._parse_transport_conflict(row) + if c is not None: + conflicts.append(c) + + return TransportPolicyValidateResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or ""), + code=str(data.get("code") or ""), + valid=bool(data.get("valid", False)), + base_revision=self._to_int(data.get("base_revision")), + confirm_token=str(data.get("confirm_token") or "").strip(), + summary=TransportPolicyValidateSummary( + block_count=self._to_int(summary_raw.get("block_count")), + warn_count=self._to_int(summary_raw.get("warn_count")), + ), + conflicts=conflicts, + diff=TransportPolicyDiff( + added=self._to_int(diff_raw.get("added")), + changed=self._to_int(diff_raw.get("changed")), + removed=self._to_int(diff_raw.get("removed")), + ), + ) + + def transport_policy_apply( + self, + *, + base_revision: int, + intents: List[TransportPolicyIntent], + force_override: bool = False, + confirm_token: str = "", + ) -> TransportPolicyApplyResult: + payload: Dict[str, Any] = { + "base_revision": int(base_revision), + "intents": [self._transport_intent_payload(it) for it in (intents or [])], + } + opts: Dict[str, Any] = {} + if force_override: + opts["force_override"] = True + if confirm_token: + opts["confirm_token"] = str(confirm_token).strip() + if opts: + payload["options"] = opts + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/transport/policies/apply", + json_body=payload, + ) + ) + or {}, + ) + return self._parse_transport_policy_apply(data) + + def transport_policy_rollback(self, *, base_revision: int = 0) -> TransportPolicyApplyResult: + payload: Dict[str, Any] = {} + if int(base_revision or 0) > 0: + payload["base_revision"] = int(base_revision) + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/transport/policies/rollback", + json_body=payload, + ) + ) + or {}, + ) + return self._parse_transport_policy_apply(data) + + def transport_conflicts_get(self) -> TransportConflicts: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/transport/conflicts")) or {}, + ) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + items: List[TransportConflict] = [] + for row in raw: + c = self._parse_transport_conflict(row) + if c is not None: + items.append(c) + return TransportConflicts( + has_blocking=bool(data.get("has_blocking", False)), + items=items, + ) + + def transport_capabilities_get(self) -> TransportCapabilities: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/transport/capabilities")) or {}, + ) + raw = data.get("clients") or {} + if not isinstance(raw, dict): + raw = {} + clients: Dict[str, Dict[str, bool]] = {} + for kind, caps_raw in raw.items(): + key = str(kind or "").strip().lower() + if not key: + continue + if not isinstance(caps_raw, dict): + continue + caps: Dict[str, bool] = {} + for cap_name, cap_value in caps_raw.items(): + cname = str(cap_name or "").strip().lower() + if not cname: + continue + caps[cname] = bool(cap_value) + clients[key] = caps + return TransportCapabilities(clients=clients) + + def transport_ownership_get(self) -> TransportOwnershipSnapshot: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/transport/owners")) or {}, + ) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + items: List[TransportOwnershipRecord] = [] + for row in raw: + rec = self._parse_transport_ownership_record(row) + if rec is not None: + items.append(rec) + return TransportOwnershipSnapshot( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or "").strip(), + code=str(data.get("code") or "").strip(), + policy_revision=self._to_int(data.get("policy_revision")), + plan_digest=str(data.get("plan_digest") or "").strip(), + count=self._to_int(data.get("count")), + lock_count=self._to_int(data.get("lock_count")), + items=items, + ) + + def transport_owner_locks_get(self) -> TransportOwnerLocksSnapshot: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/transport/owner-locks")) or {}, + ) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + items: List[TransportOwnerLockRecord] = [] + for row in raw: + rec = self._parse_transport_owner_lock_record(row) + if rec is not None: + items.append(rec) + return TransportOwnerLocksSnapshot( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or "").strip(), + code=str(data.get("code") or "").strip(), + policy_revision=self._to_int(data.get("policy_revision")), + count=self._to_int(data.get("count")), + items=items, + ) + + def transport_owner_locks_clear( + self, + *, + base_revision: int = 0, + client_id: str = "", + destination_ip: str = "", + destination_ips: Optional[List[str]] = None, + confirm_token: str = "", + ) -> TransportOwnerLocksClearResult: + payload: Dict[str, Any] = {} + if int(base_revision or 0) > 0: + payload["base_revision"] = int(base_revision) + cid = str(client_id or "").strip() + if cid: + payload["client_id"] = cid + dst = str(destination_ip or "").strip() + if dst: + payload["destination_ip"] = dst + ips: List[str] = [] + for raw in list(destination_ips or []): + value = str(raw or "").strip() + if not value: + continue + ips.append(value) + if ips: + payload["destination_ips"] = ips + token = str(confirm_token or "").strip() + if token: + payload["confirm_token"] = token + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/transport/owner-locks/clear", + json_body=payload, + ) + ) + or {}, + ) + + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + items: List[TransportOwnerLockRecord] = [] + for row in raw: + rec = self._parse_transport_owner_lock_record(row) + if rec is not None: + items.append(rec) + + return TransportOwnerLocksClearResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or "").strip(), + code=str(data.get("code") or "").strip(), + base_revision=self._to_int(data.get("base_revision")), + confirm_required=bool(data.get("confirm_required", False)), + confirm_token=str(data.get("confirm_token") or "").strip(), + match_count=self._to_int(data.get("match_count")), + cleared_count=self._to_int(data.get("cleared_count")), + remaining_count=self._to_int(data.get("remaining_count")), + items=items, + ) + + def _parse_transport_intent(self, row: Any) -> Optional[TransportPolicyIntent]: + if not isinstance(row, dict): + return None + selector_type = str(row.get("selector_type") or "").strip().lower() + selector_value = str(row.get("selector_value") or "").strip() + client_id = str(row.get("client_id") or "").strip() + if not selector_type or not selector_value or not client_id: + return None + mode = str(row.get("mode") or "strict").strip().lower() or "strict" + if mode not in ("strict", "fallback"): + mode = "strict" + priority = self._to_int(row.get("priority"), default=100) + if priority <= 0: + priority = 100 + return TransportPolicyIntent( + selector_type=selector_type, + selector_value=selector_value, + client_id=client_id, + priority=priority, + mode=mode, + ) + + def _transport_intent_payload(self, intent: TransportPolicyIntent) -> Dict[str, Any]: + if isinstance(intent, dict): + src = cast(Dict[str, Any], intent) + priority = self._to_int(src.get("priority"), default=100) + mode = str(src.get("mode") or "strict").strip().lower() + selector_type = str(src.get("selector_type") or "").strip().lower() + selector_value = str(src.get("selector_value") or "").strip() + client_id = str(src.get("client_id") or "").strip() + else: + priority = int(getattr(intent, "priority", 100) or 100) + mode = str(getattr(intent, "mode", "strict") or "strict").strip().lower() + selector_type = str(getattr(intent, "selector_type", "") or "").strip().lower() + selector_value = str(getattr(intent, "selector_value", "") or "").strip() + client_id = str(getattr(intent, "client_id", "") or "").strip() + if mode not in ("strict", "fallback"): + mode = "strict" + payload: Dict[str, Any] = { + "selector_type": selector_type, + "selector_value": selector_value, + "client_id": client_id, + "priority": priority, + "mode": mode, + } + return payload + + def _parse_transport_conflict(self, row: Any) -> Optional[TransportConflict]: + if not isinstance(row, dict): + return None + key = str(row.get("key") or "").strip() + if not key: + return None + raw_owners = row.get("owners") or [] + if not isinstance(raw_owners, list): + raw_owners = [] + owners = [str(x).strip() for x in raw_owners if str(x).strip()] + return TransportConflict( + key=key, + type=str(row.get("type") or "").strip().lower(), + severity=str(row.get("severity") or "warn").strip().lower(), + owners=owners, + reason=str(row.get("reason") or "").strip(), + suggested_resolution=str(row.get("suggested_resolution") or "").strip(), + ) + + def _parse_transport_ownership_record(self, row: Any) -> Optional[TransportOwnershipRecord]: + if not isinstance(row, dict): + return None + key = str(row.get("key") or "").strip() + selector_type = str(row.get("selector_type") or "").strip().lower() + selector_value = str(row.get("selector_value") or "").strip() + client_id = str(row.get("client_id") or "").strip() + if not key or not selector_type or not selector_value or not client_id: + return None + return TransportOwnershipRecord( + key=key, + selector_type=selector_type, + selector_value=selector_value, + client_id=client_id, + client_kind=str(row.get("client_kind") or "").strip().lower(), + owner_scope=str(row.get("owner_scope") or "").strip(), + owner_status=str(row.get("owner_status") or "").strip().lower(), + lock_active=bool(row.get("lock_active", False)), + iface_id=str(row.get("iface_id") or "").strip(), + routing_table=str(row.get("routing_table") or "").strip(), + mark_hex=str(row.get("mark_hex") or "").strip(), + priority_base=self._to_int(row.get("priority_base")), + mode=str(row.get("mode") or "").strip().lower(), + priority=self._to_int(row.get("priority")), + updated_at=str(row.get("updated_at") or "").strip(), + ) + + def _parse_transport_owner_lock_record(self, row: Any) -> Optional[TransportOwnerLockRecord]: + if not isinstance(row, dict): + return None + destination_ip = str(row.get("destination_ip") or "").strip() + client_id = str(row.get("client_id") or "").strip() + if not destination_ip or not client_id: + return None + return TransportOwnerLockRecord( + destination_ip=destination_ip, + client_id=client_id, + client_kind=str(row.get("client_kind") or "").strip().lower(), + iface_id=str(row.get("iface_id") or "").strip(), + mark_hex=str(row.get("mark_hex") or "").strip(), + proto=str(row.get("proto") or "").strip().lower(), + updated_at=str(row.get("updated_at") or "").strip(), + ) + + def _parse_transport_policy_apply(self, data: Dict[str, Any]) -> TransportPolicyApplyResult: + raw = data.get("conflicts") or [] + if not isinstance(raw, list): + raw = [] + conflicts: List[TransportConflict] = [] + for row in raw: + c = self._parse_transport_conflict(row) + if c is not None: + conflicts.append(c) + return TransportPolicyApplyResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or "").strip(), + code=str(data.get("code") or "").strip(), + policy_revision=self._to_int(data.get("policy_revision")), + current_revision=self._to_int(data.get("current_revision")), + apply_id=str(data.get("apply_id") or "").strip(), + rollback_available=bool(data.get("rollback_available", False)), + conflicts=conflicts, + ) diff --git a/selective-vpn-gui/api/transport_singbox.py b/selective-vpn-gui/api/transport_singbox.py new file mode 100644 index 0000000..866ba40 --- /dev/null +++ b/selective-vpn-gui/api/transport_singbox.py @@ -0,0 +1,623 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, cast + +from .errors import ApiError +from .models import * + + +class TransportSingBoxApiMixin: + def transport_singbox_profiles_get( + self, + *, + enabled_only: bool = False, + mode: str = "", + protocol: str = "", + ) -> SingBoxProfilesState: + params: Dict[str, Any] = {} + if enabled_only: + params["enabled_only"] = "true" + mode_v = str(mode or "").strip().lower() + if mode_v: + params["mode"] = mode_v + protocol_v = str(protocol or "").strip().lower() + if protocol_v: + params["protocol"] = protocol_v + + data = cast( + Dict[str, Any], + self._json( + self._request( + "GET", + "/api/v1/transport/singbox/profiles", + params=(params or None), + ) + ) + or {}, + ) + return self._parse_singbox_profiles_state(data) + + def transport_singbox_profile_get(self, profile_id: str) -> SingBoxProfile: + pid = str(profile_id or "").strip() + if not pid: + raise ValueError("missing singbox profile id") + data = cast( + Dict[str, Any], + self._json(self._request("GET", f"/api/v1/transport/singbox/profiles/{pid}")) or {}, + ) + snap = self._parse_singbox_profiles_state(data) + if snap.item is None: + raise ApiError( + "API returned malformed singbox profile payload", + "GET", + self._url(f"/api/v1/transport/singbox/profiles/{pid}"), + ) + return snap.item + + def transport_singbox_profile_create( + self, + *, + profile_id: str = "", + name: str = "", + mode: str = "raw", + protocol: str = "", + enabled: Optional[bool] = None, + schema_version: int = 1, + typed: Optional[Dict[str, Any]] = None, + raw_config: Optional[Dict[str, Any]] = None, + meta: Optional[Dict[str, Any]] = None, + secrets: Optional[Dict[str, str]] = None, + ) -> SingBoxProfilesState: + payload: Dict[str, Any] = { + "id": str(profile_id or "").strip(), + "name": str(name or "").strip(), + "mode": str(mode or "raw").strip().lower(), + "protocol": str(protocol or "").strip().lower(), + "schema_version": int(schema_version or 1), + } + if enabled is not None: + payload["enabled"] = bool(enabled) + if typed is not None: + payload["typed"] = cast(Dict[str, Any], typed) + if raw_config is not None: + payload["raw_config"] = cast(Dict[str, Any], raw_config) + if meta is not None: + payload["meta"] = cast(Dict[str, Any], meta) + if secrets is not None: + payload["secrets"] = { + str(k).strip(): str(v) + for k, v in cast(Dict[str, Any], secrets).items() + if str(k).strip() + } + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/transport/singbox/profiles", + json_body=payload, + ) + ) + or {}, + ) + return self._parse_singbox_profiles_state(data) + + def transport_singbox_profile_patch( + self, + profile_id: str, + *, + base_revision: int = 0, + name: Optional[str] = None, + mode: Optional[str] = None, + protocol: Optional[str] = None, + enabled: Optional[bool] = None, + schema_version: Optional[int] = None, + typed: Optional[Dict[str, Any]] = None, + raw_config: Optional[Dict[str, Any]] = None, + meta: Optional[Dict[str, Any]] = None, + secrets: Optional[Dict[str, str]] = None, + clear_secrets: bool = False, + ) -> SingBoxProfilesState: + pid = str(profile_id or "").strip() + if not pid: + raise ValueError("missing singbox profile id") + + payload: Dict[str, Any] = {} + if int(base_revision or 0) > 0: + payload["base_revision"] = int(base_revision) + if name is not None: + payload["name"] = str(name) + if mode is not None: + payload["mode"] = str(mode or "").strip().lower() + if protocol is not None: + payload["protocol"] = str(protocol or "").strip().lower() + if enabled is not None: + payload["enabled"] = bool(enabled) + if schema_version is not None: + payload["schema_version"] = int(schema_version) + if typed is not None: + payload["typed"] = cast(Dict[str, Any], typed) + if raw_config is not None: + payload["raw_config"] = cast(Dict[str, Any], raw_config) + if meta is not None: + payload["meta"] = cast(Dict[str, Any], meta) + if secrets is not None: + payload["secrets"] = { + str(k).strip(): str(v) + for k, v in cast(Dict[str, Any], secrets).items() + if str(k).strip() + } + if clear_secrets: + payload["clear_secrets"] = True + + data = cast( + Dict[str, Any], + self._json( + self._request( + "PATCH", + f"/api/v1/transport/singbox/profiles/{pid}", + json_body=payload, + ) + ) + or {}, + ) + return self._parse_singbox_profiles_state(data) + + def transport_singbox_profile_render( + self, + profile_id: str, + *, + base_revision: int = 0, + check_binary: Optional[bool] = None, + persist: Optional[bool] = None, + ) -> SingBoxProfileRenderResult: + pid = str(profile_id or "").strip() + if not pid: + raise ValueError("missing singbox profile id") + + payload: Dict[str, Any] = {} + if int(base_revision or 0) > 0: + payload["base_revision"] = int(base_revision) + if check_binary is not None: + payload["check_binary"] = bool(check_binary) + if persist is not None: + payload["persist"] = bool(persist) + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + f"/api/v1/transport/singbox/profiles/{pid}/render", + json_body=payload, + ) + ) + or {}, + ) + return self._parse_singbox_profile_render(data, fallback_id=pid) + + def transport_singbox_profile_validate( + self, + profile_id: str, + *, + base_revision: int = 0, + check_binary: Optional[bool] = None, + ) -> SingBoxProfileValidateResult: + pid = str(profile_id or "").strip() + if not pid: + raise ValueError("missing singbox profile id") + + payload: Dict[str, Any] = {} + if int(base_revision or 0) > 0: + payload["base_revision"] = int(base_revision) + if check_binary is not None: + payload["check_binary"] = bool(check_binary) + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + f"/api/v1/transport/singbox/profiles/{pid}/validate", + json_body=payload, + ) + ) + or {}, + ) + return self._parse_singbox_profile_validate(data, fallback_id=pid) + + def transport_singbox_profile_apply( + self, + profile_id: str, + *, + client_id: str = "", + config_path: str = "", + restart: Optional[bool] = None, + skip_runtime: bool = False, + check_binary: Optional[bool] = None, + base_revision: int = 0, + ) -> SingBoxProfileApplyResult: + pid = str(profile_id or "").strip() + if not pid: + raise ValueError("missing singbox profile id") + + payload: Dict[str, Any] = {} + if int(base_revision or 0) > 0: + payload["base_revision"] = int(base_revision) + cid = str(client_id or "").strip() + if cid: + payload["client_id"] = cid + path = str(config_path or "").strip() + if path: + payload["config_path"] = path + if restart is not None: + payload["restart"] = bool(restart) + if bool(skip_runtime): + payload["skip_runtime"] = True + if check_binary is not None: + payload["check_binary"] = bool(check_binary) + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + f"/api/v1/transport/singbox/profiles/{pid}/apply", + json_body=payload, + ) + ) + or {}, + ) + return self._parse_singbox_profile_apply(data, fallback_id=pid, fallback_client=cid) + + def transport_singbox_profile_rollback( + self, + profile_id: str, + *, + base_revision: int = 0, + client_id: str = "", + config_path: str = "", + history_id: str = "", + restart: Optional[bool] = None, + skip_runtime: bool = False, + ) -> SingBoxProfileRollbackResult: + pid = str(profile_id or "").strip() + if not pid: + raise ValueError("missing singbox profile id") + + payload: Dict[str, Any] = {} + if int(base_revision or 0) > 0: + payload["base_revision"] = int(base_revision) + cid = str(client_id or "").strip() + if cid: + payload["client_id"] = cid + path = str(config_path or "").strip() + if path: + payload["config_path"] = path + hid = str(history_id or "").strip() + if hid: + payload["history_id"] = hid + if restart is not None: + payload["restart"] = bool(restart) + if bool(skip_runtime): + payload["skip_runtime"] = True + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + f"/api/v1/transport/singbox/profiles/{pid}/rollback", + json_body=payload, + ) + ) + or {}, + ) + return self._parse_singbox_profile_rollback(data, fallback_id=pid, fallback_client=cid) + + def transport_singbox_profile_history( + self, + profile_id: str, + *, + limit: int = 20, + ) -> SingBoxProfileHistoryResult: + pid = str(profile_id or "").strip() + if not pid: + raise ValueError("missing singbox profile id") + lim = int(limit or 0) + if lim <= 0: + lim = 20 + data = cast( + Dict[str, Any], + self._json( + self._request( + "GET", + f"/api/v1/transport/singbox/profiles/{pid}/history", + params={"limit": str(lim)}, + ) + ) + or {}, + ) + return self._parse_singbox_profile_history(data, fallback_id=pid) + + # DNS / SmartDNS + + def _parse_singbox_profile_issue(self, row: Any) -> Optional[SingBoxProfileIssue]: + if not isinstance(row, dict): + return None + return SingBoxProfileIssue( + field=str(row.get("field") or "").strip(), + severity=str(row.get("severity") or "").strip().lower(), + code=str(row.get("code") or "").strip(), + message=str(row.get("message") or "").strip(), + ) + + def _parse_singbox_profile_diff(self, raw: Any) -> SingBoxProfileRenderDiff: + data = raw if isinstance(raw, dict) else {} + return SingBoxProfileRenderDiff( + added=self._to_int(data.get("added")), + changed=self._to_int(data.get("changed")), + removed=self._to_int(data.get("removed")), + ) + + def _parse_singbox_profile(self, raw: Any) -> Optional[SingBoxProfile]: + if not isinstance(raw, dict): + return None + pid = str(raw.get("id") or "").strip() + if not pid: + return None + + typed_raw = raw.get("typed") or {} + if not isinstance(typed_raw, dict): + typed_raw = {} + raw_cfg = raw.get("raw_config") or {} + if not isinstance(raw_cfg, dict): + raw_cfg = {} + meta_raw = raw.get("meta") or {} + if not isinstance(meta_raw, dict): + meta_raw = {} + masked_raw = raw.get("secrets_masked") or {} + if not isinstance(masked_raw, dict): + masked_raw = {} + masked: Dict[str, str] = {} + for k, v in masked_raw.items(): + key = str(k or "").strip() + if not key: + continue + masked[key] = str(v or "") + + return SingBoxProfile( + id=pid, + name=str(raw.get("name") or "").strip(), + mode=str(raw.get("mode") or "").strip().lower(), + protocol=str(raw.get("protocol") or "").strip().lower(), + enabled=bool(raw.get("enabled", False)), + schema_version=self._to_int(raw.get("schema_version"), default=1), + profile_revision=self._to_int(raw.get("profile_revision")), + render_revision=self._to_int(raw.get("render_revision")), + last_validated_at=str(raw.get("last_validated_at") or "").strip(), + last_applied_at=str(raw.get("last_applied_at") or "").strip(), + last_error=str(raw.get("last_error") or "").strip(), + typed=cast(Dict[str, Any], typed_raw), + raw_config=cast(Dict[str, Any], raw_cfg), + meta=cast(Dict[str, Any], meta_raw), + has_secrets=bool(raw.get("has_secrets", False)), + secrets_masked=masked, + created_at=str(raw.get("created_at") or "").strip(), + updated_at=str(raw.get("updated_at") or "").strip(), + ) + + def _parse_singbox_profiles_state(self, data: Dict[str, Any]) -> SingBoxProfilesState: + raw_items = data.get("items") or [] + if not isinstance(raw_items, list): + raw_items = [] + items: List[SingBoxProfile] = [] + for row in raw_items: + p = self._parse_singbox_profile(row) + if p is not None: + items.append(p) + + item = self._parse_singbox_profile(data.get("item")) + + return SingBoxProfilesState( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or "").strip(), + code=str(data.get("code") or "").strip(), + count=self._to_int(data.get("count"), default=len(items)), + active_profile_id=str(data.get("active_profile_id") or "").strip(), + items=items, + item=item, + ) + + def _parse_singbox_profile_validate( + self, + data: Dict[str, Any], + *, + fallback_id: str = "", + ) -> SingBoxProfileValidateResult: + raw_errors = data.get("errors") or [] + if not isinstance(raw_errors, list): + raw_errors = [] + raw_warnings = data.get("warnings") or [] + if not isinstance(raw_warnings, list): + raw_warnings = [] + + errors: List[SingBoxProfileIssue] = [] + for row in raw_errors: + issue = self._parse_singbox_profile_issue(row) + if issue is not None: + errors.append(issue) + + warnings: List[SingBoxProfileIssue] = [] + for row in raw_warnings: + issue = self._parse_singbox_profile_issue(row) + if issue is not None: + warnings.append(issue) + + return SingBoxProfileValidateResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or "").strip(), + code=str(data.get("code") or "").strip(), + profile_id=str(data.get("profile_id") or fallback_id).strip(), + profile_revision=self._to_int(data.get("profile_revision")), + valid=bool(data.get("valid", False)), + errors=errors, + warnings=warnings, + render_digest=str(data.get("render_digest") or "").strip(), + diff=self._parse_singbox_profile_diff(data.get("diff")), + ) + + def _parse_singbox_profile_apply( + self, + data: Dict[str, Any], + *, + fallback_id: str = "", + fallback_client: str = "", + ) -> SingBoxProfileApplyResult: + raw_errors = data.get("errors") or [] + if not isinstance(raw_errors, list): + raw_errors = [] + raw_warnings = data.get("warnings") or [] + if not isinstance(raw_warnings, list): + raw_warnings = [] + + errors: List[SingBoxProfileIssue] = [] + for row in raw_errors: + issue = self._parse_singbox_profile_issue(row) + if issue is not None: + errors.append(issue) + + warnings: List[SingBoxProfileIssue] = [] + for row in raw_warnings: + issue = self._parse_singbox_profile_issue(row) + if issue is not None: + warnings.append(issue) + + return SingBoxProfileApplyResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or "").strip(), + code=str(data.get("code") or "").strip(), + profile_id=str(data.get("profile_id") or fallback_id).strip(), + client_id=str(data.get("client_id") or fallback_client).strip(), + config_path=str(data.get("config_path") or "").strip(), + profile_revision=self._to_int(data.get("profile_revision")), + render_revision=self._to_int(data.get("render_revision")), + last_applied_at=str(data.get("last_applied_at") or "").strip(), + render_path=str(data.get("render_path") or "").strip(), + render_digest=str(data.get("render_digest") or "").strip(), + rollback_available=bool(data.get("rollback_available", False)), + valid=bool(data.get("valid", False)), + errors=errors, + warnings=warnings, + diff=self._parse_singbox_profile_diff(data.get("diff")), + ) + + def _parse_singbox_profile_render( + self, + data: Dict[str, Any], + *, + fallback_id: str = "", + ) -> SingBoxProfileRenderResult: + raw_errors = data.get("errors") or [] + if not isinstance(raw_errors, list): + raw_errors = [] + raw_warnings = data.get("warnings") or [] + if not isinstance(raw_warnings, list): + raw_warnings = [] + raw_config = data.get("config") or {} + if not isinstance(raw_config, dict): + raw_config = {} + + errors: List[SingBoxProfileIssue] = [] + for row in raw_errors: + issue = self._parse_singbox_profile_issue(row) + if issue is not None: + errors.append(issue) + + warnings: List[SingBoxProfileIssue] = [] + for row in raw_warnings: + issue = self._parse_singbox_profile_issue(row) + if issue is not None: + warnings.append(issue) + + return SingBoxProfileRenderResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or "").strip(), + code=str(data.get("code") or "").strip(), + profile_id=str(data.get("profile_id") or fallback_id).strip(), + profile_revision=self._to_int(data.get("profile_revision")), + render_revision=self._to_int(data.get("render_revision")), + render_path=str(data.get("render_path") or "").strip(), + render_digest=str(data.get("render_digest") or "").strip(), + changed=bool(data.get("changed", False)), + valid=bool(data.get("valid", False)), + errors=errors, + warnings=warnings, + diff=self._parse_singbox_profile_diff(data.get("diff")), + config=cast(Dict[str, Any], raw_config), + ) + + def _parse_singbox_profile_rollback( + self, + data: Dict[str, Any], + *, + fallback_id: str = "", + fallback_client: str = "", + ) -> SingBoxProfileRollbackResult: + return SingBoxProfileRollbackResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or "").strip(), + code=str(data.get("code") or "").strip(), + profile_id=str(data.get("profile_id") or fallback_id).strip(), + client_id=str(data.get("client_id") or fallback_client).strip(), + config_path=str(data.get("config_path") or "").strip(), + history_id=str(data.get("history_id") or "").strip(), + profile_revision=self._to_int(data.get("profile_revision")), + last_applied_at=str(data.get("last_applied_at") or "").strip(), + ) + + def _parse_singbox_profile_history_entry(self, raw: Any) -> Optional[SingBoxProfileHistoryEntry]: + if not isinstance(raw, dict): + return None + hid = str(raw.get("id") or "").strip() + if not hid: + return None + return SingBoxProfileHistoryEntry( + id=hid, + at=str(raw.get("at") or "").strip(), + profile_id=str(raw.get("profile_id") or "").strip(), + action=str(raw.get("action") or "").strip().lower(), + status=str(raw.get("status") or "").strip().lower(), + code=str(raw.get("code") or "").strip(), + message=str(raw.get("message") or "").strip(), + profile_revision=self._to_int(raw.get("profile_revision")), + render_revision=self._to_int(raw.get("render_revision")), + render_digest=str(raw.get("render_digest") or "").strip(), + render_path=str(raw.get("render_path") or "").strip(), + client_id=str(raw.get("client_id") or "").strip(), + ) + + def _parse_singbox_profile_history( + self, + data: Dict[str, Any], + *, + fallback_id: str = "", + ) -> SingBoxProfileHistoryResult: + raw_items = data.get("items") or [] + if not isinstance(raw_items, list): + raw_items = [] + items: List[SingBoxProfileHistoryEntry] = [] + for row in raw_items: + entry = self._parse_singbox_profile_history_entry(row) + if entry is not None: + items.append(entry) + + return SingBoxProfileHistoryResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or "").strip(), + code=str(data.get("code") or "").strip(), + profile_id=str(data.get("profile_id") or fallback_id).strip(), + count=self._to_int(data.get("count"), default=len(items)), + items=items, + ) + diff --git a/selective-vpn-gui/api/utils.py b/selective-vpn-gui/api/utils.py new file mode 100644 index 0000000..314ecfb --- /dev/null +++ b/selective-vpn-gui/api/utils.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import re + +_ANSI_RE = re.compile(r"\x1B\[[0-9;]*[A-Za-z]") + + +def strip_ansi(s: str) -> str: + """Remove ANSI escape sequences.""" + if not s: + return "" + return _ANSI_RE.sub("", s) diff --git a/selective-vpn-gui/api/vpn.py b/selective-vpn-gui/api/vpn.py new file mode 100644 index 0000000..d67a636 --- /dev/null +++ b/selective-vpn-gui/api/vpn.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from typing import Any, Dict, List, cast + +from .models import * +from .utils import strip_ansi + + +class VpnApiMixin: + # VPN + def vpn_autoloop_status(self) -> VpnAutoloopStatus: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/vpn/autoloop-status", timeout=2.0)) or {}) + raw = strip_ansi(str(data.get("raw_text") or "").strip()) + word = str(data.get("status_word") or "unknown").strip() + return VpnAutoloopStatus(raw_text=raw, status_word=word) + + def vpn_status(self) -> VpnStatus: + data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/vpn/status", timeout=2.0)) or {}) + return VpnStatus( + desired_location=str(data.get("desired_location") or "").strip(), + status_word=str(data.get("status_word") or "unknown").strip(), + raw_text=strip_ansi(str(data.get("raw_text") or "").strip()), + unit_state=str(data.get("unit_state") or "unknown").strip(), + ) + + def vpn_autoconnect(self, enable: bool) -> CmdResult: + action = "start" if enable else "stop" + data = cast( + Dict[str, Any], + self._json(self._request("POST", "/api/v1/vpn/autoconnect", json_body={"action": action})) or {}, + ) + return self._parse_cmd_result(data) + + def vpn_locations_state(self) -> VpnLocationsState: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/vpn/locations", timeout=3.0)) or {}, + ) + locs = data.get("locations") or [] + res: List[VpnLocation] = [] + if isinstance(locs, list): + for item in locs: + if isinstance(item, dict): + label = str(item.get("label") or "") + iso = str(item.get("iso") or "") + target = str(item.get("target") or "").strip() + if label and iso: + if not target: + target = iso + res.append(VpnLocation(label=label, iso=iso, target=target)) + return VpnLocationsState( + locations=res, + updated_at=str(data.get("updated_at") or "").strip(), + stale=bool(data.get("stale", False)), + refresh_in_progress=bool(data.get("refresh_in_progress", False)), + last_error=strip_ansi(str(data.get("last_error") or "").strip()), + next_retry_at=str(data.get("next_retry_at") or "").strip(), + ) + + def vpn_locations(self) -> List[VpnLocation]: + return self.vpn_locations_state().locations + + def vpn_locations_refresh_trigger(self) -> None: + self._request( + "GET", + "/api/v1/vpn/locations", + params={"refresh": "1"}, + timeout=2.0, + ) + + def vpn_set_location(self, target: str, iso: str = "", label: str = "") -> None: + val = str(target).strip() + if not val: + raise ValueError("target is required") + self._request( + "POST", + "/api/v1/vpn/location", + json_body={ + "target": val, + "iso": str(iso).strip(), + "label": str(label).strip(), + }, + ) + + def egress_identity_get(self, scope: str, *, refresh: bool = False) -> EgressIdentity: + scope_v = str(scope or "").strip() + if not scope_v: + raise ValueError("scope is required") + params: Dict[str, Any] = {"scope": scope_v} + if refresh: + params["refresh"] = "1" + data = cast( + Dict[str, Any], + self._json( + self._request( + "GET", + "/api/v1/egress/identity", + params=params, + timeout=2.0, + ) + ) + or {}, + ) + item_raw = data.get("item") or {} + if not isinstance(item_raw, dict): + item_raw = {} + return self._parse_egress_identity(item_raw, scope_fallback=scope_v) + + def egress_identity_refresh( + self, + *, + scopes: Optional[List[str]] = None, + force: bool = False, + ) -> EgressIdentityRefreshResult: + payload: Dict[str, Any] = {} + scope_items: List[str] = [] + for raw in list(scopes or []): + v = str(raw or "").strip() + if v: + scope_items.append(v) + if scope_items: + payload["scopes"] = scope_items + if force: + payload["force"] = True + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/egress/identity/refresh", + json_body=payload, + timeout=2.0, + ) + ) + or {}, + ) + + raw_items = data.get("items") or [] + if not isinstance(raw_items, list): + raw_items = [] + items: List[EgressIdentityRefreshItem] = [] + for row in raw_items: + if not isinstance(row, dict): + continue + items.append( + EgressIdentityRefreshItem( + scope=str(row.get("scope") or "").strip(), + status=str(row.get("status") or "").strip().lower(), + queued=bool(row.get("queued", False)), + reason=strip_ansi(str(row.get("reason") or "").strip()), + ) + ) + return EgressIdentityRefreshResult( + ok=bool(data.get("ok", False)), + message=strip_ansi(str(data.get("message") or "").strip()), + count=self._to_int(data.get("count")), + queued=self._to_int(data.get("queued")), + skipped=self._to_int(data.get("skipped")), + items=items, + ) + + # ---- AdGuard VPN interactive login-session ---- + + def vpn_login_session_start(self) -> LoginSessionStart: + data = cast( + Dict[str, Any], + self._json(self._request("POST", "/api/v1/vpn/login/session/start", timeout=10.0)) or {}, + ) + pid_val = data.get("pid", None) + pid: Optional[int] + try: + pid = int(pid_val) if pid_val is not None else None + except (TypeError, ValueError): + pid = None + + return LoginSessionStart( + ok=bool(data.get("ok", False)), + phase=str(data.get("phase") or ""), + level=str(data.get("level") or ""), + pid=pid, + email=strip_ansi(str(data.get("email") or "").strip()), + error=strip_ansi(str(data.get("error") or "").strip()), + ) + + def vpn_login_session_state(self, since: int = 0) -> LoginSessionState: + since_i = int(since) if since is not None else 0 + data = cast( + Dict[str, Any], + self._json( + self._request( + "GET", + "/api/v1/vpn/login/session/state", + params={"since": str(max(0, since_i))}, + timeout=5.0, + ) + ) + or {}, + ) + + lines = data.get("lines") or [] + if not isinstance(lines, list): + lines = [] + + cursor_val = data.get("cursor", 0) + try: + cursor = int(cursor_val) + except (TypeError, ValueError): + cursor = 0 + + return LoginSessionState( + ok=bool(data.get("ok", False)), + phase=str(data.get("phase") or ""), + level=str(data.get("level") or ""), + alive=bool(data.get("alive", False)), + url=strip_ansi(str(data.get("url") or "").strip()), + email=strip_ansi(str(data.get("email") or "").strip()), + cursor=cursor, + lines=[strip_ansi(str(x)) for x in lines], + can_open=bool(data.get("can_open", False)), + can_check=bool(data.get("can_check", False)), + can_cancel=bool(data.get("can_cancel", False)), + ) + + def vpn_login_session_action(self, action: Literal["open", "check", "cancel"]) -> LoginSessionAction: + act = str(action).strip().lower() + if act not in ("open", "check", "cancel"): + raise ValueError(f"Invalid login-session action: {action}") + + data = cast( + Dict[str, Any], + self._json( + self._request( + "POST", + "/api/v1/vpn/login/session/action", + json_body={"action": act}, + timeout=10.0, + ) + ) + or {}, + ) + + return LoginSessionAction( + ok=bool(data.get("ok", False)), + phase=str(data.get("phase") or ""), + level=str(data.get("level") or ""), + error=strip_ansi(str(data.get("error") or "").strip()), + ) + + def vpn_login_session_stop(self) -> CmdResult: + # Stop returns {"ok": true}; wrap into CmdResult for controller consistency. + data = cast( + Dict[str, Any], + self._json(self._request("POST", "/api/v1/vpn/login/session/stop", timeout=10.0)) or {}, + ) + ok = bool(data.get("ok", False)) + return CmdResult(ok=ok, message="login session stopped" if ok else "failed to stop login session") + + def vpn_logout(self) -> CmdResult: + data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/vpn/logout", timeout=20.0)) or {}) + return self._parse_cmd_result(data) + + def _parse_egress_identity( + self, + raw: Dict[str, Any], + *, + scope_fallback: str = "", + ) -> EgressIdentity: + data = raw if isinstance(raw, dict) else {} + return EgressIdentity( + scope=str(data.get("scope") or scope_fallback).strip(), + source=str(data.get("source") or "").strip().lower(), + source_id=str(data.get("source_id") or "").strip(), + ip=str(data.get("ip") or "").strip(), + country_code=str(data.get("country_code") or "").strip().upper(), + country_name=str(data.get("country_name") or "").strip(), + updated_at=str(data.get("updated_at") or "").strip(), + stale=bool(data.get("stale", False)), + refresh_in_progress=bool(data.get("refresh_in_progress", False)), + last_error=strip_ansi(str(data.get("last_error") or "").strip()), + next_retry_at=str(data.get("next_retry_at") or "").strip(), + ) diff --git a/selective-vpn-gui/api_client.py b/selective-vpn-gui/api_client.py index c9cf263..1cdec18 100644 --- a/selective-vpn-gui/api_client.py +++ b/selective-vpn-gui/api_client.py @@ -1,1639 +1,14 @@ #!/usr/bin/env python3 -"""Selective-VPN API client (UI-agnostic). +"""Backward-compatible facade for legacy imports. -Design goals: -- The dashboard (GUI) must NOT know any URLs, HTTP methods, JSON keys, or payload shapes. -- All REST details live here. -- Returned values are normalized into dataclasses for clean UI usage. +Prefer importing from `api` package for new code: +- `from api import ApiClient, ApiError, ...` -Env: -- SELECTIVE_VPN_API (default: http://127.0.0.1:8080) - -This file is meant to be imported by a controller (dashboard_controller.py) and UI. +Legacy import is preserved: +- `from api_client import ApiClient, ApiError, ...` """ -from __future__ import annotations - -from dataclasses import dataclass -import json -import os -import re -import time -from typing import Any, Callable, Dict, Iterator, List, Literal, Optional, cast - -import requests - - -# --------------------------- -# Small utilities -# --------------------------- - -_ANSI_RE = re.compile(r"\x1B\[[0-9;]*[A-Za-z]") - - -def strip_ansi(s: str) -> str: - """Remove ANSI escape sequences.""" - if not s: - return "" - return _ANSI_RE.sub("", s) - - -# --------------------------- -# Models (UI-friendly) -# --------------------------- - -@dataclass(frozen=True) -class Status: - timestamp: str - ip_count: int - domain_count: int - iface: str - table: str - mark: str - # NOTE: backend uses omitempty for these, so they may be absent. - policy_route_ok: Optional[bool] - route_ok: Optional[bool] - - -@dataclass(frozen=True) -class CmdResult: - ok: bool - message: str - exit_code: Optional[int] = None - stdout: str = "" - stderr: str = "" - - -@dataclass(frozen=True) -class LoginState: - state: str - email: str - msg: str - # backend may also provide UI-ready fields - text: str - color: str - - -@dataclass(frozen=True) -class UnitState: - state: str - - -@dataclass(frozen=True) -class RoutesTimerState: - enabled: bool - - -@dataclass(frozen=True) -class TrafficModeStatus: - mode: str - desired_mode: str - applied_mode: str - preferred_iface: str - advanced_active: bool - auto_local_bypass: bool - auto_local_active: bool - ingress_reply_bypass: bool - ingress_reply_active: bool - bypass_candidates: int - force_vpn_subnets: List[str] - force_vpn_uids: List[str] - force_vpn_cgroups: List[str] - force_direct_subnets: List[str] - force_direct_uids: List[str] - force_direct_cgroups: List[str] - overrides_applied: int - cgroup_resolved_uids: int - cgroup_warning: str - active_iface: str - iface_reason: str - rule_mark: bool - rule_full: bool - ingress_rule_present: bool - ingress_nft_active: bool - table_default: bool - probe_ok: bool - probe_message: str - healthy: bool - message: str - - -@dataclass(frozen=True) -class TrafficInterfaces: - interfaces: List[str] - preferred_iface: str - active_iface: str - iface_reason: str - - -@dataclass(frozen=True) -class TrafficAppMarksStatus: - vpn_count: int - direct_count: int - message: str - - -@dataclass(frozen=True) -class TrafficAppMarksResult: - ok: bool - message: str - op: str = "" - target: str = "" - cgroup: str = "" - cgroup_id: int = 0 - timeout_sec: int = 0 - - -@dataclass(frozen=True) -class TrafficAppMarkItem: - id: int - target: str # vpn|direct - cgroup: str - cgroup_rel: str - level: int - unit: str - command: str - app_key: str - added_at: str - expires_at: str - remaining_sec: int - - -@dataclass(frozen=True) -class TrafficAppProfile: - id: str - name: str - app_key: str - command: str - target: str # vpn|direct - ttl_sec: int - vpn_profile: str - created_at: str - updated_at: str - - -@dataclass(frozen=True) -class TrafficAppProfileSaveResult: - ok: bool - message: str - profile: Optional[TrafficAppProfile] = None - - -@dataclass(frozen=True) -class TrafficAudit: - ok: bool - message: str - now: str - pretty: str - issues: List[str] - - - -@dataclass(frozen=True) -class TrafficCandidateSubnet: - cidr: str - dev: str - kind: str - linkdown: bool - - -@dataclass(frozen=True) -class TrafficCandidateUnit: - unit: str - description: str - cgroup: str - - -@dataclass(frozen=True) -class TrafficCandidateUID: - uid: int - user: str - examples: List[str] - - -@dataclass(frozen=True) -class TrafficCandidates: - generated_at: str - subnets: List[TrafficCandidateSubnet] - units: List[TrafficCandidateUnit] - uids: List[TrafficCandidateUID] - - -@dataclass(frozen=True) -class DnsUpstreams: - default1: str - default2: str - meta1: str - meta2: str - - -@dataclass(frozen=True) -class DNSBenchmarkUpstream: - addr: str - enabled: bool = True - - -@dataclass(frozen=True) -class DNSBenchmarkResult: - upstream: str - attempts: int - ok: int - fail: int - nxdomain: int - timeout: int - temporary: int - other: int - avg_ms: int - p95_ms: int - score: float - color: str - - -@dataclass(frozen=True) -class DNSBenchmarkResponse: - results: List[DNSBenchmarkResult] - domains_used: List[str] - timeout_ms: int - attempts_per_domain: int - recommended_default: List[str] - recommended_meta: List[str] - - -@dataclass(frozen=True) -class DNSUpstreamPoolState: - items: List[DNSBenchmarkUpstream] - - -@dataclass(frozen=True) -class SmartdnsServiceState: - state: str - - -@dataclass(frozen=True) -class DNSStatus: - via_smartdns: bool - smartdns_addr: str - mode: str - unit_state: str - runtime_nftset: bool - wildcard_source: str - runtime_config_path: str - runtime_config_error: str - - -@dataclass(frozen=True) -class SmartdnsRuntimeState: - enabled: bool - applied_enabled: bool - wildcard_source: str - unit_state: str - config_path: str - changed: bool = False - restarted: bool = False - message: str = "" - - -@dataclass(frozen=True) -class DomainsTable: - lines: List[str] - - -@dataclass(frozen=True) -class DomainsFile: - name: str - content: str - source: str = "" - - -@dataclass(frozen=True) -class VpnAutoloopStatus: - raw_text: str - status_word: str - - -@dataclass(frozen=True) -class VpnStatus: - desired_location: str - status_word: str - raw_text: str - unit_state: str - - -@dataclass(frozen=True) -class VpnLocation: - label: str - iso: str - - -@dataclass(frozen=True) -class TraceDump: - lines: List[str] - - -@dataclass(frozen=True) -class Event: - id: int - kind: str - ts: str - data: Any - -# --------------------------- -# AdGuard VPN interactive login-session (PTY) -# --------------------------- - -@dataclass(frozen=True) -class LoginSessionStart: - ok: bool - phase: str - level: str - pid: Optional[int] = None - email: str = "" - error: str = "" - - -@dataclass(frozen=True) -class LoginSessionState: - ok: bool - phase: str - level: str - alive: bool - url: str - email: str - cursor: int - lines: List[str] - can_open: bool - can_check: bool - can_cancel: bool - - -@dataclass(frozen=True) -class LoginSessionAction: - ok: bool - phase: str = "" - level: str = "" - error: str = "" - -# --------------------------- -# Errors -# --------------------------- - -@dataclass(frozen=True) -class ApiError(Exception): - """Raised when API call fails (network or non-2xx).""" - message: str - method: str - url: str - status_code: Optional[int] = None - response_text: str = "" - - def __str__(self) -> str: - code = f" ({self.status_code})" if self.status_code is not None else "" - tail = f": {self.response_text}" if self.response_text else "" - return f"{self.message}{code} [{self.method} {self.url}]{tail}" - - -# --------------------------- -# Client -# --------------------------- - -TraceMode = Literal["full", "gui", "smartdns"] -ServiceAction = Literal["start", "stop", "restart"] - - -class ApiClient: - """Domain API client. - - Public methods here are the ONLY surface the dashboard/controller should use. - """ - - def __init__( - self, - base_url: str, - *, - timeout: float = 5.0, - session: Optional[requests.Session] = None, - ) -> None: - self.base_url = base_url.rstrip("/") - self.timeout = float(timeout) - self._s = session or requests.Session() - - @classmethod - def from_env( - cls, - env_var: str = "SELECTIVE_VPN_API", - default: str = "http://127.0.0.1:8080", - *, - timeout: float = 5.0, - ) -> "ApiClient": - base = os.environ.get(env_var, default).rstrip("/") - return cls(base, timeout=timeout) - - # ---- low-level internals (private) ---- - - def _url(self, path: str) -> str: - if not path.startswith("/"): - path = "/" + path - return self.base_url + path - - def _request( - self, - method: str, - path: str, - *, - params: Optional[Dict[str, Any]] = None, - json_body: Optional[Dict[str, Any]] = None, - timeout: Optional[float] = None, - accept_json: bool = True, - ) -> requests.Response: - url = self._url(path) - headers: Dict[str, str] = {} - if accept_json: - headers["Accept"] = "application/json" - - try: - resp = self._s.request( - method=method.upper(), - url=url, - params=params, - json=json_body, - timeout=self.timeout if timeout is None else float(timeout), - headers=headers, - ) - except requests.RequestException as e: - raise ApiError("API request failed", method.upper(), url, None, str(e)) from e - - if not (200 <= resp.status_code < 300): - txt = resp.text.strip() - raise ApiError("API returned error", method.upper(), url, resp.status_code, txt) - - return resp - - def _json(self, resp: requests.Response) -> Any: - if not resp.content: - return None - try: - return resp.json() - except ValueError: - # Backend should be JSON, but keep safe fallback. - return {"raw": resp.text} - - # ---- event stream (SSE) ---- - - def events_stream(self, since: int = 0, stop: Optional[Callable[[], bool]] = None) -> Iterator[Event]: - """ - Iterate over server-sent events. Reconnects automatically on errors. - - Args: - since: last seen event id (inclusive). Server will replay newer ones. - stop: optional callable returning True to stop streaming. - """ - last = max(0, int(since)) - backoff = 1.0 - while True: - if stop and stop(): - return - try: - for ev in self._sse_once(last, stop): - if stop and stop(): - return - last = ev.id if ev.id else last - yield ev - # normal end → reconnect - backoff = 1.0 - except ApiError: - # bubble up API errors; caller decides - raise - except Exception: - # transient error, retry with backoff - time.sleep(backoff) - backoff = min(backoff * 2, 10.0) - - def _sse_once(self, since: int, stop: Optional[Callable[[], bool]]) -> Iterator[Event]: - headers = { - "Accept": "text/event-stream", - "Cache-Control": "no-cache", - } - params = {} - if since > 0: - params["since"] = str(since) - - url = self._url("/api/v1/events/stream") - # SSE соединение живёт долго: backend шлёт heartbeat каждые 15s, - # поэтому ставим более длинный read-timeout, иначе стандартные 5s - # приводят к ложным ошибокам чтения. - read_timeout = max(self.timeout * 3, 60.0) - try: - resp = self._s.request( - method="GET", - url=url, - headers=headers, - params=params, - stream=True, - timeout=(self.timeout, read_timeout), - ) - except requests.RequestException as e: - raise ApiError("API request failed", "GET", url, None, str(e)) from e - - if not (200 <= resp.status_code < 300): - txt = resp.text.strip() - raise ApiError("API returned error", "GET", url, resp.status_code, txt) - - ev_id: Optional[int] = None - ev_kind: str = "" - data_lines: List[str] = [] - - for raw in resp.iter_lines(decode_unicode=True): - if stop and stop(): - resp.close() - return - if raw is None: - continue - line = raw.strip("\r") - if line == "": - if data_lines or ev_kind or ev_id is not None: - ev = self._make_event(ev_id, ev_kind, data_lines) - if ev: - yield ev - ev_id = None - ev_kind = "" - data_lines = [] - continue - if line.startswith(":"): - # heartbeat/comment - continue - if line.startswith("id:"): - try: - ev_id = int(line[3:].strip()) - except ValueError: - ev_id = None - continue - if line.startswith("event:"): - ev_kind = line[6:].strip() - continue - if line.startswith("data:"): - data_lines.append(line[5:].lstrip()) - continue - # unknown field → ignore - - def _make_event(self, ev_id: Optional[int], ev_kind: str, data_lines: List[str]) -> Optional[Event]: - payload: Any = None - if data_lines: - data_str = "\n".join(data_lines) - try: - payload = json.loads(data_str) - except Exception: - payload = data_str - if isinstance(payload, dict): - id_val = ev_id - if id_val is None: - try: - id_val = int(payload.get("id", 0)) - except Exception: - id_val = 0 - kind_val = ev_kind or str(payload.get("kind") or "") - ts_val = str(payload.get("ts") or "") - data_val = payload.get("data", payload) - return Event(id=id_val, kind=kind_val, ts=ts_val, data=data_val) - return Event(id=ev_id or 0, kind=ev_kind, ts="", data=payload) - - # ---- domain methods ---- - - # Status / system - def get_status(self) -> Status: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/status")) or {}) - return Status( - timestamp=str(data.get("timestamp") or ""), - ip_count=int(data.get("ip_count") or 0), - domain_count=int(data.get("domain_count") or 0), - iface=str(data.get("iface") or ""), - table=str(data.get("table") or ""), - mark=str(data.get("mark") or ""), - policy_route_ok=cast(Optional[bool], data.get("policy_route_ok", None)), - route_ok=cast(Optional[bool], data.get("route_ok", None)), - ) - - def systemd_state(self, unit: str) -> UnitState: - data = cast( - Dict[str, Any], - self._json( - self._request("GET", "/api/v1/systemd/state", params={"unit": unit}, timeout=2.0) - ) - or {}, - ) - st = str(data.get("state") or "unknown").strip() or "unknown" - return UnitState(state=st) - - def get_login_state(self) -> LoginState: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/vpn/login-state", timeout=2.0)) or {}) - # Normalize and strip ANSI - state = str(data.get("state") or "unknown").strip() - email = strip_ansi(str(data.get("email") or "").strip()) - msg = strip_ansi(str(data.get("msg") or "").strip()) - text = strip_ansi(str(data.get("text") or "").strip()) - color = str(data.get("color") or "").strip() - - return LoginState( - state=state, - email=email, - msg=msg, - text=text, - color=color, - ) - - # Routes - def routes_service(self, action: ServiceAction) -> CmdResult: - action_l = action.lower() - if action_l not in ("start", "stop", "restart"): - raise ValueError(f"Invalid action: {action}") - url = self._url("/api/v1/routes/service") - payload = {"action": action_l} - try: - # короткий read-timeout: если systemctl висит минутами, отваливаемся, - # но сервер всё равно продолжит выполнение (runCommand не привязан к r.Context()). - resp = self._s.post(url, json=payload, timeout=(self.timeout, 2.0)) - except requests.Timeout: - return CmdResult( - ok=True, - message=f"{action_l} accepted; backend is still running systemctl", - exit_code=None, - ) - except requests.RequestException as e: - raise ApiError("API request failed", "POST", url, None, str(e)) from e - - if not (200 <= resp.status_code < 300): - txt = resp.text.strip() - raise ApiError("API returned error", "POST", url, resp.status_code, txt) - - data = cast(Dict[str, Any], self._json(resp) or {}) - return self._parse_cmd_result(data) - - def routes_clear(self) -> CmdResult: - data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/routes/clear")) or {}) - return self._parse_cmd_result(data) - - def routes_cache_restore(self) -> CmdResult: - data = cast( - Dict[str, Any], - self._json(self._request("POST", "/api/v1/routes/cache/restore")) or {}, - ) - return self._parse_cmd_result(data) - - def routes_fix_policy_route(self) -> CmdResult: - data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/routes/fix-policy-route")) or {}) - return self._parse_cmd_result(data) - - def routes_timer_get(self) -> RoutesTimerState: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/routes/timer")) or {}) - return RoutesTimerState(enabled=bool(data.get("enabled", False))) - - def routes_timer_set(self, enabled: bool) -> CmdResult: - data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/routes/timer", json_body={"enabled": bool(enabled)})) or {}) - return self._parse_cmd_result(data) - - def traffic_mode_get(self) -> TrafficModeStatus: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/traffic/mode")) or {}) - return TrafficModeStatus( - mode=str(data.get("mode") or "selective"), - desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"), - applied_mode=str(data.get("applied_mode") or "direct"), - preferred_iface=str(data.get("preferred_iface") or ""), - advanced_active=bool(data.get("advanced_active", False)), - auto_local_bypass=bool(data.get("auto_local_bypass", True)), - auto_local_active=bool(data.get("auto_local_active", False)), - ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)), - ingress_reply_active=bool(data.get("ingress_reply_active", False)), - bypass_candidates=int(data.get("bypass_candidates", 0) or 0), - force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()], - force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()], - force_vpn_cgroups=[str(x) for x in (data.get("force_vpn_cgroups") or []) if str(x).strip()], - force_direct_subnets=[str(x) for x in (data.get("force_direct_subnets") or []) if str(x).strip()], - force_direct_uids=[str(x) for x in (data.get("force_direct_uids") or []) if str(x).strip()], - force_direct_cgroups=[str(x) for x in (data.get("force_direct_cgroups") or []) if str(x).strip()], - overrides_applied=int(data.get("overrides_applied", 0) or 0), - cgroup_resolved_uids=int(data.get("cgroup_resolved_uids", 0) or 0), - cgroup_warning=str(data.get("cgroup_warning") or ""), - active_iface=str(data.get("active_iface") or ""), - iface_reason=str(data.get("iface_reason") or ""), - rule_mark=bool(data.get("rule_mark", False)), - rule_full=bool(data.get("rule_full", False)), - ingress_rule_present=bool(data.get("ingress_rule_present", False)), - ingress_nft_active=bool(data.get("ingress_nft_active", False)), - table_default=bool(data.get("table_default", False)), - probe_ok=bool(data.get("probe_ok", False)), - probe_message=str(data.get("probe_message") or ""), - healthy=bool(data.get("healthy", False)), - message=str(data.get("message") or ""), - ) - - def traffic_mode_set( - self, - mode: str, - preferred_iface: Optional[str] = None, - auto_local_bypass: Optional[bool] = None, - ingress_reply_bypass: Optional[bool] = None, - force_vpn_subnets: Optional[List[str]] = None, - force_vpn_uids: Optional[List[str]] = None, - force_vpn_cgroups: Optional[List[str]] = None, - force_direct_subnets: Optional[List[str]] = None, - force_direct_uids: Optional[List[str]] = None, - force_direct_cgroups: Optional[List[str]] = None, - ) -> TrafficModeStatus: - m = str(mode or "").strip().lower() - if m not in ("selective", "full_tunnel", "direct"): - raise ValueError(f"Invalid traffic mode: {mode}") - payload: Dict[str, Any] = {"mode": m} - if preferred_iface is not None: - payload["preferred_iface"] = str(preferred_iface).strip() - if auto_local_bypass is not None: - payload["auto_local_bypass"] = bool(auto_local_bypass) - if ingress_reply_bypass is not None: - payload["ingress_reply_bypass"] = bool(ingress_reply_bypass) - if force_vpn_subnets is not None: - payload["force_vpn_subnets"] = [str(x) for x in force_vpn_subnets] - if force_vpn_uids is not None: - payload["force_vpn_uids"] = [str(x) for x in force_vpn_uids] - if force_vpn_cgroups is not None: - payload["force_vpn_cgroups"] = [str(x) for x in force_vpn_cgroups] - if force_direct_subnets is not None: - payload["force_direct_subnets"] = [str(x) for x in force_direct_subnets] - if force_direct_uids is not None: - payload["force_direct_uids"] = [str(x) for x in force_direct_uids] - if force_direct_cgroups is not None: - payload["force_direct_cgroups"] = [str(x) for x in force_direct_cgroups] - data = cast( - Dict[str, Any], - self._json( - self._request( - "POST", - "/api/v1/traffic/mode", - json_body=payload, - ) - ) - or {}, - ) - return TrafficModeStatus( - mode=str(data.get("mode") or m), - desired_mode=str(data.get("desired_mode") or data.get("mode") or m), - applied_mode=str(data.get("applied_mode") or "direct"), - preferred_iface=str(data.get("preferred_iface") or ""), - advanced_active=bool(data.get("advanced_active", False)), - auto_local_bypass=bool(data.get("auto_local_bypass", True)), - auto_local_active=bool(data.get("auto_local_active", False)), - ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)), - ingress_reply_active=bool(data.get("ingress_reply_active", False)), - bypass_candidates=int(data.get("bypass_candidates", 0) or 0), - force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()], - force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()], - force_vpn_cgroups=[str(x) for x in (data.get("force_vpn_cgroups") or []) if str(x).strip()], - force_direct_subnets=[str(x) for x in (data.get("force_direct_subnets") or []) if str(x).strip()], - force_direct_uids=[str(x) for x in (data.get("force_direct_uids") or []) if str(x).strip()], - force_direct_cgroups=[str(x) for x in (data.get("force_direct_cgroups") or []) if str(x).strip()], - overrides_applied=int(data.get("overrides_applied", 0) or 0), - cgroup_resolved_uids=int(data.get("cgroup_resolved_uids", 0) or 0), - cgroup_warning=str(data.get("cgroup_warning") or ""), - active_iface=str(data.get("active_iface") or ""), - iface_reason=str(data.get("iface_reason") or ""), - rule_mark=bool(data.get("rule_mark", False)), - rule_full=bool(data.get("rule_full", False)), - ingress_rule_present=bool(data.get("ingress_rule_present", False)), - ingress_nft_active=bool(data.get("ingress_nft_active", False)), - table_default=bool(data.get("table_default", False)), - probe_ok=bool(data.get("probe_ok", False)), - probe_message=str(data.get("probe_message") or ""), - healthy=bool(data.get("healthy", False)), - message=str(data.get("message") or ""), - ) - - def traffic_mode_test(self) -> TrafficModeStatus: - data = cast( - Dict[str, Any], - self._json(self._request("GET", "/api/v1/traffic/mode/test")) or {}, - ) - return TrafficModeStatus( - mode=str(data.get("mode") or "selective"), - desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"), - applied_mode=str(data.get("applied_mode") or "direct"), - preferred_iface=str(data.get("preferred_iface") or ""), - advanced_active=bool(data.get("advanced_active", False)), - auto_local_bypass=bool(data.get("auto_local_bypass", True)), - auto_local_active=bool(data.get("auto_local_active", False)), - ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)), - ingress_reply_active=bool(data.get("ingress_reply_active", False)), - bypass_candidates=int(data.get("bypass_candidates", 0) or 0), - force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()], - force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()], - force_vpn_cgroups=[str(x) for x in (data.get("force_vpn_cgroups") or []) if str(x).strip()], - force_direct_subnets=[str(x) for x in (data.get("force_direct_subnets") or []) if str(x).strip()], - force_direct_uids=[str(x) for x in (data.get("force_direct_uids") or []) if str(x).strip()], - force_direct_cgroups=[str(x) for x in (data.get("force_direct_cgroups") or []) if str(x).strip()], - overrides_applied=int(data.get("overrides_applied", 0) or 0), - cgroup_resolved_uids=int(data.get("cgroup_resolved_uids", 0) or 0), - cgroup_warning=str(data.get("cgroup_warning") or ""), - active_iface=str(data.get("active_iface") or ""), - iface_reason=str(data.get("iface_reason") or ""), - rule_mark=bool(data.get("rule_mark", False)), - rule_full=bool(data.get("rule_full", False)), - ingress_rule_present=bool(data.get("ingress_rule_present", False)), - ingress_nft_active=bool(data.get("ingress_nft_active", False)), - table_default=bool(data.get("table_default", False)), - probe_ok=bool(data.get("probe_ok", False)), - probe_message=str(data.get("probe_message") or ""), - healthy=bool(data.get("healthy", False)), - message=str(data.get("message") or ""), - ) - - def traffic_advanced_reset(self) -> TrafficModeStatus: - data = cast( - Dict[str, Any], - self._json(self._request("POST", "/api/v1/traffic/advanced/reset")) or {}, - ) - return TrafficModeStatus( - mode=str(data.get("mode") or "selective"), - desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"), - applied_mode=str(data.get("applied_mode") or "direct"), - preferred_iface=str(data.get("preferred_iface") or ""), - advanced_active=bool(data.get("advanced_active", False)), - auto_local_bypass=bool(data.get("auto_local_bypass", True)), - auto_local_active=bool(data.get("auto_local_active", False)), - ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)), - ingress_reply_active=bool(data.get("ingress_reply_active", False)), - bypass_candidates=int(data.get("bypass_candidates", 0) or 0), - force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()], - force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()], - force_vpn_cgroups=[str(x) for x in (data.get("force_vpn_cgroups") or []) if str(x).strip()], - force_direct_subnets=[str(x) for x in (data.get("force_direct_subnets") or []) if str(x).strip()], - force_direct_uids=[str(x) for x in (data.get("force_direct_uids") or []) if str(x).strip()], - force_direct_cgroups=[str(x) for x in (data.get("force_direct_cgroups") or []) if str(x).strip()], - overrides_applied=int(data.get("overrides_applied", 0) or 0), - cgroup_resolved_uids=int(data.get("cgroup_resolved_uids", 0) or 0), - cgroup_warning=str(data.get("cgroup_warning") or ""), - active_iface=str(data.get("active_iface") or ""), - iface_reason=str(data.get("iface_reason") or ""), - rule_mark=bool(data.get("rule_mark", False)), - rule_full=bool(data.get("rule_full", False)), - ingress_rule_present=bool(data.get("ingress_rule_present", False)), - ingress_nft_active=bool(data.get("ingress_nft_active", False)), - table_default=bool(data.get("table_default", False)), - probe_ok=bool(data.get("probe_ok", False)), - probe_message=str(data.get("probe_message") or ""), - healthy=bool(data.get("healthy", False)), - message=str(data.get("message") or ""), - ) - - def traffic_interfaces_get(self) -> TrafficInterfaces: - data = cast( - Dict[str, Any], - self._json(self._request("GET", "/api/v1/traffic/interfaces")) or {}, - ) - raw = data.get("interfaces") or [] - if not isinstance(raw, list): - raw = [] - return TrafficInterfaces( - interfaces=[str(x) for x in raw if str(x).strip()], - preferred_iface=str(data.get("preferred_iface") or ""), - active_iface=str(data.get("active_iface") or ""), - iface_reason=str(data.get("iface_reason") or ""), - ) - - def traffic_candidates_get(self) -> TrafficCandidates: - data = cast( - Dict[str, Any], - self._json(self._request("GET", "/api/v1/traffic/candidates")) or {}, - ) - - subnets: List[TrafficCandidateSubnet] = [] - for it in (data.get("subnets") or []): - if not isinstance(it, dict): - continue - cidr = str(it.get("cidr") or "").strip() - if not cidr: - continue - subnets.append( - TrafficCandidateSubnet( - cidr=cidr, - dev=str(it.get("dev") or "").strip(), - kind=str(it.get("kind") or "").strip(), - linkdown=bool(it.get("linkdown", False)), - ) - ) - - units: List[TrafficCandidateUnit] = [] - for it in (data.get("units") or []): - if not isinstance(it, dict): - continue - unit = str(it.get("unit") or "").strip() - if not unit: - continue - units.append( - TrafficCandidateUnit( - unit=unit, - description=str(it.get("description") or "").strip(), - cgroup=str(it.get("cgroup") or "").strip(), - ) - ) - - uids: List[TrafficCandidateUID] = [] - for it in (data.get("uids") or []): - if not isinstance(it, dict): - continue - try: - uid = int(it.get("uid", 0) or 0) - except Exception: - continue - user = str(it.get("user") or "").strip() - raw_ex = it.get("examples") or [] - if not isinstance(raw_ex, list): - raw_ex = [] - examples = [str(x) for x in raw_ex if str(x).strip()] - uids.append(TrafficCandidateUID(uid=uid, user=user, examples=examples)) - - return TrafficCandidates( - generated_at=str(data.get("generated_at") or ""), - subnets=subnets, - units=units, - uids=uids, - ) - - def traffic_appmarks_status(self) -> TrafficAppMarksStatus: - data = cast( - Dict[str, Any], - self._json(self._request("GET", "/api/v1/traffic/appmarks")) or {}, - ) - return TrafficAppMarksStatus( - vpn_count=int(data.get("vpn_count", 0) or 0), - direct_count=int(data.get("direct_count", 0) or 0), - message=str(data.get("message") or ""), - ) - - def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]: - data = cast( - Dict[str, Any], - self._json(self._request("GET", "/api/v1/traffic/appmarks/items")) or {}, - ) - raw = data.get("items") or [] - if not isinstance(raw, list): - raw = [] - - out: List[TrafficAppMarkItem] = [] - for it in raw: - if not isinstance(it, dict): - continue - try: - mid = int(it.get("id", 0) or 0) - except Exception: - mid = 0 - tgt = str(it.get("target") or "").strip().lower() - if mid <= 0 or tgt not in ("vpn", "direct"): - continue - out.append( - TrafficAppMarkItem( - id=mid, - target=tgt, - cgroup=str(it.get("cgroup") or "").strip(), - cgroup_rel=str(it.get("cgroup_rel") or "").strip(), - level=int(it.get("level", 0) or 0), - unit=str(it.get("unit") or "").strip(), - command=str(it.get("command") or "").strip(), - app_key=str(it.get("app_key") or "").strip(), - added_at=str(it.get("added_at") or "").strip(), - expires_at=str(it.get("expires_at") or "").strip(), - remaining_sec=int(it.get("remaining_sec", 0) or 0), - ) - ) - return out - - def traffic_appmarks_apply( - self, - *, - op: str, - target: str, - cgroup: str = "", - unit: str = "", - command: str = "", - app_key: str = "", - timeout_sec: int = 0, - ) -> TrafficAppMarksResult: - payload: Dict[str, Any] = { - "op": str(op or "").strip().lower(), - "target": str(target or "").strip().lower(), - } - if cgroup: - payload["cgroup"] = str(cgroup).strip() - if unit: - payload["unit"] = str(unit).strip() - if command: - payload["command"] = str(command).strip() - if app_key: - payload["app_key"] = str(app_key).strip() - if int(timeout_sec or 0) > 0: - payload["timeout_sec"] = int(timeout_sec) - - data = cast( - Dict[str, Any], - self._json(self._request("POST", "/api/v1/traffic/appmarks", json_body=payload)) - or {}, - ) - return TrafficAppMarksResult( - ok=bool(data.get("ok", False)), - message=str(data.get("message") or ""), - op=str(data.get("op") or payload["op"]), - target=str(data.get("target") or payload["target"]), - cgroup=str(data.get("cgroup") or payload.get("cgroup") or ""), - cgroup_id=int(data.get("cgroup_id", 0) or 0), - timeout_sec=int(data.get("timeout_sec", 0) or 0), - ) - - def traffic_app_profiles_list(self) -> List[TrafficAppProfile]: - data = cast( - Dict[str, Any], - self._json(self._request("GET", "/api/v1/traffic/app-profiles")) or {}, - ) - raw = data.get("profiles") or [] - if not isinstance(raw, list): - raw = [] - - out: List[TrafficAppProfile] = [] - for it in raw: - if not isinstance(it, dict): - continue - pid = str(it.get("id") or "").strip() - if not pid: - continue - out.append( - TrafficAppProfile( - id=pid, - name=str(it.get("name") or "").strip(), - app_key=str(it.get("app_key") or "").strip(), - command=str(it.get("command") or "").strip(), - target=str(it.get("target") or "").strip().lower(), - ttl_sec=int(it.get("ttl_sec", 0) or 0), - vpn_profile=str(it.get("vpn_profile") or "").strip(), - created_at=str(it.get("created_at") or "").strip(), - updated_at=str(it.get("updated_at") or "").strip(), - ) - ) - return out - - def traffic_app_profile_upsert( - self, - *, - id: str = "", - name: str = "", - app_key: str = "", - command: str, - target: str, - ttl_sec: int = 0, - vpn_profile: str = "", - ) -> TrafficAppProfileSaveResult: - payload: Dict[str, Any] = { - "command": str(command or "").strip(), - "target": str(target or "").strip().lower(), - } - if id: - payload["id"] = str(id).strip() - if name: - payload["name"] = str(name).strip() - if app_key: - payload["app_key"] = str(app_key).strip() - if int(ttl_sec or 0) > 0: - payload["ttl_sec"] = int(ttl_sec) - if vpn_profile: - payload["vpn_profile"] = str(vpn_profile).strip() - - data = cast( - Dict[str, Any], - self._json( - self._request("POST", "/api/v1/traffic/app-profiles", json_body=payload) - ) - or {}, - ) - msg = str(data.get("message") or "") - raw = data.get("profiles") or [] - if not isinstance(raw, list): - raw = [] - prof: Optional[TrafficAppProfile] = None - if raw and isinstance(raw[0], dict): - it = cast(Dict[str, Any], raw[0]) - pid = str(it.get("id") or "").strip() - if pid: - prof = TrafficAppProfile( - id=pid, - name=str(it.get("name") or "").strip(), - app_key=str(it.get("app_key") or "").strip(), - command=str(it.get("command") or "").strip(), - target=str(it.get("target") or "").strip().lower(), - ttl_sec=int(it.get("ttl_sec", 0) or 0), - vpn_profile=str(it.get("vpn_profile") or "").strip(), - created_at=str(it.get("created_at") or "").strip(), - updated_at=str(it.get("updated_at") or "").strip(), - ) - - ok = bool(prof) and (msg.strip().lower() in ("saved", "ok")) - if not msg and ok: - msg = "saved" - return TrafficAppProfileSaveResult(ok=ok, message=msg, profile=prof) - - def traffic_app_profile_delete(self, id: str) -> CmdResult: - pid = str(id or "").strip() - if not pid: - raise ValueError("missing id") - data = cast( - Dict[str, Any], - self._json( - self._request("DELETE", "/api/v1/traffic/app-profiles", params={"id": pid}) - ) - or {}, - ) - return CmdResult( - ok=bool(data.get("ok", False)), - message=str(data.get("message") or ""), - exit_code=None, - stdout="", - stderr="", - ) - - def traffic_audit_get(self) -> TrafficAudit: - data = cast( - Dict[str, Any], - self._json(self._request("GET", "/api/v1/traffic/audit")) or {}, - ) - raw_issues = data.get("issues") or [] - if not isinstance(raw_issues, list): - raw_issues = [] - return TrafficAudit( - ok=bool(data.get("ok", False)), - message=strip_ansi(str(data.get("message") or "").strip()), - now=str(data.get("now") or "").strip(), - pretty=strip_ansi(str(data.get("pretty") or "").strip()), - issues=[strip_ansi(str(x)).strip() for x in raw_issues if str(x).strip()], - ) - - # DNS / SmartDNS - def dns_upstreams_get(self) -> DnsUpstreams: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns-upstreams")) or {}) - return DnsUpstreams( - default1=str(data.get("default1") or ""), - default2=str(data.get("default2") or ""), - meta1=str(data.get("meta1") or ""), - meta2=str(data.get("meta2") or ""), - ) - - def dns_upstreams_set(self, cfg: DnsUpstreams) -> None: - self._request( - "POST", - "/api/v1/dns-upstreams", - json_body={ - "default1": cfg.default1, - "default2": cfg.default2, - "meta1": cfg.meta1, - "meta2": cfg.meta2, - }, - ) - - def dns_upstream_pool_get(self) -> DNSUpstreamPoolState: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/upstream-pool")) or {}) - raw = data.get("items") or [] - if not isinstance(raw, list): - raw = [] - items: List[DNSBenchmarkUpstream] = [] - for row in raw: - if not isinstance(row, dict): - continue - addr = str(row.get("addr") or "").strip() - if not addr: - continue - items.append(DNSBenchmarkUpstream(addr=addr, enabled=bool(row.get("enabled", True)))) - return DNSUpstreamPoolState(items=items) - - def dns_upstream_pool_set(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState: - data = cast( - Dict[str, Any], - self._json( - self._request( - "POST", - "/api/v1/dns/upstream-pool", - json_body={ - "items": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (items or [])], - }, - ) - ) - or {}, - ) - raw = data.get("items") or [] - if not isinstance(raw, list): - raw = [] - out: List[DNSBenchmarkUpstream] = [] - for row in raw: - if not isinstance(row, dict): - continue - addr = str(row.get("addr") or "").strip() - if not addr: - continue - out.append(DNSBenchmarkUpstream(addr=addr, enabled=bool(row.get("enabled", True)))) - return DNSUpstreamPoolState(items=out) - - def dns_benchmark( - self, - upstreams: List[DNSBenchmarkUpstream], - domains: List[str], - timeout_ms: int = 1800, - attempts: int = 1, - concurrency: int = 6, - ) -> DNSBenchmarkResponse: - # Benchmark can legitimately run much longer than the default 5s API timeout. - # Estimate a safe read timeout from payload size and cap it to keep UI responsive. - upstream_count = len(upstreams or []) - domain_count = len(domains or []) - if domain_count <= 0: - domain_count = 6 # backend default domains - clamped_attempts = max(1, min(int(attempts), 3)) - clamped_concurrency = max(1, min(int(concurrency), 32)) - if upstream_count <= 0: - upstream_count = 1 - waves = (upstream_count + clamped_concurrency - 1) // clamped_concurrency - per_wave_sec = domain_count * clamped_attempts * (max(300, int(timeout_ms)) / 1000.0) - bench_timeout = min(180.0, max(15.0, waves*per_wave_sec*1.2+5.0)) - - data = cast( - Dict[str, Any], - self._json( - self._request( - "POST", - "/api/v1/dns/benchmark", - json_body={ - "upstreams": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (upstreams or [])], - "domains": [str(d or "").strip() for d in (domains or []) if str(d or "").strip()], - "timeout_ms": int(timeout_ms), - "attempts": int(attempts), - "concurrency": int(concurrency), - }, - timeout=bench_timeout, - ) - ) - or {}, - ) - raw_results = data.get("results") or [] - if not isinstance(raw_results, list): - raw_results = [] - results: List[DNSBenchmarkResult] = [] - for row in raw_results: - if not isinstance(row, dict): - continue - results.append( - DNSBenchmarkResult( - upstream=str(row.get("upstream") or "").strip(), - attempts=int(row.get("attempts", 0) or 0), - ok=int(row.get("ok", 0) or 0), - fail=int(row.get("fail", 0) or 0), - nxdomain=int(row.get("nxdomain", 0) or 0), - timeout=int(row.get("timeout", 0) or 0), - temporary=int(row.get("temporary", 0) or 0), - other=int(row.get("other", 0) or 0), - avg_ms=int(row.get("avg_ms", 0) or 0), - p95_ms=int(row.get("p95_ms", 0) or 0), - score=float(row.get("score", 0.0) or 0.0), - color=str(row.get("color") or "").strip().lower(), - ) - ) - return DNSBenchmarkResponse( - results=results, - domains_used=[str(d or "").strip() for d in (data.get("domains_used") or []) if str(d or "").strip()], - timeout_ms=int(data.get("timeout_ms", 0) or 0), - attempts_per_domain=int(data.get("attempts_per_domain", 0) or 0), - recommended_default=[str(d or "").strip() for d in (data.get("recommended_default") or []) if str(d or "").strip()], - recommended_meta=[str(d or "").strip() for d in (data.get("recommended_meta") or []) if str(d or "").strip()], - ) - - def dns_status_get(self) -> DNSStatus: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/status")) or {}) - return self._parse_dns_status(data) - - def dns_mode_set(self, via_smartdns: bool, smartdns_addr: str) -> DNSStatus: - mode = "hybrid_wildcard" if bool(via_smartdns) else "direct" - data = cast( - Dict[str, Any], - self._json( - self._request( - "POST", - "/api/v1/dns/mode", - json_body={ - "via_smartdns": bool(via_smartdns), - "smartdns_addr": str(smartdns_addr or ""), - "mode": mode, - }, - ) - ) - or {}, - ) - return self._parse_dns_status(data) - - def dns_smartdns_service_set(self, action: ServiceAction) -> DNSStatus: - act = action.lower() - if act not in ("start", "stop", "restart"): - raise ValueError(f"Invalid action: {action}") - data = cast( - Dict[str, Any], - self._json( - self._request( - "POST", - "/api/v1/dns/smartdns-service", - json_body={"action": act}, - ) - ) - or {}, - ) - if not bool(data.get("ok", False)): - raise ValueError(str(data.get("message") or f"SmartDNS {act} failed")) - return self._parse_dns_status(data) - - def smartdns_service_get(self) -> SmartdnsServiceState: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/smartdns/service")) or {}) - return SmartdnsServiceState(state=str(data.get("state") or "unknown")) - - def smartdns_service_set(self, action: ServiceAction) -> CmdResult: - act = action.lower() - if act not in ("start", "stop", "restart"): - raise ValueError(f"Invalid action: {action}") - data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/smartdns/service", json_body={"action": act})) or {}) - return self._parse_cmd_result(data) - - def smartdns_runtime_get(self) -> SmartdnsRuntimeState: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/smartdns/runtime")) or {}) - return SmartdnsRuntimeState( - enabled=bool(data.get("enabled", False)), - applied_enabled=bool(data.get("applied_enabled", False)), - wildcard_source=str(data.get("wildcard_source") or ("both" if bool(data.get("enabled", False)) else "resolver")), - unit_state=str(data.get("unit_state") or "unknown"), - config_path=str(data.get("config_path") or ""), - changed=bool(data.get("changed", False)), - restarted=bool(data.get("restarted", False)), - message=str(data.get("message") or ""), - ) - - def smartdns_runtime_set(self, enabled: bool, restart: bool = True) -> SmartdnsRuntimeState: - data = cast( - Dict[str, Any], - self._json( - self._request( - "POST", - "/api/v1/smartdns/runtime", - json_body={"enabled": bool(enabled), "restart": bool(restart)}, - ) - ) - or {}, - ) - return SmartdnsRuntimeState( - enabled=bool(data.get("enabled", False)), - applied_enabled=bool(data.get("applied_enabled", False)), - wildcard_source=str(data.get("wildcard_source") or ("both" if bool(data.get("enabled", False)) else "resolver")), - unit_state=str(data.get("unit_state") or "unknown"), - config_path=str(data.get("config_path") or ""), - changed=bool(data.get("changed", False)), - restarted=bool(data.get("restarted", False)), - message=str(data.get("message") or ""), - ) - - def smartdns_prewarm(self, limit: int = 0, aggressive_subs: bool = False) -> CmdResult: - payload: Dict[str, Any] = {} - if int(limit) > 0: - payload["limit"] = int(limit) - if aggressive_subs: - payload["aggressive_subs"] = True - data = cast( - Dict[str, Any], - self._json(self._request("POST", "/api/v1/smartdns/prewarm", json_body=payload)) or {}, - ) - return self._parse_cmd_result(data) - - def _parse_dns_status(self, data: Dict[str, Any]) -> DNSStatus: - via = bool(data.get("via_smartdns", False)) - runtime = bool(data.get("runtime_nftset", True)) - return DNSStatus( - via_smartdns=via, - smartdns_addr=str(data.get("smartdns_addr") or ""), - mode=str(data.get("mode") or ("hybrid_wildcard" if via else "direct")), - unit_state=str(data.get("unit_state") or "unknown"), - runtime_nftset=runtime, - wildcard_source=str(data.get("wildcard_source") or ("both" if runtime else "resolver")), - runtime_config_path=str(data.get("runtime_config_path") or ""), - runtime_config_error=str(data.get("runtime_config_error") or ""), - ) - - # Domains editor - def domains_table(self) -> DomainsTable: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/domains/table")) or {}) - lines = data.get("lines") or [] - if not isinstance(lines, list): - lines = [] - return DomainsTable(lines=[str(x) for x in lines]) - - def domains_file_get( - self, - name: Literal[ - "bases", - "meta", - "subs", - "static", - "smartdns", - "last-ips-map", - "last-ips-map-direct", - "last-ips-map-wildcard", - "wildcard-observed-hosts", - ], - ) -> DomainsFile: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/domains/file", params={"name": name})) or {}) - content = str(data.get("content") or "") - source = str(data.get("source") or "") - return DomainsFile(name=name, content=content, source=source) - - def domains_file_set( - self, - name: Literal[ - "bases", - "meta", - "subs", - "static", - "smartdns", - "last-ips-map", - "last-ips-map-direct", - "last-ips-map-wildcard", - "wildcard-observed-hosts", - ], - content: str, - ) -> None: - self._request("POST", "/api/v1/domains/file", json_body={"name": name, "content": content}) - - # VPN - def vpn_autoloop_status(self) -> VpnAutoloopStatus: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/vpn/autoloop-status", timeout=2.0)) or {}) - raw = strip_ansi(str(data.get("raw_text") or "").strip()) - word = str(data.get("status_word") or "unknown").strip() - return VpnAutoloopStatus(raw_text=raw, status_word=word) - - def vpn_status(self) -> VpnStatus: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/vpn/status", timeout=2.0)) or {}) - return VpnStatus( - desired_location=str(data.get("desired_location") or "").strip(), - status_word=str(data.get("status_word") or "unknown").strip(), - raw_text=strip_ansi(str(data.get("raw_text") or "").strip()), - unit_state=str(data.get("unit_state") or "unknown").strip(), - ) - - def vpn_autoconnect(self, enable: bool) -> CmdResult: - action = "start" if enable else "stop" - data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/vpn/autoconnect", json_body={"action": action})) or {}) - return self._parse_cmd_result(data) - - def vpn_locations(self) -> List[VpnLocation]: - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/vpn/locations", timeout=10.0)) or {}) - locs = data.get("locations") or [] - res: List[VpnLocation] = [] - if isinstance(locs, list): - for item in locs: - if isinstance(item, dict): - label = str(item.get("label") or "") - iso = str(item.get("iso") or "") - if label and iso: - res.append(VpnLocation(label=label, iso=iso)) - return res - - def vpn_set_location(self, iso: str) -> None: - val = str(iso).strip() - if not val: - raise ValueError("iso is required") - self._request("POST", "/api/v1/vpn/location", json_body={"iso": val}) - - # Trace - def trace_get(self, mode: TraceMode = "full") -> TraceDump: - m = str(mode).lower().strip() - if m not in ("full", "gui", "smartdns"): - m = "full" - data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/trace-json", params={"mode": m}, timeout=5.0)) or {}) - lines = data.get("lines") or [] - if not isinstance(lines, list): - lines = [] - return TraceDump(lines=[strip_ansi(str(x)) for x in lines]) - - def trace_append(self, kind: Literal["gui", "smartdns", "info"], line: str) -> None: - try: - self._request("POST", "/api/v1/trace/append", json_body={"kind": kind, "line": str(line)}, timeout=2.0) - except ApiError: - # Logging must never crash UI. - pass - - # ---- AdGuard VPN interactive login-session (NEW) ---- - - def vpn_login_session_start(self) -> LoginSessionStart: - data = cast( - Dict[str, Any], - self._json(self._request("POST", "/api/v1/vpn/login/session/start", timeout=10.0)) or {}, - ) - pid_val = data.get("pid", None) - pid: Optional[int] - try: - pid = int(pid_val) if pid_val is not None else None - except (TypeError, ValueError): - pid = None - - return LoginSessionStart( - ok=bool(data.get("ok", False)), - phase=str(data.get("phase") or ""), - level=str(data.get("level") or ""), - pid=pid, - email=strip_ansi(str(data.get("email") or "").strip()), - error=strip_ansi(str(data.get("error") or "").strip()), - ) - - def vpn_login_session_state(self, since: int = 0) -> LoginSessionState: - since_i = int(since) if since is not None else 0 - data = cast( - Dict[str, Any], - self._json( - self._request( - "GET", - "/api/v1/vpn/login/session/state", - params={"since": str(max(0, since_i))}, - timeout=5.0, - ) - ) - or {}, - ) - - lines = data.get("lines") or [] - if not isinstance(lines, list): - lines = [] - - cursor_val = data.get("cursor", 0) - try: - cursor = int(cursor_val) - except (TypeError, ValueError): - cursor = 0 - - return LoginSessionState( - ok=bool(data.get("ok", False)), - phase=str(data.get("phase") or ""), - level=str(data.get("level") or ""), - alive=bool(data.get("alive", False)), - url=strip_ansi(str(data.get("url") or "").strip()), - email=strip_ansi(str(data.get("email") or "").strip()), - cursor=cursor, - lines=[strip_ansi(str(x)) for x in lines], - can_open=bool(data.get("can_open", False)), - can_check=bool(data.get("can_check", False)), - can_cancel=bool(data.get("can_cancel", False)), - ) - - def vpn_login_session_action(self, action: Literal["open", "check", "cancel"]) -> LoginSessionAction: - act = str(action).strip().lower() - if act not in ("open", "check", "cancel"): - raise ValueError(f"Invalid login-session action: {action}") - - data = cast( - Dict[str, Any], - self._json( - self._request( - "POST", - "/api/v1/vpn/login/session/action", - json_body={"action": act}, - timeout=10.0, - ) - ) - or {}, - ) - - # backend может вернуть {ok:false,error:"..."} без phase/level - return LoginSessionAction( - ok=bool(data.get("ok", False)), - phase=str(data.get("phase") or ""), - level=str(data.get("level") or ""), - error=strip_ansi(str(data.get("error") or "").strip()), - ) - - def vpn_login_session_stop(self) -> CmdResult: - # stop returns {"ok": true} — завернём в CmdResult, чтобы UI/Controller единообразно печатал - data = cast( - Dict[str, Any], - self._json(self._request("POST", "/api/v1/vpn/login/session/stop", timeout=10.0)) or {}, - ) - ok = bool(data.get("ok", False)) - return CmdResult(ok=ok, message="login session stopped" if ok else "failed to stop login session") - - def vpn_logout(self) -> CmdResult: - data = cast(Dict[str, Any], self._json(self._request("POST", "/api/v1/vpn/logout", timeout=20.0)) or {}) - return self._parse_cmd_result(data) - - # ---- helpers ---- - - def _parse_cmd_result(self, data: Dict[str, Any]) -> CmdResult: - ok = bool(data.get("ok", False)) - msg = str(data.get("message") or "") - exit_code_val = data.get("exitCode", None) - exit_code: Optional[int] - try: - exit_code = int(exit_code_val) if exit_code_val is not None else None - except (TypeError, ValueError): - exit_code = None - - stdout = strip_ansi(str(data.get("stdout") or "")) - stderr = strip_ansi(str(data.get("stderr") or "")) - return CmdResult(ok=ok, message=msg, exit_code=exit_code, stdout=stdout, stderr=stderr) +from api.client import ApiClient +from api.errors import ApiError +from api.models import * +from api.utils import strip_ansi diff --git a/selective-vpn-gui/controllers/__init__.py b/selective-vpn-gui/controllers/__init__.py new file mode 100644 index 0000000..f3229c2 --- /dev/null +++ b/selective-vpn-gui/controllers/__init__.py @@ -0,0 +1,10 @@ +from .views import * +from .core_controller import ControllerCoreMixin +from .status_controller import StatusControllerMixin +from .vpn_controller import VpnControllerMixin +from .routes_controller import RoutesControllerMixin +from .traffic_controller import TrafficControllerMixin +from .transport_controller import TransportControllerMixin +from .dns_controller import DNSControllerMixin +from .domains_controller import DomainsControllerMixin +from .trace_controller import TraceControllerMixin diff --git a/selective-vpn-gui/controllers/core_controller.py b/selective-vpn-gui/controllers/core_controller.py new file mode 100644 index 0000000..300ce3b --- /dev/null +++ b/selective-vpn-gui/controllers/core_controller.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +from typing import Iterable, List, Optional + +from api_client import CmdResult, Event, LoginState, VpnStatus + +# Вырезаем спам автопроверки из логов (CLI любит писать "Next check in ..."). +_NEXT_CHECK_RE = re.compile( + r"(?:\b\d+s\.)?\s*Next check in\s+\d+s\.?,?", re.IGNORECASE +) + + +class ControllerCoreMixin: + # -------- logging -------- + + def log_gui(self, msg: str) -> None: + self.client.trace_append("gui", msg) + + def log_smartdns(self, msg: str) -> None: + self.client.trace_append("smartdns", msg) + + # -------- events stream -------- + + def iter_events(self, since: int = 0, stop=None): + return self.client.events_stream(since=since, stop=stop) + + def classify_event(self, ev: Event) -> List[str]: + """Return list of areas to refresh for given event kind.""" + k = (ev.kind or "").strip().lower() + if not k: + return [] + if k in ("status_changed", "status_error"): + return ["status", "routes", "vpn"] + if k in ("login_state_changed", "login_state_error"): + return ["login", "vpn"] + if k == "autoloop_status_changed": + return ["vpn"] + if k == "vpn_locations_changed": + return ["vpn"] + if k == "unit_state_changed": + return ["status", "vpn", "routes", "dns"] + if k in ("trace_changed", "trace_append"): + return ["trace"] + if k == "routes_nft_progress": + # Перерисовать блок "routes" (кнопки + прогресс). + return ["routes"] + if k == "traffic_mode_changed": + return ["routes", "status"] + if k == "traffic_profiles_changed": + # Used by Traffic mode dialog (Apps/runtime) for persistent app profiles. + return ["routes"] + if k in ( + "transport_client_state_changed", + "transport_client_health_changed", + "transport_client_provisioned", + "transport_policy_validated", + "transport_policy_applied", + "transport_conflict_detected", + ): + return ["transport", "status"] + if k == "egress_identity_changed": + return ["vpn", "transport"] + return [] + + # -------- helpers -------- + + def _is_logged_in_state(self, st: LoginState) -> bool: + # Backend "state" может быть любым, делаем устойчивую проверку. + s = (st.state or "").strip().lower() + if st.email: + return True + if s in ("ok", "logged", "logged_in", "success", "authorized", "ready"): + return True + return False + + def _level_to_color(self, level: str) -> str: + lv = (level or "").strip().lower() + if lv in ("green", "ok", "true", "success"): + return "green" + if lv in ("red", "error", "false", "failed"): + return "red" + return "orange" + + def _format_policy_route( + self, + policy_ok: Optional[bool], + route_ok: Optional[bool], + ) -> str: + if policy_ok is None and route_ok is None: + return "unknown (not checked)" + val = policy_ok if policy_ok is not None else route_ok + if val is True: + return "OK (default route present in VPN table)" + return "MISSING default route in VPN table" + + def _resolve_routes_unit(self, iface: str) -> str: + forced = (self.routes_unit or "").strip() + if forced: + return forced + ifc = (iface or "").strip() + if ifc and ifc != "-": + return f"selective-vpn2@{ifc}.service" + return "" + + # -------- formatting helpers -------- + + def _pretty_cmd(self, res: CmdResult) -> str: + lines: List[str] = [] + lines.append("OK" if res.ok else "ERROR") + if res.message: + lines.append(res.message.strip()) + if res.exit_code is not None: + lines.append(f"exit_code: {res.exit_code}") + if res.stdout.strip(): + lines.append("") + lines.append("stdout:") + lines.append(res.stdout.rstrip()) + if res.stderr.strip() and res.stderr.strip() != res.stdout.strip(): + lines.append("") + lines.append("stderr:") + lines.append(res.stderr.rstrip()) + return "\n".join(lines).strip() + "\n" + + def _pretty_cmd_then_status(self, res: CmdResult, st: VpnStatus) -> str: + return ( + self._pretty_cmd(res).rstrip() + + "\n\n" + + self._pretty_vpn_status(st).rstrip() + + "\n" + ) + + def _clean_login_lines(self, lines: Iterable[str]) -> List[str]: + out: List[str] = [] + for raw in lines or []: + if raw is None: + continue + + s = str(raw).replace("\r", "\n") + for part in s.splitlines(): + t = part.strip() + if not t: + continue + + # Вырезаем спам "Next check in ...". + t2 = _NEXT_CHECK_RE.sub("", t).strip() + if not t2: + continue + + # На всякий повторно. + t2 = _NEXT_CHECK_RE.sub("", t2).strip() + if not t2: + continue + + out.append(t2) + return out diff --git a/selective-vpn-gui/controllers/dns_controller.py b/selective-vpn-gui/controllers/dns_controller.py new file mode 100644 index 0000000..93ee71a --- /dev/null +++ b/selective-vpn-gui/controllers/dns_controller.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from typing import List, cast + +from api_client import ( + DNSBenchmarkResponse, + DNSBenchmarkUpstream, + DNSStatus, + DNSUpstreamPoolState, + DnsUpstreams, + SmartdnsRuntimeState, +) + +from .views import ActionView, ServiceAction + + +class DNSControllerMixin: + def dns_upstreams_view(self) -> DnsUpstreams: + return self.client.dns_upstreams_get() + + def dns_upstreams_save(self, cfg: DnsUpstreams) -> None: + self.client.dns_upstreams_set(cfg) + + def dns_upstream_pool_view(self) -> DNSUpstreamPoolState: + return self.client.dns_upstream_pool_get() + + def dns_upstream_pool_save(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState: + return self.client.dns_upstream_pool_set(items) + + def dns_benchmark( + self, + upstreams: List[DNSBenchmarkUpstream], + domains: List[str], + timeout_ms: int = 1800, + attempts: int = 1, + concurrency: int = 6, + profile: str = "load", + ) -> DNSBenchmarkResponse: + return self.client.dns_benchmark( + upstreams=upstreams, + domains=domains, + timeout_ms=timeout_ms, + attempts=attempts, + concurrency=concurrency, + profile=profile, + ) + + def dns_status_view(self) -> DNSStatus: + return self.client.dns_status_get() + + def dns_mode_set(self, via: bool, smartdns_addr: str) -> DNSStatus: + return self.client.dns_mode_set(via, smartdns_addr) + + def smartdns_service_action(self, action: str) -> DNSStatus: + act = action.strip().lower() + if act not in ("start", "stop", "restart"): + raise ValueError(f"Invalid SmartDNS action: {action}") + return self.client.dns_smartdns_service_set(cast(ServiceAction, act)) + + def smartdns_prewarm(self, limit: int = 0, aggressive_subs: bool = False) -> ActionView: + res = self.client.smartdns_prewarm(limit=limit, aggressive_subs=aggressive_subs) + return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) + + def smartdns_runtime_view(self) -> SmartdnsRuntimeState: + return self.client.smartdns_runtime_get() + + def smartdns_runtime_set(self, enabled: bool, restart: bool = True) -> SmartdnsRuntimeState: + return self.client.smartdns_runtime_set(enabled=enabled, restart=restart) + + # -------- Domains -------- + diff --git a/selective-vpn-gui/controllers/domains_controller.py b/selective-vpn-gui/controllers/domains_controller.py new file mode 100644 index 0000000..1d72d6b --- /dev/null +++ b/selective-vpn-gui/controllers/domains_controller.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from typing import Literal, cast + +from api_client import DomainsFile, DomainsTable + + +class DomainsControllerMixin: + def domains_table_view(self) -> DomainsTable: + return self.client.domains_table() + + def domains_file_load(self, name: str) -> DomainsFile: + nm = name.strip().lower() + if nm not in ( + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ): + raise ValueError(f"Invalid domains file name: {name}") + return self.client.domains_file_get( + cast( + Literal[ + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ], + nm, + ) + ) + + def domains_file_save(self, name: str, content: str) -> None: + nm = name.strip().lower() + if nm not in ( + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ): + raise ValueError(f"Invalid domains file name: {name}") + self.client.domains_file_set( + cast( + Literal[ + "bases", + "meta", + "subs", + "static", + "smartdns", + "last-ips-map", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + ], + nm, + ), + content, + ) + + # -------- Trace -------- + diff --git a/selective-vpn-gui/controllers/routes_controller.py b/selective-vpn-gui/controllers/routes_controller.py new file mode 100644 index 0000000..80cbf40 --- /dev/null +++ b/selective-vpn-gui/controllers/routes_controller.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +from typing import cast + +from api_client import CmdResult, Event + +from .views import ActionView, RoutesNftProgressView, RoutesResolveSummaryView, ServiceAction + + +class RoutesControllerMixin: + def routes_service_action(self, action: str) -> ActionView: + act = action.strip().lower() + if act not in ("start", "stop", "restart"): + raise ValueError(f"Invalid routes action: {action}") + res = self.client.routes_service(cast(ServiceAction, act)) + return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) + + def routes_clear(self) -> ActionView: + res = self.client.routes_clear() + return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) + + def routes_cache_restore(self) -> ActionView: + res = self.client.routes_cache_restore() + return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) + + def routes_precheck_debug(self, run_now: bool = True) -> ActionView: + res = self.client.routes_precheck_debug(run_now=run_now) + return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) + + def routes_fix_policy_route(self) -> ActionView: + res = self.client.routes_fix_policy_route() + return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) + + def routes_timer_enabled(self) -> bool: + st = self.client.routes_timer_get() + return bool(st.enabled) + + def routes_timer_set(self, enabled: bool) -> ActionView: + res = self.client.routes_timer_set(bool(enabled)) + return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) + + def routes_resolve_summary_view(self) -> RoutesResolveSummaryView: + dump = self.client.trace_get("full") + lines = list(getattr(dump, "lines", []) or []) + line = "" + for raw in reversed(lines): + s = str(raw or "") + if "resolve summary:" in s: + line = s + break + if not line: + return RoutesResolveSummaryView( + available=False, + text="Resolve summary: no data yet", + recheck_text="Timeout recheck: —", + color="gray", + recheck_color="gray", + ) + + tail = line.split("resolve summary:", 1)[1] + pairs: dict[str, int] = {} + for m in re.finditer(r"([a-zA-Z0-9_]+)=(-?\d+)", tail): + k = str(m.group(1) or "").strip().lower() + try: + pairs[k] = int(m.group(2)) + except Exception: + continue + + unique_ips = int(pairs.get("unique_ips", 0)) + direct_ips = int(pairs.get("direct_ips", 0)) + wildcard_ips = int(pairs.get("wildcard_ips", 0)) + unresolved = int(pairs.get("unresolved", 0)) + unresolved_live = int(pairs.get("unresolved_live", 0)) + unresolved_suppressed = int(pairs.get("unresolved_suppressed", 0)) + q_hits = int(pairs.get("quarantine_hits", 0)) + dns_attempts = int(pairs.get("dns_attempts", 0)) + dns_timeout = int(pairs.get("dns_timeout", 0)) + dns_cooldown_skips = int(pairs.get("dns_cooldown_skips", 0)) + live_batch_target = int(pairs.get("live_batch_target", 0)) + live_batch_deferred = int(pairs.get("live_batch_deferred", 0)) + live_batch_p1 = int(pairs.get("live_batch_p1", 0)) + live_batch_p2 = int(pairs.get("live_batch_p2", 0)) + live_batch_p3 = int(pairs.get("live_batch_p3", 0)) + live_batch_nxheavy_pct = int(pairs.get("live_batch_nxheavy_pct", 0)) + live_batch_nxheavy_skip = int(pairs.get("live_batch_nxheavy_skip", 0)) + + r_checked = int(pairs.get("timeout_recheck_checked", 0)) + r_recovered = int(pairs.get("timeout_recheck_recovered", 0)) + r_recovered_ips = int(pairs.get("timeout_recheck_recovered_ips", 0)) + r_still_timeout = int(pairs.get("timeout_recheck_still_timeout", 0)) + r_now_nx = int(pairs.get("timeout_recheck_now_nxdomain", 0)) + r_now_tmp = int(pairs.get("timeout_recheck_now_temporary", 0)) + + text = ( + f"Resolve: ips={unique_ips} (direct={direct_ips}, wildcard={wildcard_ips}, " + f"+recheck_ips={r_recovered_ips}) | unresolved={unresolved} " + f"(live={unresolved_live}, suppressed={unresolved_suppressed}) | " + f"quarantine_hits={q_hits} | dns_timeout={dns_timeout} " + f"| cooldown_skips={dns_cooldown_skips} | attempts={dns_attempts} " + f"| live_batch={live_batch_target} deferred={live_batch_deferred} " + f"(p1={live_batch_p1}, p2={live_batch_p2}, p3={live_batch_p3}, nx_pct={live_batch_nxheavy_pct}, nx_skip={live_batch_nxheavy_skip})" + ) + recheck_text = ( + f"Timeout recheck: checked={r_checked} recovered={r_recovered} " + f"still_timeout={r_still_timeout} now_nxdomain={r_now_nx} now_temporary={r_now_tmp}" + ) + + color = "green" if unresolved < 4000 else ("#b58900" if unresolved < 10000 else "red") + if dns_timeout > 500 and color == "green": + color = "#b58900" + if live_batch_p3 > 0 and (live_batch_p1+live_batch_p2) > 0: + ratio = float(live_batch_p3) / float(live_batch_p1 + live_batch_p2 + live_batch_p3) + if ratio > 0.8: + color = "#b58900" if color == "green" else color + if ratio > 0.95: + color = "red" + recheck_color = "green" if r_still_timeout <= 20 else ("#b58900" if r_still_timeout <= 100 else "red") + return RoutesResolveSummaryView( + available=True, + text=text, + recheck_text=recheck_text, + color=color, + recheck_color=recheck_color, + ) + + + def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView: + """ + Превращает Event(kind='routes_nft_progress') в удобную модель + для прогресс-бара/лейбла. + """ + payload = ( + getattr(ev, "data", None) + or getattr(ev, "payload", None) + or getattr(ev, "extra", None) + or {} + ) + + if not isinstance(payload, dict): + payload = {} + + try: + percent = int(payload.get("percent", 0)) + except Exception: + percent = 0 + + msg = str(payload.get("message", "")) if payload is not None else "" + if not msg: + msg = "Updating nft set…" + + active = 0 <= percent < 100 + + return RoutesNftProgressView( + percent=percent, + message=msg, + active=active, + ) + + # -------- DNS / SmartDNS -------- diff --git a/selective-vpn-gui/controllers/status_controller.py b/selective-vpn-gui/controllers/status_controller.py new file mode 100644 index 0000000..3aeffe5 --- /dev/null +++ b/selective-vpn-gui/controllers/status_controller.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from api_client import LoginState, Status, UnitState, VpnStatus + +from .views import LoginView, StatusOverviewView + + +class StatusControllerMixin: + def get_login_view(self) -> LoginView: + st: LoginState = self.client.get_login_state() + + # Prefer backend UI-ready "text" if provided, else build it. + if st.text: + txt = st.text + else: + if st.email: + txt = f"AdGuard VPN: logged in as {st.email}" + else: + txt = "AdGuard VPN: (no login data)" + + logged_in = self._is_logged_in_state(st) + + # Цвет: либо из backend, либо простой нормализованный вариант + if st.color: + color = st.color + else: + if logged_in: + color = "green" + else: + s = (st.state or "").strip().lower() + color = "orange" if s in ("unknown", "checking") else "red" + + return LoginView( + text=txt, + color=color, + logged_in=logged_in, + email=st.email or "", + ) + + def get_status_overview(self) -> StatusOverviewView: + st: Status = self.client.get_status() + + routes_unit = self._resolve_routes_unit(st.iface) + routes_s: UnitState = ( + self.client.systemd_state(routes_unit) + if routes_unit + else UnitState(state="unknown") + ) + smartdns_s: UnitState = self.client.systemd_state(self.smartdns_unit) + vpn_st: VpnStatus = self.client.vpn_status() + + counts = f"domains={st.domain_count}, ips={st.ip_count}" + iface = f"iface={st.iface} table={st.table} mark={st.mark}" + + policy_route = self._format_policy_route(st.policy_route_ok, st.route_ok) + + # SmartDNS: если state пустой/unknown — считаем это ошибкой + smart_state = smartdns_s.state or "unknown" + if smart_state.lower() in ("", "unknown", "failed"): + smart_state = "ERROR (unknown state)" + + return StatusOverviewView( + timestamp=st.timestamp or "—", + counts=counts, + iface_table_mark=iface, + policy_route=policy_route, + routes_service=f"{routes_unit or 'selective-vpn2@.service'}: {routes_s.state}", + smartdns_service=f"{self.smartdns_unit}: {smart_state}", + # это состояние самого VPN-юнита, НЕ autoloop: + # т.е. работает ли AdGuardVPN-daemon / туннель + vpn_service=f"VPN: {vpn_st.unit_state}", + ) + diff --git a/selective-vpn-gui/controllers/trace_controller.py b/selective-vpn-gui/controllers/trace_controller.py new file mode 100644 index 0000000..6a59d9e --- /dev/null +++ b/selective-vpn-gui/controllers/trace_controller.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from .views import TraceMode +from api_client import TraceDump + + +class TraceControllerMixin: + # -------- Trace -------- + + def trace_view(self, mode: TraceMode = "full") -> TraceDump: + return self.client.trace_get(mode) diff --git a/selective-vpn-gui/controllers/traffic_controller.py b/selective-vpn-gui/controllers/traffic_controller.py new file mode 100644 index 0000000..92afd3e --- /dev/null +++ b/selective-vpn-gui/controllers/traffic_controller.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from typing import List, Optional + +from api_client import ( + CmdResult, + TrafficAppMarkItem, + TrafficAppMarksResult, + TrafficAppMarksStatus, + TrafficAppProfile, + TrafficAppProfileSaveResult, + TrafficAudit, + TrafficCandidates, + TrafficInterfaces, + TrafficModeStatus, +) + +from .views import TrafficModeView + + +class TrafficControllerMixin: + def traffic_mode_view(self) -> TrafficModeView: + st: TrafficModeStatus = self.client.traffic_mode_get() + return TrafficModeView( + desired_mode=(st.desired_mode or st.mode or "selective"), + applied_mode=(st.applied_mode or "direct"), + preferred_iface=st.preferred_iface or "", + advanced_active=bool(st.advanced_active), + auto_local_bypass=bool(st.auto_local_bypass), + auto_local_active=bool(st.auto_local_active), + ingress_reply_bypass=bool(st.ingress_reply_bypass), + ingress_reply_active=bool(st.ingress_reply_active), + bypass_candidates=int(st.bypass_candidates), + force_vpn_subnets=list(st.force_vpn_subnets or []), + force_vpn_uids=list(st.force_vpn_uids or []), + force_vpn_cgroups=list(st.force_vpn_cgroups or []), + force_direct_subnets=list(st.force_direct_subnets or []), + force_direct_uids=list(st.force_direct_uids or []), + force_direct_cgroups=list(st.force_direct_cgroups or []), + overrides_applied=int(st.overrides_applied), + cgroup_resolved_uids=int(st.cgroup_resolved_uids), + cgroup_warning=st.cgroup_warning or "", + active_iface=st.active_iface or "", + iface_reason=st.iface_reason or "", + ingress_rule_present=bool(st.ingress_rule_present), + ingress_nft_active=bool(st.ingress_nft_active), + probe_ok=bool(st.probe_ok), + probe_message=st.probe_message or "", + healthy=bool(st.healthy), + message=st.message or "", + ) + + def traffic_mode_set( + self, + mode: str, + preferred_iface: Optional[str] = None, + auto_local_bypass: Optional[bool] = None, + ingress_reply_bypass: Optional[bool] = None, + force_vpn_subnets: Optional[List[str]] = None, + force_vpn_uids: Optional[List[str]] = None, + force_vpn_cgroups: Optional[List[str]] = None, + force_direct_subnets: Optional[List[str]] = None, + force_direct_uids: Optional[List[str]] = None, + force_direct_cgroups: Optional[List[str]] = None, + ) -> TrafficModeView: + st: TrafficModeStatus = self.client.traffic_mode_set( + mode, + preferred_iface, + auto_local_bypass, + ingress_reply_bypass, + force_vpn_subnets, + force_vpn_uids, + force_vpn_cgroups, + force_direct_subnets, + force_direct_uids, + force_direct_cgroups, + ) + return TrafficModeView( + desired_mode=(st.desired_mode or st.mode or mode), + applied_mode=(st.applied_mode or "direct"), + preferred_iface=st.preferred_iface or "", + advanced_active=bool(st.advanced_active), + auto_local_bypass=bool(st.auto_local_bypass), + auto_local_active=bool(st.auto_local_active), + ingress_reply_bypass=bool(st.ingress_reply_bypass), + ingress_reply_active=bool(st.ingress_reply_active), + bypass_candidates=int(st.bypass_candidates), + force_vpn_subnets=list(st.force_vpn_subnets or []), + force_vpn_uids=list(st.force_vpn_uids or []), + force_vpn_cgroups=list(st.force_vpn_cgroups or []), + force_direct_subnets=list(st.force_direct_subnets or []), + force_direct_uids=list(st.force_direct_uids or []), + force_direct_cgroups=list(st.force_direct_cgroups or []), + overrides_applied=int(st.overrides_applied), + cgroup_resolved_uids=int(st.cgroup_resolved_uids), + cgroup_warning=st.cgroup_warning or "", + active_iface=st.active_iface or "", + iface_reason=st.iface_reason or "", + ingress_rule_present=bool(st.ingress_rule_present), + ingress_nft_active=bool(st.ingress_nft_active), + probe_ok=bool(st.probe_ok), + probe_message=st.probe_message or "", + healthy=bool(st.healthy), + message=st.message or "", + ) + + def traffic_mode_test(self) -> TrafficModeView: + st: TrafficModeStatus = self.client.traffic_mode_test() + return TrafficModeView( + desired_mode=(st.desired_mode or st.mode or "selective"), + applied_mode=(st.applied_mode or "direct"), + preferred_iface=st.preferred_iface or "", + advanced_active=bool(st.advanced_active), + auto_local_bypass=bool(st.auto_local_bypass), + auto_local_active=bool(st.auto_local_active), + ingress_reply_bypass=bool(st.ingress_reply_bypass), + ingress_reply_active=bool(st.ingress_reply_active), + bypass_candidates=int(st.bypass_candidates), + force_vpn_subnets=list(st.force_vpn_subnets or []), + force_vpn_uids=list(st.force_vpn_uids or []), + force_vpn_cgroups=list(st.force_vpn_cgroups or []), + force_direct_subnets=list(st.force_direct_subnets or []), + force_direct_uids=list(st.force_direct_uids or []), + force_direct_cgroups=list(st.force_direct_cgroups or []), + overrides_applied=int(st.overrides_applied), + cgroup_resolved_uids=int(st.cgroup_resolved_uids), + cgroup_warning=st.cgroup_warning or "", + active_iface=st.active_iface or "", + iface_reason=st.iface_reason or "", + ingress_rule_present=bool(st.ingress_rule_present), + ingress_nft_active=bool(st.ingress_nft_active), + probe_ok=bool(st.probe_ok), + probe_message=st.probe_message or "", + healthy=bool(st.healthy), + message=st.message or "", + ) + + def traffic_advanced_reset(self) -> TrafficModeView: + st: TrafficModeStatus = self.client.traffic_advanced_reset() + return TrafficModeView( + desired_mode=(st.desired_mode or st.mode or "selective"), + applied_mode=(st.applied_mode or "direct"), + preferred_iface=st.preferred_iface or "", + advanced_active=bool(st.advanced_active), + auto_local_bypass=bool(st.auto_local_bypass), + auto_local_active=bool(st.auto_local_active), + ingress_reply_bypass=bool(st.ingress_reply_bypass), + ingress_reply_active=bool(st.ingress_reply_active), + bypass_candidates=int(st.bypass_candidates), + force_vpn_subnets=list(st.force_vpn_subnets or []), + force_vpn_uids=list(st.force_vpn_uids or []), + force_vpn_cgroups=list(st.force_vpn_cgroups or []), + force_direct_subnets=list(st.force_direct_subnets or []), + force_direct_uids=list(st.force_direct_uids or []), + force_direct_cgroups=list(st.force_direct_cgroups or []), + overrides_applied=int(st.overrides_applied), + cgroup_resolved_uids=int(st.cgroup_resolved_uids), + cgroup_warning=st.cgroup_warning or "", + active_iface=st.active_iface or "", + iface_reason=st.iface_reason or "", + ingress_rule_present=bool(st.ingress_rule_present), + ingress_nft_active=bool(st.ingress_nft_active), + probe_ok=bool(st.probe_ok), + probe_message=st.probe_message or "", + healthy=bool(st.healthy), + message=st.message or "", + ) + + def traffic_interfaces(self) -> List[str]: + st: TrafficInterfaces = self.client.traffic_interfaces_get() + vals = [x for x in st.interfaces if x] + if st.preferred_iface and st.preferred_iface not in vals: + vals.insert(0, st.preferred_iface) + return vals + + def traffic_candidates(self) -> TrafficCandidates: + return self.client.traffic_candidates_get() + + def traffic_appmarks_status(self) -> TrafficAppMarksStatus: + return self.client.traffic_appmarks_status() + + def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]: + return self.client.traffic_appmarks_items() + + def traffic_appmarks_apply( + self, + *, + op: str, + target: str, + cgroup: str = "", + unit: str = "", + command: str = "", + app_key: str = "", + timeout_sec: int = 0, + ) -> TrafficAppMarksResult: + return self.client.traffic_appmarks_apply( + op=op, + target=target, + cgroup=cgroup, + unit=unit, + command=command, + app_key=app_key, + timeout_sec=timeout_sec, + ) + + def traffic_app_profiles_list(self) -> List[TrafficAppProfile]: + return self.client.traffic_app_profiles_list() + + def traffic_app_profile_upsert( + self, + *, + id: str = "", + name: str = "", + app_key: str = "", + command: str, + target: str, + ttl_sec: int = 0, + vpn_profile: str = "", + ) -> TrafficAppProfileSaveResult: + return self.client.traffic_app_profile_upsert( + id=id, + name=name, + app_key=app_key, + command=command, + target=target, + ttl_sec=ttl_sec, + vpn_profile=vpn_profile, + ) + + def traffic_app_profile_delete(self, id: str) -> CmdResult: + return self.client.traffic_app_profile_delete(id) + + def traffic_audit(self) -> TrafficAudit: + return self.client.traffic_audit_get() + + # -------- Transport flow (E4.2 foundation) -------- + diff --git a/selective-vpn-gui/controllers/transport_controller.py b/selective-vpn-gui/controllers/transport_controller.py new file mode 100644 index 0000000..7e43bef --- /dev/null +++ b/selective-vpn-gui/controllers/transport_controller.py @@ -0,0 +1,807 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from dataclasses import replace +import json +from typing import Any, Dict, List, Optional, cast + +from api_client import ( + ApiError, + CmdResult, + SingBoxProfile, + SingBoxProfileApplyResult, + SingBoxProfileHistoryResult, + SingBoxProfileIssue, + SingBoxProfileRenderResult, + SingBoxProfileRollbackResult, + SingBoxProfilesState, + SingBoxProfileValidateResult, + TransportCapabilities, + TransportClient, + TransportClientActionResult, + TransportClientHealthSnapshot, + TransportInterfacesSnapshot, + TransportConflict, + TransportConflicts, + TransportHealthRefreshResult, + TransportNetnsToggleResult, + TransportOwnerLocksClearResult, + TransportOwnerLocksSnapshot, + TransportOwnershipSnapshot, + TransportPolicy, + TransportPolicyApplyResult, + TransportPolicyIntent, + TransportPolicyValidateResult, +) + +from .views import ActionView, TransportClientAction, TransportFlowPhase, TransportPolicyFlowView + + +class TransportControllerMixin: + def transport_clients( + self, + enabled_only: bool = False, + kind: str = "", + include_virtual: bool = False, + ) -> List[TransportClient]: + return self.client.transport_clients_get( + enabled_only=enabled_only, + kind=kind, + include_virtual=include_virtual, + ) + + def transport_interfaces(self) -> TransportInterfacesSnapshot: + return self.client.transport_interfaces_get() + + def transport_health_refresh( + self, + *, + client_ids: Optional[List[str]] = None, + force: bool = False, + ) -> TransportHealthRefreshResult: + return self.client.transport_health_refresh(client_ids=client_ids, force=force) + + def transport_client_health(self, client_id: str) -> TransportClientHealthSnapshot: + return self.client.transport_client_health_get(client_id) + + def transport_client_create_action( + self, + *, + client_id: str, + kind: str, + name: str = "", + enabled: bool = True, + config: Optional[Dict[str, Any]] = None, + ) -> ActionView: + cid = str(client_id or "").strip() + if not cid: + raise ValueError("missing transport client id") + res: CmdResult = self.client.transport_client_create( + client_id=cid, + kind=str(kind or "").strip().lower(), + name=name, + enabled=enabled, + config=config, + ) + msg = res.message or "client create completed" + return ActionView(ok=bool(res.ok), pretty_text=f"create {cid}: {msg}") + + def transport_client_action(self, client_id: str, action: TransportClientAction) -> ActionView: + res: TransportClientActionResult = self.client.transport_client_action(client_id, action) + status_bits = [] + before = (res.status_before or "").strip() + after = (res.status_after or "").strip() + if before or after: + status_bits.append(f"status {before or '-'} -> {after or '-'}") + if res.code: + status_bits.append(f"code={res.code}") + if res.last_error: + status_bits.append(f"last_error={res.last_error}") + extra = f" ({'; '.join(status_bits)})" if status_bits else "" + msg = res.message or f"{res.action} completed" + return ActionView(ok=bool(res.ok), pretty_text=f"{res.action} {res.client_id}: {msg}{extra}") + + def transport_client_patch_action( + self, + client_id: str, + *, + name: Optional[str] = None, + enabled: Optional[bool] = None, + config: Optional[Dict[str, Any]] = None, + ) -> ActionView: + cid = str(client_id or "").strip() + if not cid: + raise ValueError("missing transport client id") + res: CmdResult = self.client.transport_client_patch( + cid, + name=name, + enabled=enabled, + config=config, + ) + msg = res.message or "client patch completed" + return ActionView(ok=bool(res.ok), pretty_text=f"patch {cid}: {msg}") + + def transport_client_delete_action( + self, + client_id: str, + *, + force: bool = False, + cleanup: bool = True, + ) -> ActionView: + cid = str(client_id or "").strip() + if not cid: + raise ValueError("missing transport client id") + res: CmdResult = self.client.transport_client_delete(cid, force=force, cleanup=cleanup) + msg = res.message or "delete completed" + return ActionView(ok=bool(res.ok), pretty_text=f"delete {cid}: {msg}") + + def transport_netns_toggle( + self, + *, + enabled: Optional[bool] = None, + client_ids: Optional[List[str]] = None, + provision: bool = True, + restart_running: bool = True, + ) -> TransportNetnsToggleResult: + ids = [ + str(x).strip() + for x in (client_ids or []) + if str(x).strip() + ] if client_ids is not None else None + return self.client.transport_netns_toggle( + enabled=enabled, + client_ids=ids, + provision=provision, + restart_running=restart_running, + ) + + def transport_policy_rollback_action(self, base_revision: int = 0) -> ActionView: + base = int(base_revision or 0) + if base <= 0: + base = int(self.client.transport_policy_get().revision or 0) + res: TransportPolicyApplyResult = self.client.transport_policy_rollback(base_revision=base) + if res.ok: + msg = res.message or "policy rollback applied" + bits = [f"revision={int(res.policy_revision or 0)}"] + if res.apply_id: + bits.append(f"apply_id={res.apply_id}") + return ActionView(ok=True, pretty_text=f"{msg} ({', '.join(bits)})") + msg = res.message or "policy rollback failed" + if res.code: + msg = f"{msg} (code={res.code})" + return ActionView(ok=False, pretty_text=msg) + + def transport_policy(self) -> TransportPolicy: + return self.client.transport_policy_get() + + def transport_ownership(self) -> TransportOwnershipSnapshot: + return self.client.transport_ownership_get() + + def transport_owner_locks(self) -> TransportOwnerLocksSnapshot: + return self.client.transport_owner_locks_get() + + def transport_owner_locks_clear( + self, + *, + base_revision: int = 0, + client_id: str = "", + destination_ip: str = "", + destination_ips: Optional[List[str]] = None, + confirm_token: str = "", + ) -> TransportOwnerLocksClearResult: + return self.client.transport_owner_locks_clear( + base_revision=int(base_revision or 0), + client_id=str(client_id or "").strip(), + destination_ip=str(destination_ip or "").strip(), + destination_ips=[ + str(x).strip() + for x in list(destination_ips or []) + if str(x).strip() + ], + confirm_token=str(confirm_token or "").strip(), + ) + + def transport_owner_locks_clear_action( + self, + *, + base_revision: int = 0, + client_id: str = "", + destination_ip: str = "", + destination_ips: Optional[List[str]] = None, + confirm_token: str = "", + ) -> ActionView: + res = self.transport_owner_locks_clear( + base_revision=base_revision, + client_id=client_id, + destination_ip=destination_ip, + destination_ips=destination_ips, + confirm_token=confirm_token, + ) + bits: List[str] = [] + if res.code: + bits.append(f"code={res.code}") + bits.append(f"match={int(res.match_count)}") + bits.append(f"cleared={int(res.cleared_count)}") + bits.append(f"remaining={int(res.remaining_count)}") + msg = (res.message or "owner-lock clear").strip() + return ActionView(ok=bool(res.ok), pretty_text=f"{msg} ({', '.join(bits)})") + + def transport_conflicts(self) -> TransportConflicts: + return self.client.transport_conflicts_get() + + def transport_capabilities(self) -> TransportCapabilities: + return self.client.transport_capabilities_get() + + def transport_flow_draft( + self, + intents: Optional[List[TransportPolicyIntent]] = None, + *, + base_revision: int = 0, + ) -> TransportPolicyFlowView: + pol = self.client.transport_policy_get() + rev = int(base_revision) if int(base_revision or 0) > 0 else int(pol.revision) + return TransportPolicyFlowView( + phase="draft", + intents=list(intents) if intents is not None else list(pol.intents), + base_revision=rev, + current_revision=int(pol.revision), + applied_revision=0, + confirm_token="", + valid=False, + block_count=0, + warn_count=0, + diff_added=0, + diff_changed=0, + diff_removed=0, + conflicts=[], + apply_id="", + rollback_available=False, + message="draft ready", + code="", + ) + + def transport_flow_update_draft( + self, + flow: TransportPolicyFlowView, + intents: List[TransportPolicyIntent], + *, + base_revision: int = 0, + ) -> TransportPolicyFlowView: + rev = int(base_revision) if int(base_revision or 0) > 0 else int(flow.current_revision or flow.base_revision) + return replace( + flow, + phase="draft", + intents=list(intents), + base_revision=rev, + applied_revision=0, + confirm_token="", + valid=False, + block_count=0, + warn_count=0, + diff_added=0, + diff_changed=0, + diff_removed=0, + conflicts=[], + apply_id="", + rollback_available=False, + message="draft updated", + code="", + ) + + def transport_flow_validate( + self, + flow: TransportPolicyFlowView, + *, + allow_warnings: bool = True, + ) -> TransportPolicyFlowView: + res: TransportPolicyValidateResult = self.client.transport_policy_validate( + base_revision=int(flow.base_revision or 0), + intents=list(flow.intents), + allow_warnings=allow_warnings, + force_override=False, + ) + phase: TransportFlowPhase = "validated" + if not res.valid or int(res.summary.block_count) > 0: + phase = "risky" + return replace( + flow, + phase=phase, + base_revision=int(res.base_revision or flow.base_revision), + current_revision=int(res.base_revision or flow.current_revision), + confirm_token=res.confirm_token, + valid=bool(res.valid), + block_count=int(res.summary.block_count), + warn_count=int(res.summary.warn_count), + diff_added=int(res.diff.added), + diff_changed=int(res.diff.changed), + diff_removed=int(res.diff.removed), + conflicts=list(res.conflicts or []), + apply_id="", + rollback_available=False, + message=res.message or ("validated" if phase == "validated" else "blocking conflicts found"), + code=res.code or "", + ) + + def transport_flow_confirm(self, flow: TransportPolicyFlowView) -> TransportPolicyFlowView: + if flow.phase != "risky": + raise ValueError("confirm step is allowed only after risky validate") + if not flow.confirm_token: + raise ValueError("missing confirm token; run validate again") + return replace( + flow, + phase="confirm", + message="force apply requires explicit confirmation", + code="FORCE_CONFIRM_REQUIRED", + ) + + def transport_flow_apply( + self, + flow: TransportPolicyFlowView, + *, + force_override: bool = False, + ) -> TransportPolicyFlowView: + if flow.phase == "draft": + return replace( + flow, + message="policy must be validated before apply", + code="VALIDATE_REQUIRED", + ) + if flow.phase == "risky" and not force_override: + return replace( + flow, + message="policy has blocking conflicts; open confirm step", + code="POLICY_CONFLICT_BLOCK", + ) + if force_override and flow.phase != "confirm": + return replace( + flow, + phase="risky", + message="force apply requires confirm state", + code="FORCE_CONFIRM_REQUIRED", + ) + if force_override and not flow.confirm_token: + return replace( + flow, + phase="risky", + message="confirm token is missing or expired; run validate again", + code="FORCE_OVERRIDE_CONFIRM_REQUIRED", + ) + + res: TransportPolicyApplyResult = self.client.transport_policy_apply( + base_revision=int(flow.base_revision), + intents=list(flow.intents), + force_override=bool(force_override), + confirm_token=flow.confirm_token if force_override else "", + ) + return self._transport_flow_from_apply_result(flow, res) + + def transport_flow_rollback(self, flow: TransportPolicyFlowView) -> TransportPolicyFlowView: + base = int(flow.current_revision or flow.base_revision) + res: TransportPolicyApplyResult = self.client.transport_policy_rollback(base_revision=base) + return self._transport_flow_from_apply_result(flow, res) + + def _transport_flow_from_apply_result( + self, + flow: TransportPolicyFlowView, + res: TransportPolicyApplyResult, + ) -> TransportPolicyFlowView: + if res.ok: + pol = self.client.transport_policy_get() + applied_rev = int(res.policy_revision or pol.revision) + return TransportPolicyFlowView( + phase="applied", + intents=list(pol.intents), + base_revision=applied_rev, + current_revision=applied_rev, + applied_revision=applied_rev, + confirm_token="", + valid=True, + block_count=0, + warn_count=0, + diff_added=0, + diff_changed=0, + diff_removed=0, + conflicts=[], + apply_id=res.apply_id or "", + rollback_available=bool(res.rollback_available), + message=res.message or "policy applied", + code=res.code or "", + ) + + if res.code == "POLICY_REVISION_MISMATCH": + current_rev = int(res.current_revision or 0) + if current_rev <= 0: + current_rev = int(self.client.transport_policy_get().revision) + return replace( + flow, + phase="draft", + base_revision=current_rev, + current_revision=current_rev, + confirm_token="", + valid=False, + message="policy revision changed; validate again", + code=res.code, + ) + + if res.code in ("POLICY_CONFLICT_BLOCK", "FORCE_OVERRIDE_CONFIRM_REQUIRED"): + conflicts = list(res.conflicts or flow.conflicts) + block_count = len([x for x in conflicts if (x.severity or "").strip().lower() == "block"]) + return replace( + flow, + phase="risky", + valid=False, + block_count=block_count, + conflicts=conflicts, + message=res.message or "blocking conflicts", + code=res.code, + ) + + return replace( + flow, + phase="error", + valid=False, + message=res.message or "transport apply failed", + code=res.code or "TRANSPORT_APPLY_ERROR", + ) + + def singbox_profile_id_for_client(self, client: Optional[TransportClient]) -> str: + if client is None: + return "" + cfg = getattr(client, "config", {}) or {} + if isinstance(cfg, dict): + for key in ("profile_id", "singbox_profile_id", "profile"): + v = str(cfg.get(key) or "").strip() + if v: + return v + return str(getattr(client, "id", "") or "").strip() + + def singbox_profile_ensure_linked( + self, + client: TransportClient, + *, + preferred_profile_id: str = "", + ) -> ActionView: + pid, state = self._ensure_singbox_profile_for_client( + client, + preferred_profile_id=str(preferred_profile_id or "").strip(), + ) + cid = str(getattr(client, "id", "") or "").strip() + if state == "created": + return ActionView(ok=True, pretty_text=f"profile {pid} created and linked to {cid}") + if state == "linked": + return ActionView(ok=True, pretty_text=f"profile {pid} linked to {cid}") + return ActionView(ok=True, pretty_text=f"profile {pid} already linked to {cid}") + + def singbox_profile_validate_action( + self, + profile_id: str, + *, + check_binary: Optional[bool] = None, + client: Optional[TransportClient] = None, + ) -> ActionView: + pid = self._resolve_singbox_profile_id(profile_id, client) + res: SingBoxProfileValidateResult = self.client.transport_singbox_profile_validate( + pid, + check_binary=check_binary, + ) + ok = bool(res.ok and res.valid) + if ok: + msg = res.message or "profile is valid" + return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("validate", pid, msg, res)) + msg = res.message or "profile validation failed" + return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("validate", pid, msg, res)) + + def singbox_profile_render_preview_action( + self, + profile_id: str, + *, + check_binary: Optional[bool] = None, + persist: bool = False, + client: Optional[TransportClient] = None, + ) -> ActionView: + pid = self._resolve_singbox_profile_id(profile_id, client) + res: SingBoxProfileRenderResult = self.client.transport_singbox_profile_render( + pid, + check_binary=check_binary, + persist=bool(persist), + ) + ok = bool(res.ok and res.valid) + if ok: + msg = res.message or ("rendered" if persist else "render preview ready") + return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("render", pid, msg, res)) + msg = res.message or "render failed" + return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("render", pid, msg, res)) + + def singbox_profile_apply_action( + self, + profile_id: str, + *, + client_id: str = "", + restart: Optional[bool] = True, + skip_runtime: bool = False, + check_binary: Optional[bool] = None, + client: Optional[TransportClient] = None, + ) -> ActionView: + pid = self._resolve_singbox_profile_id(profile_id, client) + cid = str(client_id or "").strip() + if not cid and client is not None: + cid = str(getattr(client, "id", "") or "").strip() + res: SingBoxProfileApplyResult = self.client.transport_singbox_profile_apply( + pid, + client_id=cid, + restart=restart, + skip_runtime=skip_runtime, + check_binary=check_binary, + ) + if res.ok: + msg = res.message or "profile applied" + return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("apply", pid, msg, res)) + msg = res.message or "profile apply failed" + return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("apply", pid, msg, res)) + + def singbox_profile_rollback_action( + self, + profile_id: str, + *, + client_id: str = "", + restart: Optional[bool] = True, + skip_runtime: bool = False, + history_id: str = "", + client: Optional[TransportClient] = None, + ) -> ActionView: + pid = self._resolve_singbox_profile_id(profile_id, client) + cid = str(client_id or "").strip() + if not cid and client is not None: + cid = str(getattr(client, "id", "") or "").strip() + res: SingBoxProfileRollbackResult = self.client.transport_singbox_profile_rollback( + pid, + client_id=cid, + history_id=history_id, + restart=restart, + skip_runtime=skip_runtime, + ) + if res.ok: + msg = res.message or "profile rollback applied" + return ActionView(ok=True, pretty_text=self._format_singbox_profile_action("rollback", pid, msg, res)) + msg = res.message or "profile rollback failed" + return ActionView(ok=False, pretty_text=self._format_singbox_profile_action("rollback", pid, msg, res)) + + def singbox_profile_history_lines( + self, + profile_id: str, + *, + limit: int = 20, + client: Optional[TransportClient] = None, + ) -> List[str]: + pid = self._resolve_singbox_profile_id(profile_id, client) + res: SingBoxProfileHistoryResult = self.client.transport_singbox_profile_history(pid, limit=limit) + lines: List[str] = [] + for it in list(res.items or []): + lines.append(self._format_singbox_history_line(it)) + return lines + + def singbox_profile_get_for_client( + self, + client: TransportClient, + *, + profile_id: str = "", + ) -> SingBoxProfile: + pid = self._resolve_singbox_profile_id(profile_id, client) + return self.client.transport_singbox_profile_get(pid) + + def singbox_profile_save_raw_for_client( + self, + client: TransportClient, + *, + profile_id: str = "", + name: str = "", + enabled: bool = True, + protocol: str = "vless", + raw_config: Optional[Dict[str, Any]] = None, + ) -> ActionView: + pid = self._resolve_singbox_profile_id(profile_id, client) + current = self.client.transport_singbox_profile_get(pid) + snap = self.client.transport_singbox_profile_patch( + pid, + base_revision=int(current.profile_revision or 0), + name=(str(name or "").strip() or current.name or pid), + enabled=bool(enabled), + protocol=(str(protocol or "").strip().lower() or "vless"), + mode="raw", + raw_config=cast(Dict[str, Any], raw_config or {}), + ) + item = snap.item + if item is None: + return ActionView(ok=False, pretty_text=f"save profile {pid}: backend returned empty item") + return ActionView( + ok=True, + pretty_text=( + f"save profile {pid}: revision={int(item.profile_revision or 0)} " + f"render_revision={int(item.render_revision or 0)}" + ), + ) + + def _format_singbox_profile_action( + self, + action: str, + profile_id: str, + message: str, + res: SingBoxProfileValidateResult | SingBoxProfileRenderResult | SingBoxProfileApplyResult | SingBoxProfileRollbackResult, + ) -> str: + bits: List[str] = [] + if getattr(res, "code", ""): + bits.append(f"code={str(getattr(res, 'code', '')).strip()}") + + rev = int(getattr(res, "profile_revision", 0) or 0) + if rev > 0: + bits.append(f"rev={rev}") + + diff = getattr(res, "diff", None) + if diff is not None: + added = int(getattr(diff, "added", 0) or 0) + changed = int(getattr(diff, "changed", 0) or 0) + removed = int(getattr(diff, "removed", 0) or 0) + bits.append(f"diff=+{added}/~{changed}/-{removed}") + + render_digest = str(getattr(res, "render_digest", "") or "").strip() + if render_digest: + bits.append(f"digest={render_digest[:12]}") + + client_id = str(getattr(res, "client_id", "") or "").strip() + if client_id: + bits.append(f"client={client_id}") + config_path = str(getattr(res, "config_path", "") or "").strip() + if config_path: + bits.append(f"config={config_path}") + history_id = str(getattr(res, "history_id", "") or "").strip() + if history_id: + bits.append(f"history={history_id}") + render_path = str(getattr(res, "render_path", "") or "").strip() + if render_path: + bits.append(f"render={render_path}") + render_revision = int(getattr(res, "render_revision", 0) or 0) + if render_revision > 0: + bits.append(f"render_rev={render_revision}") + + rollback_available = bool(getattr(res, "rollback_available", False)) + if rollback_available: + bits.append("rollback=available") + + errors = cast(List[SingBoxProfileIssue], list(getattr(res, "errors", []) or [])) + warnings = cast(List[SingBoxProfileIssue], list(getattr(res, "warnings", []) or [])) + if warnings: + bits.append(f"warnings={len(warnings)}") + if errors: + bits.append(f"errors={len(errors)}") + first = self._format_singbox_issue_brief(errors[0]) + if first: + bits.append(f"first_error={first}") + + tail = f" ({'; '.join(bits)})" if bits else "" + return f"{action} profile {profile_id}: {message}{tail}" + + def _format_singbox_history_line(self, it) -> str: + at = str(getattr(it, "at", "") or "").strip() or "-" + action = str(getattr(it, "action", "") or "").strip() or "event" + status = str(getattr(it, "status", "") or "").strip() or "unknown" + msg = str(getattr(it, "message", "") or "").strip() + code = str(getattr(it, "code", "") or "").strip() + digest = str(getattr(it, "render_digest", "") or "").strip() + client_id = str(getattr(it, "client_id", "") or "").strip() + bits: List[str] = [] + if code: + bits.append(f"code={code}") + if client_id: + bits.append(f"client={client_id}") + if digest: + bits.append(f"digest={digest[:12]}") + tail = f" ({'; '.join(bits)})" if bits else "" + body = msg or "-" + return f"{at} | {action} | {status} | {body}{tail}" + + def _resolve_singbox_profile_id(self, profile_id: str, client: Optional[TransportClient]) -> str: + pid = str(profile_id or "").strip() + if client is not None: + ensured_pid, _ = self._ensure_singbox_profile_for_client(client, preferred_profile_id=pid) + pid = ensured_pid + if not pid: + raise ValueError("missing singbox profile id") + return pid + + def _ensure_singbox_profile_for_client( + self, + client: TransportClient, + *, + preferred_profile_id: str = "", + ) -> tuple[str, str]: + cid = str(getattr(client, "id", "") or "").strip() + if not cid: + raise ValueError("missing transport client id") + + pid = str(preferred_profile_id or "").strip() + if not pid: + pid = self.singbox_profile_id_for_client(client) + if not pid: + raise ValueError("cannot resolve singbox profile id for selected client") + + try: + cur = self.client.transport_singbox_profile_get(pid) + except ApiError as e: + if int(getattr(e, "status_code", 0) or 0) != 404: + raise + raw_cfg = self._load_singbox_raw_config_from_client(client) + protocol = self._infer_singbox_protocol(client, raw_cfg) + snap: SingBoxProfilesState = self.client.transport_singbox_profile_create( + profile_id=pid, + name=str(getattr(client, "name", "") or "").strip() or pid, + mode="raw", + protocol=protocol, + raw_config=raw_cfg, + meta={"client_id": cid}, + enabled=True, + ) + created = snap.item + if created is None: + raise RuntimeError("profile create returned empty item") + return str(created.id or pid).strip(), "created" + + meta = dict(cur.meta or {}) + if str(meta.get("client_id") or "").strip() == cid: + return pid, "ok" + meta["client_id"] = cid + snap = self.client.transport_singbox_profile_patch( + pid, + base_revision=int(cur.profile_revision or 0), + meta=meta, + ) + if snap.item is not None: + pid = str(snap.item.id or pid).strip() + return pid, "linked" + + def _load_singbox_raw_config_from_client(self, client: TransportClient) -> dict: + cfg = getattr(client, "config", {}) or {} + if not isinstance(cfg, dict): + return {} + path = str(cfg.get("config_path") or "").strip() + if not path: + return {} + try: + with open(path, "r", encoding="utf-8") as f: + parsed = json.load(f) + if isinstance(parsed, dict): + return cast(dict, parsed) + except Exception: + return {} + return {} + + def _infer_singbox_protocol(self, client: TransportClient, raw_cfg: dict) -> str: + cfg = getattr(client, "config", {}) or {} + if isinstance(cfg, dict): + p = str(cfg.get("protocol") or "").strip().lower() + if p: + return p + if isinstance(raw_cfg, dict): + outbounds = raw_cfg.get("outbounds") or [] + if isinstance(outbounds, list): + for row in outbounds: + if not isinstance(row, dict): + continue + t = str(row.get("type") or "").strip().lower() + if not t: + continue + if t in ("direct", "block", "dns"): + continue + return t + return "vless" + + def _format_singbox_issue_brief(self, issue: SingBoxProfileIssue) -> str: + code = str(getattr(issue, "code", "") or "").strip() + field = str(getattr(issue, "field", "") or "").strip() + message = str(getattr(issue, "message", "") or "").strip() + parts = [x for x in (code, field, message) if x] + if not parts: + return "" + out = ": ".join(parts[:2]) if len(parts) > 1 else parts[0] + if len(parts) > 2: + out = f"{out}: {parts[2]}" + return out if len(out) <= 140 else out[:137] + "..." diff --git a/selective-vpn-gui/controllers/views.py b/selective-vpn-gui/controllers/views.py new file mode 100644 index 0000000..efb84f5 --- /dev/null +++ b/selective-vpn-gui/controllers/views.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Literal + +from api_client import TransportConflict, TransportPolicyIntent + +TraceMode = Literal["full", "gui", "smartdns"] +ServiceAction = Literal["start", "stop", "restart"] +LoginAction = Literal["open", "check", "cancel"] +TransportClientAction = Literal["provision", "start", "stop", "restart"] +TransportFlowPhase = Literal["draft", "validated", "risky", "confirm", "applied", "error"] + + +@dataclass(frozen=True) +class LoginView: + text: str + color: str + logged_in: bool + email: str + + +@dataclass(frozen=True) +class StatusOverviewView: + timestamp: str + counts: str + iface_table_mark: str + policy_route: str + routes_service: str + smartdns_service: str + vpn_service: str + + +@dataclass(frozen=True) +class VpnStatusView: + desired_location: str + pretty_text: str + + +@dataclass(frozen=True) +class ActionView: + ok: bool + pretty_text: str + + +@dataclass(frozen=True) +class LoginFlowView: + phase: str + level: str + dot_color: str + status_text: str + url: str + email: str + alive: bool + cursor: int + lines: List[str] + can_open: bool + can_check: bool + can_cancel: bool + + +@dataclass(frozen=True) +class VpnAutoconnectView: + """Для блока Autoconnect на вкладке AdGuardVPN.""" + enabled: bool # True = включён autoloop + unit_text: str # строка вида "unit: active" + color: str # "green" / "red" / "orange" + + +@dataclass(frozen=True) +class RoutesNftProgressView: + """Прогресс обновления nft-наборов (agvpn4).""" + percent: int + message: str + active: bool # True — пока идёт апдейт, False — когда закончили / ничего не идёт + + +@dataclass(frozen=True) +class TrafficModeView: + desired_mode: str + applied_mode: str + preferred_iface: str + advanced_active: bool + auto_local_bypass: bool + auto_local_active: bool + ingress_reply_bypass: bool + ingress_reply_active: bool + bypass_candidates: int + force_vpn_subnets: List[str] + force_vpn_uids: List[str] + force_vpn_cgroups: List[str] + force_direct_subnets: List[str] + force_direct_uids: List[str] + force_direct_cgroups: List[str] + overrides_applied: int + cgroup_resolved_uids: int + cgroup_warning: str + active_iface: str + iface_reason: str + ingress_rule_present: bool + ingress_nft_active: bool + probe_ok: bool + probe_message: str + healthy: bool + message: str + + +@dataclass(frozen=True) +class RoutesResolveSummaryView: + available: bool + text: str + recheck_text: str + color: str + recheck_color: str + + +@dataclass(frozen=True) +class TransportPolicyFlowView: + phase: TransportFlowPhase + intents: List[TransportPolicyIntent] + base_revision: int + current_revision: int + applied_revision: int + confirm_token: str + valid: bool + block_count: int + warn_count: int + diff_added: int + diff_changed: int + diff_removed: int + conflicts: List[TransportConflict] + apply_id: str + rollback_available: bool + message: str + code: str diff --git a/selective-vpn-gui/controllers/vpn_controller.py b/selective-vpn-gui/controllers/vpn_controller.py new file mode 100644 index 0000000..e45b06f --- /dev/null +++ b/selective-vpn-gui/controllers/vpn_controller.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from typing import List, Optional, cast + +from api_client import ( + CmdResult, + EgressIdentity, + EgressIdentityRefreshResult, + LoginSessionAction, + LoginSessionStart, + LoginSessionState, + LoginState, + VpnLocation, + VpnLocationsState, + VpnStatus, +) + +from .views import ActionView, LoginAction, LoginFlowView, VpnAutoconnectView, VpnStatusView + + +class VpnControllerMixin: + def vpn_locations_view(self) -> List[VpnLocation]: + return self.client.vpn_locations() + + def vpn_locations_state_view(self) -> VpnLocationsState: + return self.client.vpn_locations_state() + + def vpn_locations_refresh_trigger(self) -> None: + self.client.vpn_locations_refresh_trigger() + + def vpn_status_view(self) -> VpnStatusView: + st = self.client.vpn_status() + pretty = self._pretty_vpn_status(st) + return VpnStatusView( + desired_location=st.desired_location, + pretty_text=pretty, + ) + + def vpn_status_model(self) -> VpnStatus: + return self.client.vpn_status() + + # --- autoconnect / autoloop --- + + def _autoconnect_from_auto(self, auto) -> bool: + """ + Вытаскиваем True/False из ответа /vpn/autoloop/status. + + Приоритет: + 1) явное поле auto.enabled (bool) + 2) эвристика по status_word / raw_text + """ + enabled_field = getattr(auto, "enabled", None) + if isinstance(enabled_field, bool): + return enabled_field + + word = (getattr(auto, "status_word", "") or "").strip().lower() + raw = (getattr(auto, "raw_text", "") or "").lower() + + # приоритет — явные статусы + if word in ( + "active", + "running", + "enabled", + "on", + "up", + "started", + "ok", + "true", + "yes", + ): + return True + if word in ("inactive", "stopped", "disabled", "off", "down", "false", "no"): + return False + + # фоллбек — по raw_text + if "inactive" in raw or "disabled" in raw or "failed" in raw: + return False + if "active" in raw or "running" in raw or "enabled" in raw: + return True + return False + + def vpn_autoconnect_view(self) -> VpnAutoconnectView: + try: + auto = self.client.vpn_autoloop_status() + except Exception as e: + return VpnAutoconnectView( + enabled=False, + unit_text=f"unit: ERROR ({e})", + color="red", + ) + + enabled = self._autoconnect_from_auto(auto) + + unit_state = ( + getattr(auto, "unit_state", "") # если backend так отдаёт + or (auto.status_word or "") + or "unknown" + ) + + text = f"unit: {unit_state}" + + low = f"{unit_state} {(auto.raw_text or '')}".lower() + if any(x in low for x in ("failed", "error", "unknown", "inactive", "dead")): + color = "red" + elif "active" in low or "running" in low or "enabled" in low: + color = "green" + else: + color = "orange" + + return VpnAutoconnectView(enabled=enabled, unit_text=text, color=color) + + def vpn_autoconnect_enabled(self) -> bool: + """Старый интерфейс — оставляем для кнопки toggle.""" + return self.vpn_autoconnect_view().enabled + + def vpn_set_autoconnect(self, enable: bool) -> VpnStatusView: + res = self.client.vpn_autoconnect(enable) + st = self.client.vpn_status() + pretty = self._pretty_cmd_then_status(res, st) + return VpnStatusView( + desired_location=st.desired_location, + pretty_text=pretty, + ) + + def vpn_set_location(self, target: str, iso: str = "", label: str = "") -> VpnStatusView: + self.client.vpn_set_location(target=target, iso=iso, label=label) + st = self.client.vpn_status() + pretty = self._pretty_vpn_status(st) + return VpnStatusView( + desired_location=st.desired_location, + pretty_text=pretty, + ) + + def egress_identity(self, scope: str, *, refresh: bool = False) -> EgressIdentity: + return self.client.egress_identity_get(scope, refresh=refresh) + + def egress_identity_refresh( + self, + *, + scopes: Optional[List[str]] = None, + force: bool = False, + ) -> EgressIdentityRefreshResult: + return self.client.egress_identity_refresh(scopes=scopes, force=force) + + def _pretty_vpn_status(self, st: VpnStatus) -> str: + lines = [ + f"unit_state: {st.unit_state}", + f"desired_location: {st.desired_location or '—'}", + f"status: {st.status_word}", + ] + if st.raw_text: + lines.append("") + lines.append(st.raw_text.strip()) + return "\n".join(lines).strip() + "\n" + + # -------- Login Flow (interactive) -------- + + def login_flow_start(self) -> LoginFlowView: + s: LoginSessionStart = self.client.vpn_login_session_start() + + dot = self._level_to_color(s.level) + + if not s.ok: + txt = s.error or "Failed to start login session" + return LoginFlowView( + phase=s.phase or "failed", + level=s.level or "red", + dot_color="red", + status_text=txt, + url="", + email="", + alive=False, + cursor=0, + lines=[txt], + can_open=False, + can_check=False, + can_cancel=False, + ) + + if (s.phase or "").lower() == "already_logged": + txt = ( + f"Already logged in as {s.email}" + if s.email + else "Already logged in" + ) + return LoginFlowView( + phase="already_logged", + level="green", + dot_color="green", + status_text=txt, + url="", + email=s.email or "", + alive=False, + cursor=0, + lines=[txt], + can_open=False, + can_check=False, + can_cancel=False, + ) + + txt = f"Login started (pid={s.pid})" if s.pid else "Login started" + return LoginFlowView( + phase=s.phase or "starting", + level=s.level or "yellow", + dot_color=dot, + status_text=txt, + url="", + email="", + alive=True, + cursor=0, + lines=[], + can_open=True, + can_check=True, + can_cancel=True, + ) + + def login_flow_poll(self, since: int) -> LoginFlowView: + st: LoginSessionState = self.client.vpn_login_session_state(since=since) + + dot = self._level_to_color(st.level) + + phase = (st.phase or "").lower() + if phase == "waiting_browser": + status_txt = "Waiting for browser authorization…" + elif phase == "checking": + status_txt = "Checking…" + elif phase == "success": + status_txt = "✅ Logged in" + elif phase == "failed": + status_txt = "❌ Login failed" + elif phase == "cancelled": + status_txt = "Cancelled" + elif phase == "already_logged": + status_txt = ( + f"Already logged in as {st.email}" + if st.email + else "Already logged in" + ) + else: + status_txt = st.phase or "…" + + clean_lines = self._clean_login_lines(st.lines) + + return LoginFlowView( + phase=st.phase, + level=st.level, + dot_color=dot, + status_text=status_txt, + url=st.url, + email=st.email, + alive=st.alive, + cursor=st.cursor, + can_open=st.can_open, + can_check=st.can_cancel, + can_cancel=st.can_cancel, + lines=clean_lines, + ) + + def login_flow_action(self, action: str) -> ActionView: + act = action.strip().lower() + if act not in ("open", "check", "cancel"): + raise ValueError(f"Invalid login action: {action}") + + res: LoginSessionAction = self.client.vpn_login_session_action( + cast(LoginAction, act) + ) + + if not res.ok: + txt = res.error or "Login action failed" + return ActionView(ok=False, pretty_text=txt + "\n") + + txt = f"OK: {act} → phase={res.phase} level={res.level}" + return ActionView(ok=True, pretty_text=txt + "\n") + + def login_flow_stop(self) -> ActionView: + res = self.client.vpn_login_session_stop() + return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) + + def vpn_logout(self) -> ActionView: + res = self.client.vpn_logout() + return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) + + # Баннер "AdGuard VPN: logged in as ...", по клику показываем инфу как в CLI + def login_banner_cli_text(self) -> str: + try: + st: LoginState = self.client.get_login_state() + except Exception as e: + return f"Failed to query login state: {e}" + + # backend может не иметь поля error, поэтому через getattr + err = getattr(st, "error", None) or getattr(st, "message", None) + if err: + return str(err) + + if st.email: + return f"You are already logged in.\nCurrent user is {st.email}" + + if st.state: + return f"Login state: {st.state}" + + return "No login information available." + + # -------- Routes -------- + diff --git a/selective-vpn-gui/dashboard_controller.py b/selective-vpn-gui/dashboard_controller.py index 6c75a08..8b4ca4c 100644 --- a/selective-vpn-gui/dashboard_controller.py +++ b/selective-vpn-gui/dashboard_controller.py @@ -1,175 +1,56 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -DashboardController +"""DashboardController facade. -Тонкий "мозг" между UI и ApiClient. - -UI не должен знать URL'ы / JSON, только вызывать методы этого контроллера. +Тонкий слой между UI и ApiClient; доменная логика вынесена в `controllers/*` mixin-модули. """ from __future__ import annotations -from dataclasses import dataclass import os -import re -from typing import Iterable, List, Literal, Optional, cast +from typing import Optional -# вырезаем спам автопроверки из логов (CLI любит писать "Next check in ...") -_NEXT_CHECK_RE = re.compile( - r"(?:\b\d+s\.)?\s*Next check in\s+\d+s\.?", re.IGNORECASE +from api_client import ApiClient +from controllers import ( + ActionView, + ControllerCoreMixin, + DNSControllerMixin, + DomainsControllerMixin, + LoginAction, + LoginFlowView, + LoginView, + RoutesControllerMixin, + RoutesNftProgressView, + RoutesResolveSummaryView, + ServiceAction, + StatusControllerMixin, + StatusOverviewView, + TraceControllerMixin, + TraceMode, + TrafficControllerMixin, + TrafficModeView, + TransportClientAction, + TransportControllerMixin, + TransportFlowPhase, + TransportPolicyFlowView, + VpnAutoconnectView, + VpnControllerMixin, + VpnStatusView, ) -from api_client import ( - ApiClient, - CmdResult, - DNSBenchmarkResponse, - DNSBenchmarkUpstream, - DNSUpstreamPoolState, - DNSStatus, - DnsUpstreams, - DomainsFile, - DomainsTable, - Event, - LoginState, - Status, - TrafficCandidates, - TrafficAppMarksResult, - TrafficAppMarksStatus, - TrafficAppMarkItem, - TrafficAppProfile, - TrafficAppProfileSaveResult, - TrafficAudit, - TrafficInterfaces, - TrafficModeStatus, - TraceDump, - UnitState, - VpnLocation, - VpnStatus, - SmartdnsRuntimeState, - # login flow models - LoginSessionStart, - LoginSessionState, - LoginSessionAction, -) -TraceMode = Literal["full", "gui", "smartdns"] -ServiceAction = Literal["start", "stop", "restart"] -LoginAction = Literal["open", "check", "cancel"] - - -# --------------------------- -# View models (UI-friendly) -# --------------------------- - -@dataclass(frozen=True) -class LoginView: - text: str - color: str - logged_in: bool - email: str - - -@dataclass(frozen=True) -class StatusOverviewView: - timestamp: str - counts: str - iface_table_mark: str - policy_route: str - routes_service: str - smartdns_service: str - vpn_service: str - - -@dataclass(frozen=True) -class VpnStatusView: - desired_location: str - pretty_text: str - - -@dataclass(frozen=True) -class ActionView: - ok: bool - pretty_text: str - - -@dataclass(frozen=True) -class LoginFlowView: - phase: str - level: str - dot_color: str - status_text: str - url: str - email: str - alive: bool - cursor: int - lines: List[str] - can_open: bool - can_check: bool - can_cancel: bool - - -@dataclass(frozen=True) -class VpnAutoconnectView: - """Для блока Autoconnect на вкладке AdGuardVPN.""" - enabled: bool # True = включён autoloop - unit_text: str # строка вида "unit: active" - color: str # "green" / "red" / "orange" - - -@dataclass(frozen=True) -class RoutesNftProgressView: - """Прогресс обновления nft-наборов (agvpn4).""" - percent: int - message: str - active: bool # True — пока идёт апдейт, False — когда закончили / ничего не идёт - - -@dataclass(frozen=True) -class TrafficModeView: - desired_mode: str - applied_mode: str - preferred_iface: str - advanced_active: bool - auto_local_bypass: bool - auto_local_active: bool - ingress_reply_bypass: bool - ingress_reply_active: bool - bypass_candidates: int - force_vpn_subnets: List[str] - force_vpn_uids: List[str] - force_vpn_cgroups: List[str] - force_direct_subnets: List[str] - force_direct_uids: List[str] - force_direct_cgroups: List[str] - overrides_applied: int - cgroup_resolved_uids: int - cgroup_warning: str - active_iface: str - iface_reason: str - ingress_rule_present: bool - ingress_nft_active: bool - probe_ok: bool - probe_message: str - healthy: bool - message: str - - -@dataclass(frozen=True) -class RoutesResolveSummaryView: - available: bool - text: str - recheck_text: str - color: str - recheck_color: str - - -# --------------------------- -# Controller -# --------------------------- - -class DashboardController: +class DashboardController( + StatusControllerMixin, + VpnControllerMixin, + RoutesControllerMixin, + TrafficControllerMixin, + TransportControllerMixin, + DNSControllerMixin, + DomainsControllerMixin, + TraceControllerMixin, + ControllerCoreMixin, +): def __init__( self, client: ApiClient, @@ -189,952 +70,22 @@ class DashboardController: or "smartdns-local.service" ) - # -------- logging -------- - def log_gui(self, msg: str) -> None: - self.client.trace_append("gui", msg) - - def log_smartdns(self, msg: str) -> None: - self.client.trace_append("smartdns", msg) - - # -------- events stream -------- - - def iter_events(self, since: int = 0, stop=None): - return self.client.events_stream(since=since, stop=stop) - - def classify_event(self, ev: Event) -> List[str]: - """Return list of areas to refresh for given event kind.""" - k = (ev.kind or "").strip().lower() - if not k: - return [] - if k in ("status_changed", "status_error"): - return ["status", "routes", "vpn"] - if k in ("login_state_changed", "login_state_error"): - return ["login", "vpn"] - if k == "autoloop_status_changed": - return ["vpn"] - if k == "unit_state_changed": - return ["status", "vpn", "routes", "dns"] - if k in ("trace_changed", "trace_append"): - return ["trace"] - if k == "routes_nft_progress": - # перерисовать блок "routes" (кнопки + прогресс) - return ["routes"] - if k == "traffic_mode_changed": - return ["routes", "status"] - if k == "traffic_profiles_changed": - # Used by Traffic mode dialog (Apps/runtime) for persistent app profiles. - return ["routes"] - return [] - - # -------- helpers -------- - - def _is_logged_in_state(self, st: LoginState) -> bool: - # backend “state” может быть любым, делаем устойчивую проверку - s = (st.state or "").strip().lower() - if st.email: - return True - if s in ("ok", "logged", "logged_in", "success", "authorized", "ready"): - return True - return False - - def _level_to_color(self, level: str) -> str: - lv = (level or "").strip().lower() - if lv in ("green", "ok", "true", "success"): - return "green" - if lv in ("red", "error", "false", "failed"): - return "red" - return "orange" - - # -------- overview / status -------- - - def get_login_view(self) -> LoginView: - st: LoginState = self.client.get_login_state() - - # Prefer backend UI-ready "text" if provided, else build it. - if st.text: - txt = st.text - else: - if st.email: - txt = f"AdGuard VPN: logged in as {st.email}" - else: - txt = "AdGuard VPN: (no login data)" - - logged_in = self._is_logged_in_state(st) - - # Цвет: либо из backend, либо простой нормализованный вариант - if st.color: - color = st.color - else: - if logged_in: - color = "green" - else: - s = (st.state or "").strip().lower() - color = "orange" if s in ("unknown", "checking") else "red" - - return LoginView( - text=txt, - color=color, - logged_in=logged_in, - email=st.email or "", - ) - - def get_status_overview(self) -> StatusOverviewView: - st: Status = self.client.get_status() - - routes_unit = self._resolve_routes_unit(st.iface) - routes_s: UnitState = ( - self.client.systemd_state(routes_unit) - if routes_unit - else UnitState(state="unknown") - ) - smartdns_s: UnitState = self.client.systemd_state(self.smartdns_unit) - vpn_st: VpnStatus = self.client.vpn_status() - - counts = f"domains={st.domain_count}, ips={st.ip_count}" - iface = f"iface={st.iface} table={st.table} mark={st.mark}" - - policy_route = self._format_policy_route(st.policy_route_ok, st.route_ok) - - # SmartDNS: если state пустой/unknown — считаем это ошибкой - smart_state = smartdns_s.state or "unknown" - if smart_state.lower() in ("", "unknown", "failed"): - smart_state = "ERROR (unknown state)" - - return StatusOverviewView( - timestamp=st.timestamp or "—", - counts=counts, - iface_table_mark=iface, - policy_route=policy_route, - routes_service=f"{routes_unit or 'selective-vpn2@.service'}: {routes_s.state}", - smartdns_service=f"{self.smartdns_unit}: {smart_state}", - # это состояние самого VPN-юнита, НЕ autoloop: - # т.е. работает ли AdGuardVPN-daemon / туннель - vpn_service=f"VPN: {vpn_st.unit_state}", - ) - - def _format_policy_route( - self, - policy_ok: Optional[bool], - route_ok: Optional[bool], - ) -> str: - if policy_ok is None and route_ok is None: - return "unknown (not checked)" - val = policy_ok if policy_ok is not None else route_ok - if val is True: - return "OK (default route present in VPN table)" - return "MISSING default route in VPN table" - - def _resolve_routes_unit(self, iface: str) -> str: - forced = (self.routes_unit or "").strip() - if forced: - return forced - ifc = (iface or "").strip() - if ifc and ifc != "-": - return f"selective-vpn2@{ifc}.service" - return "" - - # -------- VPN -------- - - def vpn_locations_view(self) -> List[VpnLocation]: - return self.client.vpn_locations() - - def vpn_status_view(self) -> VpnStatusView: - st = self.client.vpn_status() - pretty = self._pretty_vpn_status(st) - return VpnStatusView( - desired_location=st.desired_location, - pretty_text=pretty, - ) - - # --- autoconnect / autoloop --- - - def _autoconnect_from_auto(self, auto) -> bool: - """ - Вытаскиваем True/False из ответа /vpn/autoloop/status. - - Приоритет: - 1) явное поле auto.enabled (bool) - 2) эвристика по status_word / raw_text - """ - enabled_field = getattr(auto, "enabled", None) - if isinstance(enabled_field, bool): - return enabled_field - - word = (getattr(auto, "status_word", "") or "").strip().lower() - raw = (getattr(auto, "raw_text", "") or "").lower() - - # приоритет — явные статусы - if word in ( - "active", - "running", - "enabled", - "on", - "up", - "started", - "ok", - "true", - "yes", - ): - return True - if word in ("inactive", "stopped", "disabled", "off", "down", "false", "no"): - return False - - # фоллбек — по raw_text - if "inactive" in raw or "disabled" in raw or "failed" in raw: - return False - if "active" in raw or "running" in raw or "enabled" in raw: - return True - return False - - def vpn_autoconnect_view(self) -> VpnAutoconnectView: - try: - auto = self.client.vpn_autoloop_status() - except Exception as e: - return VpnAutoconnectView( - enabled=False, - unit_text=f"unit: ERROR ({e})", - color="red", - ) - - enabled = self._autoconnect_from_auto(auto) - - unit_state = ( - getattr(auto, "unit_state", "") # если backend так отдаёт - or (auto.status_word or "") - or "unknown" - ) - - text = f"unit: {unit_state}" - - low = f"{unit_state} {(auto.raw_text or '')}".lower() - if any(x in low for x in ("failed", "error", "unknown", "inactive", "dead")): - color = "red" - elif "active" in low or "running" in low or "enabled" in low: - color = "green" - else: - color = "orange" - - return VpnAutoconnectView(enabled=enabled, unit_text=text, color=color) - - def vpn_autoconnect_enabled(self) -> bool: - """Старый интерфейс — оставляем для кнопки toggle.""" - return self.vpn_autoconnect_view().enabled - - def vpn_set_autoconnect(self, enable: bool) -> VpnStatusView: - res = self.client.vpn_autoconnect(enable) - st = self.client.vpn_status() - pretty = self._pretty_cmd_then_status(res, st) - return VpnStatusView( - desired_location=st.desired_location, - pretty_text=pretty, - ) - - def vpn_set_location(self, iso: str) -> VpnStatusView: - self.client.vpn_set_location(iso) - st = self.client.vpn_status() - pretty = self._pretty_vpn_status(st) - return VpnStatusView( - desired_location=st.desired_location, - pretty_text=pretty, - ) - - def _pretty_vpn_status(self, st: VpnStatus) -> str: - lines = [ - f"unit_state: {st.unit_state}", - f"desired_location: {st.desired_location or '—'}", - f"status: {st.status_word}", - ] - if st.raw_text: - lines.append("") - lines.append(st.raw_text.strip()) - return "\n".join(lines).strip() + "\n" - - # -------- Login Flow (interactive) -------- - - def login_flow_start(self) -> LoginFlowView: - s: LoginSessionStart = self.client.vpn_login_session_start() - - dot = self._level_to_color(s.level) - - if not s.ok: - txt = s.error or "Failed to start login session" - return LoginFlowView( - phase=s.phase or "failed", - level=s.level or "red", - dot_color="red", - status_text=txt, - url="", - email="", - alive=False, - cursor=0, - lines=[txt], - can_open=False, - can_check=False, - can_cancel=False, - ) - - if (s.phase or "").lower() == "already_logged": - txt = ( - f"Already logged in as {s.email}" - if s.email - else "Already logged in" - ) - return LoginFlowView( - phase="already_logged", - level="green", - dot_color="green", - status_text=txt, - url="", - email=s.email or "", - alive=False, - cursor=0, - lines=[txt], - can_open=False, - can_check=False, - can_cancel=False, - ) - - txt = f"Login started (pid={s.pid})" if s.pid else "Login started" - return LoginFlowView( - phase=s.phase or "starting", - level=s.level or "yellow", - dot_color=dot, - status_text=txt, - url="", - email="", - alive=True, - cursor=0, - lines=[], - can_open=True, - can_check=True, - can_cancel=True, - ) - - def login_flow_poll(self, since: int) -> LoginFlowView: - st: LoginSessionState = self.client.vpn_login_session_state(since=since) - - dot = self._level_to_color(st.level) - - phase = (st.phase or "").lower() - if phase == "waiting_browser": - status_txt = "Waiting for browser authorization…" - elif phase == "checking": - status_txt = "Checking…" - elif phase == "success": - status_txt = "✅ Logged in" - elif phase == "failed": - status_txt = "❌ Login failed" - elif phase == "cancelled": - status_txt = "Cancelled" - elif phase == "already_logged": - status_txt = ( - f"Already logged in as {st.email}" - if st.email - else "Already logged in" - ) - else: - status_txt = st.phase or "…" - - clean_lines = self._clean_login_lines(st.lines) - - return LoginFlowView( - phase=st.phase, - level=st.level, - dot_color=dot, - status_text=status_txt, - url=st.url, - email=st.email, - alive=st.alive, - cursor=st.cursor, - can_open=st.can_open, - can_check=st.can_cancel, - can_cancel=st.can_cancel, - lines=clean_lines, - ) - - def login_flow_action(self, action: str) -> ActionView: - act = action.strip().lower() - if act not in ("open", "check", "cancel"): - raise ValueError(f"Invalid login action: {action}") - - res: LoginSessionAction = self.client.vpn_login_session_action( - cast(LoginAction, act) - ) - - if not res.ok: - txt = res.error or "Login action failed" - return ActionView(ok=False, pretty_text=txt + "\n") - - txt = f"OK: {act} → phase={res.phase} level={res.level}" - return ActionView(ok=True, pretty_text=txt + "\n") - - def login_flow_stop(self) -> ActionView: - res = self.client.vpn_login_session_stop() - return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) - - def vpn_logout(self) -> ActionView: - res = self.client.vpn_logout() - return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) - - # Баннер "AdGuard VPN: logged in as ...", по клику показываем инфу как в CLI - def login_banner_cli_text(self) -> str: - try: - st: LoginState = self.client.get_login_state() - except Exception as e: - return f"Failed to query login state: {e}" - - # backend может не иметь поля error, поэтому через getattr - err = getattr(st, "error", None) or getattr(st, "message", None) - if err: - return str(err) - - if st.email: - return f"You are already logged in.\nCurrent user is {st.email}" - - if st.state: - return f"Login state: {st.state}" - - return "No login information available." - - # -------- Routes -------- - - def routes_service_action(self, action: str) -> ActionView: - act = action.strip().lower() - if act not in ("start", "stop", "restart"): - raise ValueError(f"Invalid routes action: {action}") - res = self.client.routes_service(cast(ServiceAction, act)) - return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) - - def routes_clear(self) -> ActionView: - res = self.client.routes_clear() - return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) - - def routes_cache_restore(self) -> ActionView: - res = self.client.routes_cache_restore() - return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) - - def routes_precheck_debug(self, run_now: bool = True) -> ActionView: - res = self.client.routes_precheck_debug(run_now=run_now) - return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) - - def routes_fix_policy_route(self) -> ActionView: - res = self.client.routes_fix_policy_route() - return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) - - def routes_timer_enabled(self) -> bool: - st = self.client.routes_timer_get() - return bool(st.enabled) - - def routes_timer_set(self, enabled: bool) -> ActionView: - res = self.client.routes_timer_set(bool(enabled)) - return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) - - def routes_resolve_summary_view(self) -> RoutesResolveSummaryView: - dump = self.client.trace_get("full") - lines = list(getattr(dump, "lines", []) or []) - line = "" - for raw in reversed(lines): - s = str(raw or "") - if "resolve summary:" in s: - line = s - break - if not line: - return RoutesResolveSummaryView( - available=False, - text="Resolve summary: no data yet", - recheck_text="Timeout recheck: —", - color="gray", - recheck_color="gray", - ) - - tail = line.split("resolve summary:", 1)[1] - pairs: dict[str, int] = {} - for m in re.finditer(r"([a-zA-Z0-9_]+)=(-?\d+)", tail): - k = str(m.group(1) or "").strip().lower() - try: - pairs[k] = int(m.group(2)) - except Exception: - continue - - unique_ips = int(pairs.get("unique_ips", 0)) - direct_ips = int(pairs.get("direct_ips", 0)) - wildcard_ips = int(pairs.get("wildcard_ips", 0)) - unresolved = int(pairs.get("unresolved", 0)) - unresolved_live = int(pairs.get("unresolved_live", 0)) - unresolved_suppressed = int(pairs.get("unresolved_suppressed", 0)) - q_hits = int(pairs.get("quarantine_hits", 0)) - dns_attempts = int(pairs.get("dns_attempts", 0)) - dns_timeout = int(pairs.get("dns_timeout", 0)) - dns_cooldown_skips = int(pairs.get("dns_cooldown_skips", 0)) - live_batch_target = int(pairs.get("live_batch_target", 0)) - live_batch_deferred = int(pairs.get("live_batch_deferred", 0)) - live_batch_p1 = int(pairs.get("live_batch_p1", 0)) - live_batch_p2 = int(pairs.get("live_batch_p2", 0)) - live_batch_p3 = int(pairs.get("live_batch_p3", 0)) - live_batch_nxheavy_pct = int(pairs.get("live_batch_nxheavy_pct", 0)) - live_batch_nxheavy_skip = int(pairs.get("live_batch_nxheavy_skip", 0)) - - r_checked = int(pairs.get("timeout_recheck_checked", 0)) - r_recovered = int(pairs.get("timeout_recheck_recovered", 0)) - r_recovered_ips = int(pairs.get("timeout_recheck_recovered_ips", 0)) - r_still_timeout = int(pairs.get("timeout_recheck_still_timeout", 0)) - r_now_nx = int(pairs.get("timeout_recheck_now_nxdomain", 0)) - r_now_tmp = int(pairs.get("timeout_recheck_now_temporary", 0)) - - text = ( - f"Resolve: ips={unique_ips} (direct={direct_ips}, wildcard={wildcard_ips}, " - f"+recheck_ips={r_recovered_ips}) | unresolved={unresolved} " - f"(live={unresolved_live}, suppressed={unresolved_suppressed}) | " - f"quarantine_hits={q_hits} | dns_timeout={dns_timeout} " - f"| cooldown_skips={dns_cooldown_skips} | attempts={dns_attempts} " - f"| live_batch={live_batch_target} deferred={live_batch_deferred} " - f"(p1={live_batch_p1}, p2={live_batch_p2}, p3={live_batch_p3}, nx_pct={live_batch_nxheavy_pct}, nx_skip={live_batch_nxheavy_skip})" - ) - recheck_text = ( - f"Timeout recheck: checked={r_checked} recovered={r_recovered} " - f"still_timeout={r_still_timeout} now_nxdomain={r_now_nx} now_temporary={r_now_tmp}" - ) - - color = "green" if unresolved < 4000 else ("#b58900" if unresolved < 10000 else "red") - if dns_timeout > 500 and color == "green": - color = "#b58900" - if live_batch_p3 > 0 and (live_batch_p1+live_batch_p2) > 0: - ratio = float(live_batch_p3) / float(live_batch_p1 + live_batch_p2 + live_batch_p3) - if ratio > 0.8: - color = "#b58900" if color == "green" else color - if ratio > 0.95: - color = "red" - recheck_color = "green" if r_still_timeout <= 20 else ("#b58900" if r_still_timeout <= 100 else "red") - return RoutesResolveSummaryView( - available=True, - text=text, - recheck_text=recheck_text, - color=color, - recheck_color=recheck_color, - ) - - def traffic_mode_view(self) -> TrafficModeView: - st: TrafficModeStatus = self.client.traffic_mode_get() - return TrafficModeView( - desired_mode=(st.desired_mode or st.mode or "selective"), - applied_mode=(st.applied_mode or "direct"), - preferred_iface=st.preferred_iface or "", - advanced_active=bool(st.advanced_active), - auto_local_bypass=bool(st.auto_local_bypass), - auto_local_active=bool(st.auto_local_active), - ingress_reply_bypass=bool(st.ingress_reply_bypass), - ingress_reply_active=bool(st.ingress_reply_active), - bypass_candidates=int(st.bypass_candidates), - force_vpn_subnets=list(st.force_vpn_subnets or []), - force_vpn_uids=list(st.force_vpn_uids or []), - force_vpn_cgroups=list(st.force_vpn_cgroups or []), - force_direct_subnets=list(st.force_direct_subnets or []), - force_direct_uids=list(st.force_direct_uids or []), - force_direct_cgroups=list(st.force_direct_cgroups or []), - overrides_applied=int(st.overrides_applied), - cgroup_resolved_uids=int(st.cgroup_resolved_uids), - cgroup_warning=st.cgroup_warning or "", - active_iface=st.active_iface or "", - iface_reason=st.iface_reason or "", - ingress_rule_present=bool(st.ingress_rule_present), - ingress_nft_active=bool(st.ingress_nft_active), - probe_ok=bool(st.probe_ok), - probe_message=st.probe_message or "", - healthy=bool(st.healthy), - message=st.message or "", - ) - - def traffic_mode_set( - self, - mode: str, - preferred_iface: Optional[str] = None, - auto_local_bypass: Optional[bool] = None, - ingress_reply_bypass: Optional[bool] = None, - force_vpn_subnets: Optional[List[str]] = None, - force_vpn_uids: Optional[List[str]] = None, - force_vpn_cgroups: Optional[List[str]] = None, - force_direct_subnets: Optional[List[str]] = None, - force_direct_uids: Optional[List[str]] = None, - force_direct_cgroups: Optional[List[str]] = None, - ) -> TrafficModeView: - st: TrafficModeStatus = self.client.traffic_mode_set( - mode, - preferred_iface, - auto_local_bypass, - ingress_reply_bypass, - force_vpn_subnets, - force_vpn_uids, - force_vpn_cgroups, - force_direct_subnets, - force_direct_uids, - force_direct_cgroups, - ) - return TrafficModeView( - desired_mode=(st.desired_mode or st.mode or mode), - applied_mode=(st.applied_mode or "direct"), - preferred_iface=st.preferred_iface or "", - advanced_active=bool(st.advanced_active), - auto_local_bypass=bool(st.auto_local_bypass), - auto_local_active=bool(st.auto_local_active), - ingress_reply_bypass=bool(st.ingress_reply_bypass), - ingress_reply_active=bool(st.ingress_reply_active), - bypass_candidates=int(st.bypass_candidates), - force_vpn_subnets=list(st.force_vpn_subnets or []), - force_vpn_uids=list(st.force_vpn_uids or []), - force_vpn_cgroups=list(st.force_vpn_cgroups or []), - force_direct_subnets=list(st.force_direct_subnets or []), - force_direct_uids=list(st.force_direct_uids or []), - force_direct_cgroups=list(st.force_direct_cgroups or []), - overrides_applied=int(st.overrides_applied), - cgroup_resolved_uids=int(st.cgroup_resolved_uids), - cgroup_warning=st.cgroup_warning or "", - active_iface=st.active_iface or "", - iface_reason=st.iface_reason or "", - ingress_rule_present=bool(st.ingress_rule_present), - ingress_nft_active=bool(st.ingress_nft_active), - probe_ok=bool(st.probe_ok), - probe_message=st.probe_message or "", - healthy=bool(st.healthy), - message=st.message or "", - ) - - def traffic_mode_test(self) -> TrafficModeView: - st: TrafficModeStatus = self.client.traffic_mode_test() - return TrafficModeView( - desired_mode=(st.desired_mode or st.mode or "selective"), - applied_mode=(st.applied_mode or "direct"), - preferred_iface=st.preferred_iface or "", - advanced_active=bool(st.advanced_active), - auto_local_bypass=bool(st.auto_local_bypass), - auto_local_active=bool(st.auto_local_active), - ingress_reply_bypass=bool(st.ingress_reply_bypass), - ingress_reply_active=bool(st.ingress_reply_active), - bypass_candidates=int(st.bypass_candidates), - force_vpn_subnets=list(st.force_vpn_subnets or []), - force_vpn_uids=list(st.force_vpn_uids or []), - force_vpn_cgroups=list(st.force_vpn_cgroups or []), - force_direct_subnets=list(st.force_direct_subnets or []), - force_direct_uids=list(st.force_direct_uids or []), - force_direct_cgroups=list(st.force_direct_cgroups or []), - overrides_applied=int(st.overrides_applied), - cgroup_resolved_uids=int(st.cgroup_resolved_uids), - cgroup_warning=st.cgroup_warning or "", - active_iface=st.active_iface or "", - iface_reason=st.iface_reason or "", - ingress_rule_present=bool(st.ingress_rule_present), - ingress_nft_active=bool(st.ingress_nft_active), - probe_ok=bool(st.probe_ok), - probe_message=st.probe_message or "", - healthy=bool(st.healthy), - message=st.message or "", - ) - - def traffic_advanced_reset(self) -> TrafficModeView: - st: TrafficModeStatus = self.client.traffic_advanced_reset() - return TrafficModeView( - desired_mode=(st.desired_mode or st.mode or "selective"), - applied_mode=(st.applied_mode or "direct"), - preferred_iface=st.preferred_iface or "", - advanced_active=bool(st.advanced_active), - auto_local_bypass=bool(st.auto_local_bypass), - auto_local_active=bool(st.auto_local_active), - ingress_reply_bypass=bool(st.ingress_reply_bypass), - ingress_reply_active=bool(st.ingress_reply_active), - bypass_candidates=int(st.bypass_candidates), - force_vpn_subnets=list(st.force_vpn_subnets or []), - force_vpn_uids=list(st.force_vpn_uids or []), - force_vpn_cgroups=list(st.force_vpn_cgroups or []), - force_direct_subnets=list(st.force_direct_subnets or []), - force_direct_uids=list(st.force_direct_uids or []), - force_direct_cgroups=list(st.force_direct_cgroups or []), - overrides_applied=int(st.overrides_applied), - cgroup_resolved_uids=int(st.cgroup_resolved_uids), - cgroup_warning=st.cgroup_warning or "", - active_iface=st.active_iface or "", - iface_reason=st.iface_reason or "", - ingress_rule_present=bool(st.ingress_rule_present), - ingress_nft_active=bool(st.ingress_nft_active), - probe_ok=bool(st.probe_ok), - probe_message=st.probe_message or "", - healthy=bool(st.healthy), - message=st.message or "", - ) - - def traffic_interfaces(self) -> List[str]: - st: TrafficInterfaces = self.client.traffic_interfaces_get() - vals = [x for x in st.interfaces if x] - if st.preferred_iface and st.preferred_iface not in vals: - vals.insert(0, st.preferred_iface) - return vals - - def traffic_candidates(self) -> TrafficCandidates: - return self.client.traffic_candidates_get() - - def traffic_appmarks_status(self) -> TrafficAppMarksStatus: - return self.client.traffic_appmarks_status() - - def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]: - return self.client.traffic_appmarks_items() - - def traffic_appmarks_apply( - self, - *, - op: str, - target: str, - cgroup: str = "", - unit: str = "", - command: str = "", - app_key: str = "", - timeout_sec: int = 0, - ) -> TrafficAppMarksResult: - return self.client.traffic_appmarks_apply( - op=op, - target=target, - cgroup=cgroup, - unit=unit, - command=command, - app_key=app_key, - timeout_sec=timeout_sec, - ) - - def traffic_app_profiles_list(self) -> List[TrafficAppProfile]: - return self.client.traffic_app_profiles_list() - - def traffic_app_profile_upsert( - self, - *, - id: str = "", - name: str = "", - app_key: str = "", - command: str, - target: str, - ttl_sec: int = 0, - vpn_profile: str = "", - ) -> TrafficAppProfileSaveResult: - return self.client.traffic_app_profile_upsert( - id=id, - name=name, - app_key=app_key, - command=command, - target=target, - ttl_sec=ttl_sec, - vpn_profile=vpn_profile, - ) - - def traffic_app_profile_delete(self, id: str) -> CmdResult: - return self.client.traffic_app_profile_delete(id) - - def traffic_audit(self) -> TrafficAudit: - return self.client.traffic_audit_get() - - def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView: - """ - Превращает Event(kind='routes_nft_progress') в удобную модель - для прогресс-бара/лейбла. - """ - payload = ( - getattr(ev, "data", None) - or getattr(ev, "payload", None) - or getattr(ev, "extra", None) - or {} - ) - - if not isinstance(payload, dict): - payload = {} - - try: - percent = int(payload.get("percent", 0)) - except Exception: - percent = 0 - - msg = str(payload.get("message", "")) if payload is not None else "" - if not msg: - msg = "Updating nft set…" - - active = 0 <= percent < 100 - - return RoutesNftProgressView( - percent=percent, - message=msg, - active=active, - ) - - # -------- DNS / SmartDNS -------- - - def dns_upstreams_view(self) -> DnsUpstreams: - return self.client.dns_upstreams_get() - - def dns_upstreams_save(self, cfg: DnsUpstreams) -> None: - self.client.dns_upstreams_set(cfg) - - def dns_upstream_pool_view(self) -> DNSUpstreamPoolState: - return self.client.dns_upstream_pool_get() - - def dns_upstream_pool_save(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState: - return self.client.dns_upstream_pool_set(items) - - def dns_benchmark( - self, - upstreams: List[DNSBenchmarkUpstream], - domains: List[str], - timeout_ms: int = 1800, - attempts: int = 1, - concurrency: int = 6, - profile: str = "load", - ) -> DNSBenchmarkResponse: - return self.client.dns_benchmark( - upstreams=upstreams, - domains=domains, - timeout_ms=timeout_ms, - attempts=attempts, - concurrency=concurrency, - profile=profile, - ) - - def dns_status_view(self) -> DNSStatus: - return self.client.dns_status_get() - - def dns_mode_set(self, via: bool, smartdns_addr: str) -> DNSStatus: - return self.client.dns_mode_set(via, smartdns_addr) - - def smartdns_service_action(self, action: str) -> DNSStatus: - act = action.strip().lower() - if act not in ("start", "stop", "restart"): - raise ValueError(f"Invalid SmartDNS action: {action}") - return self.client.dns_smartdns_service_set(cast(ServiceAction, act)) - - def smartdns_prewarm(self, limit: int = 0, aggressive_subs: bool = False) -> ActionView: - res = self.client.smartdns_prewarm(limit=limit, aggressive_subs=aggressive_subs) - return ActionView(ok=res.ok, pretty_text=self._pretty_cmd(res)) - - def smartdns_runtime_view(self) -> SmartdnsRuntimeState: - return self.client.smartdns_runtime_get() - - def smartdns_runtime_set(self, enabled: bool, restart: bool = True) -> SmartdnsRuntimeState: - return self.client.smartdns_runtime_set(enabled=enabled, restart=restart) - - # -------- Domains -------- - - def domains_table_view(self) -> DomainsTable: - return self.client.domains_table() - - def domains_file_load(self, name: str) -> DomainsFile: - nm = name.strip().lower() - if nm not in ( - "bases", - "meta", - "subs", - "static", - "smartdns", - "last-ips-map", - "last-ips-map-direct", - "last-ips-map-wildcard", - "wildcard-observed-hosts", - ): - raise ValueError(f"Invalid domains file name: {name}") - return self.client.domains_file_get( - cast( - Literal[ - "bases", - "meta", - "subs", - "static", - "smartdns", - "last-ips-map", - "last-ips-map-direct", - "last-ips-map-wildcard", - "wildcard-observed-hosts", - ], - nm, - ) - ) - - def domains_file_save(self, name: str, content: str) -> None: - nm = name.strip().lower() - if nm not in ( - "bases", - "meta", - "subs", - "static", - "smartdns", - "last-ips-map", - "last-ips-map-direct", - "last-ips-map-wildcard", - "wildcard-observed-hosts", - ): - raise ValueError(f"Invalid domains file name: {name}") - self.client.domains_file_set( - cast( - Literal[ - "bases", - "meta", - "subs", - "static", - "smartdns", - "last-ips-map", - "last-ips-map-direct", - "last-ips-map-wildcard", - "wildcard-observed-hosts", - ], - nm, - ), - content, - ) - - # -------- Trace -------- - - def trace_view(self, mode: TraceMode = "full") -> TraceDump: - return self.client.trace_get(mode) - - # -------- formatting helpers -------- - - def _pretty_cmd(self, res: CmdResult) -> str: - lines: List[str] = [] - lines.append("OK" if res.ok else "ERROR") - if res.message: - lines.append(res.message.strip()) - if res.exit_code is not None: - lines.append(f"exit_code: {res.exit_code}") - if res.stdout.strip(): - lines.append("") - lines.append("stdout:") - lines.append(res.stdout.rstrip()) - if res.stderr.strip() and res.stderr.strip() != res.stdout.strip(): - lines.append("") - lines.append("stderr:") - lines.append(res.stderr.rstrip()) - return "\n".join(lines).strip() + "\n" - - def _pretty_cmd_then_status(self, res: CmdResult, st: VpnStatus) -> str: - return ( - self._pretty_cmd(res).rstrip() - + "\n\n" - + self._pretty_vpn_status(st).rstrip() - + "\n" - ) - - def _clean_login_lines(self, lines: Iterable[str]) -> List[str]: - out: List[str] = [] - for raw in lines or []: - if raw is None: - continue - - s = str(raw).replace("\r", "\n") - for part in s.splitlines(): - t = part.strip() - if not t: - continue - - # вырезаем спам "Next check in ..." - t2 = _NEXT_CHECK_RE.sub("", t).strip() - if not t2: - continue - - # на всякий — повторно - t2 = _NEXT_CHECK_RE.sub("", t2).strip() - if not t2: - continue - - out.append(t2) - return out +__all__ = [ + "DashboardController", + "TraceMode", + "ServiceAction", + "LoginAction", + "TransportClientAction", + "TransportFlowPhase", + "LoginView", + "StatusOverviewView", + "VpnStatusView", + "ActionView", + "LoginFlowView", + "VpnAutoconnectView", + "RoutesNftProgressView", + "TrafficModeView", + "RoutesResolveSummaryView", + "TransportPolicyFlowView", +] diff --git a/selective-vpn-gui/dns_benchmark_dialog.py b/selective-vpn-gui/dns_benchmark_dialog.py index aabef1b..4325a9c 100644 --- a/selective-vpn-gui/dns_benchmark_dialog.py +++ b/selective-vpn-gui/dns_benchmark_dialog.py @@ -7,6 +7,7 @@ from typing import Callable, List from PySide6.QtCore import Qt, QSettings from PySide6.QtGui import QColor from PySide6.QtWidgets import ( + QCheckBox, QDialog, QHBoxLayout, QLabel, @@ -131,6 +132,14 @@ class DNSBenchmarkDialog(QDialog): opts.addWidget(QLabel("Parallel DNS checks:")) opts.addWidget(self.spin_concurrency) + self.chk_load_profile = QCheckBox("Load profile (realistic)") + self.chk_load_profile.setChecked(True) + self.chk_load_profile.setToolTip( + "EN: Load profile adds synthetic NX probes and burst rounds to simulate resolver pressure.\n" + "RU: Load-профиль добавляет synthetic NX-пробы и burst-раунды, чтобы симулировать нагрузку резолвера." + ) + opts.addWidget(self.chk_load_profile) + self.btn_run = QPushButton("Run benchmark") self.btn_run.clicked.connect(self.on_run_benchmark) opts.addWidget(self.btn_run) @@ -339,6 +348,7 @@ class DNSBenchmarkDialog(QDialog): timeout_ms=int(self.spin_timeout.value()), attempts=int(self.spin_attempts.value()), concurrency=int(self.spin_concurrency.value()), + profile="load" if self.chk_load_profile.isChecked() else "quick", ) self._render_results(resp) if self.refresh_cb: @@ -376,7 +386,7 @@ class DNSBenchmarkDialog(QDialog): self.lbl_summary.setText( f"Checked: {len(resp.results)} DNS | domains={len(resp.domains_used)} " - f"| timeout={resp.timeout_ms}ms" + f"| timeout={resp.timeout_ms}ms | profile={str(getattr(resp, 'profile', '') or 'load')}" ) self.lbl_summary.setStyleSheet("color: gray;") diff --git a/selective-vpn-gui/main_window/__init__.py b/selective-vpn-gui/main_window/__init__.py new file mode 100644 index 0000000..0f185de --- /dev/null +++ b/selective-vpn-gui/main_window/__init__.py @@ -0,0 +1,26 @@ +from .constants import ( + LOCATION_TARGET_ROLE, + LoginPage, + SINGBOX_EDITOR_PROTOCOL_IDS, + SINGBOX_EDITOR_PROTOCOL_OPTIONS, + SINGBOX_PROTOCOL_SEED_SPEC, + SINGBOX_STATUS_ROLE, +) +from .runtime_actions_mixin import MainWindowRuntimeActionsMixin +from .singbox_mixin import SingBoxMainWindowMixin +from .ui_shell_mixin import MainWindowUIShellMixin +from .workers import EventThread, LocationsThread + +__all__ = [ + "EventThread", + "LOCATION_TARGET_ROLE", + "MainWindowRuntimeActionsMixin", + "MainWindowUIShellMixin", + "LocationsThread", + "LoginPage", + "SingBoxMainWindowMixin", + "SINGBOX_EDITOR_PROTOCOL_IDS", + "SINGBOX_EDITOR_PROTOCOL_OPTIONS", + "SINGBOX_PROTOCOL_SEED_SPEC", + "SINGBOX_STATUS_ROLE", +] diff --git a/selective-vpn-gui/main_window/constants.py b/selective-vpn-gui/main_window/constants.py new file mode 100644 index 0000000..e74e76e --- /dev/null +++ b/selective-vpn-gui/main_window/constants.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import re +from typing import Any +from typing import Literal + +from PySide6.QtCore import Qt + +_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s") +LoginPage = Literal["main", "login"] +LOCATION_TARGET_ROLE = Qt.UserRole + 1 +SINGBOX_STATUS_ROLE = Qt.UserRole + 2 +SINGBOX_EDITOR_PROTOCOL_OPTIONS = [ + ("VLESS", "vless"), + ("Trojan", "trojan"), + ("Shadowsocks", "shadowsocks"), + ("Hysteria2", "hysteria2"), + ("TUIC", "tuic"), + ("WireGuard", "wireguard"), +] +SINGBOX_EDITOR_PROTOCOL_IDS = tuple([pid for _label, pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS]) +SINGBOX_PROTOCOL_SEED_SPEC: dict[str, dict[str, Any]] = { + "vless": { + "port": 443, + "security": "none", + "proxy_defaults": { + "uuid": "", + }, + }, + "trojan": { + "port": 443, + "security": "tls", + "proxy_defaults": { + "password": "", + }, + }, + "shadowsocks": { + "port": 443, + "security": "none", + "proxy_defaults": { + "method": "aes-128-gcm", + "password": "", + }, + }, + "hysteria2": { + "port": 443, + "security": "tls", + "proxy_defaults": { + "password": "", + }, + "tls_security": "tls", + }, + "tuic": { + "port": 443, + "security": "tls", + "proxy_defaults": { + "uuid": "", + "password": "", + }, + "tls_security": "tls", + }, + "wireguard": { + "port": 51820, + "security": "none", + "proxy_defaults": { + "private_key": "", + "peer_public_key": "", + "local_address": [], + }, + }, +} + +__all__ = [ + "LOCATION_TARGET_ROLE", + "LoginPage", + "SINGBOX_EDITOR_PROTOCOL_IDS", + "SINGBOX_EDITOR_PROTOCOL_OPTIONS", + "SINGBOX_PROTOCOL_SEED_SPEC", + "SINGBOX_STATUS_ROLE", + "_NEXT_CHECK_RE", +] diff --git a/selective-vpn-gui/main_window/runtime_actions_mixin.py b/selective-vpn-gui/main_window/runtime_actions_mixin.py new file mode 100644 index 0000000..dc52884 --- /dev/null +++ b/selective-vpn-gui/main_window/runtime_actions_mixin.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from main_window.runtime_auth_mixin import RuntimeAuthMixin +from main_window.runtime_ops_mixin import RuntimeOpsMixin +from main_window.runtime_refresh_mixin import RuntimeRefreshMixin +from main_window.runtime_state_mixin import RuntimeStateMixin + + +class MainWindowRuntimeActionsMixin( + RuntimeOpsMixin, + RuntimeAuthMixin, + RuntimeRefreshMixin, + RuntimeStateMixin, +): + """Facade mixin for backward-compatible MainWindow inheritance.""" + + +__all__ = ["MainWindowRuntimeActionsMixin"] diff --git a/selective-vpn-gui/main_window/runtime_auth_mixin.py b/selective-vpn-gui/main_window/runtime_auth_mixin.py new file mode 100644 index 0000000..7a7ba9e --- /dev/null +++ b/selective-vpn-gui/main_window/runtime_auth_mixin.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import subprocess + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QApplication, QMessageBox + + +class RuntimeAuthMixin: + def on_auth_button(self) -> None: + def work(): + view = self.ctrl.get_login_view() + if view.logged_in: + self.on_logout() + else: + # при логине всегда переходим на вкладку AdGuardVPN и + # показываем страницу логина + self.tabs.setCurrentWidget(self.tab_vpn) + self._show_vpn_page("login") + self.on_start_login() + self._safe(work, title="Auth error") + + def on_login_banner_clicked(self) -> None: + def work(): + txt = self.ctrl.login_banner_cli_text() + QMessageBox.information(self, "AdGuard VPN", txt) + self._safe(work, title="Login banner error") + + # ---------------- LOGIN FLOW ACTIONS ---------------- + + def on_start_login(self) -> None: + def work(): + self.ctrl.log_gui("Top Login clicked") + self._show_vpn_page("login") + self._login_flow_reset_ui() + + start = self.ctrl.login_flow_start() + + self._login_cursor = int(start.cursor) + self.lbl_login_flow_status.setText( + f"Status: {start.status_text or '—'}" + ) + self.lbl_login_flow_email.setText( + f"User: {start.email}" if start.email else "" + ) + self.edit_login_url.setText(start.url or "") + + self._login_flow_set_buttons( + can_open=start.can_open, + can_check=start.can_check, + can_cancel=start.can_cancel, + ) + + if start.lines: + cleaned = self._clean_ui_lines(start.lines) + if cleaned: + self._append_text(self.txt_login_flow, cleaned + "\n") + + if not start.alive: + self._login_flow_autopoll_stop() + self._login_flow_set_buttons( + can_open=False, can_check=False, can_cancel=False + ) + self.btn_login_stop.setEnabled(False) + QTimer.singleShot(250, self.refresh_login_banner) + return + + self._login_flow_autopoll_start() + + self._safe(work, title="Login start error") + + def _login_flow_reset_ui(self) -> None: + self._login_cursor = 0 + self._login_url_opened = False + self.edit_login_url.setText("") + self.lbl_login_flow_status.setText("Status: —") + self.lbl_login_flow_email.setText("") + self._set_text(self.txt_login_flow, "") + + def _login_flow_set_buttons( + self, + *, + can_open: bool, + can_check: bool, + can_cancel: bool, + ) -> None: + self.btn_login_open.setEnabled(bool(can_open)) + self.btn_login_copy.setEnabled(bool(self.edit_login_url.text().strip())) + self.btn_login_check.setEnabled(bool(can_check)) + self.btn_login_close.setEnabled(bool(can_cancel)) + self.btn_login_stop.setEnabled(True) + + def _login_flow_autopoll_start(self) -> None: + self._login_flow_active = True + if not self.login_poll_timer.isActive(): + self.login_poll_timer.start() + + def _login_flow_autopoll_stop(self) -> None: + self._login_flow_active = False + if self.login_poll_timer.isActive(): + self.login_poll_timer.stop() + + def _login_poll_tick(self) -> None: + if not self._login_flow_active: + return + + def work(): + view = self.ctrl.login_flow_poll(self._login_cursor) + self._login_cursor = int(view.cursor) + + self.lbl_login_flow_status.setText( + f"Status: {view.status_text or '—'}" + ) + self.lbl_login_flow_email.setText( + f"User: {view.email}" if view.email else "" + ) + + if view.url: + self.edit_login_url.setText(view.url) + + self._login_flow_set_buttons( + can_open=view.can_open, + can_check=view.can_check, + can_cancel=view.can_cancel, + ) + + cleaned = self._clean_ui_lines(view.lines) + if cleaned: + self._append_text(self.txt_login_flow, cleaned + "\n") + + if (not self._login_url_opened) and view.url: + self._login_url_opened = True + try: + subprocess.Popen(["xdg-open", view.url]) + except Exception: + pass + + phase = (view.phase or "").strip().lower() + if (not view.alive) or phase in ( + "success", + "failed", + "cancelled", + "already_logged", + ): + self._login_flow_autopoll_stop() + self._login_flow_set_buttons( + can_open=False, can_check=False, can_cancel=False + ) + self.btn_login_stop.setEnabled(False) + QTimer.singleShot(250, self.refresh_login_banner) + + self._safe(work, title="Login flow error") + + def on_login_copy(self) -> None: + def work(): + u = self.edit_login_url.text().strip() + if u: + QApplication.clipboard().setText(u) + self.ctrl.log_gui("Login flow: copy-url") + self._safe(work, title="Login copy error") + + def on_login_open(self) -> None: + def work(): + u = self.edit_login_url.text().strip() + if u: + try: + subprocess.Popen(["xdg-open", u]) + except Exception: + pass + self.ctrl.log_gui("Login flow: open") + self._safe(work, title="Login open error") + + def on_login_check(self) -> None: + def work(): + # если ещё ничего не запущено — считаем это стартом логина + if ( + not self._login_flow_active + and self._login_cursor == 0 + and not self.edit_login_url.text().strip() + and not self.txt_login_flow.toPlainText().strip() + ): + self.on_start_login() + return + + self.ctrl.login_flow_action("check") + self.ctrl.log_gui("Login flow: check") + self._safe(work, title="Login check error") + + def on_login_cancel(self) -> None: + def work(): + self.ctrl.login_flow_action("cancel") + self.ctrl.log_gui("Login flow: cancel") + self._safe(work, title="Login cancel error") + + def on_login_stop(self) -> None: + def work(): + self.ctrl.login_flow_stop() + self.ctrl.log_gui("Login flow: stop") + self._login_flow_autopoll_stop() + QTimer.singleShot(250, self.refresh_login_banner) + self._safe(work, title="Login stop error") + + def on_logout(self) -> None: + def work(): + self.ctrl.log_gui("Top Logout clicked") + res = self.ctrl.vpn_logout() + self._set_text(self.txt_vpn, res.pretty_text or str(res)) + QTimer.singleShot(250, self.refresh_login_banner) + self._safe(work, title="Logout error") diff --git a/selective-vpn-gui/main_window/runtime_ops_mixin.py b/selective-vpn-gui/main_window/runtime_ops_mixin.py new file mode 100644 index 0000000..7aac343 --- /dev/null +++ b/selective-vpn-gui/main_window/runtime_ops_mixin.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +from typing import Literal + +from PySide6.QtWidgets import QApplication, QMessageBox + +from dns_benchmark_dialog import DNSBenchmarkDialog +from main_window.constants import LOCATION_TARGET_ROLE +from traffic_mode_dialog import TrafficModeDialog + + +class RuntimeOpsMixin: + def on_toggle_autoconnect(self) -> None: + def work(): + current = self.ctrl.vpn_autoconnect_enabled() + enable = not current + self.ctrl.vpn_set_autoconnect(enable) + self.ctrl.log_gui(f"VPN autoconnect set to {enable}") + self.refresh_vpn_tab() + self._safe(work, title="Autoconnect error") + + def on_location_activated(self, _index: int) -> None: + self._safe(self._apply_selected_location, title="Location error") + + def on_set_location(self) -> None: + self._safe(self._apply_selected_location, title="Location error") + + def _apply_selected_location(self) -> None: + idx = self.cmb_locations.currentIndex() + if idx < 0: + return + + iso = str(self.cmb_locations.currentData() or "").strip().upper() + target = str(self.cmb_locations.currentData(LOCATION_TARGET_ROLE) or "").strip() + label = str(self.cmb_locations.currentText() or "").strip() + if not target: + target = iso + if not iso or not target: + return + + desired = (self._vpn_desired_location or "").strip().lower() + if desired and desired in (iso.lower(), target.lower()): + return + + self.lbl_locations_meta.setText(f"Applying location {target}...") + self.lbl_locations_meta.setStyleSheet("color: orange;") + + self._start_vpn_location_switching(target) + self.refresh_login_banner() + QApplication.processEvents() + + try: + self.ctrl.vpn_set_location(target=target, iso=iso, label=label) + except Exception: + self._stop_vpn_location_switching() + self.refresh_login_banner() + raise + + self.ctrl.log_gui(f"VPN location set to {target} ({iso})") + self._vpn_desired_location = target + self.refresh_vpn_tab() + self._trigger_vpn_egress_refresh(reason=f"location switch to {target}") + + # ---- Routes actions ------------------------------------------------ + + def on_routes_action( + self, action: Literal["start", "stop", "restart"] + ) -> None: + def work(): + res = self.ctrl.routes_service_action(action) + self._set_text(self.txt_routes, res.pretty_text or str(res)) + self.refresh_status_tab() + self._safe(work, title="Routes error") + + def _append_routes_log(self, msg: str) -> None: + line = (msg or "").strip() + if not line: + return + self._append_text(self.txt_routes, line + "\n") + self.ctrl.log_gui(line) + + def on_open_traffic_settings(self) -> None: + def work(): + def refresh_all_traffic() -> None: + self.refresh_routes_tab() + self.refresh_status_tab() + + dlg = TrafficModeDialog( + self.ctrl, + log_cb=self._append_routes_log, + refresh_cb=refresh_all_traffic, + parent=self, + ) + dlg.exec() + refresh_all_traffic() + self._safe(work, title="Traffic mode dialog error") + + def on_test_traffic_mode(self) -> None: + def work(): + view = self.ctrl.traffic_mode_test() + msg = ( + f"Traffic mode test: desired={view.desired_mode}, applied={view.applied_mode}, " + f"iface={view.active_iface or '-'}, probe_ok={view.probe_ok}, " + f"healthy={view.healthy}, auto_local_bypass={view.auto_local_bypass}, " + f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, " + f"cgroup_uids={view.cgroup_resolved_uids}, cgroup_warning={view.cgroup_warning or '-'}, " + f"message={view.message}, probe={view.probe_message}" + ) + self._append_routes_log(msg) + self.refresh_routes_tab() + self.refresh_status_tab() + self._safe(work, title="Traffic mode test error") + + def on_routes_precheck_debug(self) -> None: + def work(): + res = self.ctrl.routes_precheck_debug(run_now=True) + txt = (res.pretty_text or "").strip() + if res.ok: + QMessageBox.information(self, "Resolve precheck debug", txt or "OK") + else: + QMessageBox.critical(self, "Resolve precheck debug", txt or "ERROR") + self.refresh_routes_tab() + self.refresh_status_tab() + self.refresh_trace_tab() + self._safe(work, title="Resolve precheck debug error") + + def on_toggle_timer(self) -> None: + def work(): + enabled = self.chk_timer.isChecked() + res = self.ctrl.routes_timer_set(enabled) + self.ctrl.log_gui(f"Routes timer set to {enabled}") + self._set_text(self.txt_routes, res.pretty_text or str(res)) + self.refresh_routes_tab() + self._safe(work, title="Timer error") + + def on_fix_policy_route(self) -> None: + def work(): + res = self.ctrl.routes_fix_policy_route() + self._set_text(self.txt_routes, res.pretty_text or str(res)) + self.refresh_status_tab() + self._safe(work, title="Policy route error") + + # ---- DNS actions --------------------------------------------------- + + def _schedule_dns_autosave(self, _text: str = "") -> None: + if self._dns_ui_refresh: + return + self.dns_save_timer.start() + + def _apply_dns_autosave(self) -> None: + def work(): + if self._dns_ui_refresh: + return + self.ctrl.dns_mode_set( + self.chk_dns_via_smartdns.isChecked(), + self.ent_smartdns_addr.text().strip(), + ) + self.ctrl.log_gui("DNS settings autosaved") + self._safe(work, title="DNS save error") + + def on_open_dns_benchmark(self) -> None: + def work(): + dlg = DNSBenchmarkDialog( + self.ctrl, + settings=self._ui_settings, + refresh_cb=self.refresh_dns_tab, + parent=self, + ) + dlg.exec() + self.refresh_dns_tab() + self._safe(work, title="DNS benchmark error") + + def on_dns_mode_toggle(self) -> None: + def work(): + via = self.chk_dns_via_smartdns.isChecked() + self.ctrl.dns_mode_set(via, self.ent_smartdns_addr.text().strip()) + mode = "hybrid_wildcard" if via else "direct" + self.ctrl.log_gui(f"DNS mode changed: mode={mode}") + self.refresh_dns_tab() + self._safe(work, title="DNS mode error") + + def on_smartdns_unit_toggle(self) -> None: + def work(): + enable = self.chk_dns_unit_relay.isChecked() + action = "start" if enable else "stop" + self.ctrl.smartdns_service_action(action) + self.ctrl.log_smartdns(f"SmartDNS unit action from GUI: {action}") + self.refresh_dns_tab() + self.refresh_status_tab() + self._safe(work, title="SmartDNS error") + + def on_smartdns_runtime_toggle(self) -> None: + def work(): + if self._dns_ui_refresh: + return + enable = self.chk_dns_runtime_nftset.isChecked() + st = self.ctrl.smartdns_runtime_set(enabled=enable, restart=True) + self.ctrl.log_smartdns( + f"SmartDNS runtime accelerator set from GUI: enabled={enable} changed={st.changed} restarted={st.restarted} source={st.wildcard_source}" + ) + self.refresh_dns_tab() + self.refresh_trace_tab() + self._safe(work, title="SmartDNS runtime error") + + def on_smartdns_prewarm(self) -> None: + def work(): + aggressive = bool(self.chk_routes_prewarm_aggressive.isChecked()) + result = self.ctrl.smartdns_prewarm(aggressive_subs=aggressive) + mode_txt = "aggressive_subs=on" if aggressive else "aggressive_subs=off" + self.ctrl.log_smartdns(f"SmartDNS prewarm requested from GUI: {mode_txt}") + txt = (result.pretty_text or "").strip() + if result.ok: + QMessageBox.information(self, "SmartDNS prewarm", txt or "OK") + else: + QMessageBox.critical(self, "SmartDNS prewarm", txt or "ERROR") + self.refresh_trace_tab() + self._safe(work, title="SmartDNS prewarm error") + + def _update_prewarm_mode_label(self, _state: int = 0) -> None: + aggressive = bool(self.chk_routes_prewarm_aggressive.isChecked()) + if aggressive: + self.lbl_routes_prewarm_mode.setText("Prewarm mode: aggressive (subs enabled)") + self.lbl_routes_prewarm_mode.setStyleSheet("color: orange;") + else: + self.lbl_routes_prewarm_mode.setText("Prewarm mode: wildcard-only") + self.lbl_routes_prewarm_mode.setStyleSheet("color: gray;") + + def _on_prewarm_aggressive_changed(self, _state: int = 0) -> None: + self._update_prewarm_mode_label(_state) + self._save_ui_preferences() + + # ---- Domains actions ----------------------------------------------- + + def on_domains_load(self) -> None: + def work(): + name = self._get_selected_domains_file() + content, source, path = self._load_file_content(name) + is_readonly = name in ("last-ips-map-direct", "last-ips-map-wildcard", "wildcard-observed-hosts") + self.txt_domains.setReadOnly(is_readonly) + self.btn_domains_save.setEnabled(not is_readonly) + self._set_text(self.txt_domains, content) + ro = "read-only" if is_readonly else "editable" + self.lbl_domains_info.setText(f"{name} ({source}, {ro}) [{path}]") + self._safe(work, title="Domains load error") + + def on_domains_save(self) -> None: + def work(): + name = self._get_selected_domains_file() + content = self.txt_domains.toPlainText() + self._save_file_content(name, content) + self.ctrl.log_gui(f"Domains file saved: {name}") + self._safe(work, title="Domains save error") + + # ---- close event --------------------------------------------------- + + def closeEvent(self, event) -> None: # pragma: no cover - GUI + try: + self._save_ui_preferences() + self._login_flow_autopoll_stop() + self.loc_typeahead_timer.stop() + if self.locations_thread: + self.locations_thread.quit() + self.locations_thread.wait(1500) + if self.events_thread: + self.events_thread.stop() + self.events_thread.wait(1500) + finally: + super().closeEvent(event) diff --git a/selective-vpn-gui/main_window/runtime_refresh_mixin.py b/selective-vpn-gui/main_window/runtime_refresh_mixin.py new file mode 100644 index 0000000..92855c6 --- /dev/null +++ b/selective-vpn-gui/main_window/runtime_refresh_mixin.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +import time + +from PySide6 import QtCore + +from dashboard_controller import TraceMode +from main_window.workers import EventThread, LocationsThread + + +class RuntimeRefreshMixin: + def _start_events_stream(self) -> None: + if self.events_thread: + return + self.events_thread = EventThread(self.ctrl, self) + self.events_thread.eventReceived.connect(self._handle_event) + self.events_thread.error.connect(self._handle_event_error) + self.events_thread.start() + + @QtCore.Slot(object) + def _handle_event(self, ev) -> None: + try: + kinds = self.ctrl.classify_event(ev) + except Exception: + kinds = [] + + # Отдельно ловим routes_nft_progress, чтобы обновить лейбл прогресса. + try: + k = (getattr(ev, "kind", "") or "").strip().lower() + except Exception: + k = "" + + if k == "routes_nft_progress": + try: + prog_view = self.ctrl.routes_nft_progress_from_event(ev) + self._update_routes_progress_label(prog_view) + except Exception: + # не роняем UI, просто игнор + pass + + # Простая стратегия: триггерить существующие refresh-функции. + if "status" in kinds: + self.refresh_status_tab() + if "login" in kinds: + self.refresh_login_banner() + if "vpn" in kinds: + self.refresh_vpn_tab() + if "routes" in kinds: + self.refresh_routes_tab() + if "dns" in kinds: + self.refresh_dns_tab() + if "transport" in kinds: + self.refresh_singbox_tab() + self._refresh_selected_transport_health_live(silent=True) + if "trace" in kinds: + self.refresh_trace_tab() + + + @QtCore.Slot(str) + def _handle_event_error(self, msg: str) -> None: + # Логируем в trace, UI не блокируем. + try: + self.ctrl.log_gui(f"[sse-error] {msg}") + except Exception: + pass + + # ---------------- REFRESH ---------------- + + def refresh_everything(self) -> None: + self.refresh_login_banner() + self.refresh_status_tab() + self.refresh_vpn_tab() + self.refresh_singbox_tab() + self.refresh_routes_tab() + self.refresh_dns_tab() + self.refresh_domains_tab() + self.refresh_trace_tab() + + def refresh_login_banner(self) -> None: + def work(): + view = self.ctrl.get_login_view() + + self._set_auth_button(view.logged_in) + + if self._vpn_switching_active: + if self._is_vpn_switching_expired(): + self._stop_vpn_location_switching() + else: + target = (self._vpn_switching_target or "").strip() + msg = "AdGuard VPN: Switching location..." + if target: + msg = f"AdGuard VPN: Switching location to {target}..." + self.btn_login_banner.setText(msg) + self.btn_login_banner.setStyleSheet( + "text-align: left; border: none; color: #d4a200;" + ) + return + + self.btn_login_banner.setText(view.text) + # Принудительно: зелёный если залогинен, серый если нет + color = "green" if view.logged_in else "gray" + base_style = "text-align: left; border: none;" + self.btn_login_banner.setStyleSheet( + f"{base_style} color: {color};" + ) + + self._safe(work, title="Login state error") + + def refresh_status_tab(self) -> None: + def work(): + view = self.ctrl.get_status_overview() + self.st_timestamp.setText(view.timestamp) + self.st_counts.setText(view.counts) + self.st_iface.setText(view.iface_table_mark) + + self._set_status_label_color( + self.st_route, view.policy_route, kind="policy" + ) + self._set_status_label_color( + self.st_routes_service, view.routes_service, kind="service" + ) + self._set_status_label_color( + self.st_smartdns_service, view.smartdns_service, kind="service" + ) + self._set_status_label_color( + self.st_vpn_service, view.vpn_service, kind="service" + ) + + self._safe(work, title="Status error") + + def refresh_vpn_tab(self) -> None: + def work(): + view = self.ctrl.vpn_status_view() + prev_desired = (self._vpn_desired_location_last_seen or "").strip().lower() + self._vpn_desired_location = (view.desired_location or "").strip() + current_desired = (self._vpn_desired_location or "").strip().lower() + self._vpn_desired_location_last_seen = self._vpn_desired_location + txt = [] + if view.desired_location: + txt.append(f"Desired location: {view.desired_location}") + if view.pretty_text: + txt.append(view.pretty_text.rstrip()) + self._set_text(self.txt_vpn, "\n".join(txt).strip() + "\n") + + auto_view = self.ctrl.vpn_autoconnect_view() + self.btn_autoconnect_toggle.setText( + "Disable autoconnect" if auto_view.enabled else "Enable autoconnect" + ) + self.lbl_autoconnect_state.setText(auto_view.unit_text) + self.lbl_autoconnect_state.setStyleSheet( + f"color: {auto_view.color};" + ) + + vpn_egress = self._refresh_egress_identity_scope( + "adguardvpn", + trigger_refresh=True, + silent=True, + ) + self._render_vpn_egress_label(vpn_egress) + self._maybe_trigger_vpn_egress_refresh_on_autoloop(auto_view.unit_text) + if prev_desired and current_desired and prev_desired != current_desired: + self._trigger_vpn_egress_refresh( + reason=f"desired location changed: {prev_desired} -> {current_desired}" + ) + + if self._vpn_switching_active: + unit_low = (auto_view.unit_text or "").strip().lower() + elapsed = self._vpn_switching_elapsed_sec() + if any( + x in unit_low + for x in ("disconnected", "reconnecting", "unknown", "error", "inactive", "failed", "dead") + ): + self._vpn_switching_seen_non_connected = True + + desired_now = (self._vpn_desired_location or "").strip().lower() + target_now = (self._vpn_switching_target or "").strip().lower() + desired_matches = bool(target_now and desired_now and target_now == desired_now) + + if self._is_vpn_switching_expired(): + self._stop_vpn_location_switching() + elif ( + "connected" in unit_low + and "disconnected" not in unit_low + and elapsed >= float(self._vpn_switching_min_visible_sec) + and (self._vpn_switching_seen_non_connected or desired_matches) + ): + switched_to = (self._vpn_switching_target or "").strip() + self._stop_vpn_location_switching() + if switched_to: + self._trigger_vpn_egress_refresh( + reason=f"location switch completed: {switched_to}" + ) + self.refresh_login_banner() + + self._refresh_locations_async() + + self._safe(work, title="VPN error") + + def refresh_singbox_tab(self) -> None: + def work(): + self.refresh_transport_engines(silent=True) + self.refresh_transport_policy_locks(silent=True) + self._apply_singbox_profile_controls() + self._safe(work, title="SingBox error") + + def _start_vpn_location_switching(self, target: str) -> None: + self._vpn_switching_active = True + self._vpn_switching_target = str(target or "").strip() + self._vpn_switching_started_at = time.monotonic() + self._vpn_switching_seen_non_connected = False + + def _stop_vpn_location_switching(self) -> None: + self._vpn_switching_active = False + self._vpn_switching_target = "" + self._vpn_switching_started_at = 0.0 + self._vpn_switching_seen_non_connected = False + + def _is_vpn_switching_expired(self) -> bool: + if not self._vpn_switching_active: + return False + started = float(self._vpn_switching_started_at or 0.0) + if started <= 0: + return False + return (time.monotonic() - started) >= float(self._vpn_switching_timeout_sec) + + def _vpn_switching_elapsed_sec(self) -> float: + if not self._vpn_switching_active: + return 0.0 + started = float(self._vpn_switching_started_at or 0.0) + if started <= 0: + return 0.0 + return max(0.0, time.monotonic() - started) + + def _refresh_locations_async(self, force_refresh: bool = False) -> None: + if self.locations_thread and self.locations_thread.isRunning(): + self._locations_refresh_pending = True + if force_refresh: + self._locations_force_refresh_pending = True + return + + run_force_refresh = bool(force_refresh or self._locations_force_refresh_pending) + self._locations_refresh_pending = False + self._locations_force_refresh_pending = False + self.locations_thread = LocationsThread( + self.ctrl, + force_refresh=run_force_refresh, + parent=self, + ) + self.locations_thread.loaded.connect(self._on_locations_loaded) + self.locations_thread.error.connect(self._on_locations_error) + self.locations_thread.finished.connect(self._on_locations_finished) + self.locations_thread.start() + + @QtCore.Slot(object) + def _on_locations_loaded(self, state) -> None: + try: + self._apply_locations_state(state) + except Exception as e: + self._on_locations_error(str(e)) + + @QtCore.Slot(str) + def _on_locations_error(self, msg: str) -> None: + msg = (msg or "").strip() + if not msg: + msg = "failed to load locations" + self.lbl_locations_meta.setText(f"Locations: {msg}") + self.lbl_locations_meta.setStyleSheet("color: red;") + try: + self.ctrl.log_gui(f"[vpn-locations] {msg}") + except Exception: + pass + + @QtCore.Slot() + def _on_locations_finished(self) -> None: + self.locations_thread = None + if self._locations_refresh_pending: + force_refresh = self._locations_force_refresh_pending + self._locations_refresh_pending = False + self._locations_force_refresh_pending = False + self._refresh_locations_async(force_refresh=force_refresh) + + def _apply_locations_state(self, state) -> None: + all_items: list[tuple[str, str, str, str, int]] = [] + for loc in getattr(state, "locations", []) or []: + iso = str(getattr(loc, "iso", "") or "").strip().upper() + label = str(getattr(loc, "label", "") or "").strip() + target = str(getattr(loc, "target", "") or "").strip() + if not iso or not label: + continue + if not target: + target = iso + name, ping = self._location_name_ping(label, iso, target) + all_items.append((label, iso, target, name, ping)) + + self._all_locations = all_items + self._apply_location_search_filter() + self._render_locations_meta(state) + + def _render_locations_meta(self, state) -> None: + parts = [] + color = "gray" + + updated_at = str(getattr(state, "updated_at", "") or "").strip() + stale = bool(getattr(state, "stale", False)) + refreshing = bool(getattr(state, "refresh_in_progress", False)) + last_error = str(getattr(state, "last_error", "") or "").strip() + next_retry = str(getattr(state, "next_retry_at", "") or "").strip() + + if refreshing: + parts.append("refreshing") + color = "orange" + if updated_at: + parts.append(f"updated: {updated_at}") + else: + parts.append("updated: n/a") + if stale: + parts.append("stale cache") + color = "orange" + if last_error: + cut = last_error if len(last_error) <= 120 else last_error[:117] + "..." + parts.append(f"last error: {cut}") + color = "red" if not refreshing else "orange" + if next_retry: + parts.append(f"next retry: {next_retry}") + + self.lbl_locations_meta.setText(" | ".join(parts)) + self.lbl_locations_meta.setStyleSheet(f"color: {color};") + + def refresh_routes_tab(self) -> None: + def work(): + timer_enabled = self.ctrl.routes_timer_enabled() + self.chk_timer.blockSignals(True) + self.chk_timer.setChecked(bool(timer_enabled)) + self.chk_timer.blockSignals(False) + + t = self.ctrl.traffic_mode_view() + self._set_traffic_mode_state( + t.desired_mode, + t.applied_mode, + t.preferred_iface, + bool(t.advanced_active), + bool(t.auto_local_bypass), + bool(t.auto_local_active), + bool(t.ingress_reply_bypass), + bool(t.ingress_reply_active), + int(t.bypass_candidates), + int(t.overrides_applied), + int(t.cgroup_resolved_uids), + t.cgroup_warning, + bool(t.healthy), + bool(t.ingress_rule_present), + bool(t.ingress_nft_active), + bool(t.probe_ok), + t.probe_message, + t.active_iface, + t.iface_reason, + t.message, + ) + rs = self.ctrl.routes_resolve_summary_view() + self.lbl_routes_resolve_summary.setText(rs.text) + self.lbl_routes_resolve_summary.setStyleSheet(f"color: {rs.color};") + self.lbl_routes_recheck_summary.setText(rs.recheck_text) + self.lbl_routes_recheck_summary.setStyleSheet(f"color: {rs.recheck_color};") + self._safe(work, title="Routes error") + + def refresh_dns_tab(self) -> None: + def work(): + self._dns_ui_refresh = True + try: + pool = self.ctrl.dns_upstream_pool_view() + self._set_dns_resolver_summary(getattr(pool, "items", [])) + + st = self.ctrl.dns_status_view() + self.ent_smartdns_addr.setText(st.smartdns_addr or "") + + mode = (getattr(st, "mode", "") or "").strip().lower() + if mode in ("hybrid_wildcard", "hybrid"): + hybrid_enabled = True + mode = "hybrid_wildcard" + else: + hybrid_enabled = False + mode = "direct" + + self.chk_dns_via_smartdns.blockSignals(True) + self.chk_dns_via_smartdns.setChecked(hybrid_enabled) + self.chk_dns_via_smartdns.blockSignals(False) + + unit_state = (st.unit_state or "unknown").strip().lower() + unit_active = unit_state == "active" + self.chk_dns_unit_relay.blockSignals(True) + self.chk_dns_unit_relay.setChecked(unit_active) + self.chk_dns_unit_relay.blockSignals(False) + + self.chk_dns_runtime_nftset.blockSignals(True) + self.chk_dns_runtime_nftset.setChecked(bool(getattr(st, "runtime_nftset", True))) + self.chk_dns_runtime_nftset.blockSignals(False) + self._set_dns_unit_relay_state(unit_active) + self._set_dns_runtime_state( + bool(getattr(st, "runtime_nftset", True)), + str(getattr(st, "wildcard_source", "") or ""), + str(getattr(st, "runtime_config_error", "") or ""), + ) + self._set_dns_mode_state(mode) + finally: + self._dns_ui_refresh = False + self._safe(work, title="DNS error") + + def refresh_domains_tab(self) -> None: + def work(): + # reload currently selected file + self.on_domains_load() + self._safe(work, title="Domains error") + + def refresh_trace_tab(self) -> None: + def work(): + if self.radio_trace_gui.isChecked(): + mode: TraceMode = "gui" + elif self.radio_trace_smartdns.isChecked(): + mode = "smartdns" + else: + mode = "full" + dump = self.ctrl.trace_view(mode) + text = "\n".join(dump.lines).rstrip() + if dump.lines: + text += "\n" + self._set_text(self.txt_trace, text, preserve_scroll=True) + self._safe(work, title="Trace error") diff --git a/selective-vpn-gui/main_window/runtime_state_mixin.py b/selective-vpn-gui/main_window/runtime_state_mixin.py new file mode 100644 index 0000000..41ca2d5 --- /dev/null +++ b/selective-vpn-gui/main_window/runtime_state_mixin.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QLabel + +from main_window.constants import LoginPage + + +class RuntimeStateMixin: + def _get_selected_domains_file(self) -> str: + item = self.lst_files.currentItem() + return item.text() if item is not None else "bases" + + def _load_file_content(self, name: str) -> tuple[str, str, str]: + api_map = { + "bases": "bases", + "meta-special": "meta", + "subs": "subs", + "static-ips": "static", + "last-ips-map-direct": "last-ips-map-direct", + "last-ips-map-wildcard": "last-ips-map-wildcard", + "wildcard-observed-hosts": "wildcard-observed-hosts", + "smartdns.conf": "smartdns", + } + if name in api_map: + f = self.ctrl.domains_file_load(api_map[name]) + content = f.content or "" + source = getattr(f, "source", "") or "api" + if name == "smartdns.conf": + path = "/var/lib/selective-vpn/smartdns-wildcards.json -> /etc/selective-vpn/smartdns.conf" + elif name == "last-ips-map-direct": + path = "/var/lib/selective-vpn/last-ips-map-direct.txt (artifact: agvpn4)" + elif name == "last-ips-map-wildcard": + path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (artifact: agvpn_dyn4)" + elif name == "wildcard-observed-hosts": + path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (derived unique hosts)" + else: + path = f"/etc/selective-vpn/domains/{name}.txt" + return content, source, path + return "", "unknown", name + + def _save_file_content(self, name: str, content: str) -> None: + api_map = { + "bases": "bases", + "meta-special": "meta", + "subs": "subs", + "static-ips": "static", + "smartdns.conf": "smartdns", + } + if name in api_map: + self.ctrl.domains_file_save(api_map[name], content) + return + + def _show_vpn_page(self, which: LoginPage) -> None: + self.vpn_stack.setCurrentIndex(1 if which == "login" else 0) + + def _set_auth_button(self, logged: bool) -> None: + self.btn_auth.setText("Logout" if logged else "Login") + + def _set_status_label_color(self, lbl: QLabel, text: str, *, kind: str) -> None: + """Подкраска Policy route / services.""" + lbl.setText(text) + low = (text or "").lower() + color = "black" + if kind == "policy": + if "ok" in low and "missing" not in low and "error" not in low: + color = "green" + elif any(w in low for w in ("missing", "error", "failed")): + color = "red" + else: + color = "orange" + else: # service + if any(w in low for w in ("failed", "error", "unknown", "inactive", "dead")): + color = "red" + elif "active" in low or "running" in low: + color = "green" + else: + color = "orange" + lbl.setStyleSheet(f"color: {color};") + + def _set_dns_unit_relay_state(self, enabled: bool) -> None: + txt = "SmartDNS unit relay: ON" if enabled else "SmartDNS unit relay: OFF" + color = "green" if enabled else "red" + self.chk_dns_unit_relay.setText(txt) + self.chk_dns_unit_relay.setStyleSheet(f"color: {color};") + + def _set_dns_runtime_state(self, enabled: bool, source: str, cfg_error: str = "") -> None: + txt = "SmartDNS runtime accelerator (nftset -> agvpn_dyn4): ON" if enabled else "SmartDNS runtime accelerator (nftset -> agvpn_dyn4): OFF" + color = "green" if enabled else "orange" + self.chk_dns_runtime_nftset.setText(txt) + self.chk_dns_runtime_nftset.setStyleSheet(f"color: {color};") + + src = (source or "").strip().lower() + if src == "both": + src_txt = "Wildcard source: both (resolver + smartdns_runtime)" + src_color = "green" + elif src == "smartdns_runtime": + src_txt = "Wildcard source: smartdns_runtime" + src_color = "orange" + else: + src_txt = "Wildcard source: resolver" + src_color = "gray" + if cfg_error: + src_txt = f"{src_txt} | runtime cfg: {cfg_error}" + src_color = "orange" + self.lbl_dns_wildcard_source.setText(src_txt) + self.lbl_dns_wildcard_source.setStyleSheet(f"color: {src_color};") + + def _set_dns_mode_state(self, mode: str) -> None: + low = (mode or "").strip().lower() + if low in ("hybrid_wildcard", "hybrid"): + txt = "Resolver mode: hybrid wildcard (SmartDNS for wildcard domains)" + color = "green" + elif low == "direct": + txt = "Resolver mode: direct upstreams" + color = "red" + else: + txt = "Resolver mode: unknown" + color = "orange" + self.lbl_dns_mode_state.setText(txt) + self.lbl_dns_mode_state.setStyleSheet(f"color: {color};") + + def _set_dns_resolver_summary(self, pool_items) -> None: + active = [] + total = 0 + for item in pool_items or []: + addr = str(getattr(item, "addr", "") or "").strip() + if not addr: + continue + total += 1 + if bool(getattr(item, "enabled", False)): + active.append(addr) + applied = len(active) + if applied > 12: + applied = 12 + if not active: + text = f"Resolver upstreams: active=0/{total} (empty set)" + else: + preview = ", ".join(active[:4]) + if len(active) > 4: + preview += f", +{len(active)-4} more" + text = f"Resolver upstreams: active={len(active)}/{total}, applied={applied}/12 [{preview}]" + self.lbl_dns_resolver_upstreams.setText(text) + self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;") + + avg_ms = self._ui_settings.value("dns_benchmark/last_avg_ms", None) + ok = self._ui_settings.value("dns_benchmark/last_ok", None) + fail = self._ui_settings.value("dns_benchmark/last_fail", None) + timeout = self._ui_settings.value("dns_benchmark/last_timeout", None) + if avg_ms is None or ok is None or fail is None: + self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet") + self.lbl_dns_resolver_health.setStyleSheet("color: gray;") + return + try: + avg = int(avg_ms) + ok_i = int(ok) + fail_i = int(fail) + timeout_i = int(timeout or 0) + except Exception: + self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet") + self.lbl_dns_resolver_health.setStyleSheet("color: gray;") + return + color = "green" if avg < 200 else ("#b58900" if avg <= 400 else "red") + if timeout_i > 0 and color != "red": + color = "#b58900" + self.lbl_dns_resolver_health.setText( + f"Resolver health: avg={avg}ms ok={ok_i} fail={fail_i} timeout={timeout_i}" + ) + self.lbl_dns_resolver_health.setStyleSheet(f"color: {color};") + + def _set_traffic_mode_state( + self, + desired_mode: str, + applied_mode: str, + preferred_iface: str, + advanced_active: bool, + auto_local_bypass: bool, + auto_local_active: bool, + ingress_reply_bypass: bool, + ingress_reply_active: bool, + bypass_candidates: int, + overrides_applied: int, + cgroup_resolved_uids: int, + cgroup_warning: str, + healthy: bool, + ingress_rule_present: bool, + ingress_nft_active: bool, + probe_ok: bool, + probe_message: str, + active_iface: str, + iface_reason: str, + message: str, + ) -> None: + desired = (desired_mode or "").strip().lower() or "selective" + applied = (applied_mode or "").strip().lower() or "direct" + + if healthy: + color = "green" + health_txt = "OK" + else: + color = "red" + health_txt = "MISMATCH" + + text = f"Traffic mode: {desired} (applied: {applied}) [{health_txt}]" + diag_parts = [] + diag_parts.append(f"preferred={preferred_iface or 'auto'}") + diag_parts.append( + f"advanced={'on' if advanced_active else 'off'}" + ) + diag_parts.append( + f"auto_local={'on' if auto_local_bypass else 'off'}" + f"({'active' if auto_local_active else 'saved'})" + ) + diag_parts.append( + f"ingress_reply={'on' if ingress_reply_bypass else 'off'}" + f"({'active' if ingress_reply_active else 'saved'})" + ) + if auto_local_active and bypass_candidates > 0: + diag_parts.append(f"bypass_routes={bypass_candidates}") + diag_parts.append(f"overrides={overrides_applied}") + if cgroup_resolved_uids > 0: + diag_parts.append(f"cgroup_uids={cgroup_resolved_uids}") + if cgroup_warning: + diag_parts.append(f"cgroup_warning={cgroup_warning}") + if active_iface: + diag_parts.append(f"iface={active_iface}") + if iface_reason: + diag_parts.append(f"source={iface_reason}") + diag_parts.append( + f"ingress_diag=rule:{'ok' if ingress_rule_present else 'off'}" + f"/nft:{'ok' if ingress_nft_active else 'off'}" + ) + diag_parts.append(f"probe={'ok' if probe_ok else 'fail'}") + if probe_message: + diag_parts.append(probe_message) + if message: + diag_parts.append(message) + diag = " | ".join(diag_parts) if diag_parts else "—" + + self.lbl_traffic_mode_state.setText(text) + self.lbl_traffic_mode_state.setStyleSheet(f"color: {color};") + self.lbl_traffic_mode_diag.setText(diag) + self.lbl_traffic_mode_diag.setStyleSheet("color: gray;") + + def _update_routes_progress_label(self, view) -> None: + """ + Обновляет прогресс nft по RoutesNftProgressView. + view ожидаем с полями percent, message, active (duck-typing). + """ + if view is None: + # сброс до idle + self._routes_progress_last = 0 + self.routes_progress.setValue(0) + self.lbl_routes_progress.setText("NFT: idle") + self.lbl_routes_progress.setStyleSheet("color: gray;") + return + + # аккуратно ограничим 0..100 + try: + percent = max(0, min(100, int(view.percent))) + except Exception: + percent = 0 + + # не даём прогрессу дёргаться назад, кроме явного сброса (percent==0) + if percent == 0: + self._routes_progress_last = 0 + else: + percent = max(percent, self._routes_progress_last) + self._routes_progress_last = percent + + self.routes_progress.setValue(percent) + + text = f"{percent}% – {view.message}" + if not view.active and percent >= 100: + color = "green" + elif view.active: + color = "orange" + else: + color = "gray" + + self.lbl_routes_progress.setText(text) + self.lbl_routes_progress.setStyleSheet(f"color: {color};") + + def _load_ui_preferences(self) -> None: + raw = self._ui_settings.value("routes/prewarm_aggressive", False) + if isinstance(raw, str): + val = raw.strip().lower() in ("1", "true", "yes", "on") + else: + val = bool(raw) + self.chk_routes_prewarm_aggressive.blockSignals(True) + self.chk_routes_prewarm_aggressive.setChecked(val) + self.chk_routes_prewarm_aggressive.blockSignals(False) + self._update_prewarm_mode_label() + + sort_mode = str(self._ui_settings.value("vpn/locations_sort", "ping") or "ping").strip().lower() + idx = self.cmb_locations_sort.findData(sort_mode) + if idx < 0: + idx = 0 + self.cmb_locations_sort.blockSignals(True) + self.cmb_locations_sort.setCurrentIndex(idx) + self.cmb_locations_sort.blockSignals(False) + + g_route = str( + self._ui_settings.value("singbox/global_routing", "selective") or "selective" + ).strip().lower() + idx = self.cmb_singbox_global_routing.findData(g_route) + if idx < 0: + idx = 0 + self.cmb_singbox_global_routing.blockSignals(True) + self.cmb_singbox_global_routing.setCurrentIndex(idx) + self.cmb_singbox_global_routing.blockSignals(False) + + g_dns = str( + self._ui_settings.value("singbox/global_dns", "system_resolver") or "system_resolver" + ).strip().lower() + idx = self.cmb_singbox_global_dns.findData(g_dns) + if idx < 0: + idx = 0 + self.cmb_singbox_global_dns.blockSignals(True) + self.cmb_singbox_global_dns.setCurrentIndex(idx) + self.cmb_singbox_global_dns.blockSignals(False) + + g_kill = str( + self._ui_settings.value("singbox/global_killswitch", "on") or "on" + ).strip().lower() + idx = self.cmb_singbox_global_killswitch.findData(g_kill) + if idx < 0: + idx = 0 + self.cmb_singbox_global_killswitch.blockSignals(True) + self.cmb_singbox_global_killswitch.setCurrentIndex(idx) + self.cmb_singbox_global_killswitch.blockSignals(False) + + p_route = str( + self._ui_settings.value("singbox/profile_routing", "global") or "global" + ).strip().lower() + idx = self.cmb_singbox_profile_routing.findData(p_route) + if idx < 0: + idx = 0 + self.cmb_singbox_profile_routing.blockSignals(True) + self.cmb_singbox_profile_routing.setCurrentIndex(idx) + self.cmb_singbox_profile_routing.blockSignals(False) + + p_dns = str( + self._ui_settings.value("singbox/profile_dns", "global") or "global" + ).strip().lower() + idx = self.cmb_singbox_profile_dns.findData(p_dns) + if idx < 0: + idx = 0 + self.cmb_singbox_profile_dns.blockSignals(True) + self.cmb_singbox_profile_dns.setCurrentIndex(idx) + self.cmb_singbox_profile_dns.blockSignals(False) + + p_kill = str( + self._ui_settings.value("singbox/profile_killswitch", "global") or "global" + ).strip().lower() + idx = self.cmb_singbox_profile_killswitch.findData(p_kill) + if idx < 0: + idx = 0 + self.cmb_singbox_profile_killswitch.blockSignals(True) + self.cmb_singbox_profile_killswitch.setCurrentIndex(idx) + self.cmb_singbox_profile_killswitch.blockSignals(False) + + raw = self._ui_settings.value("singbox/profile_use_global_routing", True) + use_global_route = ( + raw.strip().lower() in ("1", "true", "yes", "on") + if isinstance(raw, str) + else bool(raw) + ) + raw = self._ui_settings.value("singbox/profile_use_global_dns", True) + use_global_dns = ( + raw.strip().lower() in ("1", "true", "yes", "on") + if isinstance(raw, str) + else bool(raw) + ) + raw = self._ui_settings.value("singbox/profile_use_global_killswitch", True) + use_global_kill = ( + raw.strip().lower() in ("1", "true", "yes", "on") + if isinstance(raw, str) + else bool(raw) + ) + self.chk_singbox_profile_use_global_routing.blockSignals(True) + self.chk_singbox_profile_use_global_routing.setChecked(use_global_route) + self.chk_singbox_profile_use_global_routing.blockSignals(False) + self.chk_singbox_profile_use_global_dns.blockSignals(True) + self.chk_singbox_profile_use_global_dns.setChecked(use_global_dns) + self.chk_singbox_profile_use_global_dns.blockSignals(False) + self.chk_singbox_profile_use_global_killswitch.blockSignals(True) + self.chk_singbox_profile_use_global_killswitch.setChecked(use_global_kill) + self.chk_singbox_profile_use_global_killswitch.blockSignals(False) + + raw = self._ui_settings.value("singbox/ui_show_profile_settings", False) + show_profile = ( + raw.strip().lower() in ("1", "true", "yes", "on") + if isinstance(raw, str) + else bool(raw) + ) + raw = self._ui_settings.value("singbox/ui_show_global_defaults", False) + show_global = ( + raw.strip().lower() in ("1", "true", "yes", "on") + if isinstance(raw, str) + else bool(raw) + ) + raw = self._ui_settings.value("singbox/ui_show_activity_log", False) + show_activity = ( + raw.strip().lower() in ("1", "true", "yes", "on") + if isinstance(raw, str) + else bool(raw) + ) + if show_profile and show_global: + show_global = False + + self.btn_singbox_toggle_profile_settings.blockSignals(True) + self.btn_singbox_toggle_profile_settings.setChecked(show_profile) + self.btn_singbox_toggle_profile_settings.blockSignals(False) + self.btn_singbox_toggle_global_defaults.blockSignals(True) + self.btn_singbox_toggle_global_defaults.setChecked(show_global) + self.btn_singbox_toggle_global_defaults.blockSignals(False) + self.btn_singbox_toggle_activity.blockSignals(True) + self.btn_singbox_toggle_activity.setChecked(show_activity) + self.btn_singbox_toggle_activity.blockSignals(False) + + self._apply_singbox_profile_controls() + self._apply_singbox_compact_visibility() + + def _save_ui_preferences(self) -> None: + self._ui_settings.setValue( + "routes/prewarm_aggressive", + bool(self.chk_routes_prewarm_aggressive.isChecked()), + ) + self._ui_settings.setValue( + "vpn/locations_sort", + str(self.cmb_locations_sort.currentData() or "ping"), + ) + self._ui_settings.setValue( + "singbox/global_routing", + str(self.cmb_singbox_global_routing.currentData() or "selective"), + ) + self._ui_settings.setValue( + "singbox/global_dns", + str(self.cmb_singbox_global_dns.currentData() or "system_resolver"), + ) + self._ui_settings.setValue( + "singbox/global_killswitch", + str(self.cmb_singbox_global_killswitch.currentData() or "on"), + ) + self._ui_settings.setValue( + "singbox/profile_use_global_routing", + bool(self.chk_singbox_profile_use_global_routing.isChecked()), + ) + self._ui_settings.setValue( + "singbox/profile_use_global_dns", + bool(self.chk_singbox_profile_use_global_dns.isChecked()), + ) + self._ui_settings.setValue( + "singbox/profile_use_global_killswitch", + bool(self.chk_singbox_profile_use_global_killswitch.isChecked()), + ) + self._ui_settings.setValue( + "singbox/profile_routing", + str(self.cmb_singbox_profile_routing.currentData() or "global"), + ) + self._ui_settings.setValue( + "singbox/profile_dns", + str(self.cmb_singbox_profile_dns.currentData() or "global"), + ) + self._ui_settings.setValue( + "singbox/profile_killswitch", + str(self.cmb_singbox_profile_killswitch.currentData() or "global"), + ) + self._ui_settings.setValue( + "singbox/ui_show_profile_settings", + bool(self.btn_singbox_toggle_profile_settings.isChecked()), + ) + self._ui_settings.setValue( + "singbox/ui_show_global_defaults", + bool(self.btn_singbox_toggle_global_defaults.isChecked()), + ) + self._ui_settings.setValue( + "singbox/ui_show_activity_log", + bool(self.btn_singbox_toggle_activity.isChecked()), + ) + self._ui_settings.sync() diff --git a/selective-vpn-gui/main_window/singbox/__init__.py b/selective-vpn-gui/main_window/singbox/__init__.py new file mode 100644 index 0000000..89080bc --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/__init__.py @@ -0,0 +1,11 @@ +from .cards_mixin import SingBoxCardsMixin +from .editor_mixin import SingBoxEditorMixin +from .links_mixin import SingBoxLinksMixin +from .runtime_mixin import SingBoxRuntimeMixin + +__all__ = [ + "SingBoxCardsMixin", + "SingBoxEditorMixin", + "SingBoxLinksMixin", + "SingBoxRuntimeMixin", +] diff --git a/selective-vpn-gui/main_window/singbox/cards_mixin.py b/selective-vpn-gui/main_window/singbox/cards_mixin.py new file mode 100644 index 0000000..795b919 --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/cards_mixin.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import QFrame, QLabel, QListWidgetItem, QVBoxLayout + +from main_window.constants import SINGBOX_STATUS_ROLE +from transport_protocol_summary import transport_protocol_summary + + +class SingBoxCardsMixin: + def _singbox_client_protocol_summary(self, client) -> str: + protocol_txt = transport_protocol_summary(client) + if protocol_txt == "n/a": + cid = str(getattr(client, "id", "") or "").strip() + if ( + cid + and cid == str(self._singbox_editor_profile_client_id or "").strip() + and str(self._singbox_editor_protocol or "").strip() + ): + protocol_txt = str(self._singbox_editor_protocol).strip().lower() + return protocol_txt + + def _make_singbox_profile_card_widget( + self, + *, + name: str, + protocol_txt: str, + status: str, + latency_txt: str, + cid: str, + ) -> QFrame: + frame = QFrame() + frame.setObjectName("singboxProfileCard") + lay = QVBoxLayout(frame) + lay.setContentsMargins(10, 8, 10, 8) + lay.setSpacing(2) + + lbl_name = QLabel(name) + lbl_name.setObjectName("cardName") + lbl_name.setAlignment(Qt.AlignHCenter) + lay.addWidget(lbl_name) + + lbl_proto = QLabel(protocol_txt) + lbl_proto.setObjectName("cardProto") + lbl_proto.setAlignment(Qt.AlignHCenter) + lay.addWidget(lbl_proto) + + lbl_state = QLabel(f"{str(status or '').upper()} · {latency_txt}") + lbl_state.setObjectName("cardState") + lbl_state.setAlignment(Qt.AlignHCenter) + lay.addWidget(lbl_state) + + frame.setToolTip(f"{cid}\n{protocol_txt}\nstatus={status}") + return frame + + def _style_singbox_profile_card_widget( + self, + card: QFrame, + *, + active: bool, + selected: bool, + ) -> None: + if active and selected: + bg = "#c7f1d5" + border = "#208f47" + name_color = "#11552e" + meta_color = "#1f6f43" + elif active: + bg = "#eafaf0" + border = "#2f9e44" + name_color = "#14532d" + meta_color = "#1f6f43" + elif selected: + bg = "#e8f1ff" + border = "#2f80ed" + name_color = "#1b2f50" + meta_color = "#28568a" + else: + bg = "#f7f7f7" + border = "#c9c9c9" + name_color = "#202020" + meta_color = "#666666" + + card.setStyleSheet( + f""" + QFrame#singboxProfileCard {{ + border: 1px solid {border}; + border-radius: 6px; + background: {bg}; + }} + QLabel#cardName {{ + color: {name_color}; + font-weight: 600; + }} + QLabel#cardProto {{ + color: {meta_color}; + }} + QLabel#cardState {{ + color: {meta_color}; + }} + """ + ) + + def _refresh_singbox_profile_card_styles(self) -> None: + current_id = self._selected_transport_engine_id() + for i in range(self.lst_singbox_profile_cards.count()): + item = self.lst_singbox_profile_cards.item(i) + cid = str(item.data(Qt.UserRole) or "").strip() + status = str(item.data(SINGBOX_STATUS_ROLE) or "").strip().lower() + card = self.lst_singbox_profile_cards.itemWidget(item) + if not isinstance(card, QFrame): + continue + self._style_singbox_profile_card_widget( + card, + active=(status == "up"), + selected=bool(current_id and cid == current_id), + ) + + def _render_singbox_profile_cards(self) -> None: + current_id = self._selected_transport_engine_id() + self.lst_singbox_profile_cards.blockSignals(True) + self.lst_singbox_profile_cards.clear() + selected_item = None + + if not self._transport_api_supported: + item = QListWidgetItem("Transport API unavailable") + item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable) + self.lst_singbox_profile_cards.addItem(item) + self.lst_singbox_profile_cards.blockSignals(False) + return + + if not self._transport_clients: + item = QListWidgetItem("No SingBox profiles configured") + item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable) + self.lst_singbox_profile_cards.addItem(item) + self.lst_singbox_profile_cards.blockSignals(False) + return + + for c in self._transport_clients: + cid = str(getattr(c, "id", "") or "").strip() + if not cid: + continue + name = str(getattr(c, "name", "") or "").strip() or cid + status, latency, _last_error, _last_check = self._transport_live_health_for_client(c) + latency_txt = f"{latency}ms" if latency > 0 else "no ping" + protocol_txt = self._singbox_client_protocol_summary(c) + item = QListWidgetItem("") + item.setData(Qt.UserRole, cid) + item.setData(SINGBOX_STATUS_ROLE, status) + item.setSizeHint(QSize(228, 78)) + self.lst_singbox_profile_cards.addItem(item) + self.lst_singbox_profile_cards.setItemWidget( + item, + self._make_singbox_profile_card_widget( + name=name, + protocol_txt=protocol_txt, + status=status, + latency_txt=latency_txt, + cid=cid, + ), + ) + if current_id and cid == current_id: + selected_item = item + + if selected_item is not None: + self.lst_singbox_profile_cards.setCurrentItem(selected_item) + elif self.lst_singbox_profile_cards.count() > 0: + self.lst_singbox_profile_cards.setCurrentRow(0) + self.lst_singbox_profile_cards.blockSignals(False) + self._refresh_singbox_profile_card_styles() + + def _sync_singbox_profile_card_selection(self, cid: str) -> None: + if self._syncing_singbox_selection: + return + self._syncing_singbox_selection = True + try: + self.lst_singbox_profile_cards.blockSignals(True) + self.lst_singbox_profile_cards.clearSelection() + target = str(cid or "").strip() + if target: + for i in range(self.lst_singbox_profile_cards.count()): + item = self.lst_singbox_profile_cards.item(i) + if str(item.data(Qt.UserRole) or "").strip() == target: + self.lst_singbox_profile_cards.setCurrentItem(item) + break + self.lst_singbox_profile_cards.blockSignals(False) + finally: + self._syncing_singbox_selection = False + self._refresh_singbox_profile_card_styles() + + def _select_transport_engine_by_id(self, cid: str) -> bool: + target = str(cid or "").strip() + if not target: + return False + idx = self.cmb_transport_engine.findData(target) + if idx < 0: + return False + if idx != self.cmb_transport_engine.currentIndex(): + self.cmb_transport_engine.setCurrentIndex(idx) + else: + self._sync_singbox_profile_card_selection(target) + self._sync_selected_singbox_profile_link(silent=True) + self._load_singbox_editor_for_selected(silent=True) + self._update_transport_engine_view() + return True diff --git a/selective-vpn-gui/main_window/singbox/editor_mixin.py b/selective-vpn-gui/main_window/singbox/editor_mixin.py new file mode 100644 index 0000000..940699b --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/editor_mixin.py @@ -0,0 +1,633 @@ +from __future__ import annotations + +import json +from typing import Any + +from main_window.constants import SINGBOX_EDITOR_PROTOCOL_IDS, SINGBOX_PROTOCOL_SEED_SPEC + + +class SingBoxEditorMixin: + def _selected_singbox_profile_id(self) -> str: + selected = self._selected_transport_client() + if selected is not None: + selected_cid = str(getattr(selected, "id", "") or "").strip() + if ( + selected_cid + and self._singbox_editor_profile_id + and selected_cid == str(self._singbox_editor_profile_client_id or "").strip() + ): + return str(self._singbox_editor_profile_id).strip() + if selected_cid: + # Desktop SingBox tab keeps one deterministic profile per engine card. + return selected_cid + return self._selected_transport_engine_id() + + def _set_singbox_editor_enabled(self, enabled: bool) -> None: + widgets = [ + self.ent_singbox_proto_name, + self.chk_singbox_proto_enabled, + self.cmb_singbox_proto_protocol, + self.ent_singbox_vless_server, + self.spn_singbox_vless_port, + self.ent_singbox_vless_uuid, + self.ent_singbox_proto_password, + self.cmb_singbox_vless_flow, + self.cmb_singbox_vless_packet_encoding, + self.cmb_singbox_ss_method, + self.ent_singbox_ss_plugin, + self.spn_singbox_hy2_up_mbps, + self.spn_singbox_hy2_down_mbps, + self.ent_singbox_hy2_obfs, + self.ent_singbox_hy2_obfs_password, + self.cmb_singbox_tuic_congestion, + self.cmb_singbox_tuic_udp_mode, + self.chk_singbox_tuic_zero_rtt, + self.ent_singbox_wg_private_key, + self.ent_singbox_wg_peer_public_key, + self.ent_singbox_wg_psk, + self.ent_singbox_wg_local_address, + self.ent_singbox_wg_reserved, + self.spn_singbox_wg_mtu, + self.btn_singbox_wg_paste_private, + self.btn_singbox_wg_copy_private, + self.btn_singbox_wg_paste_peer, + self.btn_singbox_wg_copy_peer, + self.btn_singbox_wg_paste_psk, + self.btn_singbox_wg_copy_psk, + self.cmb_singbox_vless_transport, + self.ent_singbox_vless_path, + self.ent_singbox_vless_grpc_service, + self.cmb_singbox_vless_security, + self.ent_singbox_vless_sni, + self.ent_singbox_tls_alpn, + self.cmb_singbox_vless_utls_fp, + self.ent_singbox_vless_reality_pk, + self.ent_singbox_vless_reality_sid, + self.chk_singbox_vless_insecure, + self.chk_singbox_vless_sniff, + ] + for w in widgets: + w.setEnabled(bool(enabled)) + + def _clear_singbox_editor(self) -> None: + self._singbox_editor_loading = True + try: + self._singbox_editor_profile_id = "" + self._singbox_editor_profile_client_id = "" + self._singbox_editor_protocol = "vless" + self._singbox_editor_source_raw = {} + self.ent_singbox_proto_name.setText("") + self.chk_singbox_proto_enabled.setChecked(True) + self.cmb_singbox_proto_protocol.setCurrentIndex(0) + self.ent_singbox_vless_server.setText("") + self.spn_singbox_vless_port.setValue(443) + self.ent_singbox_vless_uuid.setText("") + self.ent_singbox_proto_password.setText("") + self.cmb_singbox_vless_flow.setCurrentIndex(0) + self.cmb_singbox_vless_flow.setEditText("") + self.cmb_singbox_vless_packet_encoding.setCurrentIndex(0) + self.cmb_singbox_ss_method.setCurrentIndex(0) + self.ent_singbox_ss_plugin.setText("") + self.spn_singbox_hy2_up_mbps.setValue(0) + self.spn_singbox_hy2_down_mbps.setValue(0) + self.ent_singbox_hy2_obfs.setText("") + self.ent_singbox_hy2_obfs_password.setText("") + self.cmb_singbox_tuic_congestion.setCurrentIndex(0) + self.cmb_singbox_tuic_udp_mode.setCurrentIndex(0) + self.chk_singbox_tuic_zero_rtt.setChecked(False) + self.ent_singbox_wg_private_key.setText("") + self.ent_singbox_wg_peer_public_key.setText("") + self.ent_singbox_wg_psk.setText("") + self.ent_singbox_wg_local_address.setText("") + self.ent_singbox_wg_reserved.setText("") + self.spn_singbox_wg_mtu.setValue(0) + self.cmb_singbox_vless_transport.setCurrentIndex(0) + self.ent_singbox_vless_path.setText("") + self.ent_singbox_vless_grpc_service.setText("") + self.cmb_singbox_vless_security.setCurrentIndex(0) + self.ent_singbox_vless_sni.setText("") + self.ent_singbox_tls_alpn.setText("") + self.cmb_singbox_vless_utls_fp.setCurrentIndex(0) + self.ent_singbox_vless_reality_pk.setText("") + self.ent_singbox_vless_reality_sid.setText("") + self.chk_singbox_vless_insecure.setChecked(False) + self.chk_singbox_vless_sniff.setChecked(True) + finally: + self._singbox_editor_loading = False + self.on_singbox_vless_editor_changed() + + def _load_singbox_editor_for_selected(self, *, silent: bool = True) -> None: + client = self._selected_transport_client() + if client is None: + self._clear_singbox_editor() + self._set_singbox_editor_enabled(False) + return + try: + cid = str(getattr(client, "id", "") or "").strip() + profile = self.ctrl.singbox_profile_get_for_client( + client, + profile_id=self._selected_singbox_profile_id(), + ) + self._apply_singbox_editor_profile(profile, fallback_name=str(getattr(client, "name", "") or "").strip()) + self._singbox_editor_profile_client_id = cid + self._set_singbox_editor_enabled(True) + except Exception as e: + if not silent: + raise + self._append_transport_log(f"[profile] editor load failed: {e}") + self._clear_singbox_editor() + self._set_singbox_editor_enabled(False) + + def _find_editor_proxy_outbound(self, outbounds: list[Any]) -> dict[str, Any]: + proxy = {} + for row in outbounds: + if not isinstance(row, dict): + continue + t = str(row.get("type") or "").strip().lower() + tag = str(row.get("tag") or "").strip().lower() + if self._is_supported_editor_protocol(t): + proxy = row + break + if tag == "proxy": + proxy = row + return dict(proxy) if isinstance(proxy, dict) else {} + + def _find_editor_sniff_inbound(self, inbounds: list[Any]) -> dict[str, Any]: + inbound = {} + for row in inbounds: + if not isinstance(row, dict): + continue + tag = str(row.get("tag") or "").strip().lower() + t = str(row.get("type") or "").strip().lower() + if tag == "socks-in" or t == "socks": + inbound = row + break + return dict(inbound) if isinstance(inbound, dict) else {} + + def _apply_singbox_editor_profile(self, profile, *, fallback_name: str = "") -> None: + raw = getattr(profile, "raw_config", {}) or {} + if not isinstance(raw, dict): + raw = {} + protocol = str(getattr(profile, "protocol", "") or "").strip().lower() or "vless" + outbounds = raw.get("outbounds") or [] + if not isinstance(outbounds, list): + outbounds = [] + inbounds = raw.get("inbounds") or [] + if not isinstance(inbounds, list): + inbounds = [] + + proxy = self._find_editor_proxy_outbound(outbounds) + inbound = self._find_editor_sniff_inbound(inbounds) + proxy_type = str(proxy.get("type") or "").strip().lower() + if self._is_supported_editor_protocol(proxy_type): + protocol = proxy_type + + tls = proxy.get("tls") if isinstance(proxy.get("tls"), dict) else {} + reality = tls.get("reality") if isinstance(tls.get("reality"), dict) else {} + utls = tls.get("utls") if isinstance(tls.get("utls"), dict) else {} + transport = proxy.get("transport") if isinstance(proxy.get("transport"), dict) else {} + + security = "none" + if bool(tls.get("enabled", False)): + security = "tls" + if bool(reality.get("enabled", False)): + security = "reality" + + transport_type = str(transport.get("type") or "").strip().lower() or "tcp" + path = str(transport.get("path") or "").strip() + grpc_service = str(transport.get("service_name") or "").strip() + alpn_vals = tls.get("alpn") or [] + if not isinstance(alpn_vals, list): + alpn_vals = [] + alpn_text = ",".join([str(x).strip() for x in alpn_vals if str(x).strip()]) + + self._singbox_editor_loading = True + try: + self._singbox_editor_profile_id = str(getattr(profile, "id", "") or "").strip() + self._singbox_editor_protocol = protocol + self._singbox_editor_source_raw = json.loads(json.dumps(raw)) + self.ent_singbox_proto_name.setText( + str(getattr(profile, "name", "") or "").strip() or fallback_name or self._singbox_editor_profile_id + ) + self.chk_singbox_proto_enabled.setChecked(bool(getattr(profile, "enabled", True))) + pidx = self.cmb_singbox_proto_protocol.findData(protocol) + self.cmb_singbox_proto_protocol.setCurrentIndex(pidx if pidx >= 0 else 0) + self.ent_singbox_vless_server.setText(str(proxy.get("server") or "").strip()) + try: + self.spn_singbox_vless_port.setValue(int(proxy.get("server_port") or 443)) + except Exception: + self.spn_singbox_vless_port.setValue(443) + self.ent_singbox_vless_uuid.setText(str(proxy.get("uuid") or "").strip()) + self.ent_singbox_proto_password.setText(str(proxy.get("password") or "").strip()) + + flow_value = str(proxy.get("flow") or "").strip() + idx = self.cmb_singbox_vless_flow.findData(flow_value) + if idx >= 0: + self.cmb_singbox_vless_flow.setCurrentIndex(idx) + else: + self.cmb_singbox_vless_flow.setEditText(flow_value) + + pe = str(proxy.get("packet_encoding") or "").strip().lower() + if pe in ("none", "off", "false"): + pe = "" + idx = self.cmb_singbox_vless_packet_encoding.findData(pe) + self.cmb_singbox_vless_packet_encoding.setCurrentIndex(idx if idx >= 0 else 0) + + ss_method = str(proxy.get("method") or "").strip().lower() + idx = self.cmb_singbox_ss_method.findData(ss_method) + if idx >= 0: + self.cmb_singbox_ss_method.setCurrentIndex(idx) + else: + self.cmb_singbox_ss_method.setEditText(ss_method) + self.ent_singbox_ss_plugin.setText(str(proxy.get("plugin") or "").strip()) + + try: + self.spn_singbox_hy2_up_mbps.setValue(int(proxy.get("up_mbps") or 0)) + except Exception: + self.spn_singbox_hy2_up_mbps.setValue(0) + try: + self.spn_singbox_hy2_down_mbps.setValue(int(proxy.get("down_mbps") or 0)) + except Exception: + self.spn_singbox_hy2_down_mbps.setValue(0) + obfs = proxy.get("obfs") if isinstance(proxy.get("obfs"), dict) else {} + self.ent_singbox_hy2_obfs.setText(str(obfs.get("type") or "").strip()) + self.ent_singbox_hy2_obfs_password.setText(str(obfs.get("password") or "").strip()) + + cc = str(proxy.get("congestion_control") or "").strip() + idx = self.cmb_singbox_tuic_congestion.findData(cc) + if idx >= 0: + self.cmb_singbox_tuic_congestion.setCurrentIndex(idx) + else: + self.cmb_singbox_tuic_congestion.setCurrentIndex(0) + udp_mode = str(proxy.get("udp_relay_mode") or "").strip() + idx = self.cmb_singbox_tuic_udp_mode.findData(udp_mode) + self.cmb_singbox_tuic_udp_mode.setCurrentIndex(idx if idx >= 0 else 0) + self.chk_singbox_tuic_zero_rtt.setChecked(bool(proxy.get("zero_rtt_handshake", False))) + + self.ent_singbox_wg_private_key.setText(str(proxy.get("private_key") or "").strip()) + self.ent_singbox_wg_peer_public_key.setText(str(proxy.get("peer_public_key") or "").strip()) + self.ent_singbox_wg_psk.setText(str(proxy.get("pre_shared_key") or "").strip()) + local_addr = proxy.get("local_address") or [] + if not isinstance(local_addr, list): + if str(local_addr or "").strip(): + local_addr = [str(local_addr).strip()] + else: + local_addr = [] + self.ent_singbox_wg_local_address.setText( + ",".join([str(x).strip() for x in local_addr if str(x).strip()]) + ) + reserved = proxy.get("reserved") or [] + if not isinstance(reserved, list): + if str(reserved or "").strip(): + reserved = [str(reserved).strip()] + else: + reserved = [] + self.ent_singbox_wg_reserved.setText( + ",".join([str(x).strip() for x in reserved if str(x).strip()]) + ) + try: + self.spn_singbox_wg_mtu.setValue(int(proxy.get("mtu") or 0)) + except Exception: + self.spn_singbox_wg_mtu.setValue(0) + + idx = self.cmb_singbox_vless_transport.findData(transport_type) + self.cmb_singbox_vless_transport.setCurrentIndex(idx if idx >= 0 else 0) + self.ent_singbox_vless_path.setText(path) + self.ent_singbox_vless_grpc_service.setText(grpc_service) + + idx = self.cmb_singbox_vless_security.findData(security) + self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 0) + self.ent_singbox_vless_sni.setText(str(tls.get("server_name") or "").strip()) + self.ent_singbox_tls_alpn.setText(alpn_text) + idx = self.cmb_singbox_vless_utls_fp.findData(str(utls.get("fingerprint") or "").strip()) + self.cmb_singbox_vless_utls_fp.setCurrentIndex(idx if idx >= 0 else 0) + self.ent_singbox_vless_reality_pk.setText(str(reality.get("public_key") or "").strip()) + self.ent_singbox_vless_reality_sid.setText(str(reality.get("short_id") or "").strip()) + self.chk_singbox_vless_insecure.setChecked(bool(tls.get("insecure", False))) + self.chk_singbox_vless_sniff.setChecked(bool(inbound.get("sniff", True))) + finally: + self._singbox_editor_loading = False + self.on_singbox_vless_editor_changed() + + def _validate_singbox_editor_form(self) -> None: + protocol = self._current_editor_protocol() + addr = self.ent_singbox_vless_server.text().strip() + if not addr: + raise RuntimeError("Address is required") + security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower() + transport = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower() + if protocol == "vless": + if not self.ent_singbox_vless_uuid.text().strip(): + raise RuntimeError("UUID is required for VLESS") + if security == "reality" and not self.ent_singbox_vless_reality_pk.text().strip(): + raise RuntimeError("Reality public key is required for Reality security mode") + elif protocol == "trojan": + if not self.ent_singbox_proto_password.text().strip(): + raise RuntimeError("Password is required for Trojan") + if security == "reality": + raise RuntimeError("Reality security is not supported for Trojan in this editor") + elif protocol == "shadowsocks": + method = str(self.cmb_singbox_ss_method.currentData() or "").strip() + if not method: + method = self.cmb_singbox_ss_method.currentText().strip() + if not method: + raise RuntimeError("SS method is required for Shadowsocks") + if not self.ent_singbox_proto_password.text().strip(): + raise RuntimeError("Password is required for Shadowsocks") + elif protocol == "hysteria2": + if not self.ent_singbox_proto_password.text().strip(): + raise RuntimeError("Password is required for Hysteria2") + elif protocol == "tuic": + if not self.ent_singbox_vless_uuid.text().strip(): + raise RuntimeError("UUID is required for TUIC") + if not self.ent_singbox_proto_password.text().strip(): + raise RuntimeError("Password is required for TUIC") + elif protocol == "wireguard": + if not self.ent_singbox_wg_private_key.text().strip(): + raise RuntimeError("WireGuard private key is required") + if not self.ent_singbox_wg_peer_public_key.text().strip(): + raise RuntimeError("WireGuard peer public key is required") + local_addr = [str(x).strip() for x in self.ent_singbox_wg_local_address.text().split(",") if str(x).strip()] + if not local_addr: + raise RuntimeError("WireGuard local address is required (CIDR list)") + self._parse_wg_reserved_values( + [str(x).strip() for x in self.ent_singbox_wg_reserved.text().split(",") if str(x).strip()], + strict=True, + ) + + if protocol in ("vless", "trojan"): + if transport == "grpc" and not self.ent_singbox_vless_grpc_service.text().strip(): + raise RuntimeError("gRPC service is required for gRPC transport") + if transport in ("ws", "http", "httpupgrade") and not self.ent_singbox_vless_path.text().strip(): + raise RuntimeError("Transport path is required for selected transport") + + def _build_singbox_editor_raw_config(self) -> dict[str, Any]: + base = self._singbox_editor_source_raw + if not isinstance(base, dict): + base = {} + raw: dict[str, Any] = json.loads(json.dumps(base)) + protocol = self._current_editor_protocol() + + outbounds = raw.get("outbounds") or [] + if not isinstance(outbounds, list): + outbounds = [] + + proxy_idx = -1 + for i, row in enumerate(outbounds): + if not isinstance(row, dict): + continue + t = str(row.get("type") or "").strip().lower() + tag = str(row.get("tag") or "").strip().lower() + if self._is_supported_editor_protocol(t) or tag == "proxy": + proxy_idx = i + break + + proxy: dict[str, Any] + if proxy_idx >= 0: + proxy = dict(outbounds[proxy_idx]) if isinstance(outbounds[proxy_idx], dict) else {} + else: + proxy = {} + + proxy["type"] = protocol + proxy["tag"] = str(proxy.get("tag") or "proxy") + proxy["server"] = self.ent_singbox_vless_server.text().strip() + proxy["server_port"] = int(self.spn_singbox_vless_port.value()) + # clear protocol-specific keys before repopulating + for key in ( + "uuid", + "password", + "method", + "plugin", + "flow", + "packet_encoding", + "up_mbps", + "down_mbps", + "obfs", + "congestion_control", + "udp_relay_mode", + "zero_rtt_handshake", + "private_key", + "peer_public_key", + "pre_shared_key", + "local_address", + "reserved", + "mtu", + ): + proxy.pop(key, None) + + if protocol == "vless": + proxy["uuid"] = self.ent_singbox_vless_uuid.text().strip() + flow = str(self.cmb_singbox_vless_flow.currentData() or "").strip() + if not flow: + flow = self.cmb_singbox_vless_flow.currentText().strip() + if flow: + proxy["flow"] = flow + packet_encoding = str(self.cmb_singbox_vless_packet_encoding.currentData() or "").strip().lower() + if packet_encoding and packet_encoding != "none": + proxy["packet_encoding"] = packet_encoding + elif protocol == "trojan": + proxy["password"] = self.ent_singbox_proto_password.text().strip() + elif protocol == "shadowsocks": + method = str(self.cmb_singbox_ss_method.currentData() or "").strip() + if not method: + method = self.cmb_singbox_ss_method.currentText().strip() + proxy["method"] = method + proxy["password"] = self.ent_singbox_proto_password.text().strip() + plugin = self.ent_singbox_ss_plugin.text().strip() + if plugin: + proxy["plugin"] = plugin + elif protocol == "hysteria2": + proxy["password"] = self.ent_singbox_proto_password.text().strip() + up = int(self.spn_singbox_hy2_up_mbps.value()) + down = int(self.spn_singbox_hy2_down_mbps.value()) + if up > 0: + proxy["up_mbps"] = up + if down > 0: + proxy["down_mbps"] = down + obfs_type = self.ent_singbox_hy2_obfs.text().strip() + if obfs_type: + obfs: dict[str, Any] = {"type": obfs_type} + obfs_password = self.ent_singbox_hy2_obfs_password.text().strip() + if obfs_password: + obfs["password"] = obfs_password + proxy["obfs"] = obfs + elif protocol == "tuic": + proxy["uuid"] = self.ent_singbox_vless_uuid.text().strip() + proxy["password"] = self.ent_singbox_proto_password.text().strip() + cc = str(self.cmb_singbox_tuic_congestion.currentData() or "").strip() + if not cc: + cc = self.cmb_singbox_tuic_congestion.currentText().strip() + if cc: + proxy["congestion_control"] = cc + udp_mode = str(self.cmb_singbox_tuic_udp_mode.currentData() or "").strip() + if udp_mode: + proxy["udp_relay_mode"] = udp_mode + if self.chk_singbox_tuic_zero_rtt.isChecked(): + proxy["zero_rtt_handshake"] = True + elif protocol == "wireguard": + proxy["private_key"] = self.ent_singbox_wg_private_key.text().strip() + proxy["peer_public_key"] = self.ent_singbox_wg_peer_public_key.text().strip() + psk = self.ent_singbox_wg_psk.text().strip() + if psk: + proxy["pre_shared_key"] = psk + local_addr = [str(x).strip() for x in self.ent_singbox_wg_local_address.text().split(",") if str(x).strip()] + if local_addr: + proxy["local_address"] = local_addr + reserved_vals = self._parse_wg_reserved_values( + [str(x).strip() for x in self.ent_singbox_wg_reserved.text().split(",") if str(x).strip()], + strict=True, + ) + if reserved_vals: + proxy["reserved"] = reserved_vals + mtu = int(self.spn_singbox_wg_mtu.value()) + if mtu > 0: + proxy["mtu"] = mtu + + transport_type = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower() + if protocol in ("vless", "trojan"): + self._apply_proxy_transport( + proxy, + transport=transport_type, + path=self.ent_singbox_vless_path.text().strip(), + grpc_service=self.ent_singbox_vless_grpc_service.text().strip(), + ) + else: + proxy.pop("transport", None) + + security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower() + if protocol == "vless": + pass + elif protocol == "trojan": + if security == "reality": + security = "tls" + elif protocol in ("hysteria2", "tuic"): + security = "tls" + else: + security = "none" + + alpn = [] + for p in self.ent_singbox_tls_alpn.text().split(","): + v = str(p or "").strip() + if v: + alpn.append(v) + self._apply_proxy_tls( + proxy, + security=security, + sni=self.ent_singbox_vless_sni.text().strip(), + utls_fp=str(self.cmb_singbox_vless_utls_fp.currentData() or "").strip(), + tls_insecure=bool(self.chk_singbox_vless_insecure.isChecked()), + reality_public_key=self.ent_singbox_vless_reality_pk.text().strip(), + reality_short_id=self.ent_singbox_vless_reality_sid.text().strip(), + alpn=alpn, + ) + + if proxy_idx >= 0: + outbounds[proxy_idx] = proxy + else: + outbounds.insert(0, proxy) + + has_direct = any( + isinstance(row, dict) + and str(row.get("type") or "").strip().lower() == "direct" + and str(row.get("tag") or "").strip().lower() == "direct" + for row in outbounds + ) + if not has_direct: + outbounds.append({"type": "direct", "tag": "direct"}) + raw["outbounds"] = outbounds + + inbounds = raw.get("inbounds") or [] + if not isinstance(inbounds, list): + inbounds = [] + inbound_idx = -1 + for i, row in enumerate(inbounds): + if not isinstance(row, dict): + continue + tag = str(row.get("tag") or "").strip().lower() + t = str(row.get("type") or "").strip().lower() + if tag == "socks-in" or t == "socks": + inbound_idx = i + break + inbound = ( + dict(inbounds[inbound_idx]) if inbound_idx >= 0 and isinstance(inbounds[inbound_idx], dict) else {} + ) + inbound["type"] = str(inbound.get("type") or "socks") + inbound["tag"] = str(inbound.get("tag") or "socks-in") + inbound["listen"] = str(inbound.get("listen") or "127.0.0.1") + inbound["listen_port"] = int(inbound.get("listen_port") or 10808) + sniff = bool(self.chk_singbox_vless_sniff.isChecked()) + inbound["sniff"] = sniff + inbound["sniff_override_destination"] = sniff + if inbound_idx >= 0: + inbounds[inbound_idx] = inbound + else: + inbounds.insert(0, inbound) + raw["inbounds"] = inbounds + + route = raw.get("route") if isinstance(raw.get("route"), dict) else {} + route["final"] = str(route.get("final") or "direct") + rules = route.get("rules") or [] + if not isinstance(rules, list): + rules = [] + has_proxy_rule = False + for row in rules: + if not isinstance(row, dict): + continue + outbound = str(row.get("outbound") or "").strip().lower() + inbound_list = row.get("inbound") or [] + if not isinstance(inbound_list, list): + inbound_list = [] + inbound_norm = [str(x).strip().lower() for x in inbound_list if str(x).strip()] + if outbound == "proxy" and "socks-in" in inbound_norm: + has_proxy_rule = True + break + if not has_proxy_rule: + rules.insert(0, {"inbound": ["socks-in"], "outbound": "proxy"}) + route["rules"] = rules + raw["route"] = route + return raw + + def _save_singbox_editor_draft(self, client, *, profile_id: str = ""): + protocol = self._current_editor_protocol() + self._validate_singbox_editor_form() + raw_cfg = self._build_singbox_editor_raw_config() + name = self.ent_singbox_proto_name.text().strip() + enabled = bool(self.chk_singbox_proto_enabled.isChecked()) + res = self.ctrl.singbox_profile_save_raw_for_client( + client, + profile_id=profile_id, + name=name, + enabled=enabled, + protocol=protocol, + raw_config=raw_cfg, + ) + profile = self.ctrl.singbox_profile_get_for_client(client, profile_id=profile_id) + self._singbox_editor_profile_id = str(getattr(profile, "id", "") or "").strip() + self._singbox_editor_profile_client_id = str(getattr(client, "id", "") or "").strip() + self._singbox_editor_protocol = str(getattr(profile, "protocol", "") or protocol).strip().lower() or protocol + self._singbox_editor_source_raw = json.loads(json.dumps(getattr(profile, "raw_config", {}) or {})) + return res + + def _sync_selected_singbox_profile_link(self, *, silent: bool = True) -> None: + client = self._selected_transport_client() + if client is None: + return + try: + preferred_pid = str(getattr(client, "id", "") or "").strip() + res = self.ctrl.singbox_profile_ensure_linked( + client, + preferred_profile_id=preferred_pid, + ) + except Exception as e: + if not silent: + raise + self._append_transport_log(f"[profile] auto-link skipped: {e}") + return + line = (res.pretty_text or "").strip() + if not line: + return + # Keep noisy "already linked" messages out of normal flow. + if "already linked" in line.lower() and silent: + return + self._append_transport_log(f"[profile] {line}") + self.ctrl.log_gui(f"[singbox-profile] {line}") diff --git a/selective-vpn-gui/main_window/singbox/links_actions_mixin.py b/selective-vpn-gui/main_window/singbox/links_actions_mixin.py new file mode 100644 index 0000000..5a4fb00 --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/links_actions_mixin.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import json + +from PySide6.QtWidgets import QApplication, QInputDialog, QMenu + +from main_window.constants import SINGBOX_EDITOR_PROTOCOL_OPTIONS + + +class SingBoxLinksActionsMixin: + def _apply_singbox_editor_values(self, values: dict[str, Any]) -> None: + incoming = dict(values or {}) + target_protocol = str(incoming.get("protocol") or self._current_editor_protocol() or "vless").strip().lower() or "vless" + payload = self._seed_editor_values_for_protocol( + target_protocol, + profile_name=str(incoming.get("profile_name") or "").strip(), + ) + payload.update(incoming) + self._singbox_editor_loading = True + try: + name = str(payload.get("profile_name") or "").strip() + self.ent_singbox_proto_name.setText(name) + self.chk_singbox_proto_enabled.setChecked(bool(payload.get("enabled", True))) + protocol = str(payload.get("protocol") or "").strip().lower() + if protocol: + pidx = self.cmb_singbox_proto_protocol.findData(protocol) + self.cmb_singbox_proto_protocol.setCurrentIndex(pidx if pidx >= 0 else self.cmb_singbox_proto_protocol.currentIndex()) + self.ent_singbox_vless_server.setText(str(payload.get("server") or "").strip()) + + try: + self.spn_singbox_vless_port.setValue(int(payload.get("port") or 443)) + except Exception: + self.spn_singbox_vless_port.setValue(443) + + self.ent_singbox_vless_uuid.setText(str(payload.get("uuid") or "").strip()) + self.ent_singbox_proto_password.setText(str(payload.get("password") or "").strip()) + + flow_v = str(payload.get("flow") or "").strip() + flow_idx = self.cmb_singbox_vless_flow.findData(flow_v) + if flow_idx >= 0: + self.cmb_singbox_vless_flow.setCurrentIndex(flow_idx) + else: + self.cmb_singbox_vless_flow.setEditText(flow_v) + + packet_v = str(payload.get("packet_encoding") or "").strip().lower() + if packet_v in ("none", "off", "false"): + packet_v = "" + packet_idx = self.cmb_singbox_vless_packet_encoding.findData(packet_v) + self.cmb_singbox_vless_packet_encoding.setCurrentIndex(packet_idx if packet_idx >= 0 else 0) + + transport_v = str(payload.get("transport") or "tcp").strip().lower() + transport_idx = self.cmb_singbox_vless_transport.findData(transport_v) + self.cmb_singbox_vless_transport.setCurrentIndex(transport_idx if transport_idx >= 0 else 0) + + self.ent_singbox_vless_path.setText(str(payload.get("path") or "").strip()) + self.ent_singbox_vless_grpc_service.setText(str(payload.get("grpc_service") or "").strip()) + + sec_v = str(payload.get("security") or "none").strip().lower() + sec_idx = self.cmb_singbox_vless_security.findData(sec_v) + self.cmb_singbox_vless_security.setCurrentIndex(sec_idx if sec_idx >= 0 else 0) + + self.ent_singbox_vless_sni.setText(str(payload.get("sni") or "").strip()) + fp_v = str(payload.get("utls_fp") or "").strip().lower() + fp_idx = self.cmb_singbox_vless_utls_fp.findData(fp_v) + self.cmb_singbox_vless_utls_fp.setCurrentIndex(fp_idx if fp_idx >= 0 else 0) + self.ent_singbox_vless_reality_pk.setText(str(payload.get("reality_public_key") or "").strip()) + self.ent_singbox_vless_reality_sid.setText(str(payload.get("reality_short_id") or "").strip()) + self.chk_singbox_vless_insecure.setChecked(bool(payload.get("tls_insecure", False))) + self.chk_singbox_vless_sniff.setChecked(bool(payload.get("sniff", True))) + + ss_method = str(payload.get("ss_method") or "").strip().lower() + if ss_method: + idx = self.cmb_singbox_ss_method.findData(ss_method) + if idx >= 0: + self.cmb_singbox_ss_method.setCurrentIndex(idx) + else: + self.cmb_singbox_ss_method.setEditText(ss_method) + else: + self.cmb_singbox_ss_method.setCurrentIndex(0) + self.ent_singbox_ss_plugin.setText(str(payload.get("ss_plugin") or "").strip()) + + try: + self.spn_singbox_hy2_up_mbps.setValue(int(payload.get("hy2_up_mbps") or 0)) + except Exception: + self.spn_singbox_hy2_up_mbps.setValue(0) + try: + self.spn_singbox_hy2_down_mbps.setValue(int(payload.get("hy2_down_mbps") or 0)) + except Exception: + self.spn_singbox_hy2_down_mbps.setValue(0) + self.ent_singbox_hy2_obfs.setText(str(payload.get("hy2_obfs") or "").strip()) + self.ent_singbox_hy2_obfs_password.setText(str(payload.get("hy2_obfs_password") or "").strip()) + + tuic_cc = str(payload.get("tuic_congestion") or "").strip() + idx = self.cmb_singbox_tuic_congestion.findData(tuic_cc) + self.cmb_singbox_tuic_congestion.setCurrentIndex(idx if idx >= 0 else 0) + tuic_udp = str(payload.get("tuic_udp_mode") or "").strip() + idx = self.cmb_singbox_tuic_udp_mode.findData(tuic_udp) + self.cmb_singbox_tuic_udp_mode.setCurrentIndex(idx if idx >= 0 else 0) + self.chk_singbox_tuic_zero_rtt.setChecked(bool(payload.get("tuic_zero_rtt", False))) + + self.ent_singbox_wg_private_key.setText(str(payload.get("wg_private_key") or "").strip()) + self.ent_singbox_wg_peer_public_key.setText(str(payload.get("wg_peer_public_key") or "").strip()) + self.ent_singbox_wg_psk.setText(str(payload.get("wg_psk") or "").strip()) + wg_local = payload.get("wg_local_address") or [] + if isinstance(wg_local, list): + self.ent_singbox_wg_local_address.setText( + ",".join([str(x).strip() for x in wg_local if str(x).strip()]) + ) + else: + self.ent_singbox_wg_local_address.setText(str(wg_local or "").strip()) + wg_reserved = payload.get("wg_reserved") or [] + if isinstance(wg_reserved, list): + self.ent_singbox_wg_reserved.setText( + ",".join([str(x).strip() for x in wg_reserved if str(x).strip()]) + ) + else: + self.ent_singbox_wg_reserved.setText(str(wg_reserved or "").strip()) + try: + self.spn_singbox_wg_mtu.setValue(int(payload.get("wg_mtu") or 0)) + except Exception: + self.spn_singbox_wg_mtu.setValue(0) + finally: + self._singbox_editor_loading = False + self.on_singbox_vless_editor_changed() + + def _create_singbox_connection( + self, + *, + profile_name: str, + protocol: str = "vless", + raw_config: dict[str, Any] | None = None, + editor_values: dict[str, Any] | None = None, + auto_save: bool = False, + ) -> str: + name = str(profile_name or "").strip() or "SingBox connection" + client_id = self._next_free_transport_client_id(name) + proto = self._normalized_seed_protocol(protocol) + config = self._default_new_singbox_client_config(client_id, protocol=proto) + + created = self.ctrl.transport_client_create_action( + client_id=client_id, + kind="singbox", + name=name, + enabled=True, + config=config, + ) + line = (created.pretty_text or "").strip() or f"create {client_id}" + self._append_transport_log(f"[engine] {line}") + self.ctrl.log_gui(f"[transport-engine] {line}") + if not created.ok: + raise RuntimeError(line) + + self.refresh_transport_engines(silent=True) + if not self._select_transport_engine_by_id(client_id): + raise RuntimeError(f"created client '{client_id}' was not found after refresh") + + self._sync_selected_singbox_profile_link(silent=False) + client, _eid, pid = self._selected_singbox_profile_context() + + seed_raw = raw_config if isinstance(raw_config, dict) else self._seed_raw_config_for_protocol(proto) + saved_seed = self.ctrl.singbox_profile_save_raw_for_client( + client, + profile_id=pid, + name=name, + enabled=True, + protocol=proto, + raw_config=seed_raw, + ) + seed_line = (saved_seed.pretty_text or "").strip() or f"save profile {pid}" + self._append_transport_log(f"[profile] {seed_line}") + self.ctrl.log_gui(f"[singbox-profile] {seed_line}") + self._load_singbox_editor_for_selected(silent=True) + + if editor_values: + payload = dict(editor_values) + seeded = self._seed_editor_values_for_protocol(proto, profile_name=name) + seeded.update(payload) + payload = seeded + if not str(payload.get("profile_name") or "").strip(): + payload["profile_name"] = name + self._apply_singbox_editor_values(payload) + if auto_save: + saved = self._save_singbox_editor_draft(client, profile_id=pid) + save_line = (saved.pretty_text or "").strip() or f"save profile {pid}" + self._append_transport_log(f"[profile] {save_line}") + self.ctrl.log_gui(f"[singbox-profile] {save_line}") + return client_id + + def on_singbox_create_connection_click(self) -> None: + menu = QMenu(self) + act_clip = menu.addAction("Create from clipboard") + act_link = menu.addAction("Create from link...") + act_manual = menu.addAction("Create manual") + pos = self.btn_singbox_profile_create.mapToGlobal( + self.btn_singbox_profile_create.rect().bottomLeft() + ) + chosen = menu.exec(pos) + if chosen is None: + return + + if chosen == act_clip: + self._safe(self.on_singbox_create_connection_from_clipboard, title="Create connection error") + return + if chosen == act_link: + self._safe(self.on_singbox_create_connection_from_link, title="Create connection error") + return + if chosen == act_manual: + self._safe(self.on_singbox_create_connection_manual, title="Create connection error") + + def on_singbox_create_connection_from_clipboard(self) -> None: + raw = str(QApplication.clipboard().text() or "").strip() + if not raw: + raise RuntimeError("Clipboard is empty") + payload = self._parse_connection_link_payload(raw) + profile_name = str(payload.get("profile_name") or "").strip() or "SingBox Clipboard" + cid = self._create_singbox_connection( + profile_name=profile_name, + protocol=str(payload.get("protocol") or "vless"), + raw_config=payload.get("raw_config") if isinstance(payload.get("raw_config"), dict) else None, + editor_values=payload.get("editor_values") if isinstance(payload.get("editor_values"), dict) else None, + auto_save=True, + ) + self.on_singbox_profile_edit_dialog(cid) + + def on_singbox_create_connection_from_link(self) -> None: + raw, ok = QInputDialog.getText( + self, + "Create connection from link", + "Paste connection link (vless:// trojan:// ss:// hysteria2:// hy2:// tuic:// wireguard://):", + ) + if not ok: + return + payload = self._parse_connection_link_payload(raw) + profile_name = str(payload.get("profile_name") or "").strip() or "SingBox Link" + cid = self._create_singbox_connection( + profile_name=profile_name, + protocol=str(payload.get("protocol") or "vless"), + raw_config=payload.get("raw_config") if isinstance(payload.get("raw_config"), dict) else None, + editor_values=payload.get("editor_values") if isinstance(payload.get("editor_values"), dict) else None, + auto_save=True, + ) + self.on_singbox_profile_edit_dialog(cid) + + def on_singbox_create_connection_manual(self) -> None: + name, ok = QInputDialog.getText( + self, + "Create manual connection", + "Connection name:", + ) + if not ok: + return + profile_name = str(name or "").strip() or "SingBox Manual" + proto_title, ok = QInputDialog.getItem( + self, + "Create manual connection", + "Protocol:", + [label for label, _pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS], + 0, + False, + ) + if not ok: + return + proto_map = {label.lower(): pid for label, pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS} + proto = self._normalized_seed_protocol(proto_map.get(str(proto_title or "").strip().lower(), "vless")) + cid = self._create_singbox_connection( + profile_name=profile_name, + protocol=proto, + editor_values=self._seed_editor_values_for_protocol(proto, profile_name=profile_name), + auto_save=False, + ) + self.on_singbox_profile_edit_dialog(cid) diff --git a/selective-vpn-gui/main_window/singbox/links_helpers_mixin.py b/selective-vpn-gui/main_window/singbox/links_helpers_mixin.py new file mode 100644 index 0000000..a389a71 --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/links_helpers_mixin.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import base64 +import binascii +import json +import re +from urllib.parse import unquote +from typing import Any + +from main_window.constants import SINGBOX_EDITOR_PROTOCOL_IDS, SINGBOX_PROTOCOL_SEED_SPEC + + +class SingBoxLinksHelpersMixin: + def _slugify_connection_id(self, text: str) -> str: + raw = str(text or "").strip().lower() + raw = re.sub(r"[^a-z0-9]+", "-", raw) + raw = re.sub(r"-{2,}", "-", raw).strip("-") + if not raw: + raw = "connection" + if not raw.startswith("sg-"): + raw = f"sg-{raw}" + return raw + + def _next_free_transport_client_id(self, base_hint: str) -> str: + base = self._slugify_connection_id(base_hint) + existing = {str(getattr(c, "id", "") or "").strip() for c in (self._transport_clients or [])} + if base not in existing: + return base + i = 2 + while True: + cid = f"{base}-{i}" + if cid not in existing: + return cid + i += 1 + + def _template_singbox_client(self): + selected = self._selected_transport_client() + if selected is not None and str(getattr(selected, "kind", "") or "").strip().lower() == "singbox": + return selected + for c in self._transport_clients or []: + if str(getattr(c, "kind", "") or "").strip().lower() == "singbox": + return c + return None + + def _default_new_singbox_client_config(self, client_id: str, *, protocol: str = "vless") -> dict[str, Any]: + cfg: dict[str, Any] = {} + tpl = self._template_singbox_client() + if tpl is not None: + src_cfg = getattr(tpl, "config", {}) or {} + if isinstance(src_cfg, dict): + for key in ( + "runner", + "runtime_mode", + "require_binary", + "exec_start", + "singbox_bin", + "packaging_profile", + "packaging_system_fallback", + "bin_root", + "hardening_enabled", + "hardening_profile", + "restart", + "restart_sec", + "watchdog_sec", + "start_limit_interval_sec", + "start_limit_burst", + "timeout_start_sec", + "timeout_stop_sec", + "bootstrap_bypass_strict", + "netns_enabled", + "netns_name", + "netns_auto_cleanup", + "netns_setup_strict", + "singbox_dns_migrate_legacy", + "singbox_dns_migrate_strict", + ): + if key in src_cfg: + cfg[key] = json.loads(json.dumps(src_cfg.get(key))) + + cid = str(client_id or "").strip() + if not cid: + return cfg + + for key in ("profile", "profile_id", "singbox_profile_id"): + cfg.pop(key, None) + + config_path = f"/etc/selective-vpn/transports/{cid}/singbox.json" + cfg["config_path"] = config_path + cfg["singbox_config_path"] = config_path + + runner = str(cfg.get("runner") or "").strip().lower() + if not runner: + cfg["runner"] = "systemd" + runner = "systemd" + + if runner == "systemd": + cfg["unit"] = "singbox@.service" + + if "runtime_mode" not in cfg: + cfg["runtime_mode"] = "exec" + if "require_binary" not in cfg: + cfg["require_binary"] = True + + cfg["profile_id"] = cid + cfg["protocol"] = self._normalized_seed_protocol(protocol) + return cfg + + def _normalized_seed_protocol(self, protocol: str) -> str: + proto = str(protocol or "vless").strip().lower() or "vless" + if proto not in SINGBOX_EDITOR_PROTOCOL_IDS: + proto = "vless" + return proto + + def _protocol_seed_spec(self, protocol: str) -> dict[str, Any]: + proto = self._normalized_seed_protocol(protocol) + spec = SINGBOX_PROTOCOL_SEED_SPEC.get(proto) or SINGBOX_PROTOCOL_SEED_SPEC.get("vless") or {} + if not isinstance(spec, dict): + spec = {} + return dict(spec) + + def _seed_editor_values_for_protocol(self, protocol: str, *, profile_name: str = "") -> dict[str, Any]: + proto = self._normalized_seed_protocol(protocol) + spec = self._protocol_seed_spec(proto) + security = str(spec.get("security") or "none").strip().lower() or "none" + port = int(spec.get("port") or (51820 if proto == "wireguard" else 443)) + return { + "profile_name": str(profile_name or "").strip(), + "enabled": True, + "protocol": proto, + "server": "", + "port": port, + "uuid": "", + "password": "", + "flow": "", + "packet_encoding": "", + "transport": "tcp", + "path": "", + "grpc_service": "", + "security": security, + "sni": "", + "utls_fp": "", + "reality_public_key": "", + "reality_short_id": "", + "tls_insecure": False, + "sniff": True, + "ss_method": "aes-128-gcm", + "ss_plugin": "", + "hy2_up_mbps": 0, + "hy2_down_mbps": 0, + "hy2_obfs": "", + "hy2_obfs_password": "", + "tuic_congestion": "", + "tuic_udp_mode": "", + "tuic_zero_rtt": False, + "wg_private_key": "", + "wg_peer_public_key": "", + "wg_psk": "", + "wg_local_address": "", + "wg_reserved": "", + "wg_mtu": 0, + } + + def _seed_raw_config_for_protocol(self, protocol: str) -> dict[str, Any]: + proto = self._normalized_seed_protocol(protocol) + spec = self._protocol_seed_spec(proto) + port = int(spec.get("port") or (51820 if proto == "wireguard" else 443)) + proxy: dict[str, Any] = { + "type": proto, + "tag": "proxy", + "server": "", + "server_port": port, + } + proxy_defaults = spec.get("proxy_defaults") or {} + if isinstance(proxy_defaults, dict): + for key, value in proxy_defaults.items(): + proxy[key] = json.loads(json.dumps(value)) + + tls_security = str(spec.get("tls_security") or "").strip().lower() + if tls_security in ("tls", "reality"): + self._apply_proxy_tls(proxy, security=tls_security) + return self._build_singbox_raw_config_from_proxy(proxy, sniff=True) + + def _parse_wg_reserved_values(self, raw_values: list[str], *, strict: bool) -> list[int]: + vals = [str(x).strip() for x in list(raw_values or []) if str(x).strip()] + if len(vals) > 3: + if strict: + raise RuntimeError("WG reserved accepts up to 3 values (0..255)") + vals = vals[:3] + + out: list[int] = [] + for token in vals: + try: + num = int(token) + except Exception: + if strict: + raise RuntimeError(f"WG reserved value '{token}' is not an integer") + continue + if num < 0 or num > 255: + if strict: + raise RuntimeError(f"WG reserved value '{token}' must be in range 0..255") + continue + out.append(num) + return out + + def _query_value(self, query: dict[str, list[str]], *keys: str) -> str: + for k in keys: + vals = query.get(str(k or "").strip()) + if not vals: + continue + v = str(vals[0] or "").strip() + if v: + return unquote(v) + return "" + + def _query_bool(self, query: dict[str, list[str]], *keys: str) -> bool: + v = self._query_value(query, *keys).strip().lower() + return v in ("1", "true", "yes", "on") + + def _query_csv(self, query: dict[str, list[str]], *keys: str) -> list[str]: + raw = self._query_value(query, *keys) + if not raw: + return [] + out: list[str] = [] + for p in raw.split(","): + val = str(p or "").strip() + if val: + out.append(val) + return out + + def _normalize_link_transport(self, value: str) -> str: + v = str(value or "").strip().lower() or "tcp" + if v == "raw": + v = "tcp" + if v in ("h2", "http2"): + v = "http" + if v not in ("tcp", "ws", "grpc", "http", "httpupgrade", "quic"): + v = "tcp" + return v + + def _b64_urlsafe_decode(self, value: str) -> str: + raw = str(value or "").strip() + if not raw: + return "" + pad = "=" * ((4 - (len(raw) % 4)) % 4) + try: + data = base64.urlsafe_b64decode((raw + pad).encode("utf-8")) + return data.decode("utf-8", errors="replace") + except (binascii.Error, ValueError): + return "" + + def _apply_proxy_transport( + self, + proxy: dict[str, Any], + *, + transport: str, + path: str = "", + grpc_service: str = "", + ) -> None: + t = self._normalize_link_transport(transport) + if t in ("", "tcp"): + proxy.pop("transport", None) + return + tx: dict[str, Any] = {"type": t} + if t in ("ws", "http", "httpupgrade"): + tx["path"] = str(path or "/").strip() or "/" + if t == "grpc": + tx["service_name"] = str(grpc_service or "").strip() + proxy["transport"] = tx + + def _apply_proxy_tls( + self, + proxy: dict[str, Any], + *, + security: str, + sni: str = "", + utls_fp: str = "", + tls_insecure: bool = False, + reality_public_key: str = "", + reality_short_id: str = "", + alpn: list[str] | None = None, + ) -> None: + sec = str(security or "").strip().lower() + if sec not in ("none", "tls", "reality"): + sec = "none" + if sec == "none": + proxy.pop("tls", None) + return + tls: dict[str, Any] = { + "enabled": True, + "insecure": bool(tls_insecure), + } + if str(sni or "").strip(): + tls["server_name"] = str(sni).strip() + if str(utls_fp or "").strip(): + tls["utls"] = {"enabled": True, "fingerprint": str(utls_fp).strip().lower()} + alpn_vals = [str(x).strip() for x in list(alpn or []) if str(x).strip()] + if alpn_vals: + tls["alpn"] = alpn_vals + if sec == "reality": + reality: dict[str, Any] = { + "enabled": True, + "public_key": str(reality_public_key or "").strip(), + } + sid = str(reality_short_id or "").strip() + if sid: + reality["short_id"] = sid + tls["reality"] = reality + proxy["tls"] = tls + + def _build_singbox_raw_config_from_proxy( + self, + proxy: dict[str, Any], + *, + sniff: bool = True, + ) -> dict[str, Any]: + return { + "inbounds": [ + { + "type": "socks", + "tag": "socks-in", + "listen": "127.0.0.1", + "listen_port": 10808, + "sniff": bool(sniff), + "sniff_override_destination": bool(sniff), + } + ], + "outbounds": [ + proxy, + {"type": "direct", "tag": "direct"}, + ], + "route": { + "final": "direct", + "rules": [ + {"inbound": ["socks-in"], "outbound": "proxy"}, + ], + }, + } diff --git a/selective-vpn-gui/main_window/singbox/links_mixin.py b/selective-vpn-gui/main_window/singbox/links_mixin.py new file mode 100644 index 0000000..fec4d8f --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/links_mixin.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from main_window.singbox.links_actions_mixin import SingBoxLinksActionsMixin +from main_window.singbox.links_helpers_mixin import SingBoxLinksHelpersMixin +from main_window.singbox.links_parsers_mixin import SingBoxLinksParsersMixin + + +class SingBoxLinksMixin( + SingBoxLinksActionsMixin, + SingBoxLinksParsersMixin, + SingBoxLinksHelpersMixin, +): + """Facade mixin for SingBox link import/create workflow.""" + + +__all__ = ["SingBoxLinksMixin"] diff --git a/selective-vpn-gui/main_window/singbox/links_parsers_mixin.py b/selective-vpn-gui/main_window/singbox/links_parsers_mixin.py new file mode 100644 index 0000000..19c1c81 --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/links_parsers_mixin.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +import re +from urllib.parse import parse_qs, unquote, urlsplit +from typing import Any + + +class SingBoxLinksParsersMixin: + def _parse_vless_link_payload(self, link: str) -> dict[str, Any]: + u = urlsplit(link) + query = parse_qs(u.query or "", keep_blank_values=True) + + uuid = unquote(str(u.username or "").strip()) + host = str(u.hostname or "").strip() + if not uuid: + raise RuntimeError("VLESS link has no UUID") + if not host: + raise RuntimeError("VLESS link has no host") + try: + port = int(u.port or 443) + except Exception: + port = 443 + + transport = self._normalize_link_transport(self._query_value(query, "type", "transport")) + security = self._query_value(query, "security").strip().lower() or "none" + if security == "xtls": + security = "tls" + if security not in ("none", "tls", "reality"): + security = "none" + + path = self._query_value(query, "path", "spx") + if not path and str(u.path or "").strip() not in ("", "/"): + path = unquote(str(u.path or "").strip()) + grpc_service = self._query_value(query, "serviceName", "service_name") + if transport == "grpc" and not grpc_service: + grpc_service = self._query_value(query, "path") + + flow = self._query_value(query, "flow") + packet_encoding = self._query_value(query, "packetEncoding", "packet_encoding").strip().lower() + if packet_encoding in ("none", "off", "false"): + packet_encoding = "" + sni = self._query_value(query, "sni", "host") + utls_fp = self._query_value(query, "fp", "fingerprint") + reality_pk = self._query_value(query, "pbk", "public_key") + reality_sid = self._query_value(query, "sid", "short_id") + tls_insecure = self._query_bool(query, "allowInsecure", "insecure") + profile_name = unquote(str(u.fragment or "").strip()) or host + + proxy: dict[str, Any] = { + "type": "vless", + "tag": "proxy", + "server": host, + "server_port": port, + "uuid": uuid, + } + if packet_encoding: + proxy["packet_encoding"] = packet_encoding + if flow: + proxy["flow"] = flow + self._apply_proxy_transport(proxy, transport=transport, path=path, grpc_service=grpc_service) + self._apply_proxy_tls( + proxy, + security=security, + sni=sni, + utls_fp=utls_fp, + tls_insecure=tls_insecure, + reality_public_key=reality_pk, + reality_short_id=reality_sid, + ) + + editor_values = { + "profile_name": profile_name, + "enabled": True, + "server": host, + "port": port, + "uuid": uuid, + "flow": flow, + "packet_encoding": packet_encoding, + "transport": transport, + "path": path, + "grpc_service": grpc_service, + "security": security, + "sni": sni, + "utls_fp": utls_fp, + "reality_public_key": reality_pk, + "reality_short_id": reality_sid, + "tls_insecure": tls_insecure, + "sniff": True, + } + return { + "protocol": "vless", + "profile_name": profile_name, + "raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True), + "editor_values": editor_values, + } + + def _parse_trojan_link_payload(self, link: str) -> dict[str, Any]: + u = urlsplit(link) + query = parse_qs(u.query or "", keep_blank_values=True) + password = unquote(str(u.username or "").strip()) or self._query_value(query, "password") + host = str(u.hostname or "").strip() + if not password: + raise RuntimeError("Trojan link has no password") + if not host: + raise RuntimeError("Trojan link has no host") + try: + port = int(u.port or 443) + except Exception: + port = 443 + + transport = self._normalize_link_transport(self._query_value(query, "type", "transport")) + path = self._query_value(query, "path") + grpc_service = self._query_value(query, "serviceName", "service_name") + security = self._query_value(query, "security").strip().lower() or "tls" + if security not in ("none", "tls"): + security = "tls" + sni = self._query_value(query, "sni", "host") + utls_fp = self._query_value(query, "fp", "fingerprint") + tls_insecure = self._query_bool(query, "allowInsecure", "insecure") + alpn = self._query_csv(query, "alpn") + profile_name = unquote(str(u.fragment or "").strip()) or host + + proxy: dict[str, Any] = { + "type": "trojan", + "tag": "proxy", + "server": host, + "server_port": port, + "password": password, + } + self._apply_proxy_transport(proxy, transport=transport, path=path, grpc_service=grpc_service) + self._apply_proxy_tls( + proxy, + security=security, + sni=sni, + utls_fp=utls_fp, + tls_insecure=tls_insecure, + alpn=alpn, + ) + return { + "protocol": "trojan", + "profile_name": profile_name, + "raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True), + } + + def _parse_ss_link_payload(self, link: str) -> dict[str, Any]: + raw = str(link or "").strip() + u = urlsplit(raw) + query = parse_qs(u.query or "", keep_blank_values=True) + profile_name = unquote(str(u.fragment or "").strip()) or "Shadowsocks" + + body = raw[len("ss://"):] + body = body.split("#", 1)[0] + body = body.split("?", 1)[0] + method = "" + password = "" + host_port = "" + + if "@" in body: + left, host_port = body.rsplit("@", 1) + creds = left + if ":" not in creds: + creds = self._b64_urlsafe_decode(creds) + if ":" not in creds: + raise RuntimeError("Shadowsocks link has invalid credentials") + method, password = creds.split(":", 1) + else: + decoded = self._b64_urlsafe_decode(body) + if "@" not in decoded: + raise RuntimeError("Shadowsocks link has invalid payload") + creds, host_port = decoded.rsplit("@", 1) + if ":" not in creds: + raise RuntimeError("Shadowsocks link has invalid credentials") + method, password = creds.split(":", 1) + + hp = urlsplit("//" + host_port) + host = str(hp.hostname or "").strip() + if not host: + raise RuntimeError("Shadowsocks link has no host") + try: + port = int(hp.port or 8388) + except Exception: + port = 8388 + + proxy: dict[str, Any] = { + "type": "shadowsocks", + "tag": "proxy", + "server": host, + "server_port": port, + "method": str(method or "").strip(), + "password": str(password or "").strip(), + } + plugin = self._query_value(query, "plugin") + if plugin: + proxy["plugin"] = plugin + return { + "protocol": "shadowsocks", + "profile_name": profile_name, + "raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True), + } + + def _parse_hysteria2_link_payload(self, link: str) -> dict[str, Any]: + u = urlsplit(link) + query = parse_qs(u.query or "", keep_blank_values=True) + password = unquote(str(u.username or "").strip()) or self._query_value(query, "password") + host = str(u.hostname or "").strip() + if not password: + raise RuntimeError("Hysteria2 link has no password") + if not host: + raise RuntimeError("Hysteria2 link has no host") + try: + port = int(u.port or 443) + except Exception: + port = 443 + profile_name = unquote(str(u.fragment or "").strip()) or host + + proxy: dict[str, Any] = { + "type": "hysteria2", + "tag": "proxy", + "server": host, + "server_port": port, + "password": password, + } + up_mbps = self._query_value(query, "up_mbps", "upmbps", "up") + down_mbps = self._query_value(query, "down_mbps", "downmbps", "down") + try: + if up_mbps: + proxy["up_mbps"] = int(float(up_mbps)) + except Exception: + pass + try: + if down_mbps: + proxy["down_mbps"] = int(float(down_mbps)) + except Exception: + pass + + obfs_type = self._query_value(query, "obfs") + if obfs_type: + obfs: dict[str, Any] = {"type": obfs_type} + obfs_pw = self._query_value(query, "obfs-password", "obfs_password") + if obfs_pw: + obfs["password"] = obfs_pw + proxy["obfs"] = obfs + + self._apply_proxy_tls( + proxy, + security="tls", + sni=self._query_value(query, "sni"), + tls_insecure=self._query_bool(query, "allowInsecure", "insecure"), + alpn=self._query_csv(query, "alpn"), + ) + return { + "protocol": "hysteria2", + "profile_name": profile_name, + "raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True), + } + + def _parse_tuic_link_payload(self, link: str) -> dict[str, Any]: + u = urlsplit(link) + query = parse_qs(u.query or "", keep_blank_values=True) + uuid = unquote(str(u.username or "").strip()) + password = unquote(str(u.password or "").strip()) + host = str(u.hostname or "").strip() + if not uuid: + raise RuntimeError("TUIC link has no UUID") + if not password: + raise RuntimeError("TUIC link has no password") + if not host: + raise RuntimeError("TUIC link has no host") + try: + port = int(u.port or 443) + except Exception: + port = 443 + profile_name = unquote(str(u.fragment or "").strip()) or host + + proxy: dict[str, Any] = { + "type": "tuic", + "tag": "proxy", + "server": host, + "server_port": port, + "uuid": uuid, + "password": password, + } + cc = self._query_value(query, "congestion_control", "congestion") + if cc: + proxy["congestion_control"] = cc + udp_mode = self._query_value(query, "udp_relay_mode") + if udp_mode: + proxy["udp_relay_mode"] = udp_mode + if self._query_bool(query, "zero_rtt_handshake", "zero_rtt"): + proxy["zero_rtt_handshake"] = True + + self._apply_proxy_tls( + proxy, + security="tls", + sni=self._query_value(query, "sni", "host"), + utls_fp=self._query_value(query, "fp", "fingerprint"), + tls_insecure=self._query_bool(query, "allowInsecure", "insecure"), + alpn=self._query_csv(query, "alpn"), + ) + return { + "protocol": "tuic", + "profile_name": profile_name, + "raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True), + } + + def _parse_wireguard_link_payload(self, link: str) -> dict[str, Any]: + u = urlsplit(link) + query = parse_qs(u.query or "", keep_blank_values=True) + + private_key = unquote(str(u.username or "").strip()) or self._query_value(query, "private_key", "privateKey") + host = str(u.hostname or "").strip() + if not host: + raise RuntimeError("WireGuard link has no host") + if not private_key: + raise RuntimeError("WireGuard link has no private key") + try: + port = int(u.port or 443) + except Exception: + port = 443 + + peer_public_key = self._query_value(query, "peer_public_key", "public_key", "peerPublicKey") + if not peer_public_key: + raise RuntimeError("WireGuard link has no peer public key") + + local_address = self._query_csv(query, "local_address", "address", "localAddress") + if not local_address: + raise RuntimeError("WireGuard link has no local address") + + profile_name = unquote(str(u.fragment or "").strip()) or host + proxy: dict[str, Any] = { + "type": "wireguard", + "tag": "proxy", + "server": host, + "server_port": port, + "private_key": private_key, + "peer_public_key": peer_public_key, + "local_address": local_address, + } + psk = self._query_value(query, "pre_shared_key", "psk", "preSharedKey") + if psk: + proxy["pre_shared_key"] = psk + reserved_vals = self._parse_wg_reserved_values(self._query_csv(query, "reserved"), strict=True) + if reserved_vals: + proxy["reserved"] = reserved_vals + mtu_val = self._query_value(query, "mtu") + try: + mtu = int(mtu_val) if mtu_val else 0 + except Exception: + mtu = 0 + if mtu > 0: + proxy["mtu"] = mtu + + return { + "protocol": "wireguard", + "profile_name": profile_name, + "raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True), + } + + def _extract_first_connection_link(self, text: str) -> str: + raw = str(text or "").strip() + if not raw: + return "" + m = re.search(r"(?i)(vless|trojan|ss|hysteria2|hy2|tuic|wireguard|wg)://\S+", raw) + if m: + return str(m.group(0) or "").strip() + if "://" in raw: + return raw.splitlines()[0].strip() + return "" + + def _parse_connection_link_payload(self, text: str) -> dict[str, Any]: + raw = self._extract_first_connection_link(text) + if not raw: + raise RuntimeError( + "No supported link found. Supported schemes: " + "vless:// trojan:// ss:// hysteria2:// hy2:// tuic:// wireguard:// wg://" + ) + u = urlsplit(raw) + scheme = str(u.scheme or "").strip().lower() + if scheme == "vless": + return self._parse_vless_link_payload(raw) + if scheme == "trojan": + return self._parse_trojan_link_payload(raw) + if scheme == "ss": + return self._parse_ss_link_payload(raw) + if scheme in ("hysteria2", "hy2"): + return self._parse_hysteria2_link_payload(raw) + if scheme == "tuic": + return self._parse_tuic_link_payload(raw) + if scheme in ("wireguard", "wg"): + return self._parse_wireguard_link_payload(raw) + raise RuntimeError(f"Unsupported link scheme: {scheme}") diff --git a/selective-vpn-gui/main_window/singbox/runtime_cards_mixin.py b/selective-vpn-gui/main_window/singbox/runtime_cards_mixin.py new file mode 100644 index 0000000..580b722 --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/runtime_cards_mixin.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QMenu, QMessageBox + + +class SingBoxRuntimeCardsMixin: + def on_singbox_profile_card_context_menu(self, pos) -> None: + item = self.lst_singbox_profile_cards.itemAt(pos) + if item is None: + return + cid = str(item.data(Qt.UserRole) or "").strip() + if not cid: + return + + menu = QMenu(self) + act_run = menu.addAction("Run") + act_edit = menu.addAction("Edit") + act_delete = menu.addAction("Delete") + chosen = menu.exec(self.lst_singbox_profile_cards.viewport().mapToGlobal(pos)) + if chosen is None: + return + + if not self._select_transport_engine_by_id(cid): + QMessageBox.warning(self, "SingBox profile", f"Profile '{cid}' is no longer available.") + return + + if chosen == act_run: + self.on_transport_engine_action("start") + return + if chosen == act_edit: + self.on_singbox_profile_edit_dialog(cid) + return + if chosen == act_delete: + self.on_transport_engine_delete(cid) + return + + def on_singbox_profile_card_selected(self) -> None: + if self._syncing_singbox_selection: + return + items = self.lst_singbox_profile_cards.selectedItems() + if not items: + return + cid = str(items[0].data(Qt.UserRole) or "").strip() + if not cid: + return + idx = self.cmb_transport_engine.findData(cid) + if idx < 0: + return + if idx != self.cmb_transport_engine.currentIndex(): + self._syncing_singbox_selection = True + try: + self.cmb_transport_engine.setCurrentIndex(idx) + finally: + self._syncing_singbox_selection = False + return + self._refresh_singbox_profile_card_styles() + self._sync_selected_singbox_profile_link(silent=True) + self._load_singbox_editor_for_selected(silent=True) + self._update_transport_engine_view() + + def _singbox_value_label(self, key: str, value: str) -> str: + v = str(value or "").strip().lower() + if key == "routing": + if v == "full": + return "Full tunnel" + return "Selective" + if key == "dns": + if v == "singbox_dns": + return "SingBox DNS" + return "System resolver" + if key == "killswitch": + if v == "off": + return "Disabled" + return "Enabled" + return v or "—" + + def _effective_singbox_policy(self) -> tuple[str, str, str]: + route = str(self.cmb_singbox_global_routing.currentData() or "selective").strip().lower() + dns = str(self.cmb_singbox_global_dns.currentData() or "system_resolver").strip().lower() + killswitch = str(self.cmb_singbox_global_killswitch.currentData() or "on").strip().lower() + + if not self.chk_singbox_profile_use_global_routing.isChecked(): + route = str(self.cmb_singbox_profile_routing.currentData() or route).strip().lower() + if route == "global": + route = str(self.cmb_singbox_global_routing.currentData() or "selective").strip().lower() + if not self.chk_singbox_profile_use_global_dns.isChecked(): + dns = str(self.cmb_singbox_profile_dns.currentData() or dns).strip().lower() + if dns == "global": + dns = str(self.cmb_singbox_global_dns.currentData() or "system_resolver").strip().lower() + if not self.chk_singbox_profile_use_global_killswitch.isChecked(): + killswitch = str(self.cmb_singbox_profile_killswitch.currentData() or killswitch).strip().lower() + if killswitch == "global": + killswitch = str(self.cmb_singbox_global_killswitch.currentData() or "on").strip().lower() + return route, dns, killswitch + + def _refresh_singbox_profile_effective(self) -> None: + route, dns, killswitch = self._effective_singbox_policy() + route_txt = self._singbox_value_label("routing", route) + dns_txt = self._singbox_value_label("dns", dns) + kill_txt = self._singbox_value_label("killswitch", killswitch) + self.lbl_singbox_profile_effective.setText( + f"Effective: routing={route_txt} | dns={dns_txt} | kill-switch={kill_txt}" + ) + self.lbl_singbox_profile_effective.setStyleSheet("color: gray;") + + def _apply_singbox_profile_controls(self) -> None: + self.cmb_singbox_profile_routing.setEnabled( + not self.chk_singbox_profile_use_global_routing.isChecked() + ) + self.cmb_singbox_profile_dns.setEnabled( + not self.chk_singbox_profile_use_global_dns.isChecked() + ) + self.cmb_singbox_profile_killswitch.setEnabled( + not self.chk_singbox_profile_use_global_killswitch.isChecked() + ) + self._refresh_singbox_profile_effective() + + def _apply_singbox_compact_visibility(self) -> None: + show_profile = bool(self.btn_singbox_toggle_profile_settings.isChecked()) + self.grp_singbox_profile_settings.setVisible(show_profile) + self.btn_singbox_toggle_profile_settings.setText( + "Hide profile settings" if show_profile else "Profile settings" + ) + + show_global = bool(self.btn_singbox_toggle_global_defaults.isChecked()) + self.grp_singbox_global_defaults.setVisible(show_global) + self.btn_singbox_toggle_global_defaults.setText( + "Hide global defaults" if show_global else "Global defaults" + ) + + show_log = bool(self.btn_singbox_toggle_activity.isChecked()) + self.grp_singbox_activity.setVisible(show_log) + self.btn_singbox_toggle_activity.setText( + "Hide activity log" if show_log else "Activity log" + ) diff --git a/selective-vpn-gui/main_window/singbox/runtime_mixin.py b/selective-vpn-gui/main_window/singbox/runtime_mixin.py new file mode 100644 index 0000000..afc2cc0 --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/runtime_mixin.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from main_window.singbox.runtime_cards_mixin import SingBoxRuntimeCardsMixin +from main_window.singbox.runtime_profiles_mixin import SingBoxRuntimeProfilesMixin +from main_window.singbox.runtime_transport_mixin import SingBoxRuntimeTransportMixin + + +class SingBoxRuntimeMixin( + SingBoxRuntimeProfilesMixin, + SingBoxRuntimeTransportMixin, + SingBoxRuntimeCardsMixin, +): + """Facade mixin for SingBox runtime/profile actions.""" + + +__all__ = ["SingBoxRuntimeMixin"] diff --git a/selective-vpn-gui/main_window/singbox/runtime_profiles_mixin.py b/selective-vpn-gui/main_window/singbox/runtime_profiles_mixin.py new file mode 100644 index 0000000..75c37cc --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/runtime_profiles_mixin.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +from typing import Literal + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QApplication, + QDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QVBoxLayout, +) + + +class SingBoxRuntimeProfilesMixin: + def on_singbox_profile_edit_dialog(self, cid: str = "") -> None: + def work(): + target = str(cid or "").strip() or self._selected_transport_engine_id() + if not target: + raise RuntimeError("Select a transport engine first") + if not self._select_transport_engine_by_id(target): + raise RuntimeError(f"Transport engine '{target}' not found") + + self._sync_selected_singbox_profile_link(silent=True) + self._load_singbox_editor_for_selected(silent=False) + client = self._selected_transport_client() + pid = self._selected_singbox_profile_id() + if client is None or not pid: + raise RuntimeError("Select a SingBox profile first") + + profile_name = self.ent_singbox_proto_name.text().strip() or str(getattr(client, "name", "") or pid).strip() + host_layout = self.grp_singbox_profile_settings.layout() + if host_layout is None: + raise RuntimeError("internal layout is unavailable") + editor = self.grp_singbox_proto_editor + insert_at = host_layout.indexOf(editor) + if insert_at >= 0: + host_layout.removeWidget(editor) + + moved = False + dlg = QDialog(self) + dlg.setModal(True) + dlg.setWindowTitle(f"Edit SingBox profile: {profile_name}") + dlg.resize(860, 680) + dlg_layout = QVBoxLayout(dlg) + try: + hint = QLabel("Edit protocol fields and save draft. Use profile card menu for Run/Delete.") + hint.setStyleSheet("color: gray;") + dlg_layout.addWidget(hint) + + editor.setTitle(f"{self._singbox_editor_default_title} · {profile_name}") + editor.setParent(dlg) + editor.setVisible(True) + moved = True + dlg_layout.addWidget(editor, stretch=1) + + actions = QHBoxLayout() + btn_save = QPushButton("Save draft") + btn_close = QPushButton("Close") + actions.addWidget(btn_save) + actions.addStretch(1) + actions.addWidget(btn_close) + dlg_layout.addLayout(actions) + + def save_draft_clicked() -> None: + try: + selected_client, _eid, selected_pid = self._selected_singbox_profile_context() + saved = self._save_singbox_editor_draft(selected_client, profile_id=selected_pid) + line = (saved.pretty_text or "").strip() or f"save profile {selected_pid}" + self._append_transport_log(f"[profile] {line}") + self.ctrl.log_gui(f"[singbox-profile] {line}") + self.lbl_transport_engine_meta.setText(f"Engine: profile {selected_pid} draft saved") + self.lbl_transport_engine_meta.setStyleSheet("color: green;") + self._render_singbox_profile_cards() + self._sync_singbox_profile_card_selection(self._selected_transport_engine_id()) + QMessageBox.information(dlg, "SingBox profile", line) + except Exception as e: + QMessageBox.critical(dlg, "SingBox profile save error", str(e)) + + btn_save.clicked.connect(save_draft_clicked) + btn_close.clicked.connect(dlg.accept) + dlg.exec() + finally: + if moved: + dlg_layout.removeWidget(editor) + editor.setParent(self.grp_singbox_profile_settings) + editor.setTitle(self._singbox_editor_default_title) + if insert_at >= 0: + host_layout.insertWidget(insert_at, editor) + else: + host_layout.addWidget(editor) + editor.setVisible(False) + + self._safe(work, title="SingBox profile edit error") + + def on_transport_engine_action( + self, + action: Literal["provision", "start", "stop", "restart"], + ) -> None: + def work(): + cid = self._selected_transport_engine_id() + if not cid: + raise RuntimeError("Select a transport engine first") + + self.lbl_transport_engine_meta.setText(f"Engine: {action} {cid}...") + self.lbl_transport_engine_meta.setStyleSheet("color: orange;") + QApplication.processEvents() + + if action == "start": + selected_client = self._selected_transport_client() + if selected_client is not None and str(getattr(selected_client, "kind", "") or "").strip().lower() == "singbox": + _client, _eid, pid = self._selected_singbox_profile_context() + self.lbl_transport_engine_meta.setText(f"Engine: preparing profile {pid} for start...") + self.lbl_transport_engine_meta.setStyleSheet("color: orange;") + QApplication.processEvents() + pre = self.ctrl.singbox_profile_apply_action( + pid, + client_id=cid, + restart=False, + skip_runtime=True, + check_binary=True, + client=selected_client, + ) + pre_line = (pre.pretty_text or "").strip() or f"apply profile {pid}" + self._append_transport_log(f"[profile] {pre_line}") + self.ctrl.log_gui(f"[singbox-profile] {pre_line}") + if not pre.ok: + raise RuntimeError(f"profile preflight failed: {pre_line}") + + ok, msg = self._apply_transport_switch_policy(cid) + self._append_transport_log(f"[switch] {msg}") + self.ctrl.log_gui(f"[transport-switch] {msg}") + if not ok: + if "canceled by user" in msg.lower(): + self.refresh_transport_engines(silent=True) + return + raise RuntimeError(msg) + + res = self.ctrl.transport_client_action(cid, action if action != "start" else "start") + line = (res.pretty_text or "").strip() or f"{action} {cid}" + self._append_transport_log(f"[engine] {line}") + self.ctrl.log_gui(f"[transport-engine] {line}") + if not res.ok: + raise RuntimeError(line) + + self.refresh_transport_engines(silent=True) + self.refresh_status_tab() + + self._safe(work, title="Transport engine error") + + def on_transport_engine_delete(self, cid: str = "") -> None: + def work(): + target = str(cid or "").strip() or self._selected_transport_engine_id() + if not target: + raise RuntimeError("Select a transport engine first") + if not self._select_transport_engine_by_id(target): + raise RuntimeError(f"Transport engine '{target}' not found") + + ans = QMessageBox.question( + self, + "Delete transport profile", + ( + f"Delete profile '{target}'?\n\n" + "The client configuration and related runtime artifacts will be removed." + ), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if ans != QMessageBox.Yes: + return + + self.lbl_transport_engine_meta.setText(f"Engine: deleting {target}...") + self.lbl_transport_engine_meta.setStyleSheet("color: orange;") + QApplication.processEvents() + + res = self.ctrl.transport_client_delete_action(target, force=False, cleanup=True) + if not res.ok and "force=true" in (res.pretty_text or "").lower(): + force_ans = QMessageBox.question( + self, + "Profile is referenced", + ( + "This profile is referenced by current transport policy.\n" + "Force delete anyway?" + ), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if force_ans == QMessageBox.Yes: + res = self.ctrl.transport_client_delete_action(target, force=True, cleanup=True) + else: + self._append_transport_log(f"[engine] delete {target}: canceled by user") + self.ctrl.log_gui(f"[transport-engine] delete {target}: canceled by user") + return + + line = (res.pretty_text or "").strip() or f"delete {target}" + self._append_transport_log(f"[engine] {line}") + self.ctrl.log_gui(f"[transport-engine] {line}") + if not res.ok: + raise RuntimeError(line) + + self.refresh_transport_engines(silent=True) + self.refresh_status_tab() + + self._safe(work, title="Transport engine delete error") + + def on_transport_policy_rollback(self) -> None: + def work(): + self.lbl_transport_engine_meta.setText("Engine: rollback policy...") + self.lbl_transport_engine_meta.setStyleSheet("color: orange;") + QApplication.processEvents() + + res = self.ctrl.transport_policy_rollback_action() + line = (res.pretty_text or "").strip() or "policy rollback" + self._append_transport_log(f"[switch] {line}") + self.ctrl.log_gui(f"[transport-switch] {line}") + if not res.ok: + raise RuntimeError(line) + + self.refresh_transport_engines(silent=True) + self.refresh_status_tab() + + self._safe(work, title="Transport rollback error") + + def on_toggle_singbox_profile_settings(self, checked: bool = False) -> None: + if checked and self.btn_singbox_toggle_global_defaults.isChecked(): + self.btn_singbox_toggle_global_defaults.setChecked(False) + self._apply_singbox_compact_visibility() + self._save_ui_preferences() + + def on_toggle_singbox_global_defaults(self, checked: bool = False) -> None: + if checked and self.btn_singbox_toggle_profile_settings.isChecked(): + self.btn_singbox_toggle_profile_settings.setChecked(False) + self._apply_singbox_compact_visibility() + self._save_ui_preferences() + + def on_toggle_singbox_activity(self, _checked: bool = False) -> None: + self._apply_singbox_compact_visibility() + self._save_ui_preferences() + + def on_singbox_profile_scope_changed(self, _state: int = 0) -> None: + self._apply_singbox_profile_controls() + self._save_ui_preferences() + self._update_transport_engine_view() + + def on_singbox_global_defaults_changed(self, _index: int = 0) -> None: + self._refresh_singbox_profile_effective() + self._save_ui_preferences() + self._update_transport_engine_view() + + def on_singbox_global_save(self) -> None: + def work(): + self._save_ui_preferences() + route, dns, killswitch = self._effective_singbox_policy() + msg = ( + "Global defaults saved: " + f"routing={self._singbox_value_label('routing', route)}, " + f"dns={self._singbox_value_label('dns', dns)}, " + f"kill-switch={self._singbox_value_label('killswitch', killswitch)}" + ) + self._append_transport_log(f"[profile] {msg}") + self.ctrl.log_gui(f"[singbox-settings] {msg}") + self._safe(work, title="SingBox settings error") + + def on_singbox_profile_save(self) -> None: + def work(): + client, eid, pid = self._selected_singbox_profile_context() + self._save_ui_preferences() + self.lbl_transport_engine_meta.setText(f"Engine: saving draft for {pid}...") + self.lbl_transport_engine_meta.setStyleSheet("color: orange;") + QApplication.processEvents() + + saved = self._save_singbox_editor_draft(client, profile_id=pid) + save_line = (saved.pretty_text or "").strip() or f"save profile {pid}" + self._append_transport_log(f"[profile] {save_line}") + self.ctrl.log_gui(f"[singbox-profile] {save_line}") + + route, dns, killswitch = self._effective_singbox_policy() + msg = ( + f"profile settings saved for {eid}: " + f"routing={self._singbox_value_label('routing', route)}, " + f"dns={self._singbox_value_label('dns', dns)}, " + f"kill-switch={self._singbox_value_label('killswitch', killswitch)}" + ) + self._append_transport_log(f"[profile] {msg}") + self.ctrl.log_gui(f"[singbox-profile] {msg}") + self.lbl_transport_engine_meta.setText(f"Engine: profile {pid} draft saved") + self.lbl_transport_engine_meta.setStyleSheet("color: green;") + self.refresh_transport_engines(silent=True) + self._safe(work, title="SingBox profile save error") + + def _selected_singbox_profile_context(self): + client = self._selected_transport_client() + eid = self._selected_transport_engine_id() + pid = self._selected_singbox_profile_id() + if not eid or client is None: + raise RuntimeError("Select a transport engine first") + if not pid: + raise RuntimeError("Select a SingBox profile first") + return client, eid, pid + + def _run_singbox_profile_action( + self, + *, + verb: str, + runner, + refresh_status: bool = False, + sync_draft: bool = False, + ) -> None: + client, eid, pid = self._selected_singbox_profile_context() + if sync_draft: + self.lbl_transport_engine_meta.setText(f"Engine: syncing draft for {pid}...") + self.lbl_transport_engine_meta.setStyleSheet("color: orange;") + QApplication.processEvents() + saved = self._save_singbox_editor_draft(client, profile_id=pid) + save_line = (saved.pretty_text or "").strip() or f"save profile {pid}" + self._append_transport_log(f"[profile] {save_line}") + self.ctrl.log_gui(f"[singbox-profile] {save_line}") + + self.lbl_transport_engine_meta.setText(f"Engine: {verb} profile {pid}...") + self.lbl_transport_engine_meta.setStyleSheet("color: orange;") + QApplication.processEvents() + + res = runner(client, eid, pid) + line = (res.pretty_text or "").strip() or f"{verb} profile {pid}" + self._append_transport_log(f"[profile] {line}") + self.ctrl.log_gui(f"[singbox-profile] {line}") + + if res.ok: + self.lbl_transport_engine_meta.setText(f"Engine: profile {pid} {verb} done") + self.lbl_transport_engine_meta.setStyleSheet("color: green;") + else: + self.lbl_transport_engine_meta.setText(f"Engine: profile {pid} {verb} failed") + self.lbl_transport_engine_meta.setStyleSheet("color: orange;") + + self.refresh_transport_engines(silent=True) + if refresh_status: + self.refresh_status_tab() + + def on_singbox_profile_preview(self) -> None: + self._safe( + lambda: self._run_singbox_profile_action( + verb="previewing", + runner=lambda client, _eid, pid: self.ctrl.singbox_profile_render_preview_action( + pid, + check_binary=None, + persist=False, + client=client, + ), + refresh_status=False, + sync_draft=True, + ), + title="SingBox profile preview error", + ) + + def on_singbox_profile_validate(self) -> None: + self._safe( + lambda: self._run_singbox_profile_action( + verb="validating", + runner=lambda client, _eid, pid: self.ctrl.singbox_profile_validate_action( + pid, + client=client, + ), + refresh_status=False, + sync_draft=True, + ), + title="SingBox profile validate error", + ) + + def on_singbox_profile_apply(self) -> None: + self._safe( + lambda: self._run_singbox_profile_action( + verb="applying", + runner=lambda client, eid, pid: self.ctrl.singbox_profile_apply_action( + pid, + client_id=eid, + restart=True, + skip_runtime=False, + client=client, + ), + refresh_status=True, + sync_draft=True, + ), + title="SingBox profile apply error", + ) + + def on_singbox_profile_rollback(self) -> None: + self._safe( + lambda: self._run_singbox_profile_action( + verb="rolling back", + runner=lambda client, eid, pid: self.ctrl.singbox_profile_rollback_action( + pid, + client_id=eid, + restart=True, + skip_runtime=False, + client=client, + ), + refresh_status=True, + ), + title="SingBox profile rollback error", + ) + + def on_singbox_profile_history(self) -> None: + def work(): + client, _eid, pid = self._selected_singbox_profile_context() + self.lbl_transport_engine_meta.setText(f"Engine: loading history for {pid}...") + self.lbl_transport_engine_meta.setStyleSheet("color: orange;") + QApplication.processEvents() + + lines = self.ctrl.singbox_profile_history_lines(pid, limit=20, client=client) + if not lines: + line = f"history profile {pid}: no entries" + self._append_transport_log(f"[history] {line}") + self.ctrl.log_gui(f"[singbox-profile] {line}") + self.lbl_transport_engine_meta.setText(f"Engine: history {pid} is empty") + self.lbl_transport_engine_meta.setStyleSheet("color: gray;") + return + + header = f"history profile {pid}: {len(lines)} entries" + self._append_transport_log(f"[history] {header}") + self.ctrl.log_gui(f"[singbox-profile] {header}") + for ln in lines: + self._append_transport_log(f"[history] {ln}") + self.lbl_transport_engine_meta.setText(f"Engine: history loaded for {pid}") + self.lbl_transport_engine_meta.setStyleSheet("color: green;") + + self._safe(work, title="SingBox profile history error") diff --git a/selective-vpn-gui/main_window/singbox/runtime_transport_mixin.py b/selective-vpn-gui/main_window/singbox/runtime_transport_mixin.py new file mode 100644 index 0000000..6704860 --- /dev/null +++ b/selective-vpn-gui/main_window/singbox/runtime_transport_mixin.py @@ -0,0 +1,1457 @@ +from __future__ import annotations + +import ipaddress +from types import SimpleNamespace +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication, QMessageBox, QTableWidgetItem + +from api_client import ApiError, TransportPolicyIntent +from netns_debug import apply_singbox_netns_toggle + + +class SingBoxRuntimeTransportMixin: + def _update_transport_engine_view(self) -> None: + selected = self._selected_transport_client() + supported = bool(self._transport_api_supported) + has_selected = selected is not None + policy_supported = bool(supported) + multiif_refresh_supported = bool(supported) + + self.btn_singbox_profile_create.setEnabled(supported) + self.btn_transport_engine_provision.setEnabled(supported and has_selected) + self.btn_transport_engine_toggle.setEnabled(supported and has_selected) + self.btn_transport_engine_restart.setEnabled(supported and has_selected) + self.btn_transport_engine_rollback.setEnabled(supported) + self.btn_transport_netns_toggle.setEnabled(supported and bool(self._transport_clients)) + self.btn_singbox_profile_preview.setEnabled(supported and has_selected) + self.btn_singbox_profile_validate.setEnabled(supported and has_selected) + self.btn_singbox_profile_apply.setEnabled(supported and has_selected) + self.btn_singbox_profile_rollback.setEnabled(supported and has_selected) + self.btn_singbox_profile_history.setEnabled(supported and has_selected) + self.btn_singbox_profile_save.setEnabled(supported and has_selected) + self.btn_singbox_owner_locks_refresh.setEnabled(multiif_refresh_supported) + self.btn_singbox_owner_locks_clear.setEnabled(policy_supported) + self.btn_singbox_policy_add.setEnabled(policy_supported) + self.btn_singbox_policy_load_selected.setEnabled(policy_supported) + self.btn_singbox_policy_update_selected.setEnabled(policy_supported) + self.btn_singbox_policy_use_template.setEnabled(policy_supported) + self.btn_singbox_policy_add_demo.setEnabled(policy_supported) + self.btn_singbox_policy_remove.setEnabled(policy_supported) + self.btn_singbox_policy_reload.setEnabled(policy_supported) + self.btn_singbox_policy_validate.setEnabled(policy_supported) + self.btn_singbox_policy_apply.setEnabled(policy_supported) + self.btn_singbox_policy_rollback.setEnabled(policy_supported) + self.cmb_singbox_policy_selector_type.setEnabled(policy_supported) + self.cmb_singbox_policy_template.setEnabled(policy_supported) + self.ent_singbox_policy_selector_value.setEnabled(policy_supported) + self.cmb_singbox_policy_client_id.setEnabled(policy_supported) + self.cmb_singbox_policy_mode.setEnabled(policy_supported) + self.spn_singbox_policy_priority.setEnabled(policy_supported) + if hasattr(self, "ent_singbox_owner_lock_client"): + self.ent_singbox_owner_lock_client.setEnabled(policy_supported) + if hasattr(self, "ent_singbox_owner_lock_destination"): + self.ent_singbox_owner_lock_destination.setEnabled(policy_supported) + if hasattr(self, "cmb_singbox_owner_engine_scope"): + self.cmb_singbox_owner_engine_scope.setEnabled(True) + self._refresh_transport_netns_toggle_button() + + if not supported: + self._clear_singbox_editor() + self._set_singbox_editor_enabled(False) + self.lbl_transport_selected_engine.setText("Selected profile: API unavailable") + self.lbl_transport_selected_engine.setStyleSheet("color: orange;") + self.btn_transport_engine_toggle.blockSignals(True) + self.btn_transport_engine_toggle.setChecked(False) + self.btn_transport_engine_toggle.blockSignals(False) + self.btn_transport_engine_toggle.setText("Disconnected") + self.btn_transport_engine_toggle.setStyleSheet("color: gray;") + self.lbl_transport_engine_meta.setText("Engine: transport API is unavailable on current backend") + self.lbl_transport_engine_meta.setStyleSheet("color: orange;") + self.lbl_singbox_profile_name.setText("Profile: —") + self.lbl_singbox_profile_name.setStyleSheet("color: gray;") + self.lbl_singbox_metric_conn_value.setText("API unavailable") + self.lbl_singbox_metric_conn_sub.setText("Transport endpoints are not exposed") + self.lbl_singbox_metric_profile_value.setText("—") + self.lbl_singbox_metric_profile_sub.setText("—") + self.lbl_singbox_metric_proto_value.setText("—") + self.lbl_singbox_metric_proto_sub.setText("—") + self.lbl_singbox_metric_policy_value.setText("—") + self.lbl_singbox_metric_policy_sub.setText("—") + return + + if selected is None: + self._clear_singbox_editor() + self._set_singbox_editor_enabled(False) + self.lbl_transport_selected_engine.setText("Selected profile: —") + self.lbl_transport_selected_engine.setStyleSheet("color: gray;") + self.btn_transport_engine_toggle.blockSignals(True) + self.btn_transport_engine_toggle.setChecked(False) + self.btn_transport_engine_toggle.blockSignals(False) + self.btn_transport_engine_toggle.setText("Disconnected") + self.btn_transport_engine_toggle.setStyleSheet("color: gray;") + self.lbl_transport_engine_meta.setText("Engine: no SingBox clients configured") + self.lbl_transport_engine_meta.setStyleSheet("color: gray;") + self.lbl_singbox_profile_name.setText("Profile: —") + self.lbl_singbox_profile_name.setStyleSheet("color: gray;") + self.lbl_singbox_metric_conn_value.setText("Not selected") + self.lbl_singbox_metric_conn_sub.setText("Choose profile card below") + self.lbl_singbox_metric_profile_value.setText("—") + self.lbl_singbox_metric_profile_sub.setText("—") + self.lbl_singbox_metric_proto_value.setText("—") + self.lbl_singbox_metric_proto_sub.setText("—") + self.lbl_singbox_metric_policy_value.setText("—") + self.lbl_singbox_metric_policy_sub.setText("—") + return + + status, latency, last_error, last_check = self._transport_live_health_for_client(selected) + kind = str(getattr(selected, "kind", "") or "").strip().lower() or "engine" + cid = str(getattr(selected, "id", "") or "").strip() + iface = str(getattr(selected, "iface", "") or "").strip() + table = str(getattr(selected, "routing_table", "") or "").strip() + egress_item = self._refresh_egress_identity_scope( + f"transport:{cid}", + trigger_refresh=True, + silent=True, + ) + egress_short = self._format_egress_identity_short(egress_item) + + if status == "up": + color = "green" + elif status in ("degraded", "unknown"): + color = "orange" + else: + color = "gray" + + parts = [f"{kind} ({cid})", f"status={status}"] + if iface: + parts.append(f"iface={iface}") + if table: + parts.append(f"table={table}") + if latency > 0: + parts.append(f"latency={latency}ms") + if last_error: + short_err = last_error if len(last_error) <= 180 else last_error[:177] + "..." + parts.append(f"error={short_err}") + self.lbl_transport_engine_meta.setText("Engine: " + " | ".join(parts)) + self.lbl_transport_engine_meta.setStyleSheet(f"color: {color};") + + protocol_txt = self._singbox_client_protocol_summary(selected) + + route, dns, killswitch = self._effective_singbox_policy() + profile_title = str(getattr(selected, "name", "") or "").strip() or cid + self.lbl_transport_selected_engine.setText( + f"Selected profile: {profile_title} ({cid})" + ) + self.lbl_transport_selected_engine.setStyleSheet("color: black;") + self.lbl_singbox_profile_name.setText(f"Profile: {profile_title} ({cid})") + self.lbl_singbox_profile_name.setStyleSheet("color: black;") + + self.btn_transport_engine_toggle.blockSignals(True) + if status == "up": + self.btn_transport_engine_toggle.setChecked(True) + self.btn_transport_engine_toggle.setText("Connected") + self.btn_transport_engine_toggle.setStyleSheet("color: #1f6b2f;") + self.btn_transport_engine_toggle.setToolTip("Connected. Click to disconnect.") + elif status == "starting": + self.btn_transport_engine_toggle.setChecked(False) + self.btn_transport_engine_toggle.setText("Connecting...") + self.btn_transport_engine_toggle.setStyleSheet("color: orange;") + self.btn_transport_engine_toggle.setToolTip("Engine is starting. Please wait.") + else: + self.btn_transport_engine_toggle.setChecked(False) + self.btn_transport_engine_toggle.setText("Disconnected") + self.btn_transport_engine_toggle.setStyleSheet("color: gray;") + self.btn_transport_engine_toggle.setToolTip("Disconnected. Click to connect.") + self.btn_transport_engine_toggle.blockSignals(False) + + self.lbl_singbox_metric_conn_value.setText(status.upper()) + conn_sub = "No latency sample" + if last_error: + short = last_error if len(last_error) <= 72 else last_error[:69] + "..." + conn_sub = short + elif latency > 0: + conn_sub = f"Latency {latency}ms" + if egress_short: + conn_sub = f"{conn_sub} · {egress_short}" + self.lbl_singbox_metric_conn_sub.setText(conn_sub) + + updated_at = str(getattr(selected, "updated_at", "") or "").strip() + stamp = last_check or updated_at + self.lbl_singbox_metric_profile_value.setText(profile_title) + if stamp: + self.lbl_singbox_metric_profile_sub.setText(f"{cid} · updated {stamp}") + else: + self.lbl_singbox_metric_profile_sub.setText(cid) + + self.lbl_singbox_metric_proto_value.setText(protocol_txt) + tunnel_bits = [] + if iface: + tunnel_bits.append(f"iface={iface}") + if table: + tunnel_bits.append(f"table={table}") + self.lbl_singbox_metric_proto_sub.setText(" | ".join(tunnel_bits) if tunnel_bits else "iface/table n/a") + + self.lbl_singbox_metric_policy_value.setText( + f"{self._singbox_value_label('routing', route)} + {self._singbox_value_label('dns', dns)}" + ) + self.lbl_singbox_metric_policy_sub.setText( + f"Kill-switch: {self._singbox_value_label('killswitch', killswitch)}" + ) + + def _sort_transport_clients(self, clients) -> list: + return sorted( + list(clients or []), + key=lambda x: ( + str(getattr(x, "kind", "") or "").lower(), + str(getattr(x, "name", "") or getattr(x, "id", "")).lower(), + str(getattr(x, "id", "") or "").lower(), + ), + ) + + def _refresh_transport_policy_clients_cache(self) -> None: + fallback = self._sort_transport_clients(self._transport_clients) + try: + clients = self.ctrl.transport_clients( + enabled_only=False, + kind="", + include_virtual=True, + ) + self._transport_policy_clients = self._sort_transport_clients(clients) + except Exception: + self._transport_policy_clients = fallback + + def _policy_clients_for_editor(self) -> list: + clients = list(getattr(self, "_transport_policy_clients", []) or []) + if clients: + return clients + return self._sort_transport_clients(self._transport_clients) + + def _is_virtual_policy_client(self, client) -> bool: + cid = str(getattr(client, "id", "") or "").strip().lower() + kind = str(getattr(client, "kind", "") or "").strip().lower() + if cid == "adguardvpn": + return True + return kind in ("adguardvpn", "virtual") + + def _policy_client_role(self, client) -> str: + return "virtual" if self._is_virtual_policy_client(client) else "transport" + + def _policy_client_role_by_id(self, client_id: str) -> str: + cid = str(client_id or "").strip().lower() + if not cid: + return "transport" + for client in self._policy_clients_for_editor(): + cur = str(getattr(client, "id", "") or "").strip().lower() + if cur == cid: + return self._policy_client_role(client) + if cid == "adguardvpn": + return "virtual" + return "transport" + + def _policy_client_caption(self, client) -> str: + cid = str(getattr(client, "id", "") or "").strip() + if not cid: + return "-" + role = self._policy_client_role(client) + name = str(getattr(client, "name", "") or "").strip() or cid + base = cid if name == cid else f"{name} ({cid})" + return f"[{role}] {base}" + + def refresh_transport_engines(self, *, silent: bool = True) -> None: + prev_id = self._selected_transport_engine_id() + try: + clients = self.ctrl.transport_clients(enabled_only=False, kind=self._transport_kind) + self._transport_api_supported = True + self._transport_clients = self._sort_transport_clients(clients) + status_by_id = { + str(getattr(c, "id", "") or "").strip(): str(getattr(c, "status", "") or "").strip().lower() + for c in self._transport_clients + if str(getattr(c, "id", "") or "").strip() + } + keep_ids = { + str(getattr(c, "id", "") or "").strip() + for c in self._transport_clients + if str(getattr(c, "id", "") or "").strip() + } + cleaned_live = {} + for cid, snap in (self._transport_health_live or {}).items(): + if cid not in keep_ids: + continue + base_status = status_by_id.get(cid, "") + snap_status = str((snap or {}).get("status") or "").strip().lower() + # Drop stale optimistic/live "UP" when backend state is already non-UP. + if base_status and base_status != "up" and snap_status == "up": + continue + cleaned_live[cid] = snap + self._transport_health_live = cleaned_live + except ApiError as e: + code = int(getattr(e, "status_code", 0) or 0) + self._transport_clients = [] + self._transport_policy_clients = [] + self._transport_api_supported = False + self._transport_health_live = {} + self.cmb_transport_engine.blockSignals(True) + self.cmb_transport_engine.clear() + if code == 404: + self.cmb_transport_engine.addItem("Transport API unavailable", "") + else: + self.cmb_transport_engine.addItem("Transport engine load failed", "") + self.cmb_transport_engine.blockSignals(False) + self._render_singbox_profile_cards() + self._update_transport_engine_view() + self.refresh_transport_policy_locks(silent=True) + if not silent and code != 404: + QMessageBox.warning(self, "Transport engine error", str(e)) + return + except Exception as e: + self._transport_clients = [] + self._transport_policy_clients = [] + self._transport_api_supported = False + self._transport_health_live = {} + self.cmb_transport_engine.blockSignals(True) + self.cmb_transport_engine.clear() + self.cmb_transport_engine.addItem("Transport engine load failed", "") + self.cmb_transport_engine.blockSignals(False) + self._render_singbox_profile_cards() + self._update_transport_engine_view() + self.refresh_transport_policy_locks(silent=True) + if not silent: + QMessageBox.warning(self, "Transport engine error", str(e)) + return + + self.cmb_transport_engine.blockSignals(True) + self.cmb_transport_engine.clear() + pick = -1 + up_pick = -1 + for i, c in enumerate(self._transport_clients): + cid = str(getattr(c, "id", "") or "").strip() + if not cid: + continue + kind = str(getattr(c, "kind", "") or "").strip().upper() + name = str(getattr(c, "name", "") or "").strip() or cid + status = str(getattr(c, "status", "") or "").strip().lower() or "unknown" + self.cmb_transport_engine.addItem(f"{kind} · {name} [{status}]", cid) + if prev_id and cid == prev_id: + pick = self.cmb_transport_engine.count() - 1 + if up_pick < 0 and status == "up": + up_pick = self.cmb_transport_engine.count() - 1 + + if self.cmb_transport_engine.count() == 0: + self.cmb_transport_engine.addItem("No SingBox clients configured", "") + pick = 0 + elif pick < 0: + pick = up_pick if up_pick >= 0 else 0 + + self.cmb_transport_engine.setCurrentIndex(max(0, pick)) + self.cmb_transport_engine.blockSignals(False) + self._render_singbox_profile_cards() + self._sync_singbox_profile_card_selection(self._selected_transport_engine_id()) + self._sync_selected_singbox_profile_link(silent=True) + self._load_singbox_editor_for_selected(silent=True) + self._update_transport_engine_view() + self.refresh_transport_policy_locks(silent=True) + + def _build_switch_intents( + self, + intents, + client_id: str, + ) -> tuple[list[TransportPolicyIntent], int, int]: + out: list[TransportPolicyIntent] = [] + changed = 0 + total = 0 + for it in list(intents or []): + total += 1 + selector_type = str(getattr(it, "selector_type", "") or "").strip().lower() + selector_value = str(getattr(it, "selector_value", "") or "").strip() + prev_client_id = str(getattr(it, "client_id", "") or "").strip() + if not selector_type or not selector_value: + continue + if prev_client_id != client_id: + changed += 1 + try: + prio = int(getattr(it, "priority", 100) or 100) + except Exception: + prio = 100 + mode = str(getattr(it, "mode", "strict") or "strict").strip().lower() + if mode not in ("strict", "fallback"): + mode = "strict" + out.append( + TransportPolicyIntent( + selector_type=selector_type, + selector_value=selector_value, + client_id=client_id, + priority=prio if prio > 0 else 100, + mode=mode, + ) + ) + return out, changed, total + + def _apply_transport_switch_policy(self, client_id: str) -> tuple[bool, str]: + pol = self.ctrl.transport_policy() + next_intents, changed, total = self._build_switch_intents(pol.intents, client_id) + if total <= 0 or not next_intents: + return True, "policy intents are empty; routing switch skipped" + if changed <= 0: + return True, f"policy already points to {client_id}; switch skipped" + + flow = self.ctrl.transport_flow_draft(next_intents, base_revision=int(pol.revision)) + flow = self.ctrl.transport_flow_validate(flow, allow_warnings=True) + + if flow.phase == "risky": + preview = [] + for c in list(flow.conflicts or [])[:5]: + reason = str(getattr(c, "reason", "") or "").strip() + ctype = str(getattr(c, "type", "") or "").strip() or "conflict" + if reason: + preview.append(f"- {ctype}: {reason}") + else: + preview.append(f"- {ctype}") + extra = "\n".join(preview) + txt = ( + f"Switch has blocking conflicts.\n\n" + f"blocks={flow.block_count}, warns={flow.warn_count}\n\n" + f"{extra}\n\n" + f"Force apply anyway?" + ) + ans = QMessageBox.question( + self, + "Confirm risky switch", + txt, + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if ans != QMessageBox.Yes: + return False, "connect/switch canceled by user" + flow = self.ctrl.transport_flow_confirm(flow) + flow = self.ctrl.transport_flow_apply(flow, force_override=True) + else: + flow = self.ctrl.transport_flow_apply(flow, force_override=False) + + if flow.phase != "applied": + return False, f"policy apply failed: {flow.message or '-'} (code={flow.code or '-'})" + return True, f"policy applied revision={flow.applied_revision} apply_id={flow.apply_id or '-'}" + + def on_transport_engine_selected(self, _index: int = 0) -> None: + self._sync_singbox_profile_card_selection(self._selected_transport_engine_id()) + self._sync_selected_singbox_profile_link(silent=True) + self._load_singbox_editor_for_selected(silent=True) + self._update_transport_engine_view() + self._refresh_selected_transport_health_live(silent=True) + + def on_transport_engine_refresh(self) -> None: + selected_id = self._selected_transport_engine_id() + try: + targets = [selected_id] if selected_id else None + self.ctrl.transport_health_refresh(client_ids=targets, force=True) + except Exception: + # Endpoint is best-effort; UI must keep working with older API builds. + pass + try: + scope = f"transport:{selected_id}" if selected_id else "" + if scope: + self.ctrl.egress_identity_refresh(scopes=[scope], force=True) + except Exception: + # Optional API on older backend builds. + pass + self.refresh_transport_engines(silent=False) + self._refresh_selected_transport_health_live(force=True, silent=True) + + def on_transport_engine_toggle(self, _checked: bool = False) -> None: + selected = self._selected_transport_client() + if selected is None: + QMessageBox.warning(self, "Transport engine", "Select a transport engine first") + return + status = str(getattr(selected, "status", "") or "").strip().lower() + if status == "starting": + QMessageBox.information(self, "Transport engine", "Engine is still starting, wait a moment") + return + action: Literal["provision", "start", "stop", "restart"] = "stop" if status == "up" else "start" + self.on_transport_engine_action(action) + + def on_transport_netns_toggle(self) -> None: + def work(): + if not self._transport_api_supported: + raise RuntimeError("Transport API is unavailable") + clients = list(self._transport_clients or []) + if not clients: + raise RuntimeError("No SingBox engines configured") + + all_enabled, _any_enabled = self._singbox_clients_netns_state() + target = not all_enabled + target_text = "ON" if target else "OFF" + + ans = QMessageBox.question( + self, + "Debug netns", + ( + f"Set netns={target_text} for all SingBox engines?\n\n" + "Applied now via backend orchestration.\n" + "Config+provision are executed in Go API,\n" + "running engines will be restarted." + ), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + if ans != QMessageBox.Yes: + return + + def log_netns_line(line: str) -> None: + msg = (line or "").strip() + if not msg: + return + self._append_transport_log(f"[netns] {msg}") + self.ctrl.log_gui(f"[netns] {msg}") + + failures = apply_singbox_netns_toggle( + self.ctrl, + clients, + target, + log_netns_line, + ) + + self.refresh_transport_engines(silent=True) + self.refresh_status_tab() + + if failures: + raise RuntimeError( + "netns toggle completed with errors:\n" + "\n".join(failures) + ) + + self.lbl_transport_engine_meta.setText( + f"Engine: debug netns set to {target_text} for all SingBox engines" + ) + self.lbl_transport_engine_meta.setStyleSheet("color: green;") + + self._safe(work, title="Debug netns toggle error") + + def _selected_multiif_engine_scope(self) -> str: + combo = getattr(self, "cmb_singbox_owner_engine_scope", None) + if combo is None: + return "all" + scope = str(combo.currentData() or "").strip().lower() + if scope not in ("all", "transport", "adguardvpn"): + return "all" + return scope + + def on_singbox_owner_engine_scope_changed(self, _index: int = 0) -> None: + self._update_transport_engine_view() + self._safe( + lambda: self.refresh_transport_policy_locks(silent=True), + title="MultiIF engine filter error", + ) + + def _new_readonly_table_item(self, text: str, *, user_data: str = "") -> QTableWidgetItem: + item = QTableWidgetItem(str(text or "")) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + if user_data: + item.setData(Qt.UserRole, str(user_data)) + return item + + def _selected_owner_lock_destinations(self) -> list[str]: + rows = sorted({idx.row() for idx in self.tbl_singbox_owner_locks.selectedIndexes()}) + out: list[str] = [] + seen: set[str] = set() + for row in rows: + it = self.tbl_singbox_owner_locks.item(row, 0) + if it is None: + continue + ip = str(it.text() or "").strip() + if not ip or ip in seen: + continue + seen.add(ip) + out.append(ip) + return out + + def _parse_owner_lock_destination_filter(self) -> list[str]: + raw = str(self.ent_singbox_owner_lock_destination.text() or "").strip() + if not raw: + return [] + buf = raw.replace(",", " ").replace(";", " ").replace("\n", " ") + out: list[str] = [] + seen: set[str] = set() + for token in buf.split(): + ip = token.strip() + if not ip or ip in seen: + continue + seen.add(ip) + out.append(ip) + return out + + def on_singbox_policy_selector_type_changed(self, _index: int = 0) -> None: + selector_type = str(self.cmb_singbox_policy_selector_type.currentData() or "").strip().lower() + placeholder = "example.com" + if selector_type == "cidr": + placeholder = "1.2.3.0/24 or 1.2.3.4" + elif selector_type == "app_key": + placeholder = "steam" + elif selector_type == "cgroup": + placeholder = "user.slice/app.scope" + elif selector_type == "uid": + placeholder = "1000" + self.ent_singbox_policy_selector_value.setPlaceholderText(placeholder) + + def _set_singbox_policy_state(self, text: str, color: str = "gray") -> None: + self.lbl_singbox_policy_state.setText(str(text or "").strip() or "Policy editor: —") + self.lbl_singbox_policy_state.setStyleSheet(f"color: {color};") + + def _sync_singbox_policy_client_options(self) -> None: + prev = str(self.cmb_singbox_policy_client_id.currentData() or "").strip() + self.cmb_singbox_policy_client_id.blockSignals(True) + self.cmb_singbox_policy_client_id.clear() + seen = set() + for client in self._policy_clients_for_editor(): + cid = str(getattr(client, "id", "") or "").strip() + if not cid or cid in seen: + continue + seen.add(cid) + self.cmb_singbox_policy_client_id.addItem(self._policy_client_caption(client), cid) + + if self.cmb_singbox_policy_client_id.count() == 0: + self.cmb_singbox_policy_client_id.addItem("No clients", "") + idx = self.cmb_singbox_policy_client_id.findData(prev) + if idx < 0: + idx = 0 + self.cmb_singbox_policy_client_id.setCurrentIndex(max(0, idx)) + self.cmb_singbox_policy_client_id.blockSignals(False) + + def _policy_client_label(self, client_id: str) -> str: + cid = str(client_id or "").strip() + if not cid: + return "-" + for client in self._policy_clients_for_editor(): + cur = str(getattr(client, "id", "") or "").strip() + if cur != cid: + continue + return self._policy_client_caption(client) + return f"[{self._policy_client_role_by_id(cid)}] {cid}" + + def _set_policy_client_selection(self, client_id: str) -> None: + cid = str(client_id or "").strip() + if not cid: + return + idx = self.cmb_singbox_policy_client_id.findData(cid) + if idx >= 0: + self.cmb_singbox_policy_client_id.setCurrentIndex(idx) + + def _intent_parts(self, intent) -> tuple[str, str, str, str, int]: + if isinstance(intent, dict): + selector_type = str(intent.get("selector_type") or "").strip().lower() + selector_value = str(intent.get("selector_value") or "").strip() + client_id = str(intent.get("client_id") or "").strip() + mode = str(intent.get("mode") or "strict").strip().lower() or "strict" + try: + priority = int(intent.get("priority") or 100) + except Exception: + priority = 100 + else: + selector_type = str(getattr(intent, "selector_type", "") or "").strip().lower() + selector_value = str(getattr(intent, "selector_value", "") or "").strip() + client_id = str(getattr(intent, "client_id", "") or "").strip() + mode = str(getattr(intent, "mode", "strict") or "strict").strip().lower() or "strict" + try: + priority = int(getattr(intent, "priority", 100) or 100) + except Exception: + priority = 100 + if mode not in ("strict", "fallback"): + mode = "strict" + if priority <= 0: + priority = 100 + return selector_type, selector_value, client_id, mode, priority + + def _render_singbox_policy_table_rows(self, table, intents) -> None: + table.setRowCount(0) + for it in list(intents or []): + selector_type, selector_value, client_id, mode, priority = self._intent_parts(it) + row = table.rowCount() + table.insertRow(row) + table.setItem(row, 0, self._new_readonly_table_item(selector_type)) + table.setItem(row, 1, self._new_readonly_table_item(selector_value)) + table.setItem( + row, + 2, + self._new_readonly_table_item(self._policy_client_label(client_id), user_data=client_id), + ) + table.setItem(row, 3, self._new_readonly_table_item(mode)) + table.setItem(row, 4, self._new_readonly_table_item(str(priority))) + table.resizeRowsToContents() + + def _render_singbox_policy_table(self) -> None: + self._render_singbox_policy_table_rows( + self.tbl_singbox_policy_intents, + self._transport_policy_draft_intents, + ) + self._render_singbox_policy_table_rows( + self.tbl_singbox_policy_applied, + self._transport_policy_applied_intents, + ) + + def _render_singbox_policy_conflicts(self, conflicts) -> None: + self.tbl_singbox_policy_conflicts.setRowCount(0) + for it in list(conflicts or []): + ctype = str(getattr(it, "type", "") or "").strip() or "-" + severity = str(getattr(it, "severity", "") or "").strip() or "-" + owners_list = list(getattr(it, "owners", []) or []) + owners = ", ".join(str(x).strip() for x in owners_list if str(x).strip()) or "-" + reason = str(getattr(it, "reason", "") or "").strip() or "-" + suggested = str(getattr(it, "suggested_resolution", "") or "").strip() or "-" + row = self.tbl_singbox_policy_conflicts.rowCount() + self.tbl_singbox_policy_conflicts.insertRow(row) + self.tbl_singbox_policy_conflicts.setItem(row, 0, self._new_readonly_table_item(ctype)) + self.tbl_singbox_policy_conflicts.setItem(row, 1, self._new_readonly_table_item(severity.upper())) + self.tbl_singbox_policy_conflicts.setItem(row, 2, self._new_readonly_table_item(owners)) + self.tbl_singbox_policy_conflicts.setItem(row, 3, self._new_readonly_table_item(reason)) + self.tbl_singbox_policy_conflicts.setItem(row, 4, self._new_readonly_table_item(suggested)) + self.tbl_singbox_policy_conflicts.resizeRowsToContents() + + def refresh_transport_policy_editor(self, *, silent: bool = True, force: bool = False, policy=None) -> None: + self._sync_singbox_policy_client_options() + if not getattr(self, "_transport_api_supported", False): + self.tbl_singbox_policy_intents.setRowCount(0) + self.tbl_singbox_policy_applied.setRowCount(0) + self.tbl_singbox_policy_conflicts.setRowCount(0) + self._set_singbox_policy_state("Policy editor: API unavailable", "orange") + return + pol = policy + if pol is None: + try: + pol = self.ctrl.transport_policy() + except ApiError as e: + code = int(getattr(e, "status_code", 0) or 0) + self.tbl_singbox_policy_intents.setRowCount(0) + self.tbl_singbox_policy_applied.setRowCount(0) + self.tbl_singbox_policy_conflicts.setRowCount(0) + if code == 404: + self._set_singbox_policy_state("Policy editor: endpoint unavailable", "gray") + return + self._set_singbox_policy_state("Policy editor: refresh failed", "orange") + if not silent: + raise + return + + backend_revision = int(getattr(pol, "revision", 0) or 0) + backend_intents = list(getattr(pol, "intents", []) or []) + self._transport_policy_applied_intents = list(backend_intents) + should_reload = bool(force or not self._transport_policy_dirty or self._transport_policy_base_revision <= 0) + if should_reload: + self._transport_policy_base_revision = backend_revision + self._transport_policy_draft_intents = list(backend_intents) + self._transport_policy_dirty = False + self._transport_policy_last_apply_id = "" + self._transport_policy_last_conflicts = [] + + self._render_singbox_policy_table() + self._render_singbox_policy_conflicts(self._transport_policy_last_conflicts) + draft_count = len(list(self._transport_policy_draft_intents or [])) + applied_count = len(list(self._transport_policy_applied_intents or [])) + backend_count = len(backend_intents) + if self._transport_policy_dirty: + self._set_singbox_policy_state( + f"Policy draft: dirty (draft={draft_count}, applied={applied_count}, base_rev={self._transport_policy_base_revision}, backend_rev={backend_revision}, backend_intents={backend_count})", + "#b58900", + ) + else: + self._set_singbox_policy_state( + f"Policy draft: clean (rev={self._transport_policy_base_revision}, draft={draft_count}, applied={applied_count})", + "gray", + ) + + def on_singbox_policy_reload(self) -> None: + self._safe( + lambda: self.refresh_transport_policy_editor(silent=False, force=True), + title="Policy reload error", + ) + + def on_singbox_policy_use_template(self) -> None: + data = self.cmb_singbox_policy_template.currentData() + if not isinstance(data, dict): + QMessageBox.information( + self, + "Policy template", + "Select a template first.", + ) + return + + selector_type = str(data.get("selector_type") or "").strip().lower() + selector_value = str(data.get("selector_value") or "").strip() + mode = str(data.get("mode") or "strict").strip().lower() + try: + priority = int(data.get("priority") or 100) + except Exception: + priority = 100 + + idx = self.cmb_singbox_policy_selector_type.findData(selector_type) + if idx >= 0: + self.cmb_singbox_policy_selector_type.setCurrentIndex(idx) + self.ent_singbox_policy_selector_value.setText(selector_value) + idx_mode = self.cmb_singbox_policy_mode.findData(mode) + if idx_mode >= 0: + self.cmb_singbox_policy_mode.setCurrentIndex(idx_mode) + self.spn_singbox_policy_priority.setValue(priority if priority > 0 else 100) + + self._set_singbox_policy_state( + f"Template loaded: {selector_type}:{selector_value} ({mode}, prio={priority})", + "#1f6b2f", + ) + + def on_singbox_policy_add_demo_intent(self) -> None: + def work() -> None: + client_id = str(self.cmb_singbox_policy_client_id.currentData() or "").strip() + if not client_id: + for i in range(self.cmb_singbox_policy_client_id.count()): + cand = str(self.cmb_singbox_policy_client_id.itemData(i) or "").strip() + if cand: + client_id = cand + break + if not client_id: + QMessageBox.information( + self, + "Policy intent", + "No client is available yet. Create or select a connection first.", + ) + return + + used_values = { + str(it.selector_value or "").strip().lower() + for it in list(self._transport_policy_draft_intents or []) + if str(it.selector_type or "").strip().lower() == "domain" + } + n = 1 + selector_value = "demo.invalid" + while selector_value.lower() in used_values: + n += 1 + selector_value = f"demo-{n}.invalid" + + idx = self.cmb_singbox_policy_selector_type.findData("domain") + if idx >= 0: + self.cmb_singbox_policy_selector_type.setCurrentIndex(idx) + self.ent_singbox_policy_selector_value.setText(selector_value) + self._set_policy_client_selection(client_id) + idx_mode = self.cmb_singbox_policy_mode.findData("strict") + if idx_mode >= 0: + self.cmb_singbox_policy_mode.setCurrentIndex(idx_mode) + self.spn_singbox_policy_priority.setValue(100) + + self.on_singbox_policy_add_intent() + self._set_singbox_policy_state( + f"Demo intent added: domain:{selector_value} -> {client_id}", + "#1f6b2f", + ) + + self._safe(work, title="Policy demo intent error") + + def _selected_policy_intent_row(self) -> int: + rows = sorted({idx.row() for idx in self.tbl_singbox_policy_intents.selectedIndexes()}) + if not rows: + return -1 + row = rows[0] + if row < 0 or row >= len(self._transport_policy_draft_intents or []): + return -1 + return row + + def on_singbox_policy_load_selected_intent(self, *, _silent: bool = False) -> None: + row = self._selected_policy_intent_row() + if row < 0: + if not _silent: + QMessageBox.information(self, "Policy intent", "Select an intent row first.") + return + + selector_type, selector_value, client_id, mode, priority = self._intent_parts( + self._transport_policy_draft_intents[row] + ) + idx = self.cmb_singbox_policy_selector_type.findData(selector_type) + if idx >= 0: + self.cmb_singbox_policy_selector_type.setCurrentIndex(idx) + self.ent_singbox_policy_selector_value.setText(selector_value) + self._set_policy_client_selection(client_id) + idx_mode = self.cmb_singbox_policy_mode.findData(mode) + if idx_mode >= 0: + self.cmb_singbox_policy_mode.setCurrentIndex(idx_mode) + self.spn_singbox_policy_priority.setValue(priority if priority > 0 else 100) + self._set_singbox_policy_state( + f"Intent loaded from draft row {row + 1}: {selector_type}:{selector_value} -> {client_id}", + "#1f6b2f", + ) + + def on_singbox_policy_intent_double_clicked(self, item: QTableWidgetItem | None) -> None: + if item is None: + return + row = int(item.row()) + if row < 0: + return + self.tbl_singbox_policy_intents.selectRow(row) + self.on_singbox_policy_load_selected_intent(_silent=True) + + def _build_policy_intent_from_form(self) -> tuple[TransportPolicyIntent, str, str, str, str, int]: + selector_type = str(self.cmb_singbox_policy_selector_type.currentData() or "").strip().lower() + selector_value = str(self.ent_singbox_policy_selector_value.text() or "").strip() + client_id = str(self.cmb_singbox_policy_client_id.currentData() or "").strip() + mode = str(self.cmb_singbox_policy_mode.currentData() or "strict").strip().lower() + priority = int(self.spn_singbox_policy_priority.value() or 100) + + if not selector_type or not selector_value or not client_id: + raise ValueError("Fill selector type/value and client ID before saving intent.") + if selector_type == "cidr": + try: + if "/" in selector_value: + ipaddress.ip_network(selector_value, strict=False) + else: + ipaddress.ip_address(selector_value) + except Exception as e: + raise ValueError("CIDR/IP value looks invalid. Example: 1.2.3.0/24 or 1.2.3.4") from e + if selector_type == "uid" and not selector_value.isdigit(): + raise ValueError("UID must be numeric (example: 1000).") + if selector_type == "domain" and selector_value.isdigit(): + raise ValueError("Domain value looks invalid. Use host/domain (example.com).") + + normalized_mode = mode if mode in ("strict", "fallback") else "strict" + normalized_priority = priority if priority > 0 else 100 + intent = TransportPolicyIntent( + selector_type=selector_type, + selector_value=selector_value, + client_id=client_id, + priority=normalized_priority, + mode=normalized_mode, + ) + return ( + intent, + selector_type, + selector_value, + client_id, + normalized_mode, + normalized_priority, + ) + + def _policy_intent_exists(self, intent: TransportPolicyIntent, *, skip_index: int = -1) -> bool: + target = self._intent_parts(intent) + for i, existing in enumerate(list(self._transport_policy_draft_intents or [])): + if skip_index >= 0 and i == skip_index: + continue + if self._intent_parts(existing) == target: + return True + return False + + def on_singbox_policy_add_intent(self) -> None: + def work() -> None: + try: + intent, selector_type, selector_value, client_id, mode, priority = self._build_policy_intent_from_form() + except ValueError as e: + QMessageBox.information(self, "Policy intent", str(e)) + return + + if self._policy_intent_exists(intent): + QMessageBox.information( + self, + "Policy intent", + "Same intent is already present in draft.", + ) + return + + self._transport_policy_draft_intents.append(intent) + self._transport_policy_dirty = True + self._render_singbox_policy_table() + row = len(self._transport_policy_draft_intents) - 1 + if row >= 0 and row < self.tbl_singbox_policy_intents.rowCount(): + self.tbl_singbox_policy_intents.selectRow(row) + self._set_singbox_policy_state( + f"Policy draft: dirty (intents={len(self._transport_policy_draft_intents)}, base_rev={self._transport_policy_base_revision})", + "#b58900", + ) + self._append_transport_log( + f"[policy] draft add: {selector_type}:{selector_value} -> {client_id} ({mode}, prio={priority})" + ) + self.ctrl.log_gui( + f"[transport-policy] draft add {selector_type}:{selector_value} -> {client_id} mode={mode} priority={priority}" + ) + + self._safe(work, title="Policy add error") + + def on_singbox_policy_update_selected_intent(self) -> None: + def work() -> None: + row = self._selected_policy_intent_row() + if row < 0: + QMessageBox.information(self, "Policy intent", "Select an intent row first.") + return + + try: + intent, selector_type, selector_value, client_id, mode, priority = self._build_policy_intent_from_form() + except ValueError as e: + QMessageBox.information(self, "Policy intent", str(e)) + return + + if self._policy_intent_exists(intent, skip_index=row): + QMessageBox.information( + self, + "Policy intent", + "Another row already has the same intent.", + ) + return + + self._transport_policy_draft_intents[row] = intent + self._transport_policy_dirty = True + self._render_singbox_policy_table() + if row < self.tbl_singbox_policy_intents.rowCount(): + self.tbl_singbox_policy_intents.selectRow(row) + self._set_singbox_policy_state( + f"Policy draft: dirty (updated row={row + 1}, intents={len(self._transport_policy_draft_intents)}, base_rev={self._transport_policy_base_revision})", + "#b58900", + ) + self._append_transport_log( + f"[policy] draft update row={row + 1}: {selector_type}:{selector_value} -> {client_id} ({mode}, prio={priority})" + ) + self.ctrl.log_gui( + f"[transport-policy] draft update row={row + 1} {selector_type}:{selector_value} -> {client_id} mode={mode} priority={priority}" + ) + + self._safe(work, title="Policy update error") + + def on_singbox_policy_remove_selected(self) -> None: + def work() -> None: + rows = sorted({idx.row() for idx in self.tbl_singbox_policy_intents.selectedIndexes()}, reverse=True) + if not rows: + QMessageBox.information(self, "Policy intent", "Select one or more intent rows.") + return + removed = 0 + for row in rows: + if 0 <= row < len(self._transport_policy_draft_intents): + del self._transport_policy_draft_intents[row] + removed += 1 + if removed <= 0: + return + self._transport_policy_dirty = True + self._render_singbox_policy_table() + self._set_singbox_policy_state( + f"Policy draft: dirty (removed={removed}, intents={len(self._transport_policy_draft_intents)}, base_rev={self._transport_policy_base_revision})", + "#b58900", + ) + self._append_transport_log(f"[policy] draft remove: {removed} intent(s)") + self.ctrl.log_gui(f"[transport-policy] draft remove: {removed}") + + self._safe(work, title="Policy remove error") + + def _validate_policy_draft_flow(self): + flow = self.ctrl.transport_flow_draft( + list(self._transport_policy_draft_intents or []), + base_revision=int(self._transport_policy_base_revision or 0), + ) + flow = self.ctrl.transport_flow_validate(flow, allow_warnings=True) + line = ( + f"validate: phase={flow.phase} blocks={int(flow.block_count)} warns={int(flow.warn_count)} " + f"diff +{int(flow.diff_added)}/~{int(flow.diff_changed)}/-{int(flow.diff_removed)} " + f"code={flow.code or '-'}" + ) + self._append_transport_log(f"[policy] {line}") + self.ctrl.log_gui(f"[transport-policy] {line}") + self._transport_policy_last_conflicts = list(flow.conflicts or []) + self._render_singbox_policy_conflicts(self._transport_policy_last_conflicts) + if flow.phase == "validated": + self._set_singbox_policy_state( + f"Policy validated: blocks=0 warns={int(flow.warn_count)} diff +{int(flow.diff_added)}/~{int(flow.diff_changed)}/-{int(flow.diff_removed)}", + "green", + ) + elif flow.phase == "risky": + self._set_singbox_policy_state( + f"Policy has blocking conflicts: blocks={int(flow.block_count)} warns={int(flow.warn_count)}", + "#b58900", + ) + else: + self._set_singbox_policy_state( + f"Policy validate failed: {flow.message or '-'} (code={flow.code or '-'})", + "orange", + ) + return flow + + def on_singbox_policy_validate(self) -> None: + def work() -> None: + flow = self._validate_policy_draft_flow() + if flow.phase == "risky": + preview = [] + for c in list(flow.conflicts or [])[:8]: + reason = str(getattr(c, "reason", "") or "").strip() + ctype = str(getattr(c, "type", "") or "").strip() or "conflict" + if reason: + preview.append(f"- {ctype}: {reason}") + else: + preview.append(f"- {ctype}") + if preview: + QMessageBox.information( + self, + "Policy conflicts", + "Blocking conflicts found:\n\n" + "\n".join(preview), + ) + + self._safe(work, title="Policy validate error") + + def on_singbox_policy_apply(self) -> None: + def work() -> None: + flow = self._validate_policy_draft_flow() + if flow.phase == "risky": + preview = [] + for c in list(flow.conflicts or [])[:8]: + reason = str(getattr(c, "reason", "") or "").strip() + ctype = str(getattr(c, "type", "") or "").strip() or "conflict" + if reason: + preview.append(f"- {ctype}: {reason}") + else: + preview.append(f"- {ctype}") + text = ( + f"Policy has blocking conflicts.\n\n" + f"blocks={int(flow.block_count)}, warns={int(flow.warn_count)}\n\n" + f"{chr(10).join(preview)}\n\n" + "Force apply anyway?" + ) + ans = QMessageBox.question( + self, + "Confirm risky policy apply", + text, + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if ans != QMessageBox.Yes: + self._append_transport_log("[policy] apply canceled by user") + self.ctrl.log_gui("[transport-policy] apply canceled by user") + return + flow = self.ctrl.transport_flow_confirm(flow) + flow = self.ctrl.transport_flow_apply(flow, force_override=True) + else: + flow = self.ctrl.transport_flow_apply(flow, force_override=False) + + if flow.phase != "applied": + raise RuntimeError(f"{flow.message or 'policy apply failed'} (code={flow.code or '-'})") + + self._transport_policy_dirty = False + self._transport_policy_base_revision = int(flow.applied_revision or flow.current_revision or self._transport_policy_base_revision) + self._transport_policy_last_apply_id = str(flow.apply_id or "").strip() + self._append_transport_log( + f"[policy] applied: revision={int(flow.applied_revision)} apply_id={flow.apply_id or '-'}" + ) + self.ctrl.log_gui( + f"[transport-policy] applied revision={int(flow.applied_revision)} apply_id={flow.apply_id or '-'}" + ) + self.refresh_transport_policy_locks(silent=True) + self.refresh_transport_engines(silent=True) + self.refresh_status_tab() + + self._safe(work, title="Policy apply error") + + def on_singbox_policy_rollback_explicit(self) -> None: + def work() -> None: + res = self.ctrl.transport_policy_rollback_action() + line = (res.pretty_text or "").strip() or "policy rollback" + self._append_transport_log(f"[policy] {line}") + self.ctrl.log_gui(f"[transport-policy] {line}") + if not res.ok: + raise RuntimeError(line) + self._transport_policy_dirty = False + self._transport_policy_last_conflicts = [] + self.refresh_transport_policy_editor(silent=True, force=True) + self.refresh_transport_policy_locks(silent=True) + self.refresh_transport_engines(silent=True) + self.refresh_status_tab() + + self._safe(work, title="Policy rollback error") + + + + def refresh_transport_policy_locks(self, *, silent: bool = True) -> None: + scope = self._selected_multiif_engine_scope() + show_transport_rows = scope in ("all", "transport") + show_adguard_rows = scope in ("all", "adguardvpn") + + if not getattr(self, "_transport_api_supported", False): + self._transport_policy_clients = [] + self.tbl_singbox_interfaces.setRowCount(0) + self.tbl_singbox_policy_intents.setRowCount(0) + self.tbl_singbox_policy_applied.setRowCount(0) + self.tbl_singbox_policy_conflicts.setRowCount(0) + self.tbl_singbox_ownership.setRowCount(0) + self.tbl_singbox_owner_locks.setRowCount(0) + self.lbl_singbox_owner_locks_summary.setText("Interfaces/Ownership: API unavailable") + self.lbl_singbox_owner_locks_summary.setStyleSheet("color: orange;") + self._set_singbox_policy_state("Policy editor: API unavailable", "orange") + return + + ifaces = SimpleNamespace(count=0, items=[]) + pol = SimpleNamespace(revision=0, intents=[]) + owners = SimpleNamespace(count=0, lock_count=0, plan_digest="", items=[]) + locks = SimpleNamespace(count=0, policy_revision=0, items=[]) + + try: + ifaces = self.ctrl.transport_interfaces() + pol = self.ctrl.transport_policy() + owners = self.ctrl.transport_ownership() + locks = self.ctrl.transport_owner_locks() + except ApiError as e: + self.tbl_singbox_interfaces.setRowCount(0) + self.tbl_singbox_policy_intents.setRowCount(0) + self.tbl_singbox_policy_applied.setRowCount(0) + self.tbl_singbox_policy_conflicts.setRowCount(0) + self.tbl_singbox_ownership.setRowCount(0) + self.tbl_singbox_owner_locks.setRowCount(0) + code = int(getattr(e, "status_code", 0) or 0) + if code == 404: + self.lbl_singbox_owner_locks_summary.setText( + "Interfaces/Ownership: endpoint is unavailable on current backend" + ) + self.lbl_singbox_owner_locks_summary.setStyleSheet("color: gray;") + self._set_singbox_policy_state("Policy editor: endpoint unavailable", "gray") + return + self.lbl_singbox_owner_locks_summary.setText("Interfaces/Ownership refresh failed") + self.lbl_singbox_owner_locks_summary.setStyleSheet("color: orange;") + self._set_singbox_policy_state("Policy editor: refresh failed", "orange") + if not silent: + raise + return + + self._refresh_transport_policy_clients_cache() + self.refresh_transport_policy_editor(silent=True, force=False, policy=pol) + + scope_title = { + "all": "all", + "transport": "transport", + "adguardvpn": "adguardvpn", + }.get(scope, "all") + self.lbl_singbox_interfaces_hint.setText(f"Interfaces (read-only, filter={scope_title}, labels=[transport]/[virtual])") + + self.tbl_singbox_interfaces.setRowCount(0) + adguard_iface = None + for rec in list(getattr(ifaces, "items", []) or []): + rec_id = str(getattr(rec, "id", "") or "").strip().lower() + is_adguard = rec_id == "adguardvpn" + if is_adguard: + adguard_iface = rec + if (is_adguard and not show_adguard_rows) or ((not is_adguard) and not show_transport_rows): + continue + row = self.tbl_singbox_interfaces.rowCount() + self.tbl_singbox_interfaces.insertRow(row) + clients_txt = f"{int(rec.up_count or 0)}/{int(rec.client_count or 0)}" + iface_role = "virtual" if is_adguard else "transport" + mode_text = (rec.mode or "-").upper() + if is_adguard: + mode_text = f"{mode_text} | VIRTUAL" + self.tbl_singbox_interfaces.setItem(row, 0, self._new_readonly_table_item(f"[{iface_role}] {rec.id}", user_data=rec.id)) + self.tbl_singbox_interfaces.setItem(row, 1, self._new_readonly_table_item(mode_text)) + self.tbl_singbox_interfaces.setItem(row, 2, self._new_readonly_table_item(rec.runtime_iface or "-")) + self.tbl_singbox_interfaces.setItem(row, 3, self._new_readonly_table_item(rec.netns_name or "-")) + self.tbl_singbox_interfaces.setItem(row, 4, self._new_readonly_table_item(rec.routing_table or "-")) + self.tbl_singbox_interfaces.setItem(row, 5, self._new_readonly_table_item(clients_txt)) + self.tbl_singbox_interfaces.setItem(row, 6, self._new_readonly_table_item(rec.updated_at or "-")) + + self.tbl_singbox_ownership.setRowCount(0) + for rec in list(getattr(owners, "items", []) or []): + owner_scope_raw = str(getattr(rec, "owner_scope", "") or "").strip().lower() + owner_id = str(getattr(rec, "client_id", "") or "").strip().lower() + is_adguard = owner_scope_raw == "adguardvpn" or owner_id == "adguardvpn" + if (is_adguard and not show_adguard_rows) or ((not is_adguard) and not show_transport_rows): + continue + row = self.tbl_singbox_ownership.rowCount() + self.tbl_singbox_ownership.insertRow(row) + selector = f"{rec.selector_type}:{rec.selector_value}" + owner = rec.client_id + if rec.client_kind: + owner = f"{owner} ({rec.client_kind})" + owner_role = "virtual" if is_adguard else "transport" + owner = f"[{owner_role}] {owner}" + owner_scope = str(getattr(rec, "owner_scope", "") or "").strip() or "-" + iface_table = rec.iface_id or "-" + if rec.routing_table: + iface_table = f"{iface_table} · {rec.routing_table}" + status = rec.owner_status or "unknown" + lock_txt = "active" if rec.lock_active else "free" + self.tbl_singbox_ownership.setItem(row, 0, self._new_readonly_table_item(selector, user_data=rec.key)) + self.tbl_singbox_ownership.setItem(row, 1, self._new_readonly_table_item(owner, user_data=rec.client_id)) + self.tbl_singbox_ownership.setItem(row, 2, self._new_readonly_table_item(owner_scope)) + self.tbl_singbox_ownership.setItem(row, 3, self._new_readonly_table_item(iface_table)) + self.tbl_singbox_ownership.setItem(row, 4, self._new_readonly_table_item(status.upper())) + self.tbl_singbox_ownership.setItem(row, 5, self._new_readonly_table_item(lock_txt.upper())) + + self.tbl_singbox_owner_locks.setRowCount(0) + for rec in list(getattr(locks, "items", []) or []): + owner_id = str(getattr(rec, "client_id", "") or "").strip().lower() + is_adguard = owner_id == "adguardvpn" + if (is_adguard and not show_adguard_rows) or ((not is_adguard) and not show_transport_rows): + continue + row = self.tbl_singbox_owner_locks.rowCount() + self.tbl_singbox_owner_locks.insertRow(row) + mark_proto = rec.mark_hex or "-" + if rec.proto: + mark_proto = f"{mark_proto} / {rec.proto}" + self.tbl_singbox_owner_locks.setItem( + row, + 0, + self._new_readonly_table_item(rec.destination_ip, user_data=rec.destination_ip), + ) + lock_role = "virtual" if is_adguard else "transport" + self.tbl_singbox_owner_locks.setItem(row, 1, self._new_readonly_table_item(f"[{lock_role}] {rec.client_id}", user_data=rec.client_id)) + kind_text = rec.client_kind or ("adguardvpn" if is_adguard else "-") + self.tbl_singbox_owner_locks.setItem(row, 2, self._new_readonly_table_item(f"[{lock_role}] {kind_text}")) + self.tbl_singbox_owner_locks.setItem(row, 3, self._new_readonly_table_item(rec.iface_id or "-")) + self.tbl_singbox_owner_locks.setItem(row, 4, self._new_readonly_table_item(mark_proto)) + self.tbl_singbox_owner_locks.setItem(row, 5, self._new_readonly_table_item(rec.updated_at or "-")) + + self.tbl_singbox_interfaces.resizeRowsToContents() + self.tbl_singbox_ownership.resizeRowsToContents() + self.tbl_singbox_owner_locks.resizeRowsToContents() + + filter_label = { + "all": "All", + "transport": "Transport", + "adguardvpn": "AdGuard VPN", + }.get(scope, "All") + parts = [ + f"Engine filter: {filter_label}", + f"Interfaces: {self.tbl_singbox_interfaces.rowCount()}", + ] + + plan_digest = str(getattr(owners, "plan_digest", "") or "").strip() + plan_digest_short = plan_digest[:12] if plan_digest else "-" + parts.append( + "Policy: " + f"rev={int(getattr(pol, 'revision', 0) or 0)} intents={len(list(getattr(pol, 'intents', []) or []))}" + f" draft={len(list(self._transport_policy_draft_intents or []))}" + f" applied={len(list(self._transport_policy_applied_intents or []))}" + ) + policy_clients = self._policy_clients_for_editor() + seen_target_ids = set() + virtual_targets = 0 + transport_targets = 0 + for client in policy_clients: + cid = str(getattr(client, "id", "") or "").strip().lower() + if not cid or cid in seen_target_ids: + continue + seen_target_ids.add(cid) + if self._is_virtual_policy_client(client): + virtual_targets += 1 + else: + transport_targets += 1 + parts.append(f"Targets: transport={transport_targets} virtual={virtual_targets}") + parts.append( + "Ownership: " + f"{int(getattr(owners, 'count', 0) or 0)} selectors " + f"(lock_active={int(getattr(owners, 'lock_count', 0) or 0)})" + f" · digest={plan_digest_short}" + ) + parts.append( + "Locks: " + f"{int(getattr(locks, 'count', 0) or 0)}" + f" · revision={int(getattr(locks, 'policy_revision', 0) or 0)}" + ) + + if show_adguard_rows: + if adguard_iface is None: + parts.append("AdGuard: unavailable") + else: + adg_status = "UP" if int(getattr(adguard_iface, "up_count", 0) or 0) > 0 else "DOWN" + adg_iface = str(getattr(adguard_iface, "runtime_iface", "") or "").strip() or "-" + adg_table = str(getattr(adguard_iface, "routing_table", "") or "").strip() or "-" + parts.append(f"AdGuard: {adg_status} iface={adg_iface} table={adg_table}") + + self.lbl_singbox_owner_locks_summary.setText(" | ".join(parts)) + self.lbl_singbox_owner_locks_summary.setStyleSheet("color: gray;") + if int(getattr(pol, "revision", 0) or 0) <= 0 and len(list(getattr(pol, "intents", []) or [])) == 0: + self._set_singbox_policy_state("Policy editor: no policy yet. Add intent -> Validate -> Apply.", "#1f6b2f") + + + def on_singbox_owner_locks_refresh(self) -> None: + self._safe( + lambda: self.refresh_transport_policy_locks(silent=False), + title="Ownership locks refresh error", + ) + + def on_singbox_owner_locks_clear(self) -> None: + def work() -> None: + if not self._transport_api_supported: + raise RuntimeError("Transport API is unavailable") + + client_id = str(self.ent_singbox_owner_lock_client.text() or "").strip() + destination_ips = self._parse_owner_lock_destination_filter() + if not destination_ips: + destination_ips = self._selected_owner_lock_destinations() + if not client_id and not destination_ips: + msg = ( + "Empty filter: set client_id or destination IP,\n" + "or select destination rows in locks table." + ) + self._append_transport_log("[owner-lock] clear skipped: empty filter") + self.ctrl.log_gui("[owner-lock] clear skipped: empty filter") + QMessageBox.information(self, "Owner locks", msg) + return + + base_revision = 0 + try: + snap = self.ctrl.transport_owner_locks() + base_revision = int(getattr(snap, "policy_revision", 0) or 0) + except Exception: + pass + + probe = self.ctrl.transport_owner_locks_clear( + base_revision=base_revision, + client_id=client_id, + destination_ips=destination_ips, + confirm_token="", + ) + self._append_transport_log( + f"[owner-lock] clear probe: {probe.message or '-'} (code={probe.code or '-'})" + ) + self.ctrl.log_gui( + f"[owner-lock] clear probe: code={probe.code or '-'} match={int(probe.match_count or 0)}" + ) + + if probe.ok and not probe.confirm_required: + self.refresh_transport_policy_locks(silent=True) + self.lbl_transport_engine_meta.setText( + f"Engine: owner locks cleared ({int(probe.cleared_count or 0)})" + ) + self.lbl_transport_engine_meta.setStyleSheet("color: green;") + return + + if not probe.confirm_required: + raise RuntimeError( + f"{probe.message or 'owner-lock clear failed'} (code={probe.code or '-'})" + ) + + preview_lines = [] + for it in list(probe.items or [])[:12]: + preview_lines.append(f"- {it.destination_ip} -> {it.client_id}") + if int(probe.match_count or 0) > len(preview_lines): + preview_lines.append(f"... +{int(probe.match_count or 0) - len(preview_lines)} more") + preview = "\n".join(preview_lines) if preview_lines else "No preview rows" + text = ( + f"Matched locks: {int(probe.match_count or 0)}\n" + f"Current revision: {int(probe.base_revision or base_revision)}\n\n" + f"{preview}\n\n" + "Clear matched lock records?" + ) + ans = QMessageBox.question( + self, + "Confirm owner-lock clear", + text, + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if ans != QMessageBox.Yes: + self._append_transport_log("[owner-lock] clear canceled by user") + self.ctrl.log_gui("[owner-lock] clear canceled by user") + return + + confirmed = self.ctrl.transport_owner_locks_clear( + base_revision=int(probe.base_revision or base_revision), + client_id=client_id, + destination_ips=destination_ips, + confirm_token=str(probe.confirm_token or "").strip(), + ) + line = ( + f"{confirmed.message or '-'} " + f"(code={confirmed.code or '-'}, cleared={int(confirmed.cleared_count or 0)})" + ) + self._append_transport_log(f"[owner-lock] clear apply: {line}") + self.ctrl.log_gui(f"[owner-lock] clear apply: {line}") + if not confirmed.ok: + raise RuntimeError(line) + + self.refresh_transport_policy_locks(silent=True) + self.lbl_transport_engine_meta.setText( + f"Engine: owner locks cleared ({int(confirmed.cleared_count or 0)})" + ) + self.lbl_transport_engine_meta.setStyleSheet("color: green;") + + self._safe(work, title="Owner locks clear error") diff --git a/selective-vpn-gui/main_window/singbox_mixin.py b/selective-vpn-gui/main_window/singbox_mixin.py new file mode 100644 index 0000000..af17447 --- /dev/null +++ b/selective-vpn-gui/main_window/singbox_mixin.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from main_window.singbox import ( + SingBoxCardsMixin, + SingBoxEditorMixin, + SingBoxLinksMixin, + SingBoxRuntimeMixin, +) + + +class SingBoxMainWindowMixin( + SingBoxRuntimeMixin, + SingBoxLinksMixin, + SingBoxCardsMixin, + SingBoxEditorMixin, +): + """Facade mixin for backward-compatible MainWindow inheritance.""" + + +__all__ = ["SingBoxMainWindowMixin"] diff --git a/selective-vpn-gui/main_window/ui_helpers_mixin.py b/selective-vpn-gui/main_window/ui_helpers_mixin.py new file mode 100644 index 0000000..37816d1 --- /dev/null +++ b/selective-vpn-gui/main_window/ui_helpers_mixin.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import re + +from PySide6.QtGui import QTextCursor +from PySide6.QtWidgets import QMessageBox, QPlainTextEdit + +from main_window.constants import _NEXT_CHECK_RE + + +class UIHelpersMixin: + def _safe(self, fn, *, title: str = "Error"): + try: + return fn() + except Exception as e: # pragma: no cover - GUI + try: + self.ctrl.log_gui(f"[ui-error] {title}: {e}") + except Exception: + pass + QMessageBox.critical(self, title, str(e)) + return None + + def _set_text(self, widget: QPlainTextEdit, text: str, *, preserve_scroll: bool = False) -> None: + """Set text, optionally сохраняя положение скролла (для trace).""" + if not preserve_scroll: + widget.setPlainText(text) + return + + sb = widget.verticalScrollBar() + old_max = sb.maximum() + old_val = sb.value() + at_end = old_val >= old_max - 2 + + widget.setPlainText(text) + + new_max = sb.maximum() + if at_end: + sb.setValue(new_max) + else: + # подвинем на ту же относительную позицию, учитывая прирост размера + sb.setValue(max(0, min(new_max, old_val+(new_max-old_max)))) + + def _append_text(self, widget: QPlainTextEdit, text: str) -> None: + cursor = widget.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.insertText(text) + widget.setTextCursor(cursor) + widget.ensureCursorVisible() + + def _clean_ui_lines(self, lines) -> str: + buf = "\n".join([str(x) for x in (lines or [])]).replace("\r", "\n") + out_lines = [] + for ln in buf.splitlines(): + t = ln.strip() + if not t: + continue + t2 = _NEXT_CHECK_RE.sub("", t).strip() + if not t2: + continue + out_lines.append(t2) + return "\n".join(out_lines).rstrip() diff --git a/selective-vpn-gui/main_window/ui_location_runtime_mixin.py b/selective-vpn-gui/main_window/ui_location_runtime_mixin.py new file mode 100644 index 0000000..e8a1bcc --- /dev/null +++ b/selective-vpn-gui/main_window/ui_location_runtime_mixin.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import re +import time + +from PySide6 import QtCore +from PySide6.QtCore import QTimer, Qt +from PySide6.QtWidgets import QMessageBox + +from api_client import ApiError +from main_window.constants import LOCATION_TARGET_ROLE +from netns_debug import singbox_clients_netns_state, singbox_netns_toggle_button + + +class UILocationRuntimeMixin: + def eventFilter(self, obj, event): # pragma: no cover - GUI + cmb = getattr(self, "cmb_locations", None) + try: + view = cmb.view() if cmb is not None else None + except RuntimeError: + return super().eventFilter(obj, event) + if obj in (cmb, view): + if event.type() == QtCore.QEvent.KeyPress: + if self._handle_location_keypress(event): + return True + return super().eventFilter(obj, event) + + def _handle_location_keypress(self, event) -> bool: + key = int(event.key()) + if key == int(Qt.Key_Backspace): + if self._loc_typeahead_buf: + self._loc_typeahead_buf = self._loc_typeahead_buf[:-1] + self._apply_location_search_filter() + self.loc_typeahead_timer.start() + self.cmb_locations.showPopup() + return True + + if key == int(Qt.Key_Escape): + self._reset_location_typeahead() + return True + + text = event.text() or "" + if len(text) != 1 or not text.isprintable() or text.isspace(): + return False + + self._loc_typeahead_buf += text.lower() + count = self._apply_location_search_filter() + if count == 0 and len(self._loc_typeahead_buf) > 1: + self._loc_typeahead_buf = text.lower() + self._apply_location_search_filter() + self.loc_typeahead_timer.start() + self.cmb_locations.showPopup() + return True + + def _apply_location_search_filter(self) -> int: + source = list(self._all_locations or []) + query = (self._loc_typeahead_buf or "").strip().lower() + + if not source: + self._set_locations_combo_items([]) + return 0 + + items = source + if query: + items = [ + row + for row in source + if self._location_matches(query, row[0], row[1], row[2], row[3]) + ] + + items = self._sort_location_items(items) + self._set_locations_combo_items(items) + return len(items) + + def _location_matches( + self, + query: str, + label: str, + iso: str, + target: str, + name: str, + ) -> bool: + q = (query or "").strip().lower() + if not q: + return True + + iso_l = (iso or "").strip().lower() + label_l = (label or "").strip().lower() + target_l = (target or "").strip().lower() + name_l = (name or "").strip().lower() + + if iso_l.startswith(q): + return True + if target_l.startswith(q) or label_l.startswith(q) or name_l.startswith(q): + return True + + tokens = [t for t in re.split(r"[^\w]+", f"{target_l} {name_l} {label_l}") if t] + if any(tok.startswith(q) for tok in tokens): + return True + return q in target_l or q in name_l or q in label_l + + def _sort_location_items( + self, + items: list[tuple[str, str, str, str, int]], + ) -> list[tuple[str, str, str, str, int]]: + mode = str(self.cmb_locations_sort.currentData() or "ping").strip().lower() + if mode == "ping_desc": + return sorted(items, key=lambda x: (-x[4], x[3].lower(), x[0].lower())) + if mode == "name": + return sorted(items, key=lambda x: (x[3].lower(), x[4], x[0].lower())) + if mode == "name_desc": + return sorted(items, key=lambda x: (x[3].lower(), x[4], x[0].lower()), reverse=True) + return sorted(items, key=lambda x: (x[4], x[3].lower(), x[0].lower())) + + def _set_locations_combo_items(self, items: list[tuple[str, str, str, str, int]]) -> None: + prev_target = str(self.cmb_locations.currentData(LOCATION_TARGET_ROLE) or "").strip() + prev_iso = str(self.cmb_locations.currentData() or "").strip().upper() + desired = (self._vpn_desired_location or "").strip() + desired_l = desired.lower() + + self.cmb_locations.blockSignals(True) + self.cmb_locations.clear() + + pick = -1 + for i, (label, iso, target, _name, _ping) in enumerate(items): + self.cmb_locations.addItem(label, iso) + self.cmb_locations.setItemData(i, target, LOCATION_TARGET_ROLE) + + iso_l = (iso or "").strip().lower() + target_l = (target or "").strip().lower() + if desired_l and desired_l in (iso_l, target_l): + pick = i + if pick < 0 and prev_target and prev_target == target: + pick = i + if pick < 0 and prev_iso and prev_iso == iso: + pick = i + + if self.cmb_locations.count() > 0: + if pick < 0: + pick = 0 + self.cmb_locations.setCurrentIndex(pick) + model_index = self.cmb_locations.model().index(pick, 0) + self.cmb_locations.view().setCurrentIndex(model_index) + + self.cmb_locations.blockSignals(False) + + def _reset_location_typeahead(self) -> None: + self._loc_typeahead_buf = "" + self._apply_location_search_filter() + + def _location_name_ping(self, label: str, iso: str, target: str) -> tuple[str, int]: + text = (label or "").strip() + ping = 1_000_000 + + m = re.search(r"\((\d+)\s*ms\)\s*$", text, flags=re.IGNORECASE) + if m: + try: + ping = int(m.group(1)) + except Exception: + ping = 1_000_000 + text = text[:m.start()].strip() + + iso_pref = (iso or "").strip().upper() + pref = iso_pref + " " + if iso_pref and text.upper().startswith(pref): + text = text[len(pref):].strip() + + name = text or (target or iso_pref or "").strip() + return name, ping + + def on_locations_sort_changed(self, _index: int = 0) -> None: + self._apply_location_search_filter() + self._save_ui_preferences() + + def on_locations_refresh_click(self) -> None: + self._safe(self._trigger_locations_refresh, title="Locations refresh error") + + def _trigger_locations_refresh(self) -> None: + self.lbl_locations_meta.setText("Locations: refreshing...") + self.lbl_locations_meta.setStyleSheet("color: orange;") + self._refresh_locations_async(force_refresh=True) + + def _append_transport_log(self, line: str) -> None: + msg = (line or "").strip() + if not msg: + return + self._append_text(self.txt_transport, msg + "\n") + + def _singbox_clients_netns_state(self) -> tuple[bool, bool]: + return singbox_clients_netns_state(list(self._transport_clients or [])) + + def _refresh_transport_netns_toggle_button(self) -> None: + all_enabled, any_enabled = self._singbox_clients_netns_state() + text, color = singbox_netns_toggle_button(all_enabled, any_enabled) + self.btn_transport_netns_toggle.setText(text) + self.btn_transport_netns_toggle.setStyleSheet(f"color: {color};") + + def _selected_transport_engine_id(self) -> str: + return str(self.cmb_transport_engine.currentData() or "").strip() + + def _selected_transport_client(self): + cid = self._selected_transport_engine_id() + if not cid: + return None + for client in self._transport_clients or []: + if str(getattr(client, "id", "") or "").strip() == cid: + return client + return None + + def _transport_live_health_for_client(self, client) -> tuple[str, int, str, str]: + status = str(getattr(client, "status", "") or "").strip().lower() or "unknown" + latency = int(getattr(getattr(client, "health", None), "latency_ms", 0) or 0) + last_error = str(getattr(getattr(client, "health", None), "last_error", "") or "").strip() + last_check = str(getattr(getattr(client, "health", None), "last_check", "") or "").strip() + cid = str(getattr(client, "id", "") or "").strip() + if not cid: + return status, latency, last_error, last_check + snap = self._transport_health_live.get(cid) + if not isinstance(snap, dict): + return status, latency, last_error, last_check + snap_status = str(snap.get("status") or "").strip().lower() + if snap_status: + status = snap_status + try: + snap_latency = int(snap.get("latency_ms") or 0) + if snap_latency >= 0: + latency = snap_latency + except Exception: + pass + snap_err = str(snap.get("last_error") or "").strip() + if snap_err: + last_error = snap_err + snap_check = str(snap.get("last_check") or "").strip() + if snap_check: + last_check = snap_check + return status, latency, last_error, last_check + + def _country_flag(self, country_code: str) -> str: + cc = str(country_code or "").strip().upper() + if len(cc) != 2 or not cc.isalpha(): + return "" + try: + return "".join(chr(127397 + ord(ch)) for ch in cc) + except Exception: + return "" + + def _refresh_egress_identity_scope( + self, + scope: str, + *, + force: bool = False, + trigger_refresh: bool = True, + min_interval_sec: float = 1.0, + silent: bool = True, + ): + scope_key = str(scope or "").strip().lower() + if not scope_key: + return None + + now = time.monotonic() + last = float(self._egress_identity_last_probe_ts.get(scope_key, 0.0) or 0.0) + if not force and (now - last) < max(0.2, float(min_interval_sec)): + return self._egress_identity_cache.get(scope_key) + + self._egress_identity_last_probe_ts[scope_key] = now + try: + item = self.ctrl.egress_identity(scope_key, refresh=trigger_refresh) + self._egress_identity_cache[scope_key] = item + return item + except ApiError as e: + code = int(getattr(e, "status_code", 0) or 0) + if not silent and code != 404: + QMessageBox.warning(self, "Egress identity error", str(e)) + return self._egress_identity_cache.get(scope_key) + except Exception as e: + if not silent: + QMessageBox.warning(self, "Egress identity error", str(e)) + return self._egress_identity_cache.get(scope_key) + + def _format_egress_identity_short(self, item) -> str: + if item is None: + return "" + ip = str(getattr(item, "ip", "") or "").strip() + if not ip: + return "" + code = str(getattr(item, "country_code", "") or "").strip().upper() + flag = self._country_flag(code) + if flag: + return f"{flag} {ip}" + return ip + + def _render_vpn_egress_label(self, item) -> None: + if item is None: + self.lbl_vpn_egress.setText("Egress: n/a") + self.lbl_vpn_egress.setStyleSheet("color: gray;") + return + + ip = str(getattr(item, "ip", "") or "").strip() + code = str(getattr(item, "country_code", "") or "").strip().upper() + name = str(getattr(item, "country_name", "") or "").strip() + stale = bool(getattr(item, "stale", False)) + refreshing = bool(getattr(item, "refresh_in_progress", False)) + last_error = str(getattr(item, "last_error", "") or "").strip() + + if not ip: + if refreshing: + self.lbl_vpn_egress.setText("Egress: refreshing...") + self.lbl_vpn_egress.setStyleSheet("color: orange;") + return + if last_error: + cut = last_error if len(last_error) <= 120 else last_error[:117] + "..." + self.lbl_vpn_egress.setText(f"Egress: n/a ({cut})") + self.lbl_vpn_egress.setStyleSheet("color: red;") + return + self.lbl_vpn_egress.setText("Egress: n/a") + self.lbl_vpn_egress.setStyleSheet("color: gray;") + return + + flag = self._country_flag(code) + prefix = f"{flag} {ip}" if flag else ip + tail = "" + if name: + tail = f" ({name})" + elif code: + tail = f" ({code})" + if stale: + tail += " · stale" + self.lbl_vpn_egress.setText(f"Egress: {prefix}{tail}") + self.lbl_vpn_egress.setStyleSheet("color: orange;" if stale else "color: #1f6b2f;") + + def _poll_vpn_egress_after_switch(self, token: int, attempts_left: int) -> None: + if token != self._vpn_egress_refresh_token: + return + item = self._refresh_egress_identity_scope( + "adguardvpn", + force=True, + trigger_refresh=False, + min_interval_sec=0.0, + silent=True, + ) + self._render_vpn_egress_label(item) + if token != self._vpn_egress_refresh_token: + return + refresh_in_progress = bool(getattr(item, "refresh_in_progress", False)) if item is not None else True + has_ip = bool(str(getattr(item, "ip", "") or "").strip()) if item is not None else False + has_country = bool( + str(getattr(item, "country_code", "") or "").strip() + or str(getattr(item, "country_name", "") or "").strip() + ) if item is not None else False + if attempts_left <= 0: + return + if has_ip and has_country and not refresh_in_progress and not self._vpn_switching_active: + return + delay_ms = 450 if attempts_left > 3 else 900 + QTimer.singleShot( + delay_ms, + lambda tok=token, left=attempts_left - 1: self._poll_vpn_egress_after_switch(tok, left), + ) + + def _trigger_vpn_egress_refresh(self, *, reason: str = "") -> None: + scope = "adguardvpn" + self._vpn_egress_refresh_token += 1 + token = self._vpn_egress_refresh_token + self._egress_identity_last_probe_ts[scope] = 0.0 + self._vpn_autoloop_refresh_pending = False + self._vpn_autoloop_last_force_refresh_ts = time.monotonic() + self.lbl_vpn_egress.setText("Egress: refreshing...") + self.lbl_vpn_egress.setStyleSheet("color: orange;") + try: + self.ctrl.egress_identity_refresh(scopes=[scope], force=True) + except Exception: + pass + if reason: + try: + self.ctrl.log_gui(f"[egress] force refresh: {reason}") + except Exception: + pass + self._poll_vpn_egress_after_switch(token, attempts_left=14) + + def _normalize_vpn_autoloop_state(self, unit_text: str) -> str: + low = str(unit_text or "").strip().lower() + if ":" in low: + low = low.split(":", 1)[1].strip() + if "reconnect" in low: + return "reconnecting" + if "disconnected" in low or "inactive" in low: + return "down" + if "failed" in low or "error" in low or "dead" in low: + return "down" + if "connected" in low: + return "connected" + if "active" in low or "running" in low or "enabled" in low or "up" in low: + return "connected" + return "unknown" + + def _maybe_trigger_vpn_egress_refresh_on_autoloop(self, unit_text: str) -> None: + state = self._normalize_vpn_autoloop_state(unit_text) + prev = str(self._vpn_autoloop_last_state or "").strip().lower() + now = time.monotonic() + + if state in ("down", "reconnecting", "unknown"): + self._vpn_autoloop_refresh_pending = True + + if ( + state == "connected" + and self._vpn_autoloop_refresh_pending + and not self._vpn_switching_active + and (now - float(self._vpn_autoloop_last_force_refresh_ts or 0.0)) >= 1.0 + ): + self._trigger_vpn_egress_refresh(reason=f"autoloop {prev or 'unknown'} -> connected") + + self._vpn_autoloop_last_state = state + + def _refresh_selected_transport_health_live( + self, + *, + force: bool = False, + min_interval_sec: float = 0.8, + silent: bool = True, + ) -> bool: + if not self._transport_api_supported: + return False + cid = self._selected_transport_engine_id() + if not cid: + return False + now = time.monotonic() + if not force and (now - self._transport_health_last_probe_ts) < max(0.2, float(min_interval_sec)): + return False + self._transport_health_last_probe_ts = now + try: + snap = self.ctrl.transport_client_health(cid) + except ApiError as e: + if not silent and int(getattr(e, "status_code", 0) or 0) != 404: + QMessageBox.warning(self, "Transport health error", str(e)) + return False + except Exception as e: + if not silent: + QMessageBox.warning(self, "Transport health error", str(e)) + return False + self._transport_health_live[cid] = { + "status": str(getattr(snap, "status", "") or "").strip().lower(), + "latency_ms": int(getattr(snap, "latency_ms", 0) or 0), + "last_error": str(getattr(snap, "last_error", "") or "").strip(), + "last_check": str(getattr(snap, "last_check", "") or "").strip(), + } + self._render_singbox_profile_cards() + self._sync_singbox_profile_card_selection(cid) + self._update_transport_engine_view() diff --git a/selective-vpn-gui/main_window/ui_shell_mixin.py b/selective-vpn-gui/main_window/ui_shell_mixin.py new file mode 100644 index 0000000..76ae65f --- /dev/null +++ b/selective-vpn-gui/main_window/ui_shell_mixin.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from main_window.ui_helpers_mixin import UIHelpersMixin +from main_window.ui_location_runtime_mixin import UILocationRuntimeMixin +from main_window.ui_tabs_mixin import UITabsMixin + + +class MainWindowUIShellMixin( + UILocationRuntimeMixin, + UIHelpersMixin, + UITabsMixin, +): + """Facade mixin for backward-compatible MainWindow inheritance.""" + + +__all__ = ["MainWindowUIShellMixin"] diff --git a/selective-vpn-gui/main_window/ui_tabs_main_mixin.py b/selective-vpn-gui/main_window/ui_tabs_main_mixin.py new file mode 100644 index 0000000..792ff86 --- /dev/null +++ b/selective-vpn-gui/main_window/ui_tabs_main_mixin.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPlainTextEdit, + QListView, + QPushButton, + QStackedWidget, + QStyle, + QTabWidget, + QToolButton, + QVBoxLayout, + QWidget, + QComboBox, +) + + +class UITabsMainMixin: + def _build_ui(self) -> None: + root = QWidget() + root_layout = QVBoxLayout(root) + root.setLayout(root_layout) + self.setCentralWidget(root) + + # top bar --------------------------------------------------------- + top = QHBoxLayout() + root_layout.addLayout(top) + + # клик по этому баннеру показывает whoami + self.btn_login_banner = QPushButton("AdGuard VPN: —") + self.btn_login_banner.setFlat(True) + self.btn_login_banner.setStyleSheet( + "text-align: left; border: none; color: gray;" + ) + self.btn_login_banner.clicked.connect(self.on_login_banner_clicked) + top.addWidget(self.btn_login_banner, stretch=1) + + self.btn_auth = QPushButton("Login") + self.btn_auth.clicked.connect(self.on_auth_button) + top.addWidget(self.btn_auth) + + self.btn_refresh_all = QPushButton("Refresh all") + self.btn_refresh_all.clicked.connect(self.refresh_everything) + top.addWidget(self.btn_refresh_all) + + # tabs ------------------------------------------------------------- + self.tabs = QTabWidget() + root_layout.addWidget(self.tabs, stretch=1) + + self._build_tab_status() + self._build_tab_vpn() + self._build_tab_singbox() + self._build_tab_multiif() + self._build_tab_routes() + self._build_tab_dns() + self._build_tab_domains() + self._build_tab_trace() + + # ---------------- STATUS TAB ---------------- + + def _build_tab_status(self) -> None: + tab = QWidget() + layout = QVBoxLayout(tab) + + grid = QFormLayout() + layout.addLayout(grid) + + self.st_timestamp = QLabel("—") + self.st_counts = QLabel("—") + self.st_iface = QLabel("—") + self.st_route = QLabel("—") + self.st_routes_service = QLabel("—") + self.st_smartdns_service = QLabel("—") + self.st_vpn_service = QLabel("—") + + grid.addRow("Timestamp:", self.st_timestamp) + grid.addRow("Counts:", self.st_counts) + grid.addRow("Iface / table / mark:", self.st_iface) + grid.addRow("Policy route:", self.st_route) + grid.addRow("Routes service:", self.st_routes_service) + grid.addRow("SmartDNS:", self.st_smartdns_service) + grid.addRow("VPN service:", self.st_vpn_service) + + btns = QHBoxLayout() + layout.addLayout(btns) + btn_refresh = QPushButton("Refresh") + btn_refresh.clicked.connect(self.refresh_status_tab) + btns.addWidget(btn_refresh) + btns.addStretch(1) + + self.tabs.addTab(tab, "Status") + + # ---------------- VPN TAB ---------------- + + def _build_tab_vpn(self) -> None: + tab = QWidget() + self.tab_vpn = tab # нужно, чтобы переключаться сюда из шапки + layout = QVBoxLayout(tab) + + # stack: main vs login-flow page + self.vpn_stack = QStackedWidget() + layout.addWidget(self.vpn_stack, stretch=1) + + # ---- main page + page_main = QWidget() + main_layout = QVBoxLayout(page_main) + + # Autoconnect group + auto_group = QGroupBox("Autoconnect (AdGuardVPN autoloop)") + auto_layout = QHBoxLayout(auto_group) + self.btn_autoconnect_toggle = QPushButton("Enable autoconnect") + self.btn_autoconnect_toggle.clicked.connect(self.on_toggle_autoconnect) + auto_layout.addWidget(self.btn_autoconnect_toggle) + + auto_layout.addStretch(1) + + # справа текст "unit: active/inactive" с цветом + self.lbl_autoconnect_state = QLabel("unit: —") + self.lbl_autoconnect_state.setStyleSheet("color: gray;") + auto_layout.addWidget(self.lbl_autoconnect_state) + + main_layout.addWidget(auto_group) + + # Locations group + loc_group = QGroupBox("Location") + loc_layout = QVBoxLayout(loc_group) + loc_row = QHBoxLayout() + loc_layout.addLayout(loc_row) + + self.cmb_locations = QComboBox() + # компактный popup со скроллом, а не на весь экран + self.cmb_locations.setMaxVisibleItems(12) + self.cmb_locations.setStyleSheet("combobox-popup: 0;") + self.cmb_locations.setFocusPolicy(Qt.StrongFocus) + view = QListView() + view.setUniformItemSizes(True) + self.cmb_locations.setView(view) + self.cmb_locations.activated.connect(self.on_location_activated) + self.cmb_locations.installEventFilter(self) + view.installEventFilter(self) + + loc_row.addWidget(self.cmb_locations, stretch=1) + + self.cmb_locations_sort = QComboBox() + self.cmb_locations_sort.addItem("Sort: Ping", "ping") + self.cmb_locations_sort.addItem("Sort: Ping (slow first)", "ping_desc") + self.cmb_locations_sort.addItem("Sort: Name", "name") + self.cmb_locations_sort.addItem("Sort: Name (Z-A)", "name_desc") + self.cmb_locations_sort.currentIndexChanged.connect( + self.on_locations_sort_changed + ) + loc_row.addWidget(self.cmb_locations_sort) + + self.btn_locations_refresh = QToolButton() + self.btn_locations_refresh.setAutoRaise(True) + self.btn_locations_refresh.setIcon( + self.style().standardIcon(QStyle.SP_BrowserReload) + ) + self.btn_locations_refresh.setToolTip("Refresh locations now") + self.btn_locations_refresh.setCursor(Qt.PointingHandCursor) + self.btn_locations_refresh.setFocusPolicy(Qt.NoFocus) + self.btn_locations_refresh.clicked.connect(self.on_locations_refresh_click) + loc_row.addWidget(self.btn_locations_refresh) + + self.lbl_locations_meta = QLabel("Locations: loading...") + self.lbl_locations_meta.setStyleSheet("color: gray;") + loc_layout.addWidget(self.lbl_locations_meta) + self.lbl_vpn_egress = QLabel("Egress: n/a") + self.lbl_vpn_egress.setStyleSheet("color: gray;") + loc_layout.addWidget(self.lbl_vpn_egress) + + main_layout.addWidget(loc_group) + + # Status output + self.txt_vpn = QPlainTextEdit() + self.txt_vpn.setReadOnly(True) + main_layout.addWidget(self.txt_vpn, stretch=1) + + self.vpn_stack.addWidget(page_main) + + # ---- login page + page_login = QWidget() + lf_layout = QVBoxLayout(page_login) + + top = QHBoxLayout() + lf_layout.addLayout(top) + + self.lbl_login_flow_status = QLabel("Status: —") + top.addWidget(self.lbl_login_flow_status) + self.lbl_login_flow_email = QLabel("") + self.lbl_login_flow_email.setStyleSheet("color: gray;") + top.addWidget(self.lbl_login_flow_email) + top.addStretch(1) + + # URL + buttons row + row2 = QHBoxLayout() + lf_layout.addLayout(row2) + row2.addWidget(QLabel("URL:")) + self.edit_login_url = QLineEdit() + row2.addWidget(self.edit_login_url, stretch=1) + self.btn_login_open = QPushButton("Open") + self.btn_login_open.clicked.connect(self.on_login_open) + row2.addWidget(self.btn_login_open) + self.btn_login_copy = QPushButton("Copy") + self.btn_login_copy.clicked.connect(self.on_login_copy) + row2.addWidget(self.btn_login_copy) + self.btn_login_check = QPushButton("Check") + self.btn_login_check.clicked.connect(self.on_login_check) + row2.addWidget(self.btn_login_check) + self.btn_login_close = QPushButton("Cancel") + self.btn_login_close.clicked.connect(self.on_login_cancel) + row2.addWidget(self.btn_login_close) + self.btn_login_stop = QPushButton("Stop session") + self.btn_login_stop.clicked.connect(self.on_login_stop) + row2.addWidget(self.btn_login_stop) + + # log text + self.txt_login_flow = QPlainTextEdit() + self.txt_login_flow.setReadOnly(True) + lf_layout.addWidget(self.txt_login_flow, stretch=1) + + # bottom buttons + bottom = QHBoxLayout() + lf_layout.addLayout(bottom) + + # Start login визуально убираем, но объект оставим на всякий + self.btn_login_start = QPushButton("Start login") + self.btn_login_start.clicked.connect(self.on_start_login) + self.btn_login_start.setVisible(False) + bottom.addWidget(self.btn_login_start) + + btn_back = QPushButton("Back to VPN") + btn_back.clicked.connect(lambda: self._show_vpn_page("main")) + bottom.addWidget(btn_back) + bottom.addStretch(1) + + self.vpn_stack.addWidget(page_login) + + self.tabs.addTab(tab, "AdGuardVPN") diff --git a/selective-vpn-gui/main_window/ui_tabs_mixin.py b/selective-vpn-gui/main_window/ui_tabs_mixin.py new file mode 100644 index 0000000..18c7210 --- /dev/null +++ b/selective-vpn-gui/main_window/ui_tabs_mixin.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from main_window.ui_tabs_main_mixin import UITabsMainMixin +from main_window.ui_tabs_other_mixin import UITabsOtherMixin +from main_window.ui_tabs_singbox_mixin import UITabsSingBoxMixin + + +class UITabsMixin( + UITabsOtherMixin, + UITabsSingBoxMixin, + UITabsMainMixin, +): + """Facade mixin for MainWindow tab builders.""" + + +__all__ = ["UITabsMixin"] diff --git a/selective-vpn-gui/main_window/ui_tabs_other_mixin.py b/selective-vpn-gui/main_window/ui_tabs_other_mixin.py new file mode 100644 index 0000000..9af41c6 --- /dev/null +++ b/selective-vpn-gui/main_window/ui_tabs_other_mixin.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +from PySide6.QtWidgets import ( + QCheckBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QPlainTextEdit, + QPushButton, + QProgressBar, + QRadioButton, + QVBoxLayout, + QWidget, +) + + +class UITabsOtherMixin: + def _build_tab_routes(self) -> None: + tab = QWidget() + layout = QVBoxLayout(tab) + + # --- Service actions --- + act_group = QGroupBox("Selective routes service") + act_layout = QHBoxLayout(act_group) + + self.btn_routes_start = QPushButton("Start") + self.btn_routes_start.clicked.connect( + lambda: self.on_routes_action("start") + ) + + self.btn_routes_restart = QPushButton("Restart") + self.btn_routes_restart.clicked.connect( + lambda: self.on_routes_action("restart") + ) + + self.btn_routes_stop = QPushButton("Stop") + self.btn_routes_stop.clicked.connect( + lambda: self.on_routes_action("stop") + ) + + act_layout.addWidget(self.btn_routes_start) + act_layout.addWidget(self.btn_routes_restart) + act_layout.addWidget(self.btn_routes_stop) + act_layout.addStretch(1) + + layout.addWidget(act_group) + + # --- Timer / policy route --- + timer_group = QGroupBox("Timer") + timer_layout = QHBoxLayout(timer_group) + + self.chk_timer = QCheckBox("Enable timer") + self.chk_timer.stateChanged.connect(self.on_toggle_timer) + timer_layout.addWidget(self.chk_timer) + + self.btn_fix_policy = QPushButton("Fix policy route") + self.btn_fix_policy.clicked.connect(self.on_fix_policy_route) + timer_layout.addWidget(self.btn_fix_policy) + + timer_layout.addStretch(1) + + layout.addWidget(timer_group) + + # --- Traffic mode relay --- + traffic_group = QGroupBox("Traffic mode relay") + traffic_layout = QVBoxLayout(traffic_group) + + relay_row = QHBoxLayout() + self.btn_traffic_settings = QPushButton("Open traffic settings") + self.btn_traffic_settings.clicked.connect(self.on_open_traffic_settings) + relay_row.addWidget(self.btn_traffic_settings) + self.btn_traffic_test = QPushButton("Test mode") + self.btn_traffic_test.clicked.connect(self.on_test_traffic_mode) + relay_row.addWidget(self.btn_traffic_test) + self.btn_routes_prewarm = QPushButton("Prewarm wildcard now") + self.btn_routes_prewarm.setToolTip("""EN: Sends DNS queries for wildcard domains to prefill agvpn_dyn4 before traffic arrives. +RU: Делает DNS-запросы wildcard-доменов, чтобы заранее наполнить agvpn_dyn4.""") + self.btn_routes_prewarm.clicked.connect(self.on_smartdns_prewarm) + relay_row.addWidget(self.btn_routes_prewarm) + self.btn_routes_precheck_debug = QPushButton("Debug precheck now") + self.btn_routes_precheck_debug.setToolTip("""EN: Debug helper. Arms one-shot resolver precheck and requests routes restart now. +RU: Отладочный helper. Включает one-shot precheck резолвера и запрашивает restart routes.""") + self.btn_routes_precheck_debug.clicked.connect(self.on_routes_precheck_debug) + relay_row.addWidget(self.btn_routes_precheck_debug) + relay_row.addStretch(1) + traffic_layout.addLayout(relay_row) + + self.chk_routes_prewarm_aggressive = QCheckBox("Aggressive prewarm (use subs)") + self.chk_routes_prewarm_aggressive.setToolTip("""EN: Aggressive mode also queries subs list. This can increase DNS load. +RU: Агрессивный режим дополнительно дергает subs список. Может увеличить нагрузку на DNS.""") + self.chk_routes_prewarm_aggressive.stateChanged.connect(self._on_prewarm_aggressive_changed) + traffic_layout.addWidget(self.chk_routes_prewarm_aggressive) + + self.lbl_routes_prewarm_mode = QLabel("Prewarm mode: wildcard-only") + self.lbl_routes_prewarm_mode.setStyleSheet("color: gray;") + traffic_layout.addWidget(self.lbl_routes_prewarm_mode) + self._update_prewarm_mode_label() + + self.lbl_traffic_mode_state = QLabel("Traffic mode: —") + self.lbl_traffic_mode_state.setStyleSheet("color: gray;") + traffic_layout.addWidget(self.lbl_traffic_mode_state) + + self.lbl_traffic_mode_diag = QLabel("—") + self.lbl_traffic_mode_diag.setStyleSheet("color: gray;") + traffic_layout.addWidget(self.lbl_traffic_mode_diag) + + self.lbl_routes_resolve_summary = QLabel("Resolve summary: —") + self.lbl_routes_resolve_summary.setToolTip("""EN: Parsed from latest 'resolve summary' trace line. +RU: Берется из последней строки 'resolve summary' в trace.""") + self.lbl_routes_resolve_summary.setStyleSheet("color: gray;") + traffic_layout.addWidget(self.lbl_routes_resolve_summary) + + self.lbl_routes_recheck_summary = QLabel("Timeout recheck: —") + self.lbl_routes_recheck_summary.setToolTip("""EN: Hidden timeout-recheck counters included in resolve summary. +RU: Счетчики скрытого timeout-recheck из итогового resolve summary.""") + self.lbl_routes_recheck_summary.setStyleSheet("color: gray;") + traffic_layout.addWidget(self.lbl_routes_recheck_summary) + + layout.addWidget(traffic_group) + + # --- NFT progress (agvpn4) --- + progress_row = QHBoxLayout() + + self.routes_progress = QProgressBar() + self.routes_progress.setRange(0, 100) + self.routes_progress.setValue(0) + self.routes_progress.setFormat("") # текст выводим отдельным лейблом + self.routes_progress.setTextVisible(False) + self.routes_progress.setEnabled(False) # idle по умолчанию + + self.lbl_routes_progress = QLabel("NFT: idle") + self.lbl_routes_progress.setStyleSheet("color: gray;") + + progress_row.addWidget(self.routes_progress) + progress_row.addWidget(self.lbl_routes_progress) + + layout.addLayout(progress_row) + + # --- Log output --- + self.txt_routes = QPlainTextEdit() + self.txt_routes.setReadOnly(True) + layout.addWidget(self.txt_routes, stretch=1) + + self.tabs.addTab(tab, "Routes") + + # ---------------- DNS TAB ---------------- + + def _build_tab_dns(self) -> None: + tab = QWidget() + main_layout = QVBoxLayout(tab) + + tip = QLabel("Tip: hover fields for help. Подсказка: наведи на элементы для описания.") + tip.setWordWrap(True) + tip.setStyleSheet("color: gray;") + main_layout.addWidget(tip) + + resolver_group = QGroupBox("Resolver DNS") + resolver_group.setToolTip("""EN: Compact resolver DNS status. Open benchmark to test/apply upstreams. +RU: Компактный статус DNS резолвера. Открой benchmark для проверки/применения апстримов.""") + resolver_layout = QVBoxLayout(resolver_group) + + row = QHBoxLayout() + self.btn_dns_benchmark = QPushButton("Open DNS benchmark") + self.btn_dns_benchmark.clicked.connect(self.on_open_dns_benchmark) + row.addWidget(self.btn_dns_benchmark) + row.addStretch(1) + resolver_layout.addLayout(row) + + self.lbl_dns_resolver_upstreams = QLabel("Resolver upstreams: default[—, —] meta[—, —]") + self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;") + resolver_layout.addWidget(self.lbl_dns_resolver_upstreams) + + self.lbl_dns_resolver_health = QLabel("Resolver health: —") + self.lbl_dns_resolver_health.setStyleSheet("color: gray;") + resolver_layout.addWidget(self.lbl_dns_resolver_health) + + main_layout.addWidget(resolver_group) + + smart_group = QGroupBox("SmartDNS") + smart_group.setToolTip("""EN: SmartDNS is used for wildcard domains in hybrid mode. +RU: SmartDNS используется для wildcard-доменов в hybrid режиме.""") + smart_layout = QVBoxLayout(smart_group) + + smart_form = QFormLayout() + self.ent_smartdns_addr = QLineEdit() + self.ent_smartdns_addr.setToolTip("""EN: SmartDNS address in host#port format (example: 127.0.0.1#6053). +RU: Адрес SmartDNS в формате host#port (пример: 127.0.0.1#6053).""") + self.ent_smartdns_addr.setPlaceholderText("127.0.0.1#6053") + self.ent_smartdns_addr.textEdited.connect(self._schedule_dns_autosave) + smart_form.addRow("SmartDNS address", self.ent_smartdns_addr) + smart_layout.addLayout(smart_form) + + self.chk_dns_via_smartdns = QCheckBox("Use SmartDNS for wildcard domains") + self.chk_dns_via_smartdns.setToolTip("""EN: Hybrid wildcard mode: wildcard domains resolve via SmartDNS, other lists resolve via direct upstreams. +RU: Hybrid wildcard режим: wildcard-домены резолвятся через SmartDNS, остальные списки через direct апстримы.""") + self.chk_dns_via_smartdns.stateChanged.connect(self.on_dns_mode_toggle) + smart_layout.addWidget(self.chk_dns_via_smartdns) + + self.lbl_dns_mode_state = QLabel("Resolver mode: unknown") + self.lbl_dns_mode_state.setToolTip("""EN: Current resolver mode reported by API. +RU: Текущий режим резолвера по данным API.""") + smart_layout.addWidget(self.lbl_dns_mode_state) + + self.chk_dns_unit_relay = QCheckBox("SmartDNS unit relay: OFF") + self.chk_dns_unit_relay.setToolTip("""EN: Starts/stops smartdns-local.service. Service state is independent from resolver mode. +RU: Запускает/останавливает smartdns-local.service. Состояние сервиса не равно режиму резолвера.""") + self.chk_dns_unit_relay.stateChanged.connect(self.on_smartdns_unit_toggle) + smart_layout.addWidget(self.chk_dns_unit_relay) + + self.chk_dns_runtime_nftset = QCheckBox("SmartDNS runtime accelerator (nftset -> agvpn_dyn4): ON") + self.chk_dns_runtime_nftset.setToolTip("""EN: Optional accelerator: SmartDNS can add resolved IPs to agvpn_dyn4 in runtime (via nftset). +EN: Wildcard still works without it (resolver job + prewarm). +RU: Опциональный ускоритель: SmartDNS может добавлять IP в agvpn_dyn4 в runtime (через nftset). +RU: Wildcard работает и без него (resolver job + prewarm).""") + self.chk_dns_runtime_nftset.stateChanged.connect(self.on_smartdns_runtime_toggle) + smart_layout.addWidget(self.chk_dns_runtime_nftset) + + self.lbl_dns_wildcard_source = QLabel("Wildcard source: resolver") + self.lbl_dns_wildcard_source.setToolTip("""EN: Where wildcard IPs come from: resolver job, SmartDNS runtime nftset, or both. +RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, или оба.""") + self.lbl_dns_wildcard_source.setStyleSheet("color: gray;") + smart_layout.addWidget(self.lbl_dns_wildcard_source) + + main_layout.addWidget(smart_group) + main_layout.addStretch(1) + + self.tabs.addTab(tab, "DNS") + + # ---------------- DOMAINS TAB ---------------- + + def _build_tab_domains(self) -> None: + tab = QWidget() + main_layout = QHBoxLayout(tab) + + left = QVBoxLayout() + main_layout.addLayout(left) + + left.addWidget(QLabel("Files:")) + self.lst_files = QListWidget() + for name in ( + "bases", + "meta-special", + "subs", + "static-ips", + "last-ips-map-direct", + "last-ips-map-wildcard", + "wildcard-observed-hosts", + "smartdns.conf", + ): + QListWidgetItem(name, self.lst_files) + self.lst_files.setCurrentRow(0) + self.lst_files.itemSelectionChanged.connect(self.on_domains_load) + left.addWidget(self.lst_files) + + self.btn_domains_save = QPushButton("Save file") + self.btn_domains_save.clicked.connect(self.on_domains_save) + left.addWidget(self.btn_domains_save) + left.addStretch(1) + + right_layout = QVBoxLayout() + main_layout.addLayout(right_layout, stretch=1) + + self.lbl_domains_info = QLabel("—") + self.lbl_domains_info.setStyleSheet("color: gray;") + right_layout.addWidget(self.lbl_domains_info) + + self.txt_domains = QPlainTextEdit() + right_layout.addWidget(self.txt_domains, stretch=1) + + self.tabs.addTab(tab, "Domains") + + # ---------------- TRACE TAB ---------------- + + def _build_tab_trace(self) -> None: + tab = QWidget() + layout = QVBoxLayout(tab) + + top = QHBoxLayout() + layout.addLayout(top) + + self.radio_trace_full = QRadioButton("Full") + self.radio_trace_full.setChecked(True) + self.radio_trace_full.toggled.connect(self.refresh_trace_tab) + top.addWidget(self.radio_trace_full) + self.radio_trace_gui = QRadioButton("Events") + self.radio_trace_gui.toggled.connect(self.refresh_trace_tab) + top.addWidget(self.radio_trace_gui) + self.radio_trace_smartdns = QRadioButton("SmartDNS") + self.radio_trace_smartdns.toggled.connect(self.refresh_trace_tab) + top.addWidget(self.radio_trace_smartdns) + + btn_refresh = QPushButton("Refresh") + btn_refresh.clicked.connect(self.refresh_trace_tab) + top.addWidget(btn_refresh) + top.addStretch(1) + + self.txt_trace = QPlainTextEdit() + self.txt_trace.setReadOnly(True) + layout.addWidget(self.txt_trace, stretch=1) + + self.tabs.addTab(tab, "Trace") diff --git a/selective-vpn-gui/main_window/ui_tabs_singbox_editor_mixin.py b/selective-vpn-gui/main_window/ui_tabs_singbox_editor_mixin.py new file mode 100644 index 0000000..a1b3ec2 --- /dev/null +++ b/selective-vpn-gui/main_window/ui_tabs_singbox_editor_mixin.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +from PySide6.QtCore import QSize +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QRadioButton, + QSpinBox, + QToolButton, + QVBoxLayout, + QWidget, +) + +from main_window.constants import SINGBOX_EDITOR_PROTOCOL_IDS, SINGBOX_EDITOR_PROTOCOL_OPTIONS + + +class UITabsSingBoxEditorMixin: + def _build_singbox_vless_editor(self, parent_layout: QVBoxLayout) -> None: + grp = QGroupBox("Protocol editor (client)") + self.grp_singbox_proto_editor = grp + lay = QVBoxLayout(grp) + + self.lbl_singbox_proto_editor_info = QLabel( + "Client-side fields only. Server billing/traffic/expiry fields are excluded." + ) + self.lbl_singbox_proto_editor_info.setStyleSheet("color: gray;") + lay.addWidget(self.lbl_singbox_proto_editor_info) + + form = QFormLayout() + self.frm_singbox_proto_form = form + + self.ent_singbox_proto_name = QLineEdit() + self.ent_singbox_proto_name.setPlaceholderText("Profile name") + form.addRow("Profile name:", self.ent_singbox_proto_name) + + self.chk_singbox_proto_enabled = QCheckBox("Enabled") + self.chk_singbox_proto_enabled.setChecked(True) + form.addRow("Enabled:", self.chk_singbox_proto_enabled) + + self.cmb_singbox_proto_protocol = QComboBox() + for label, pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS: + self.cmb_singbox_proto_protocol.addItem(label, pid) + self.cmb_singbox_proto_protocol.currentIndexChanged.connect( + self.on_singbox_vless_editor_changed + ) + form.addRow("Protocol:", self.cmb_singbox_proto_protocol) + + self.ent_singbox_vless_server = QLineEdit() + self.ent_singbox_vless_server.setPlaceholderText("example.com") + form.addRow("Address:", self.ent_singbox_vless_server) + + self.spn_singbox_vless_port = QSpinBox() + self.spn_singbox_vless_port.setRange(1, 65535) + self.spn_singbox_vless_port.setValue(443) + form.addRow("Port:", self.spn_singbox_vless_port) + + self.ent_singbox_vless_uuid = QLineEdit() + self.ent_singbox_vless_uuid.setPlaceholderText("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") + form.addRow("UUID:", self.ent_singbox_vless_uuid) + + self.ent_singbox_proto_password = QLineEdit() + self.ent_singbox_proto_password.setPlaceholderText("password") + form.addRow("Password:", self.ent_singbox_proto_password) + + self.cmb_singbox_vless_flow = QComboBox() + self.cmb_singbox_vless_flow.addItem("None", "") + # sing-box v1.12/v1.13 VLESS flow preset; field remains editable for custom/raw values. + self.cmb_singbox_vless_flow.addItem("xtls-rprx-vision", "xtls-rprx-vision") + self.cmb_singbox_vless_flow.setEditable(True) + self.cmb_singbox_vless_flow.setInsertPolicy(QComboBox.NoInsert) + form.addRow("Flow:", self.cmb_singbox_vless_flow) + + self.cmb_singbox_vless_packet_encoding = QComboBox() + self.cmb_singbox_vless_packet_encoding.addItem("auto", "") + self.cmb_singbox_vless_packet_encoding.addItem("xudp", "xudp") + form.addRow("Packet encoding:", self.cmb_singbox_vless_packet_encoding) + + self.cmb_singbox_ss_method = QComboBox() + self.cmb_singbox_ss_method.setEditable(True) + self.cmb_singbox_ss_method.setInsertPolicy(QComboBox.NoInsert) + for method in ( + "aes-128-gcm", + "aes-256-gcm", + "chacha20-ietf-poly1305", + "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "none", + ): + self.cmb_singbox_ss_method.addItem(method, method) + form.addRow("SS method:", self.cmb_singbox_ss_method) + + self.ent_singbox_ss_plugin = QLineEdit() + self.ent_singbox_ss_plugin.setPlaceholderText("obfs-local;obfs=http;obfs-host=example.com") + form.addRow("SS plugin:", self.ent_singbox_ss_plugin) + + self.spn_singbox_hy2_up_mbps = QSpinBox() + self.spn_singbox_hy2_up_mbps.setRange(0, 100000) + form.addRow("HY2 up mbps:", self.spn_singbox_hy2_up_mbps) + + self.spn_singbox_hy2_down_mbps = QSpinBox() + self.spn_singbox_hy2_down_mbps.setRange(0, 100000) + form.addRow("HY2 down mbps:", self.spn_singbox_hy2_down_mbps) + + self.ent_singbox_hy2_obfs = QLineEdit() + self.ent_singbox_hy2_obfs.setPlaceholderText("salamander") + form.addRow("HY2 obfs type:", self.ent_singbox_hy2_obfs) + + self.ent_singbox_hy2_obfs_password = QLineEdit() + self.ent_singbox_hy2_obfs_password.setPlaceholderText("obfs password") + form.addRow("HY2 obfs password:", self.ent_singbox_hy2_obfs_password) + + self.cmb_singbox_tuic_congestion = QComboBox() + self.cmb_singbox_tuic_congestion.setEditable(True) + self.cmb_singbox_tuic_congestion.setInsertPolicy(QComboBox.NoInsert) + self.cmb_singbox_tuic_congestion.addItem("Default", "") + self.cmb_singbox_tuic_congestion.addItem("bbr", "bbr") + self.cmb_singbox_tuic_congestion.addItem("cubic", "cubic") + self.cmb_singbox_tuic_congestion.addItem("new_reno", "new_reno") + form.addRow("TUIC congestion:", self.cmb_singbox_tuic_congestion) + + self.cmb_singbox_tuic_udp_mode = QComboBox() + self.cmb_singbox_tuic_udp_mode.addItem("Default", "") + self.cmb_singbox_tuic_udp_mode.addItem("native", "native") + self.cmb_singbox_tuic_udp_mode.addItem("quic", "quic") + form.addRow("TUIC UDP relay:", self.cmb_singbox_tuic_udp_mode) + + self.chk_singbox_tuic_zero_rtt = QCheckBox("Enable zero RTT handshake") + form.addRow("TUIC zero RTT:", self.chk_singbox_tuic_zero_rtt) + + self.ent_singbox_wg_private_key = QLineEdit() + self.ent_singbox_wg_private_key.setPlaceholderText("wireguard private key") + self.ent_singbox_wg_private_key.setEchoMode(QLineEdit.PasswordEchoOnEdit) + form.addRow("WG private key:", self.ent_singbox_wg_private_key) + + self.ent_singbox_wg_peer_public_key = QLineEdit() + self.ent_singbox_wg_peer_public_key.setPlaceholderText("peer public key") + self.ent_singbox_wg_peer_public_key.setEchoMode(QLineEdit.PasswordEchoOnEdit) + form.addRow("WG peer public key:", self.ent_singbox_wg_peer_public_key) + + self.ent_singbox_wg_psk = QLineEdit() + self.ent_singbox_wg_psk.setPlaceholderText("pre-shared key (optional)") + self.ent_singbox_wg_psk.setEchoMode(QLineEdit.PasswordEchoOnEdit) + form.addRow("WG pre-shared key:", self.ent_singbox_wg_psk) + + self.ent_singbox_wg_local_address = QLineEdit() + self.ent_singbox_wg_local_address.setPlaceholderText("10.0.0.2/32,fd00::2/128") + form.addRow("WG local address:", self.ent_singbox_wg_local_address) + + self.ent_singbox_wg_reserved = QLineEdit() + self.ent_singbox_wg_reserved.setPlaceholderText("0,0,0 (optional)") + form.addRow("WG reserved:", self.ent_singbox_wg_reserved) + + self.spn_singbox_wg_mtu = QSpinBox() + self.spn_singbox_wg_mtu.setRange(0, 9200) + form.addRow("WG MTU:", self.spn_singbox_wg_mtu) + + self.cmb_singbox_vless_transport = QComboBox() + self.cmb_singbox_vless_transport.addItem("TCP (RAW)", "tcp") + self.cmb_singbox_vless_transport.addItem("WebSocket", "ws") + self.cmb_singbox_vless_transport.addItem("gRPC", "grpc") + self.cmb_singbox_vless_transport.addItem("HTTP", "http") + self.cmb_singbox_vless_transport.addItem("HTTP Upgrade", "httpupgrade") + self.cmb_singbox_vless_transport.addItem("QUIC", "quic") + self.cmb_singbox_vless_transport.currentIndexChanged.connect( + self.on_singbox_vless_editor_changed + ) + form.addRow("Transport:", self.cmb_singbox_vless_transport) + + self.ent_singbox_vless_path = QLineEdit() + self.ent_singbox_vless_path.setPlaceholderText("/") + form.addRow("Transport path:", self.ent_singbox_vless_path) + + self.ent_singbox_vless_grpc_service = QLineEdit() + self.ent_singbox_vless_grpc_service.setPlaceholderText("service-name") + form.addRow("gRPC service:", self.ent_singbox_vless_grpc_service) + + self.cmb_singbox_vless_security = QComboBox() + self.cmb_singbox_vless_security.addItem("None", "none") + self.cmb_singbox_vless_security.addItem("TLS", "tls") + self.cmb_singbox_vless_security.addItem("Reality", "reality") + self.cmb_singbox_vless_security.currentIndexChanged.connect( + self.on_singbox_vless_editor_changed + ) + form.addRow("Security:", self.cmb_singbox_vless_security) + + self.ent_singbox_vless_sni = QLineEdit() + self.ent_singbox_vless_sni.setPlaceholderText("www.example.com") + form.addRow("SNI:", self.ent_singbox_vless_sni) + + self.ent_singbox_tls_alpn = QLineEdit() + self.ent_singbox_tls_alpn.setPlaceholderText("h2,http/1.1") + form.addRow("TLS ALPN:", self.ent_singbox_tls_alpn) + + self.cmb_singbox_vless_utls_fp = QComboBox() + self.cmb_singbox_vless_utls_fp.addItem("Default", "") + self.cmb_singbox_vless_utls_fp.addItem("chrome", "chrome") + self.cmb_singbox_vless_utls_fp.addItem("firefox", "firefox") + self.cmb_singbox_vless_utls_fp.addItem("safari", "safari") + self.cmb_singbox_vless_utls_fp.addItem("edge", "edge") + form.addRow("uTLS fingerprint:", self.cmb_singbox_vless_utls_fp) + + self.ent_singbox_vless_reality_pk = QLineEdit() + self.ent_singbox_vless_reality_pk.setPlaceholderText("Reality public key") + form.addRow("Reality public key:", self.ent_singbox_vless_reality_pk) + + self.ent_singbox_vless_reality_sid = QLineEdit() + self.ent_singbox_vless_reality_sid.setPlaceholderText("short_id") + form.addRow("Reality short id:", self.ent_singbox_vless_reality_sid) + + self.chk_singbox_vless_insecure = QCheckBox("Allow insecure TLS") + form.addRow("TLS insecure:", self.chk_singbox_vless_insecure) + + self.chk_singbox_vless_sniff = QCheckBox("Enable sniffing for local inbound") + self.chk_singbox_vless_sniff.setChecked(True) + form.addRow("Sniffing:", self.chk_singbox_vless_sniff) + + lay.addLayout(form) + + wg_helpers = QHBoxLayout() + self.btn_singbox_wg_paste_private = QToolButton() + self.btn_singbox_wg_paste_private.setText("Paste private") + self.btn_singbox_wg_paste_private.clicked.connect( + lambda: self._paste_line_edit_from_clipboard(self.ent_singbox_wg_private_key) + ) + wg_helpers.addWidget(self.btn_singbox_wg_paste_private) + + self.btn_singbox_wg_copy_private = QToolButton() + self.btn_singbox_wg_copy_private.setText("Copy private") + self.btn_singbox_wg_copy_private.clicked.connect( + lambda: self._copy_line_edit_to_clipboard(self.ent_singbox_wg_private_key) + ) + wg_helpers.addWidget(self.btn_singbox_wg_copy_private) + + self.btn_singbox_wg_paste_peer = QToolButton() + self.btn_singbox_wg_paste_peer.setText("Paste peer") + self.btn_singbox_wg_paste_peer.clicked.connect( + lambda: self._paste_line_edit_from_clipboard(self.ent_singbox_wg_peer_public_key) + ) + wg_helpers.addWidget(self.btn_singbox_wg_paste_peer) + + self.btn_singbox_wg_copy_peer = QToolButton() + self.btn_singbox_wg_copy_peer.setText("Copy peer") + self.btn_singbox_wg_copy_peer.clicked.connect( + lambda: self._copy_line_edit_to_clipboard(self.ent_singbox_wg_peer_public_key) + ) + wg_helpers.addWidget(self.btn_singbox_wg_copy_peer) + + self.btn_singbox_wg_paste_psk = QToolButton() + self.btn_singbox_wg_paste_psk.setText("Paste PSK") + self.btn_singbox_wg_paste_psk.clicked.connect( + lambda: self._paste_line_edit_from_clipboard(self.ent_singbox_wg_psk) + ) + wg_helpers.addWidget(self.btn_singbox_wg_paste_psk) + + self.btn_singbox_wg_copy_psk = QToolButton() + self.btn_singbox_wg_copy_psk.setText("Copy PSK") + self.btn_singbox_wg_copy_psk.clicked.connect( + lambda: self._copy_line_edit_to_clipboard(self.ent_singbox_wg_psk) + ) + wg_helpers.addWidget(self.btn_singbox_wg_copy_psk) + wg_helpers.addStretch(1) + + self.wdg_singbox_wg_key_helpers = QWidget() + self.wdg_singbox_wg_key_helpers.setLayout(wg_helpers) + lay.addWidget(self.wdg_singbox_wg_key_helpers) + + self.lbl_singbox_proto_guardrails = QLabel("Guardrails: address/port/uuid required") + self.lbl_singbox_proto_guardrails.setStyleSheet("color: gray;") + lay.addWidget(self.lbl_singbox_proto_guardrails) + + parent_layout.addWidget(grp) + + self.on_singbox_vless_editor_changed() + + def _set_proto_form_row_visible(self, field: QWidget, visible: bool) -> None: + field.setVisible(visible) + label = None + form = getattr(self, "frm_singbox_proto_form", None) + if form is not None: + try: + label = form.labelForField(field) + except Exception: + label = None + if label is not None: + label.setVisible(visible) + + def _copy_line_edit_to_clipboard(self, field: QLineEdit) -> None: + txt = str(field.text() or "").strip() + if txt: + QApplication.clipboard().setText(txt) + + def _paste_line_edit_from_clipboard(self, field: QLineEdit) -> None: + txt = str(QApplication.clipboard().text() or "").strip() + field.setText(txt) + + def _current_editor_protocol(self) -> str: + return str(self.cmb_singbox_proto_protocol.currentData() or "vless").strip().lower() or "vless" + + def _is_supported_editor_protocol(self, protocol: str) -> bool: + return str(protocol or "").strip().lower() in SINGBOX_EDITOR_PROTOCOL_IDS + + def on_singbox_vless_editor_changed(self, _index: int = 0) -> None: + protocol = self._current_editor_protocol() + self._singbox_editor_protocol = protocol + + transport = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower() + security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower() + if protocol == "vless": + self.cmb_singbox_vless_security.setEnabled(True) + elif protocol == "trojan": + if security == "reality": + idx = self.cmb_singbox_vless_security.findData("tls") + self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 1) + security = "tls" + self.cmb_singbox_vless_security.setEnabled(True) + elif protocol in ("hysteria2", "tuic"): + idx = self.cmb_singbox_vless_security.findData("tls") + self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 1) + security = "tls" + self.cmb_singbox_vless_security.setEnabled(False) + elif protocol == "wireguard": + idx = self.cmb_singbox_vless_security.findData("none") + self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 0) + security = "none" + self.cmb_singbox_vless_security.setEnabled(False) + else: + idx = self.cmb_singbox_vless_security.findData("none") + self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 0) + security = "none" + self.cmb_singbox_vless_security.setEnabled(False) + + path_needed = transport in ("ws", "http", "httpupgrade") + grpc_needed = transport == "grpc" + transport_supported = protocol in ("vless", "trojan") + + self.cmb_singbox_vless_transport.setEnabled(transport_supported) + self.ent_singbox_vless_path.setEnabled(transport_supported and path_needed) + self.ent_singbox_vless_grpc_service.setEnabled(transport_supported and grpc_needed) + + tls_like = security in ("tls", "reality") + reality = security == "reality" + + self.ent_singbox_vless_sni.setEnabled(tls_like) + self.ent_singbox_tls_alpn.setEnabled(tls_like) + self.cmb_singbox_vless_utls_fp.setEnabled(tls_like) + self.chk_singbox_vless_insecure.setEnabled(tls_like) + self.ent_singbox_vless_reality_pk.setEnabled(reality) + self.ent_singbox_vless_reality_sid.setEnabled(reality) + + show_vless_auth = protocol == "vless" + show_password = protocol in ("trojan", "shadowsocks", "hysteria2", "tuic") + show_ss = protocol == "shadowsocks" + show_hy2 = protocol == "hysteria2" + show_tuic = protocol == "tuic" + show_wg = protocol == "wireguard" + + self._set_proto_form_row_visible(self.ent_singbox_vless_uuid, show_vless_auth or show_tuic) + self._set_proto_form_row_visible(self.ent_singbox_proto_password, show_password) + self._set_proto_form_row_visible(self.cmb_singbox_vless_flow, show_vless_auth) + self._set_proto_form_row_visible(self.cmb_singbox_vless_packet_encoding, show_vless_auth) + + self._set_proto_form_row_visible(self.cmb_singbox_ss_method, show_ss) + self._set_proto_form_row_visible(self.ent_singbox_ss_plugin, show_ss) + + self._set_proto_form_row_visible(self.spn_singbox_hy2_up_mbps, show_hy2) + self._set_proto_form_row_visible(self.spn_singbox_hy2_down_mbps, show_hy2) + self._set_proto_form_row_visible(self.ent_singbox_hy2_obfs, show_hy2) + self._set_proto_form_row_visible(self.ent_singbox_hy2_obfs_password, show_hy2) + + self._set_proto_form_row_visible(self.cmb_singbox_tuic_congestion, show_tuic) + self._set_proto_form_row_visible(self.cmb_singbox_tuic_udp_mode, show_tuic) + self._set_proto_form_row_visible(self.chk_singbox_tuic_zero_rtt, show_tuic) + + self._set_proto_form_row_visible(self.ent_singbox_wg_private_key, show_wg) + self._set_proto_form_row_visible(self.ent_singbox_wg_peer_public_key, show_wg) + self._set_proto_form_row_visible(self.ent_singbox_wg_psk, show_wg) + self._set_proto_form_row_visible(self.ent_singbox_wg_local_address, show_wg) + self._set_proto_form_row_visible(self.ent_singbox_wg_reserved, show_wg) + self._set_proto_form_row_visible(self.spn_singbox_wg_mtu, show_wg) + self.wdg_singbox_wg_key_helpers.setVisible(show_wg) + + self._set_proto_form_row_visible(self.cmb_singbox_vless_transport, transport_supported) + self._set_proto_form_row_visible(self.ent_singbox_vless_path, transport_supported) + self._set_proto_form_row_visible(self.ent_singbox_vless_grpc_service, transport_supported) + + self._set_proto_form_row_visible(self.cmb_singbox_vless_security, protocol not in ("shadowsocks", "wireguard")) + self._set_proto_form_row_visible(self.ent_singbox_vless_sni, tls_like) + self._set_proto_form_row_visible(self.ent_singbox_tls_alpn, tls_like) + self._set_proto_form_row_visible(self.cmb_singbox_vless_utls_fp, tls_like) + self._set_proto_form_row_visible(self.chk_singbox_vless_insecure, tls_like) + self._set_proto_form_row_visible(self.ent_singbox_vless_reality_pk, reality) + self._set_proto_form_row_visible(self.ent_singbox_vless_reality_sid, reality) + + tips = ["Guardrails:"] + if protocol == "vless": + tips.append("address/port/uuid required") + elif protocol == "trojan": + tips.append("address/port/password required") + elif protocol == "shadowsocks": + tips.append("address/port/SS method/password required") + elif protocol == "hysteria2": + tips.append("address/port/password required") + elif protocol == "tuic": + tips.append("address/port/uuid/password required") + elif protocol == "wireguard": + tips.append("address/port/private_key/peer_public_key/local_address required") + if reality: + tips.append("reality.public_key is required") + if transport_supported and grpc_needed: + tips.append("gRPC service is required") + if transport_supported and path_needed: + tips.append("transport path is required") + self.lbl_singbox_proto_guardrails.setText(" | ".join(tips)) diff --git a/selective-vpn-gui/main_window/ui_tabs_singbox_layout_mixin.py b/selective-vpn-gui/main_window/ui_tabs_singbox_layout_mixin.py new file mode 100644 index 0000000..537bcd6 --- /dev/null +++ b/selective-vpn-gui/main_window/ui_tabs_singbox_layout_mixin.py @@ -0,0 +1,734 @@ +from __future__ import annotations + +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QComboBox, + QFormLayout, + QGroupBox, + QHeaderView, + QHBoxLayout, + QLabel, + QLineEdit, + QListView, + QListWidget, + QPlainTextEdit, + QProgressBar, + QPushButton, + QScrollArea, + QSpinBox, + QTableWidget, + QStyle, + QToolButton, + QVBoxLayout, + QWidget, + QFrame, +) + + +class UITabsSingBoxLayoutMixin: + def _build_tab_singbox(self) -> None: + tab = QWidget() + layout = QVBoxLayout(tab) + + metrics_row = QHBoxLayout() + layout.addLayout(metrics_row) + + ( + _card_conn, + self.lbl_singbox_metric_conn_value, + self.lbl_singbox_metric_conn_sub, + ) = self._create_singbox_metric_card("Connection") + metrics_row.addWidget(_card_conn, stretch=1) + + ( + _card_profile, + self.lbl_singbox_metric_profile_value, + self.lbl_singbox_metric_profile_sub, + ) = self._create_singbox_metric_card("Profile") + metrics_row.addWidget(_card_profile, stretch=1) + + ( + _card_proto, + self.lbl_singbox_metric_proto_value, + self.lbl_singbox_metric_proto_sub, + ) = self._create_singbox_metric_card("Protocol / Transport / Security") + metrics_row.addWidget(_card_proto, stretch=1) + + ( + _card_policy, + self.lbl_singbox_metric_policy_value, + self.lbl_singbox_metric_policy_sub, + ) = self._create_singbox_metric_card("Routing / DNS / Killswitch") + metrics_row.addWidget(_card_policy, stretch=1) + + profiles_group = QGroupBox("Connection profiles") + profiles_layout = QVBoxLayout(profiles_group) + profiles_actions = QHBoxLayout() + self.btn_singbox_profile_create = QPushButton("Create connection") + self.btn_singbox_profile_create.clicked.connect(self.on_singbox_create_connection_click) + profiles_actions.addWidget(self.btn_singbox_profile_create) + profiles_actions.addStretch(1) + profiles_layout.addLayout(profiles_actions) + + self.lst_singbox_profile_cards = QListWidget() + self.lst_singbox_profile_cards.setViewMode(QListView.IconMode) + self.lst_singbox_profile_cards.setResizeMode(QListView.Adjust) + self.lst_singbox_profile_cards.setMovement(QListView.Static) + self.lst_singbox_profile_cards.setWrapping(True) + self.lst_singbox_profile_cards.setSpacing(8) + self.lst_singbox_profile_cards.setGridSize(QSize(240, 88)) + self.lst_singbox_profile_cards.setMinimumHeight(110) + self.lst_singbox_profile_cards.setContextMenuPolicy(Qt.CustomContextMenu) + self.lst_singbox_profile_cards.customContextMenuRequested.connect( + self.on_singbox_profile_card_context_menu + ) + self.lst_singbox_profile_cards.itemSelectionChanged.connect( + self.on_singbox_profile_card_selected + ) + profiles_layout.addWidget(self.lst_singbox_profile_cards) + layout.addWidget(profiles_group) + + card_group = QGroupBox("Connection card (runtime)") + card_layout = QVBoxLayout(card_group) + card_row = QHBoxLayout() + card_layout.addLayout(card_row) + + self.lbl_transport_selected_engine = QLabel("Selected profile: —") + self.lbl_transport_selected_engine.setStyleSheet("color: gray;") + card_row.addWidget(self.lbl_transport_selected_engine, stretch=1) + + self.cmb_transport_engine = QComboBox() + self.cmb_transport_engine.setMaxVisibleItems(10) + self.cmb_transport_engine.currentIndexChanged.connect( + self.on_transport_engine_selected + ) + # Hidden selector: internal state source (tiles are the visible selection control). + self.cmb_transport_engine.setVisible(False) + + self.btn_transport_engine_refresh = QToolButton() + self.btn_transport_engine_refresh.setAutoRaise(True) + self.btn_transport_engine_refresh.setIcon( + self.style().standardIcon(QStyle.SP_BrowserReload) + ) + self.btn_transport_engine_refresh.setToolTip("Refresh engines") + self.btn_transport_engine_refresh.clicked.connect( + self.on_transport_engine_refresh + ) + card_row.addWidget(self.btn_transport_engine_refresh) + + self.btn_transport_engine_provision = QPushButton("Prepare") + self.btn_transport_engine_provision.setToolTip( + "Optional: pre-provision runtime/config artifacts for selected profile" + ) + self.btn_transport_engine_provision.clicked.connect( + lambda: self.on_transport_engine_action("provision") + ) + card_row.addWidget(self.btn_transport_engine_provision) + + self.btn_transport_engine_toggle = QPushButton("Disconnected") + self.btn_transport_engine_toggle.setCheckable(True) + self.btn_transport_engine_toggle.setToolTip( + "Toggle connection for selected profile" + ) + self.btn_transport_engine_toggle.clicked.connect( + self.on_transport_engine_toggle + ) + card_row.addWidget(self.btn_transport_engine_toggle) + + self.btn_transport_engine_restart = QPushButton("Restart") + self.btn_transport_engine_restart.clicked.connect( + lambda: self.on_transport_engine_action("restart") + ) + card_row.addWidget(self.btn_transport_engine_restart) + + self.btn_transport_engine_rollback = QPushButton("Rollback policy") + self.btn_transport_engine_rollback.clicked.connect( + self.on_transport_policy_rollback + ) + card_row.addWidget(self.btn_transport_engine_rollback) + + self.btn_transport_netns_toggle = QPushButton("Debug netns: OFF") + self.btn_transport_netns_toggle.setToolTip( + "Toggle netns for all SingBox engines (debug/testing)" + ) + self.btn_transport_netns_toggle.clicked.connect( + self.on_transport_netns_toggle + ) + card_row.addWidget(self.btn_transport_netns_toggle) + + self.lbl_transport_engine_meta = QLabel("Engine: loading...") + self.lbl_transport_engine_meta.setStyleSheet("color: gray;") + card_layout.addWidget(self.lbl_transport_engine_meta) + + layout.addWidget(card_group) + + settings_toggle_row = QHBoxLayout() + self.btn_singbox_toggle_profile_settings = QPushButton("Profile settings") + self.btn_singbox_toggle_profile_settings.setCheckable(True) + self.btn_singbox_toggle_profile_settings.clicked.connect( + self.on_toggle_singbox_profile_settings + ) + settings_toggle_row.addWidget(self.btn_singbox_toggle_profile_settings) + self.btn_singbox_toggle_global_defaults = QPushButton("Global defaults") + self.btn_singbox_toggle_global_defaults.setCheckable(True) + self.btn_singbox_toggle_global_defaults.clicked.connect( + self.on_toggle_singbox_global_defaults + ) + settings_toggle_row.addWidget(self.btn_singbox_toggle_global_defaults) + self.btn_singbox_toggle_activity = QPushButton("Activity log") + self.btn_singbox_toggle_activity.setCheckable(True) + self.btn_singbox_toggle_activity.clicked.connect( + self.on_toggle_singbox_activity + ) + settings_toggle_row.addWidget(self.btn_singbox_toggle_activity) + settings_toggle_row.addStretch(1) + layout.addLayout(settings_toggle_row) + + profile_group = QGroupBox("Profile settings (SingBox)") + self.grp_singbox_profile_settings = profile_group + profile_layout = QVBoxLayout(profile_group) + + self.lbl_singbox_profile_name = QLabel("Profile: —") + self.lbl_singbox_profile_name.setStyleSheet("color: gray;") + profile_layout.addWidget(self.lbl_singbox_profile_name) + + profile_scope_row = QHBoxLayout() + self.chk_singbox_profile_use_global_routing = QCheckBox("Use global routing defaults") + self.chk_singbox_profile_use_global_routing.stateChanged.connect( + self.on_singbox_profile_scope_changed + ) + profile_scope_row.addWidget(self.chk_singbox_profile_use_global_routing) + self.chk_singbox_profile_use_global_dns = QCheckBox("Use global DNS defaults") + self.chk_singbox_profile_use_global_dns.stateChanged.connect( + self.on_singbox_profile_scope_changed + ) + profile_scope_row.addWidget(self.chk_singbox_profile_use_global_dns) + self.chk_singbox_profile_use_global_killswitch = QCheckBox("Use global kill-switch defaults") + self.chk_singbox_profile_use_global_killswitch.stateChanged.connect( + self.on_singbox_profile_scope_changed + ) + profile_scope_row.addWidget(self.chk_singbox_profile_use_global_killswitch) + profile_scope_row.addStretch(1) + profile_layout.addLayout(profile_scope_row) + + profile_form = QFormLayout() + self.cmb_singbox_profile_routing = QComboBox() + self.cmb_singbox_profile_routing.addItem("Global default", "global") + self.cmb_singbox_profile_routing.addItem("Selective", "selective") + self.cmb_singbox_profile_routing.addItem("Full tunnel", "full") + self.cmb_singbox_profile_routing.currentIndexChanged.connect( + self.on_singbox_profile_scope_changed + ) + profile_form.addRow("Routing mode:", self.cmb_singbox_profile_routing) + + self.cmb_singbox_profile_dns = QComboBox() + self.cmb_singbox_profile_dns.addItem("Global default", "global") + self.cmb_singbox_profile_dns.addItem("System resolver", "system_resolver") + self.cmb_singbox_profile_dns.addItem("SingBox DNS", "singbox_dns") + self.cmb_singbox_profile_dns.currentIndexChanged.connect( + self.on_singbox_profile_scope_changed + ) + profile_form.addRow("DNS mode:", self.cmb_singbox_profile_dns) + + self.cmb_singbox_profile_killswitch = QComboBox() + self.cmb_singbox_profile_killswitch.addItem("Global default", "global") + self.cmb_singbox_profile_killswitch.addItem("Enabled", "on") + self.cmb_singbox_profile_killswitch.addItem("Disabled", "off") + self.cmb_singbox_profile_killswitch.currentIndexChanged.connect( + self.on_singbox_profile_scope_changed + ) + profile_form.addRow("Kill-switch:", self.cmb_singbox_profile_killswitch) + profile_layout.addLayout(profile_form) + + profile_actions = QHBoxLayout() + self.btn_singbox_profile_preview = QPushButton("Preview render") + self.btn_singbox_profile_preview.clicked.connect(self.on_singbox_profile_preview) + profile_actions.addWidget(self.btn_singbox_profile_preview) + self.btn_singbox_profile_validate = QPushButton("Validate profile") + self.btn_singbox_profile_validate.clicked.connect(self.on_singbox_profile_validate) + profile_actions.addWidget(self.btn_singbox_profile_validate) + self.btn_singbox_profile_apply = QPushButton("Apply profile") + self.btn_singbox_profile_apply.clicked.connect(self.on_singbox_profile_apply) + profile_actions.addWidget(self.btn_singbox_profile_apply) + self.btn_singbox_profile_rollback = QPushButton("Rollback profile") + self.btn_singbox_profile_rollback.clicked.connect(self.on_singbox_profile_rollback) + profile_actions.addWidget(self.btn_singbox_profile_rollback) + self.btn_singbox_profile_history = QPushButton("History") + self.btn_singbox_profile_history.clicked.connect(self.on_singbox_profile_history) + profile_actions.addWidget(self.btn_singbox_profile_history) + self.btn_singbox_profile_save = QPushButton("Save draft") + self.btn_singbox_profile_save.clicked.connect(self.on_singbox_profile_save) + profile_actions.addWidget(self.btn_singbox_profile_save) + profile_actions.addStretch(1) + profile_layout.addLayout(profile_actions) + + self.lbl_singbox_profile_effective = QLabel("Effective: routing=— | dns=— | kill-switch=—") + self.lbl_singbox_profile_effective.setStyleSheet("color: gray;") + profile_layout.addWidget(self.lbl_singbox_profile_effective) + self._build_singbox_vless_editor(profile_layout) + self._singbox_editor_default_title = self.grp_singbox_proto_editor.title() + self.grp_singbox_proto_editor.setVisible(False) + self.lbl_singbox_editor_hint = QLabel("Right-click a profile card and select Edit to open protocol settings.") + self.lbl_singbox_editor_hint.setStyleSheet("color: gray;") + profile_layout.addWidget(self.lbl_singbox_editor_hint) + + layout.addWidget(profile_group) + profile_group.setVisible(False) + + global_group = QGroupBox("Global defaults") + self.grp_singbox_global_defaults = global_group + global_layout = QVBoxLayout(global_group) + global_form = QFormLayout() + + self.cmb_singbox_global_routing = QComboBox() + self.cmb_singbox_global_routing.addItem("Selective", "selective") + self.cmb_singbox_global_routing.addItem("Full tunnel", "full") + self.cmb_singbox_global_routing.currentIndexChanged.connect( + self.on_singbox_global_defaults_changed + ) + global_form.addRow("Default routing mode:", self.cmb_singbox_global_routing) + + self.cmb_singbox_global_dns = QComboBox() + self.cmb_singbox_global_dns.addItem("System resolver", "system_resolver") + self.cmb_singbox_global_dns.addItem("SingBox DNS", "singbox_dns") + self.cmb_singbox_global_dns.currentIndexChanged.connect( + self.on_singbox_global_defaults_changed + ) + global_form.addRow("Default DNS mode:", self.cmb_singbox_global_dns) + + self.cmb_singbox_global_killswitch = QComboBox() + self.cmb_singbox_global_killswitch.addItem("Enabled", "on") + self.cmb_singbox_global_killswitch.addItem("Disabled", "off") + self.cmb_singbox_global_killswitch.currentIndexChanged.connect( + self.on_singbox_global_defaults_changed + ) + global_form.addRow("Default kill-switch:", self.cmb_singbox_global_killswitch) + global_layout.addLayout(global_form) + + global_actions = QHBoxLayout() + self.btn_singbox_global_save = QPushButton("Save global defaults") + self.btn_singbox_global_save.clicked.connect(self.on_singbox_global_save) + global_actions.addWidget(self.btn_singbox_global_save) + global_actions.addStretch(1) + global_layout.addLayout(global_actions) + + self.lbl_singbox_global_hint = QLabel( + "Global defaults are used by profiles with 'Use global ...' enabled." + ) + self.lbl_singbox_global_hint.setStyleSheet("color: gray;") + global_layout.addWidget(self.lbl_singbox_global_hint) + + layout.addWidget(global_group) + global_group.setVisible(False) + + # During UI construction routes/dns widgets are not fully created yet, + # so apply local SingBox control state without touching global save path. + self._apply_singbox_profile_controls() + + # Multi-interface routing tools are placed on a dedicated tab. + self.grp_singbox_activity = QGroupBox("Activity log") + activity_layout = QVBoxLayout(self.grp_singbox_activity) + self.txt_transport = QPlainTextEdit() + self.txt_transport.setReadOnly(True) + activity_layout.addWidget(self.txt_transport) + layout.addWidget(self.grp_singbox_activity, stretch=1) + self.grp_singbox_activity.setVisible(False) + self._apply_singbox_compact_visibility() + + self.tabs.addTab(tab, "SingBox") + + def _build_tab_multiif(self) -> None: + tab = QWidget() + layout = QVBoxLayout(tab) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + + scroll_content = QWidget() + scroll_layout = QVBoxLayout(scroll_content) + scroll_layout.setContentsMargins(0, 0, 0, 0) + scroll_layout.setSpacing(8) + + self.grp_singbox_owner_locks = self._build_singbox_owner_locks_group() + scroll_layout.addWidget(self.grp_singbox_owner_locks) + scroll_layout.addStretch(1) + + scroll.setWidget(scroll_content) + layout.addWidget(scroll, stretch=1) + + self.tabs.addTab(tab, "MultiIF") + + def _build_singbox_owner_locks_group(self) -> QGroupBox: + group = QGroupBox("Routing policy & ownership locks") + owner_locks_layout = QVBoxLayout(group) + + owner_actions = QHBoxLayout() + self.btn_singbox_owner_locks_refresh = QPushButton("Refresh locks") + self.btn_singbox_owner_locks_refresh.clicked.connect( + self.on_singbox_owner_locks_refresh + ) + owner_actions.addWidget(self.btn_singbox_owner_locks_refresh) + self.btn_singbox_owner_locks_clear = QPushButton("Clear locks...") + self.btn_singbox_owner_locks_clear.clicked.connect( + self.on_singbox_owner_locks_clear + ) + owner_actions.addWidget(self.btn_singbox_owner_locks_clear) + owner_actions.addWidget(QLabel("Engine:")) + self.cmb_singbox_owner_engine_scope = QComboBox() + self.cmb_singbox_owner_engine_scope.addItem("All", "all") + self.cmb_singbox_owner_engine_scope.addItem("Transport", "transport") + self.cmb_singbox_owner_engine_scope.addItem("AdGuard VPN", "adguardvpn") + self.cmb_singbox_owner_engine_scope.currentIndexChanged.connect( + self.on_singbox_owner_engine_scope_changed + ) + owner_actions.addWidget(self.cmb_singbox_owner_engine_scope) + owner_actions.addStretch(1) + owner_locks_layout.addLayout(owner_actions) + + self.lbl_singbox_owner_locks_summary = QLabel("Ownership: — | Locks: —") + self.lbl_singbox_owner_locks_summary.setStyleSheet("color: gray;") + owner_locks_layout.addWidget(self.lbl_singbox_owner_locks_summary) + + self.lbl_singbox_interfaces_hint = QLabel("Interfaces (read-only)") + self.lbl_singbox_interfaces_hint.setStyleSheet("color: #666;") + owner_locks_layout.addWidget(self.lbl_singbox_interfaces_hint) + + self.tbl_singbox_interfaces = QTableWidget(0, 7) + self.tbl_singbox_interfaces.setHorizontalHeaderLabels( + ["Iface ID", "Mode", "Runtime iface", "NetNS", "Routing table", "Clients UP/Total", "Updated"] + ) + self.tbl_singbox_interfaces.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tbl_singbox_interfaces.setSelectionMode(QAbstractItemView.SingleSelection) + self.tbl_singbox_interfaces.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.tbl_singbox_interfaces.verticalHeader().setVisible(False) + self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) + self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents) + self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents) + self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(6, QHeaderView.Stretch) + self.tbl_singbox_interfaces.setMinimumHeight(120) + owner_locks_layout.addWidget(self.tbl_singbox_interfaces) + + self.lbl_singbox_policy_quick_help = QLabel( + "Policy flow: 1) Add demo/fill intent -> 2) Validate policy -> 3) Validate & apply." + ) + self.lbl_singbox_policy_quick_help.setStyleSheet("color: #1f6b2f;") + owner_locks_layout.addWidget(self.lbl_singbox_policy_quick_help) + + policy_group = QGroupBox("Policy intents") + policy_layout = QVBoxLayout(policy_group) + + self.lbl_singbox_policy_input_help = QLabel( + "Intent fields: selector type | selector value | client | mode | priority" + ) + self.lbl_singbox_policy_input_help.setStyleSheet("color: #666;") + policy_layout.addWidget(self.lbl_singbox_policy_input_help) + + policy_template_row = QHBoxLayout() + self.cmb_singbox_policy_template = QComboBox() + self.cmb_singbox_policy_template.addItem("Quick template...", "") + self.cmb_singbox_policy_template.addItem( + "Domain -> active client (strict)", + { + "selector_type": "domain", + "selector_value": "example.com", + "mode": "strict", + "priority": 100, + }, + ) + self.cmb_singbox_policy_template.addItem( + "Wildcard domain (fallback)", + { + "selector_type": "domain", + "selector_value": "*.example.com", + "mode": "fallback", + "priority": 200, + }, + ) + self.cmb_singbox_policy_template.addItem( + "CIDR subnet (strict)", + { + "selector_type": "cidr", + "selector_value": "1.2.3.0/24", + "mode": "strict", + "priority": 100, + }, + ) + self.cmb_singbox_policy_template.addItem( + "IP host (strict)", + { + "selector_type": "cidr", + "selector_value": "1.2.3.4", + "mode": "strict", + "priority": 100, + }, + ) + self.cmb_singbox_policy_template.addItem( + "App key (strict)", + { + "selector_type": "app_key", + "selector_value": "steam", + "mode": "strict", + "priority": 100, + }, + ) + self.cmb_singbox_policy_template.addItem( + "UID (strict)", + { + "selector_type": "uid", + "selector_value": "1000", + "mode": "strict", + "priority": 100, + }, + ) + self.cmb_singbox_policy_template.setToolTip( + "Prefill intent fields from a template. It does not add to draft automatically." + ) + policy_template_row.addWidget(self.cmb_singbox_policy_template, stretch=2) + self.btn_singbox_policy_use_template = QPushButton("Use template") + self.btn_singbox_policy_use_template.clicked.connect(self.on_singbox_policy_use_template) + policy_template_row.addWidget(self.btn_singbox_policy_use_template) + self.btn_singbox_policy_add_demo = QPushButton("Add demo intent") + self.btn_singbox_policy_add_demo.setToolTip( + "Create one test intent (domain -> selected client) and add it to draft." + ) + self.btn_singbox_policy_add_demo.clicked.connect(self.on_singbox_policy_add_demo_intent) + policy_template_row.addWidget(self.btn_singbox_policy_add_demo) + policy_template_row.addStretch(1) + policy_layout.addLayout(policy_template_row) + + policy_input_row = QHBoxLayout() + self.cmb_singbox_policy_selector_type = QComboBox() + self.cmb_singbox_policy_selector_type.addItem("domain", "domain") + self.cmb_singbox_policy_selector_type.addItem("cidr", "cidr") + self.cmb_singbox_policy_selector_type.addItem("app_key", "app_key") + self.cmb_singbox_policy_selector_type.addItem("cgroup", "cgroup") + self.cmb_singbox_policy_selector_type.addItem("uid", "uid") + self.cmb_singbox_policy_selector_type.currentIndexChanged.connect( + self.on_singbox_policy_selector_type_changed + ) + policy_input_row.addWidget(self.cmb_singbox_policy_selector_type) + + self.ent_singbox_policy_selector_value = QLineEdit() + self.ent_singbox_policy_selector_value.setPlaceholderText("example.com") + self.ent_singbox_policy_selector_value.setToolTip( + "Examples: domain=example.com, cidr=1.2.3.0/24, app_key=steam, cgroup=user.slice/..., uid=1000. Press Enter to add intent." + ) + self.ent_singbox_policy_selector_value.returnPressed.connect(self.on_singbox_policy_add_intent) + policy_input_row.addWidget(self.ent_singbox_policy_selector_value, stretch=2) + + self.cmb_singbox_policy_client_id = QComboBox() + self.cmb_singbox_policy_client_id.setMinimumWidth(180) + policy_input_row.addWidget(self.cmb_singbox_policy_client_id, stretch=1) + + self.cmb_singbox_policy_mode = QComboBox() + self.cmb_singbox_policy_mode.addItem("strict", "strict") + self.cmb_singbox_policy_mode.addItem("fallback", "fallback") + policy_input_row.addWidget(self.cmb_singbox_policy_mode) + + self.spn_singbox_policy_priority = QSpinBox() + self.spn_singbox_policy_priority.setRange(1, 10000) + self.spn_singbox_policy_priority.setValue(100) + self.spn_singbox_policy_priority.setToolTip("Intent priority") + policy_input_row.addWidget(self.spn_singbox_policy_priority) + + self.btn_singbox_policy_add = QPushButton("Add intent") + self.btn_singbox_policy_add.clicked.connect(self.on_singbox_policy_add_intent) + policy_input_row.addWidget(self.btn_singbox_policy_add) + + self.btn_singbox_policy_load_selected = QPushButton("Load selected") + self.btn_singbox_policy_load_selected.clicked.connect(self.on_singbox_policy_load_selected_intent) + policy_input_row.addWidget(self.btn_singbox_policy_load_selected) + + self.btn_singbox_policy_update_selected = QPushButton("Update selected") + self.btn_singbox_policy_update_selected.clicked.connect(self.on_singbox_policy_update_selected_intent) + policy_input_row.addWidget(self.btn_singbox_policy_update_selected) + + self.btn_singbox_policy_remove = QPushButton("Remove selected") + self.btn_singbox_policy_remove.clicked.connect(self.on_singbox_policy_remove_selected) + policy_input_row.addWidget(self.btn_singbox_policy_remove) + policy_layout.addLayout(policy_input_row) + + policy_actions_row = QHBoxLayout() + self.btn_singbox_policy_reload = QPushButton("Reload policy") + self.btn_singbox_policy_reload.clicked.connect(self.on_singbox_policy_reload) + policy_actions_row.addWidget(self.btn_singbox_policy_reload) + self.btn_singbox_policy_validate = QPushButton("Validate policy") + self.btn_singbox_policy_validate.clicked.connect(self.on_singbox_policy_validate) + policy_actions_row.addWidget(self.btn_singbox_policy_validate) + self.btn_singbox_policy_apply = QPushButton("Validate & apply") + self.btn_singbox_policy_apply.clicked.connect(self.on_singbox_policy_apply) + policy_actions_row.addWidget(self.btn_singbox_policy_apply) + self.btn_singbox_policy_rollback = QPushButton("Rollback policy") + self.btn_singbox_policy_rollback.clicked.connect(self.on_singbox_policy_rollback_explicit) + policy_actions_row.addWidget(self.btn_singbox_policy_rollback) + policy_actions_row.addStretch(1) + policy_layout.addLayout(policy_actions_row) + + self.lbl_singbox_policy_state = QLabel("Policy editor: loading...") + self.lbl_singbox_policy_state.setStyleSheet("color: gray;") + policy_layout.addWidget(self.lbl_singbox_policy_state) + + self.lbl_singbox_policy_conflicts_hint = QLabel( + "Validation conflicts (last validate/apply, read-only)" + ) + self.lbl_singbox_policy_conflicts_hint.setStyleSheet("color: #666;") + policy_layout.addWidget(self.lbl_singbox_policy_conflicts_hint) + + self.tbl_singbox_policy_conflicts = QTableWidget(0, 5) + self.tbl_singbox_policy_conflicts.setHorizontalHeaderLabels( + ["Type", "Severity", "Owners", "Reason", "Suggested resolution"] + ) + self.tbl_singbox_policy_conflicts.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tbl_singbox_policy_conflicts.setSelectionMode(QAbstractItemView.SingleSelection) + self.tbl_singbox_policy_conflicts.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.tbl_singbox_policy_conflicts.verticalHeader().setVisible(False) + self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) + self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(4, QHeaderView.Stretch) + self.tbl_singbox_policy_conflicts.setMinimumHeight(100) + policy_layout.addWidget(self.tbl_singbox_policy_conflicts) + + self.tbl_singbox_policy_intents = QTableWidget(0, 5) + self.tbl_singbox_policy_intents.setHorizontalHeaderLabels( + ["Selector type", "Selector value", "Client ID", "Mode", "Priority"] + ) + self.tbl_singbox_policy_intents.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tbl_singbox_policy_intents.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.tbl_singbox_policy_intents.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.tbl_singbox_policy_intents.verticalHeader().setVisible(False) + self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_intents.setMinimumHeight(130) + self.tbl_singbox_policy_intents.itemDoubleClicked.connect( + self.on_singbox_policy_intent_double_clicked + ) + policy_layout.addWidget(self.tbl_singbox_policy_intents) + + self.lbl_singbox_policy_applied_hint = QLabel( + "Applied intents (read-only, current backend policy)" + ) + self.lbl_singbox_policy_applied_hint.setStyleSheet("color: #666;") + policy_layout.addWidget(self.lbl_singbox_policy_applied_hint) + + self.tbl_singbox_policy_applied = QTableWidget(0, 5) + self.tbl_singbox_policy_applied.setHorizontalHeaderLabels( + ["Selector type", "Selector value", "Client ID", "Mode", "Priority"] + ) + self.tbl_singbox_policy_applied.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tbl_singbox_policy_applied.setSelectionMode(QAbstractItemView.SingleSelection) + self.tbl_singbox_policy_applied.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.tbl_singbox_policy_applied.verticalHeader().setVisible(False) + self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents) + self.tbl_singbox_policy_applied.setMinimumHeight(110) + policy_layout.addWidget(self.tbl_singbox_policy_applied) + + owner_locks_layout.addWidget(policy_group) + + self.lbl_singbox_ownership_hint = QLabel( + "Ownership (read-only, populated after policy apply)" + ) + self.lbl_singbox_ownership_hint.setStyleSheet("color: #666;") + owner_locks_layout.addWidget(self.lbl_singbox_ownership_hint) + + self.tbl_singbox_ownership = QTableWidget(0, 6) + self.tbl_singbox_ownership.setHorizontalHeaderLabels( + ["Selector", "Owner", "Owner scope", "Iface / table", "Status", "Lock"] + ) + self.tbl_singbox_ownership.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tbl_singbox_ownership.setSelectionMode(QAbstractItemView.SingleSelection) + self.tbl_singbox_ownership.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.tbl_singbox_ownership.verticalHeader().setVisible(False) + self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) + self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents) + self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents) + self.tbl_singbox_ownership.setMinimumHeight(130) + owner_locks_layout.addWidget(self.tbl_singbox_ownership) + + filters_row = QHBoxLayout() + self.ent_singbox_owner_lock_client = QLineEdit() + self.ent_singbox_owner_lock_client.setPlaceholderText("Filter client_id (optional)") + filters_row.addWidget(self.ent_singbox_owner_lock_client, stretch=1) + self.ent_singbox_owner_lock_destination = QLineEdit() + self.ent_singbox_owner_lock_destination.setPlaceholderText( + "Destination IP or CSV list (optional)" + ) + filters_row.addWidget(self.ent_singbox_owner_lock_destination, stretch=2) + owner_locks_layout.addLayout(filters_row) + + self.lbl_singbox_locks_hint = QLabel( + "Destination locks (read-only, conntrack sticky state)" + ) + self.lbl_singbox_locks_hint.setStyleSheet("color: #666;") + owner_locks_layout.addWidget(self.lbl_singbox_locks_hint) + + self.tbl_singbox_owner_locks = QTableWidget(0, 6) + self.tbl_singbox_owner_locks.setHorizontalHeaderLabels( + ["Destination", "Owner", "Kind", "Iface", "Mark/Proto", "Updated"] + ) + self.tbl_singbox_owner_locks.setSelectionBehavior(QAbstractItemView.SelectRows) + self.tbl_singbox_owner_locks.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.tbl_singbox_owner_locks.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.tbl_singbox_owner_locks.verticalHeader().setVisible(False) + self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) + self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents) + self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(5, QHeaderView.Stretch) + self.tbl_singbox_owner_locks.setMinimumHeight(170) + owner_locks_layout.addWidget(self.tbl_singbox_owner_locks) + + self.lbl_singbox_owner_locks_hint = QLabel( + "Clear flow is two-step confirm. Empty filter uses selected destination rows." + ) + self.lbl_singbox_owner_locks_hint.setStyleSheet("color: gray;") + owner_locks_layout.addWidget(self.lbl_singbox_owner_locks_hint) + return group + + def _create_singbox_metric_card(self, title: str) -> tuple[QFrame, QLabel, QLabel]: + frame = QFrame() + frame.setFrameShape(QFrame.StyledPanel) + frame.setObjectName("singboxMetricCard") + frame.setStyleSheet( + """ + QFrame#singboxMetricCard { + border: 1px solid #c9c9c9; + border-radius: 6px; + background: #f7f7f7; + } + """ + ) + lay = QVBoxLayout(frame) + lay.setContentsMargins(10, 8, 10, 8) + lay.setSpacing(2) + + lbl_title = QLabel(title) + lbl_title.setStyleSheet("color: #555; font-size: 11px;") + lay.addWidget(lbl_title) + + lbl_value = QLabel("—") + lbl_value.setStyleSheet("font-weight: 600;") + lay.addWidget(lbl_value) + + lbl_sub = QLabel("—") + lbl_sub.setStyleSheet("color: #666; font-size: 11px;") + lay.addWidget(lbl_sub) + return frame, lbl_value, lbl_sub diff --git a/selective-vpn-gui/main_window/ui_tabs_singbox_mixin.py b/selective-vpn-gui/main_window/ui_tabs_singbox_mixin.py new file mode 100644 index 0000000..978bde9 --- /dev/null +++ b/selective-vpn-gui/main_window/ui_tabs_singbox_mixin.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from main_window.ui_tabs_singbox_editor_mixin import UITabsSingBoxEditorMixin +from main_window.ui_tabs_singbox_layout_mixin import UITabsSingBoxLayoutMixin + + +class UITabsSingBoxMixin( + UITabsSingBoxEditorMixin, + UITabsSingBoxLayoutMixin, +): + """Facade mixin for SingBox tab UI builders.""" + + +__all__ = ["UITabsSingBoxMixin"] diff --git a/selective-vpn-gui/main_window/workers.py b/selective-vpn-gui/main_window/workers.py new file mode 100644 index 0000000..eaa6536 --- /dev/null +++ b/selective-vpn-gui/main_window/workers.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import time + +from PySide6 import QtCore + +from dashboard_controller import DashboardController + + +class EventThread(QtCore.QThread): + eventReceived = QtCore.Signal(object) + error = QtCore.Signal(str) + + def __init__(self, controller: DashboardController, parent=None) -> None: + super().__init__(parent) + self.ctrl = controller + self._stop = False + self._since = 0 + + def stop(self) -> None: + self._stop = True + + def run(self) -> None: # pragma: no cover - thread + while not self._stop: + try: + for ev in self.ctrl.iter_events(since=self._since, stop=lambda: self._stop): + if self._stop: + break + try: + self._since = int(getattr(ev, "id", self._since)) + except Exception: + pass + self.eventReceived.emit(ev) + time.sleep(0.5) + except Exception as e: + self.error.emit(str(e)) + time.sleep(1.5) + + +class LocationsThread(QtCore.QThread): + loaded = QtCore.Signal(object) + error = QtCore.Signal(str) + + def __init__( + self, + controller: DashboardController, + force_refresh: bool = False, + parent=None, + ) -> None: + super().__init__(parent) + self.ctrl = controller + self.force_refresh = bool(force_refresh) + + def run(self) -> None: # pragma: no cover - thread + try: + if self.force_refresh: + self.ctrl.vpn_locations_refresh_trigger() + self.loaded.emit(self.ctrl.vpn_locations_state_view()) + except Exception as e: + self.error.emit(str(e)) + + +__all__ = ["EventThread", "LocationsThread"] diff --git a/selective-vpn-gui/netns_debug.py b/selective-vpn-gui/netns_debug.py new file mode 100644 index 0000000..12f74d3 --- /dev/null +++ b/selective-vpn-gui/netns_debug.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import Any, Callable, Iterable + + +def singbox_clients_netns_state(clients: Iterable[Any]) -> tuple[bool, bool]: + flags: list[bool] = [] + for client in clients: + cfg = getattr(client, "config", {}) or {} + if not isinstance(cfg, dict): + cfg = {} + flags.append(bool(cfg.get("netns_enabled", False))) + if not flags: + return False, False + return all(flags), any(flags) + + +def singbox_netns_toggle_button(all_enabled: bool, any_enabled: bool) -> tuple[str, str]: + if all_enabled: + return "Debug netns: ON", "green" + if any_enabled: + return "Debug netns: MIXED", "orange" + return "Debug netns: OFF", "gray" + + +def apply_singbox_netns_toggle( + controller: Any, + clients: Iterable[Any], + target_enabled: bool, + log_line: Callable[[str], None], +) -> list[str]: + failures: list[str] = [] + client_ids: list[str] = [] + for client in clients: + cid = str(getattr(client, "id", "") or "").strip() + if cid: + client_ids.append(cid) + + if not client_ids: + return ["no SingBox clients selected"] + + target = bool(target_enabled) + result = controller.transport_netns_toggle( + enabled=target, + client_ids=client_ids, + provision=True, + restart_running=True, + ) + + summary = (result.message or "").strip() + if summary: + log_line(summary) + + for item in list(result.items or []): + cid = str(getattr(item, "client_id", "") or "").strip() or "unknown" + msg = str(getattr(item, "message", "") or "").strip() + code = str(getattr(item, "code", "") or "").strip() + status_before = str(getattr(item, "status_before", "") or "").strip().lower() + status_after = str(getattr(item, "status_after", "") or "").strip().lower() + config_updated = bool(getattr(item, "config_updated", False)) + provisioned = bool(getattr(item, "provisioned", False)) + restarted = bool(getattr(item, "restarted", False)) + ok = bool(getattr(item, "ok", False)) + + steps: list[str] = [] + if config_updated: + steps.append("config") + if provisioned: + steps.append("provision") + if restarted: + steps.append("restart") + step_text = ",".join(steps) if steps else "noop" + + parts = [f"{cid}: {'ok' if ok else 'fail'}", f"steps={step_text}"] + if status_before or status_after: + parts.append(f"status {status_before or '-'}->{status_after or '-'}") + if msg: + parts.append(msg) + elif code: + parts.append(code) + log_line(" | ".join(parts)) + + if not ok: + failures.append(f"{cid}: {msg or code or 'toggle failed'}") + + if not bool(getattr(result, "ok", False)) and not failures: + failures.append((summary or "netns toggle failed").strip()) + return failures diff --git a/selective-vpn-gui/transport_protocol_summary.py b/selective-vpn-gui/transport_protocol_summary.py new file mode 100644 index 0000000..b5b459c --- /dev/null +++ b/selective-vpn-gui/transport_protocol_summary.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class TransportProtocolInfo: + protocol: str = "" + transport: str = "" + security: str = "" + + def summary(self) -> str: + proto = self.protocol if self.protocol else "n/a" + transport = self.transport if self.transport else "n/a" + security = self.security if self.security else "n/a" + return f"{proto} / {transport} / {security}" + + +def transport_protocol_info(client: Any) -> TransportProtocolInfo: + cfg = _as_dict(getattr(client, "config", {}) or {}) + protocol = _first_non_empty( + cfg.get("protocol"), + cfg.get("profile_protocol"), + cfg.get("outbound"), + cfg.get("type"), + ).lower() + transport = _first_non_empty( + cfg.get("transport"), + cfg.get("network"), + cfg.get("stream"), + ).lower() + security = _normalize_security( + _first_non_empty( + cfg.get("security"), + cfg.get("tls_security"), + cfg.get("security_mode"), + ) + ) + + if not protocol or not transport or not security: + raw_cfg = _load_raw_config_from_client_config(cfg) + if raw_cfg: + p2, t2, s2 = _infer_from_raw_config(raw_cfg) + if not protocol and p2: + protocol = p2 + if not transport and t2: + transport = t2 + if not security and s2: + security = s2 + + if protocol and not transport: + transport = _default_transport_for_protocol(protocol) + if protocol and not security: + security = "none" + return TransportProtocolInfo(protocol=protocol, transport=transport, security=security) + + +def transport_protocol_summary(client: Any) -> str: + return transport_protocol_info(client).summary() + + +def _infer_from_raw_config(raw_cfg: dict[str, Any]) -> tuple[str, str, str]: + outbounds = raw_cfg.get("outbounds") or [] + if isinstance(outbounds, list): + for row in outbounds: + if not isinstance(row, dict): + continue + out_type = str(row.get("type") or "").strip().lower() + if not out_type or out_type in ("direct", "block", "dns"): + continue + tx = "" + transport_obj = row.get("transport") + if isinstance(transport_obj, dict): + tx = str(transport_obj.get("type") or "").strip().lower() + if not tx: + tx = str(row.get("network") or "").strip().lower() + sec = _extract_security(row) + return out_type, tx, sec + + inbounds = raw_cfg.get("inbounds") or [] + if isinstance(inbounds, list): + for row in inbounds: + if not isinstance(row, dict): + continue + in_type = str(row.get("type") or "").strip().lower() + if not in_type: + continue + network = str(row.get("network") or "").strip().lower() + sec = _extract_security(row) + return in_type, network, sec + + return "", "", "" + + +def _load_raw_config_from_client_config(cfg: dict[str, Any]) -> dict[str, Any]: + path = _first_non_empty( + cfg.get("config_path"), + cfg.get("singbox_config_path"), + cfg.get("raw_config_path"), + ) + if not path: + return {} + try: + with open(path, "r", encoding="utf-8") as f: + parsed = json.load(f) + except Exception: + return {} + if not isinstance(parsed, dict): + return {} + return parsed + + +def _as_dict(raw: Any) -> dict[str, Any]: + return raw if isinstance(raw, dict) else {} + + +def _normalize_security(value: str) -> str: + sec = str(value or "").strip().lower() + if not sec: + return "" + aliases = { + "off": "none", + "disabled": "none", + "plain": "none", + "reality-tls": "reality", + "xtls": "tls", + } + return aliases.get(sec, sec) + + +def _extract_security(node: dict[str, Any]) -> str: + sec = _normalize_security(_first_non_empty(node.get("security"), node.get("tls_security"))) + if sec: + return sec + + tls = _as_dict(node.get("tls")) + if not tls: + return "" + enabled_raw = tls.get("enabled") + if enabled_raw is False: + return "none" + + reality = _as_dict(tls.get("reality")) + if reality: + if _truthy(reality.get("enabled")): + return "reality" + if _first_non_empty(reality.get("public_key"), reality.get("short_id"), reality.get("short_ids")): + return "reality" + return "tls" + + +def _truthy(raw: Any) -> bool: + if isinstance(raw, bool): + return raw + if isinstance(raw, int): + return raw != 0 + if isinstance(raw, str): + return raw.strip().lower() in ("1", "true", "yes", "on") + return False + + +def _default_transport_for_protocol(protocol: str) -> str: + p = str(protocol or "").strip().lower() + if p in ("vless", "trojan", "shadowsocks", "socks", "http"): + return "tcp" + if p in ("wireguard", "hysteria2", "tuic"): + return "udp" + return "" + + +def _first_non_empty(*values: Any) -> str: + for value in values: + s = str(value or "").strip() + if s: + return s + return "" diff --git a/selective-vpn-gui/vpn_dashboard_qt.py b/selective-vpn-gui/vpn_dashboard_qt.py index 9b95739..04ddec2 100755 --- a/selective-vpn-gui/vpn_dashboard_qt.py +++ b/selective-vpn-gui/vpn_dashboard_qt.py @@ -1,80 +1,25 @@ #!/usr/bin/env python3 from __future__ import annotations -import re -import subprocess import sys -import time -from typing import Literal +from typing import Any -from PySide6 import QtCore -from PySide6.QtCore import Qt, QSettings, QTimer -from PySide6.QtGui import QTextCursor -from PySide6.QtWidgets import ( - QApplication, - QComboBox, - QFormLayout, - QGroupBox, - QHBoxLayout, - QLabel, - QListView, - QListWidget, - QListWidgetItem, - QMainWindow, - QMessageBox, - QPushButton, - QPlainTextEdit, - QRadioButton, - QStackedWidget, - QTabWidget, - QVBoxLayout, - QWidget, - QLineEdit, - QCheckBox, - QProgressBar, -) +from PySide6.QtCore import QSettings, QTimer +from PySide6.QtWidgets import QApplication, QMainWindow from api_client import ApiClient -from dashboard_controller import DashboardController, TraceMode -from dns_benchmark_dialog import DNSBenchmarkDialog -from traffic_mode_dialog import TrafficModeDialog +from dashboard_controller import DashboardController +from main_window.runtime_actions_mixin import MainWindowRuntimeActionsMixin +from main_window.singbox_mixin import SingBoxMainWindowMixin +from main_window.ui_shell_mixin import MainWindowUIShellMixin +from main_window.workers import EventThread, LocationsThread -_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s") -LoginPage = Literal["main", "login"] - - -class EventThread(QtCore.QThread): - eventReceived = QtCore.Signal(object) - error = QtCore.Signal(str) - - def __init__(self, controller: DashboardController, parent=None) -> None: - super().__init__(parent) - self.ctrl = controller - self._stop = False - self._since = 0 - - def stop(self) -> None: - self._stop = True - - def run(self) -> None: # pragma: no cover - thread - while not self._stop: - try: - for ev in self.ctrl.iter_events(since=self._since, stop=lambda: self._stop): - if self._stop: - break - try: - self._since = int(getattr(ev, "id", self._since)) - except Exception: - pass - self.eventReceived.emit(ev) - # graceful end -> short delay - time.sleep(0.5) - except Exception as e: - self.error.emit(str(e)) - time.sleep(1.5) - - -class MainWindow(QMainWindow): +class MainWindow( + MainWindowRuntimeActionsMixin, + SingBoxMainWindowMixin, + MainWindowUIShellMixin, + QMainWindow, +): def __init__(self, controller: DashboardController) -> None: super().__init__() self.ctrl = controller @@ -87,6 +32,43 @@ class MainWindow(QMainWindow): self._login_cursor: int = 0 self._login_url_opened: bool = False self.events_thread: EventThread | None = None + self.locations_thread: LocationsThread | None = None + self._locations_refresh_pending: bool = False + self._locations_force_refresh_pending: bool = False + self._vpn_desired_location: str = "" + self._vpn_desired_location_last_seen: str = "" + self._vpn_switching_active: bool = False + self._vpn_switching_target: str = "" + self._vpn_switching_started_at: float = 0.0 + self._vpn_switching_seen_non_connected: bool = False + self._vpn_switching_min_visible_sec: float = 1.2 + self._vpn_switching_timeout_sec: float = 15.0 + self._loc_typeahead_buf: str = "" + self._all_locations: list[tuple[str, str, str, str, int]] = [] + self._transport_clients = [] + self._transport_policy_clients = [] + self._transport_api_supported: bool = True + self._transport_kind: str = "singbox" + self._transport_health_live: dict[str, dict[str, Any]] = {} + self._transport_health_last_probe_ts: float = 0.0 + self._egress_identity_cache: dict[str, Any] = {} + self._egress_identity_last_probe_ts: dict[str, float] = {} + self._vpn_egress_refresh_token: int = 0 + self._vpn_autoloop_last_state: str = "" + self._vpn_autoloop_refresh_pending: bool = False + self._vpn_autoloop_last_force_refresh_ts: float = 0.0 + self._syncing_singbox_selection: bool = False + self._singbox_editor_loading: bool = False + self._singbox_editor_profile_id: str = "" + self._singbox_editor_profile_client_id: str = "" + self._singbox_editor_protocol: str = "vless" + self._singbox_editor_source_raw: dict[str, Any] = {} + self._transport_policy_base_revision: int = 0 + self._transport_policy_draft_intents: list[Any] = [] + self._transport_policy_applied_intents: list[Any] = [] + self._transport_policy_last_conflicts: list[Any] = [] + self._transport_policy_dirty: bool = False + self._transport_policy_last_apply_id: str = "" self._routes_progress_last: int = 0 self._dns_ui_refresh: bool = False self._ui_settings = QSettings("AdGuardVPN", "SelectiveVPNDashboardQt") @@ -100,1502 +82,24 @@ class MainWindow(QMainWindow): self.dns_save_timer.setInterval(700) self.dns_save_timer.timeout.connect(self._apply_dns_autosave) + self.loc_typeahead_timer = QTimer(self) + self.loc_typeahead_timer.setSingleShot(True) + self.loc_typeahead_timer.setInterval(900) + self.loc_typeahead_timer.timeout.connect(self._reset_location_typeahead) + self._build_ui() self._load_ui_preferences() self.refresh_everything() self._start_events_stream() # ---------------- UI BUILD ---------------- + # UI shell/build + locations/egress helpers вынесены в main_window/ui_shell_mixin.py - def _build_ui(self) -> None: - root = QWidget() - root_layout = QVBoxLayout(root) - root.setLayout(root_layout) - self.setCentralWidget(root) + # SingBox UI/actions/editor вынесены в main_window/singbox_mixin.py - # top bar --------------------------------------------------------- - top = QHBoxLayout() - root_layout.addLayout(top) - # клик по этому баннеру показывает whoami - self.btn_login_banner = QPushButton("AdGuard VPN: —") - self.btn_login_banner.setFlat(True) - self.btn_login_banner.setStyleSheet( - "text-align: left; border: none; color: gray;" - ) - self.btn_login_banner.clicked.connect(self.on_login_banner_clicked) - top.addWidget(self.btn_login_banner, stretch=1) + # Runtime/refresh/actions вынесены в main_window/runtime_actions_mixin.py - self.btn_auth = QPushButton("Login") - self.btn_auth.clicked.connect(self.on_auth_button) - top.addWidget(self.btn_auth) - - self.btn_refresh_all = QPushButton("Refresh all") - self.btn_refresh_all.clicked.connect(self.refresh_everything) - top.addWidget(self.btn_refresh_all) - - # tabs ------------------------------------------------------------- - self.tabs = QTabWidget() - root_layout.addWidget(self.tabs, stretch=1) - - self._build_tab_status() - self._build_tab_vpn() - self._build_tab_routes() - self._build_tab_dns() - self._build_tab_domains() - self._build_tab_trace() - - # ---------------- STATUS TAB ---------------- - - def _build_tab_status(self) -> None: - tab = QWidget() - layout = QVBoxLayout(tab) - - grid = QFormLayout() - layout.addLayout(grid) - - self.st_timestamp = QLabel("—") - self.st_counts = QLabel("—") - self.st_iface = QLabel("—") - self.st_route = QLabel("—") - self.st_routes_service = QLabel("—") - self.st_smartdns_service = QLabel("—") - self.st_vpn_service = QLabel("—") - - grid.addRow("Timestamp:", self.st_timestamp) - grid.addRow("Counts:", self.st_counts) - grid.addRow("Iface / table / mark:", self.st_iface) - grid.addRow("Policy route:", self.st_route) - grid.addRow("Routes service:", self.st_routes_service) - grid.addRow("SmartDNS:", self.st_smartdns_service) - grid.addRow("VPN service:", self.st_vpn_service) - - btns = QHBoxLayout() - layout.addLayout(btns) - btn_refresh = QPushButton("Refresh") - btn_refresh.clicked.connect(self.refresh_status_tab) - btns.addWidget(btn_refresh) - btns.addStretch(1) - - self.tabs.addTab(tab, "Status") - - # ---------------- VPN TAB ---------------- - - def _build_tab_vpn(self) -> None: - tab = QWidget() - self.tab_vpn = tab # нужно, чтобы переключаться сюда из шапки - layout = QVBoxLayout(tab) - - # stack: main vs login-flow page - self.vpn_stack = QStackedWidget() - layout.addWidget(self.vpn_stack, stretch=1) - - # ---- main page - page_main = QWidget() - main_layout = QVBoxLayout(page_main) - - # Autoconnect group - auto_group = QGroupBox("Autoconnect (AdGuardVPN autoloop)") - auto_layout = QHBoxLayout(auto_group) - self.btn_autoconnect_toggle = QPushButton("Enable autoconnect") - self.btn_autoconnect_toggle.clicked.connect(self.on_toggle_autoconnect) - auto_layout.addWidget(self.btn_autoconnect_toggle) - - auto_layout.addStretch(1) - - # справа текст "unit: active/inactive" с цветом - self.lbl_autoconnect_state = QLabel("unit: —") - self.lbl_autoconnect_state.setStyleSheet("color: gray;") - auto_layout.addWidget(self.lbl_autoconnect_state) - - main_layout.addWidget(auto_group) - - # Locations group - loc_group = QGroupBox("Location") - loc_layout = QHBoxLayout(loc_group) - - self.cmb_locations = QComboBox() - # компактный popup со скроллом, а не на весь экран - self.cmb_locations.setMaxVisibleItems(12) - self.cmb_locations.setStyleSheet("combobox-popup: 0;") - view = QListView() - view.setUniformItemSizes(True) - self.cmb_locations.setView(view) - - loc_layout.addWidget(self.cmb_locations, stretch=1) - - self.btn_set_location = QPushButton("Apply & restart loop") - self.btn_set_location.clicked.connect(self.on_set_location) - loc_layout.addWidget(self.btn_set_location) - - main_layout.addWidget(loc_group) - - # Status output - self.txt_vpn = QPlainTextEdit() - self.txt_vpn.setReadOnly(True) - main_layout.addWidget(self.txt_vpn, stretch=1) - - self.vpn_stack.addWidget(page_main) - - # ---- login page - page_login = QWidget() - lf_layout = QVBoxLayout(page_login) - - top = QHBoxLayout() - lf_layout.addLayout(top) - - self.lbl_login_flow_status = QLabel("Status: —") - top.addWidget(self.lbl_login_flow_status) - self.lbl_login_flow_email = QLabel("") - self.lbl_login_flow_email.setStyleSheet("color: gray;") - top.addWidget(self.lbl_login_flow_email) - top.addStretch(1) - - # URL + buttons row - row2 = QHBoxLayout() - lf_layout.addLayout(row2) - row2.addWidget(QLabel("URL:")) - self.edit_login_url = QLineEdit() - row2.addWidget(self.edit_login_url, stretch=1) - self.btn_login_open = QPushButton("Open") - self.btn_login_open.clicked.connect(self.on_login_open) - row2.addWidget(self.btn_login_open) - self.btn_login_copy = QPushButton("Copy") - self.btn_login_copy.clicked.connect(self.on_login_copy) - row2.addWidget(self.btn_login_copy) - self.btn_login_check = QPushButton("Check") - self.btn_login_check.clicked.connect(self.on_login_check) - row2.addWidget(self.btn_login_check) - self.btn_login_close = QPushButton("Cancel") - self.btn_login_close.clicked.connect(self.on_login_cancel) - row2.addWidget(self.btn_login_close) - self.btn_login_stop = QPushButton("Stop session") - self.btn_login_stop.clicked.connect(self.on_login_stop) - row2.addWidget(self.btn_login_stop) - - # log text - self.txt_login_flow = QPlainTextEdit() - self.txt_login_flow.setReadOnly(True) - lf_layout.addWidget(self.txt_login_flow, stretch=1) - - # bottom buttons - bottom = QHBoxLayout() - lf_layout.addLayout(bottom) - - # Start login визуально убираем, но объект оставим на всякий - self.btn_login_start = QPushButton("Start login") - self.btn_login_start.clicked.connect(self.on_start_login) - self.btn_login_start.setVisible(False) - bottom.addWidget(self.btn_login_start) - - btn_back = QPushButton("Back to VPN") - btn_back.clicked.connect(lambda: self._show_vpn_page("main")) - bottom.addWidget(btn_back) - bottom.addStretch(1) - - self.vpn_stack.addWidget(page_login) - - self.tabs.addTab(tab, "AdGuardVPN") - - # ---------------- ROUTES TAB ---------------- - - def _build_tab_routes(self) -> None: - tab = QWidget() - layout = QVBoxLayout(tab) - - # --- Service actions --- - act_group = QGroupBox("Selective routes service") - act_layout = QHBoxLayout(act_group) - - self.btn_routes_start = QPushButton("Start") - self.btn_routes_start.clicked.connect( - lambda: self.on_routes_action("start") - ) - - self.btn_routes_restart = QPushButton("Restart") - self.btn_routes_restart.clicked.connect( - lambda: self.on_routes_action("restart") - ) - - self.btn_routes_stop = QPushButton("Stop") - self.btn_routes_stop.clicked.connect( - lambda: self.on_routes_action("stop") - ) - - act_layout.addWidget(self.btn_routes_start) - act_layout.addWidget(self.btn_routes_restart) - act_layout.addWidget(self.btn_routes_stop) - act_layout.addStretch(1) - - layout.addWidget(act_group) - - # --- Timer / policy route --- - timer_group = QGroupBox("Timer") - timer_layout = QHBoxLayout(timer_group) - - self.chk_timer = QCheckBox("Enable timer") - self.chk_timer.stateChanged.connect(self.on_toggle_timer) - timer_layout.addWidget(self.chk_timer) - - self.btn_fix_policy = QPushButton("Fix policy route") - self.btn_fix_policy.clicked.connect(self.on_fix_policy_route) - timer_layout.addWidget(self.btn_fix_policy) - - timer_layout.addStretch(1) - - layout.addWidget(timer_group) - - # --- Traffic mode relay --- - traffic_group = QGroupBox("Traffic mode relay") - traffic_layout = QVBoxLayout(traffic_group) - - relay_row = QHBoxLayout() - self.btn_traffic_settings = QPushButton("Open traffic settings") - self.btn_traffic_settings.clicked.connect(self.on_open_traffic_settings) - relay_row.addWidget(self.btn_traffic_settings) - self.btn_traffic_test = QPushButton("Test mode") - self.btn_traffic_test.clicked.connect(self.on_test_traffic_mode) - relay_row.addWidget(self.btn_traffic_test) - self.btn_routes_prewarm = QPushButton("Prewarm wildcard now") - self.btn_routes_prewarm.setToolTip("""EN: Sends DNS queries for wildcard domains to prefill agvpn_dyn4 before traffic arrives. -RU: Делает DNS-запросы wildcard-доменов, чтобы заранее наполнить agvpn_dyn4.""") - self.btn_routes_prewarm.clicked.connect(self.on_smartdns_prewarm) - relay_row.addWidget(self.btn_routes_prewarm) - self.btn_routes_precheck_debug = QPushButton("Debug precheck now") - self.btn_routes_precheck_debug.setToolTip("""EN: Debug helper. Arms one-shot resolver precheck and requests routes restart now. -RU: Отладочный helper. Включает one-shot precheck резолвера и запрашивает restart routes.""") - self.btn_routes_precheck_debug.clicked.connect(self.on_routes_precheck_debug) - relay_row.addWidget(self.btn_routes_precheck_debug) - relay_row.addStretch(1) - traffic_layout.addLayout(relay_row) - - self.chk_routes_prewarm_aggressive = QCheckBox("Aggressive prewarm (use subs)") - self.chk_routes_prewarm_aggressive.setToolTip("""EN: Aggressive mode also queries subs list. This can increase DNS load. -RU: Агрессивный режим дополнительно дергает subs список. Может увеличить нагрузку на DNS.""") - self.chk_routes_prewarm_aggressive.stateChanged.connect(self._on_prewarm_aggressive_changed) - traffic_layout.addWidget(self.chk_routes_prewarm_aggressive) - - self.lbl_routes_prewarm_mode = QLabel("Prewarm mode: wildcard-only") - self.lbl_routes_prewarm_mode.setStyleSheet("color: gray;") - traffic_layout.addWidget(self.lbl_routes_prewarm_mode) - self._update_prewarm_mode_label() - - self.lbl_traffic_mode_state = QLabel("Traffic mode: —") - self.lbl_traffic_mode_state.setStyleSheet("color: gray;") - traffic_layout.addWidget(self.lbl_traffic_mode_state) - - self.lbl_traffic_mode_diag = QLabel("—") - self.lbl_traffic_mode_diag.setStyleSheet("color: gray;") - traffic_layout.addWidget(self.lbl_traffic_mode_diag) - - self.lbl_routes_resolve_summary = QLabel("Resolve summary: —") - self.lbl_routes_resolve_summary.setToolTip("""EN: Parsed from latest 'resolve summary' trace line. -RU: Берется из последней строки 'resolve summary' в trace.""") - self.lbl_routes_resolve_summary.setStyleSheet("color: gray;") - traffic_layout.addWidget(self.lbl_routes_resolve_summary) - - self.lbl_routes_recheck_summary = QLabel("Timeout recheck: —") - self.lbl_routes_recheck_summary.setToolTip("""EN: Hidden timeout-recheck counters included in resolve summary. -RU: Счетчики скрытого timeout-recheck из итогового resolve summary.""") - self.lbl_routes_recheck_summary.setStyleSheet("color: gray;") - traffic_layout.addWidget(self.lbl_routes_recheck_summary) - - layout.addWidget(traffic_group) - - # --- NFT progress (agvpn4) --- - progress_row = QHBoxLayout() - - self.routes_progress = QProgressBar() - self.routes_progress.setRange(0, 100) - self.routes_progress.setValue(0) - self.routes_progress.setFormat("") # текст выводим отдельным лейблом - self.routes_progress.setTextVisible(False) - self.routes_progress.setEnabled(False) # idle по умолчанию - - self.lbl_routes_progress = QLabel("NFT: idle") - self.lbl_routes_progress.setStyleSheet("color: gray;") - - progress_row.addWidget(self.routes_progress) - progress_row.addWidget(self.lbl_routes_progress) - - layout.addLayout(progress_row) - - # --- Log output --- - self.txt_routes = QPlainTextEdit() - self.txt_routes.setReadOnly(True) - layout.addWidget(self.txt_routes, stretch=1) - - self.tabs.addTab(tab, "Routes") - - # ---------------- DNS TAB ---------------- - - def _build_tab_dns(self) -> None: - tab = QWidget() - main_layout = QVBoxLayout(tab) - - tip = QLabel("Tip: hover fields for help. Подсказка: наведи на элементы для описания.") - tip.setWordWrap(True) - tip.setStyleSheet("color: gray;") - main_layout.addWidget(tip) - - resolver_group = QGroupBox("Resolver DNS") - resolver_group.setToolTip("""EN: Compact resolver DNS status. Open benchmark to test/apply upstreams. -RU: Компактный статус DNS резолвера. Открой benchmark для проверки/применения апстримов.""") - resolver_layout = QVBoxLayout(resolver_group) - - row = QHBoxLayout() - self.btn_dns_benchmark = QPushButton("Open DNS benchmark") - self.btn_dns_benchmark.clicked.connect(self.on_open_dns_benchmark) - row.addWidget(self.btn_dns_benchmark) - row.addStretch(1) - resolver_layout.addLayout(row) - - self.lbl_dns_resolver_upstreams = QLabel("Resolver upstreams: default[—, —] meta[—, —]") - self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;") - resolver_layout.addWidget(self.lbl_dns_resolver_upstreams) - - self.lbl_dns_resolver_health = QLabel("Resolver health: —") - self.lbl_dns_resolver_health.setStyleSheet("color: gray;") - resolver_layout.addWidget(self.lbl_dns_resolver_health) - - main_layout.addWidget(resolver_group) - - smart_group = QGroupBox("SmartDNS") - smart_group.setToolTip("""EN: SmartDNS is used for wildcard domains in hybrid mode. -RU: SmartDNS используется для wildcard-доменов в hybrid режиме.""") - smart_layout = QVBoxLayout(smart_group) - - smart_form = QFormLayout() - self.ent_smartdns_addr = QLineEdit() - self.ent_smartdns_addr.setToolTip("""EN: SmartDNS address in host#port format (example: 127.0.0.1#6053). -RU: Адрес SmartDNS в формате host#port (пример: 127.0.0.1#6053).""") - self.ent_smartdns_addr.setPlaceholderText("127.0.0.1#6053") - self.ent_smartdns_addr.textEdited.connect(self._schedule_dns_autosave) - smart_form.addRow("SmartDNS address", self.ent_smartdns_addr) - smart_layout.addLayout(smart_form) - - self.chk_dns_via_smartdns = QCheckBox("Use SmartDNS for wildcard domains") - self.chk_dns_via_smartdns.setToolTip("""EN: Hybrid wildcard mode: wildcard domains resolve via SmartDNS, other lists resolve via direct upstreams. -RU: Hybrid wildcard режим: wildcard-домены резолвятся через SmartDNS, остальные списки через direct апстримы.""") - self.chk_dns_via_smartdns.stateChanged.connect(self.on_dns_mode_toggle) - smart_layout.addWidget(self.chk_dns_via_smartdns) - - self.lbl_dns_mode_state = QLabel("Resolver mode: unknown") - self.lbl_dns_mode_state.setToolTip("""EN: Current resolver mode reported by API. -RU: Текущий режим резолвера по данным API.""") - smart_layout.addWidget(self.lbl_dns_mode_state) - - self.chk_dns_unit_relay = QCheckBox("SmartDNS unit relay: OFF") - self.chk_dns_unit_relay.setToolTip("""EN: Starts/stops smartdns-local.service. Service state is independent from resolver mode. -RU: Запускает/останавливает smartdns-local.service. Состояние сервиса не равно режиму резолвера.""") - self.chk_dns_unit_relay.stateChanged.connect(self.on_smartdns_unit_toggle) - smart_layout.addWidget(self.chk_dns_unit_relay) - - self.chk_dns_runtime_nftset = QCheckBox("SmartDNS runtime accelerator (nftset -> agvpn_dyn4): ON") - self.chk_dns_runtime_nftset.setToolTip("""EN: Optional accelerator: SmartDNS can add resolved IPs to agvpn_dyn4 in runtime (via nftset). -EN: Wildcard still works without it (resolver job + prewarm). -RU: Опциональный ускоритель: SmartDNS может добавлять IP в agvpn_dyn4 в runtime (через nftset). -RU: Wildcard работает и без него (resolver job + prewarm).""") - self.chk_dns_runtime_nftset.stateChanged.connect(self.on_smartdns_runtime_toggle) - smart_layout.addWidget(self.chk_dns_runtime_nftset) - - self.lbl_dns_wildcard_source = QLabel("Wildcard source: resolver") - self.lbl_dns_wildcard_source.setToolTip("""EN: Where wildcard IPs come from: resolver job, SmartDNS runtime nftset, or both. -RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, или оба.""") - self.lbl_dns_wildcard_source.setStyleSheet("color: gray;") - smart_layout.addWidget(self.lbl_dns_wildcard_source) - - main_layout.addWidget(smart_group) - main_layout.addStretch(1) - - self.tabs.addTab(tab, "DNS") - - # ---------------- DOMAINS TAB ---------------- - - def _build_tab_domains(self) -> None: - tab = QWidget() - main_layout = QHBoxLayout(tab) - - left = QVBoxLayout() - main_layout.addLayout(left) - - left.addWidget(QLabel("Files:")) - self.lst_files = QListWidget() - for name in ( - "bases", - "meta-special", - "subs", - "static-ips", - "last-ips-map-direct", - "last-ips-map-wildcard", - "wildcard-observed-hosts", - "smartdns.conf", - ): - QListWidgetItem(name, self.lst_files) - self.lst_files.setCurrentRow(0) - self.lst_files.itemSelectionChanged.connect(self.on_domains_load) - left.addWidget(self.lst_files) - - self.btn_domains_save = QPushButton("Save file") - self.btn_domains_save.clicked.connect(self.on_domains_save) - left.addWidget(self.btn_domains_save) - left.addStretch(1) - - right_layout = QVBoxLayout() - main_layout.addLayout(right_layout, stretch=1) - - self.lbl_domains_info = QLabel("—") - self.lbl_domains_info.setStyleSheet("color: gray;") - right_layout.addWidget(self.lbl_domains_info) - - self.txt_domains = QPlainTextEdit() - right_layout.addWidget(self.txt_domains, stretch=1) - - self.tabs.addTab(tab, "Domains") - - # ---------------- TRACE TAB ---------------- - - def _build_tab_trace(self) -> None: - tab = QWidget() - layout = QVBoxLayout(tab) - - top = QHBoxLayout() - layout.addLayout(top) - - self.radio_trace_full = QRadioButton("Full") - self.radio_trace_full.setChecked(True) - self.radio_trace_full.toggled.connect(self.refresh_trace_tab) - top.addWidget(self.radio_trace_full) - self.radio_trace_gui = QRadioButton("Events") - self.radio_trace_gui.toggled.connect(self.refresh_trace_tab) - top.addWidget(self.radio_trace_gui) - self.radio_trace_smartdns = QRadioButton("SmartDNS") - self.radio_trace_smartdns.toggled.connect(self.refresh_trace_tab) - top.addWidget(self.radio_trace_smartdns) - - btn_refresh = QPushButton("Refresh") - btn_refresh.clicked.connect(self.refresh_trace_tab) - top.addWidget(btn_refresh) - top.addStretch(1) - - self.txt_trace = QPlainTextEdit() - self.txt_trace.setReadOnly(True) - layout.addWidget(self.txt_trace, stretch=1) - - self.tabs.addTab(tab, "Trace") - - # ---------------- UI HELPERS ---------------- - - def _safe(self, fn, *, title: str = "Error"): - try: - return fn() - except Exception as e: # pragma: no cover - GUI - try: - self.ctrl.log_gui(f"[ui-error] {title}: {e}") - except Exception: - pass - QMessageBox.critical(self, title, str(e)) - return None - - def _set_text(self, widget: QPlainTextEdit, text: str, *, preserve_scroll: bool = False) -> None: - """Set text, optionally сохраняя положение скролла (для trace).""" - if not preserve_scroll: - widget.setPlainText(text) - return - - sb = widget.verticalScrollBar() - old_max = sb.maximum() - old_val = sb.value() - at_end = old_val >= old_max - 2 - - widget.setPlainText(text) - - new_max = sb.maximum() - if at_end: - sb.setValue(new_max) - else: - # подвинем на ту же относительную позицию, учитывая прирост размера - sb.setValue(max(0, min(new_max, old_val+(new_max-old_max)))) - - def _append_text(self, widget: QPlainTextEdit, text: str) -> None: - cursor = widget.textCursor() - cursor.movePosition(QTextCursor.End) - cursor.insertText(text) - widget.setTextCursor(cursor) - widget.ensureCursorVisible() - - def _clean_ui_lines(self, lines) -> str: - buf = "\n".join([str(x) for x in (lines or [])]).replace("\r", "\n") - out_lines = [] - for ln in buf.splitlines(): - t = ln.strip() - if not t: - continue - t2 = _NEXT_CHECK_RE.sub("", t).strip() - if not t2: - continue - out_lines.append(t2) - return "\n".join(out_lines).rstrip() - - def _get_selected_domains_file(self) -> str: - item = self.lst_files.currentItem() - return item.text() if item is not None else "bases" - - def _load_file_content(self, name: str) -> tuple[str, str, str]: - api_map = { - "bases": "bases", - "meta-special": "meta", - "subs": "subs", - "static-ips": "static", - "last-ips-map-direct": "last-ips-map-direct", - "last-ips-map-wildcard": "last-ips-map-wildcard", - "wildcard-observed-hosts": "wildcard-observed-hosts", - "smartdns.conf": "smartdns", - } - if name in api_map: - f = self.ctrl.domains_file_load(api_map[name]) - content = f.content or "" - source = getattr(f, "source", "") or "api" - if name == "smartdns.conf": - path = "/var/lib/selective-vpn/smartdns-wildcards.json -> /etc/selective-vpn/smartdns.conf" - elif name == "last-ips-map-direct": - path = "/var/lib/selective-vpn/last-ips-map-direct.txt (artifact: agvpn4)" - elif name == "last-ips-map-wildcard": - path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (artifact: agvpn_dyn4)" - elif name == "wildcard-observed-hosts": - path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (derived unique hosts)" - else: - path = f"/etc/selective-vpn/domains/{name}.txt" - return content, source, path - return "", "unknown", name - - def _save_file_content(self, name: str, content: str) -> None: - api_map = { - "bases": "bases", - "meta-special": "meta", - "subs": "subs", - "static-ips": "static", - "smartdns.conf": "smartdns", - } - if name in api_map: - self.ctrl.domains_file_save(api_map[name], content) - return - - def _show_vpn_page(self, which: LoginPage) -> None: - self.vpn_stack.setCurrentIndex(1 if which == "login" else 0) - - def _set_auth_button(self, logged: bool) -> None: - self.btn_auth.setText("Logout" if logged else "Login") - - def _set_status_label_color(self, lbl: QLabel, text: str, *, kind: str) -> None: - """Подкраска Policy route / services.""" - lbl.setText(text) - low = (text or "").lower() - color = "black" - if kind == "policy": - if "ok" in low and "missing" not in low and "error" not in low: - color = "green" - elif any(w in low for w in ("missing", "error", "failed")): - color = "red" - else: - color = "orange" - else: # service - if any(w in low for w in ("failed", "error", "unknown", "inactive", "dead")): - color = "red" - elif "active" in low or "running" in low: - color = "green" - else: - color = "orange" - lbl.setStyleSheet(f"color: {color};") - - def _set_dns_unit_relay_state(self, enabled: bool) -> None: - txt = "SmartDNS unit relay: ON" if enabled else "SmartDNS unit relay: OFF" - color = "green" if enabled else "red" - self.chk_dns_unit_relay.setText(txt) - self.chk_dns_unit_relay.setStyleSheet(f"color: {color};") - - def _set_dns_runtime_state(self, enabled: bool, source: str, cfg_error: str = "") -> None: - txt = "SmartDNS runtime accelerator (nftset -> agvpn_dyn4): ON" if enabled else "SmartDNS runtime accelerator (nftset -> agvpn_dyn4): OFF" - color = "green" if enabled else "orange" - self.chk_dns_runtime_nftset.setText(txt) - self.chk_dns_runtime_nftset.setStyleSheet(f"color: {color};") - - src = (source or "").strip().lower() - if src == "both": - src_txt = "Wildcard source: both (resolver + smartdns_runtime)" - src_color = "green" - elif src == "smartdns_runtime": - src_txt = "Wildcard source: smartdns_runtime" - src_color = "orange" - else: - src_txt = "Wildcard source: resolver" - src_color = "gray" - if cfg_error: - src_txt = f"{src_txt} | runtime cfg: {cfg_error}" - src_color = "orange" - self.lbl_dns_wildcard_source.setText(src_txt) - self.lbl_dns_wildcard_source.setStyleSheet(f"color: {src_color};") - - def _set_dns_mode_state(self, mode: str) -> None: - low = (mode or "").strip().lower() - if low in ("hybrid_wildcard", "hybrid"): - txt = "Resolver mode: hybrid wildcard (SmartDNS for wildcard domains)" - color = "green" - elif low == "direct": - txt = "Resolver mode: direct upstreams" - color = "red" - else: - txt = "Resolver mode: unknown" - color = "orange" - self.lbl_dns_mode_state.setText(txt) - self.lbl_dns_mode_state.setStyleSheet(f"color: {color};") - - def _set_dns_resolver_summary(self, pool_items) -> None: - active = [] - total = 0 - for item in pool_items or []: - addr = str(getattr(item, "addr", "") or "").strip() - if not addr: - continue - total += 1 - if bool(getattr(item, "enabled", False)): - active.append(addr) - applied = len(active) - if applied > 12: - applied = 12 - if not active: - text = f"Resolver upstreams: active=0/{total} (empty set)" - else: - preview = ", ".join(active[:4]) - if len(active) > 4: - preview += f", +{len(active)-4} more" - text = f"Resolver upstreams: active={len(active)}/{total}, applied={applied}/12 [{preview}]" - self.lbl_dns_resolver_upstreams.setText(text) - self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;") - - avg_ms = self._ui_settings.value("dns_benchmark/last_avg_ms", None) - ok = self._ui_settings.value("dns_benchmark/last_ok", None) - fail = self._ui_settings.value("dns_benchmark/last_fail", None) - timeout = self._ui_settings.value("dns_benchmark/last_timeout", None) - if avg_ms is None or ok is None or fail is None: - self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet") - self.lbl_dns_resolver_health.setStyleSheet("color: gray;") - return - try: - avg = int(avg_ms) - ok_i = int(ok) - fail_i = int(fail) - timeout_i = int(timeout or 0) - except Exception: - self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet") - self.lbl_dns_resolver_health.setStyleSheet("color: gray;") - return - color = "green" if avg < 200 else ("#b58900" if avg <= 400 else "red") - if timeout_i > 0 and color != "red": - color = "#b58900" - self.lbl_dns_resolver_health.setText( - f"Resolver health: avg={avg}ms ok={ok_i} fail={fail_i} timeout={timeout_i}" - ) - self.lbl_dns_resolver_health.setStyleSheet(f"color: {color};") - - def _set_traffic_mode_state( - self, - desired_mode: str, - applied_mode: str, - preferred_iface: str, - advanced_active: bool, - auto_local_bypass: bool, - auto_local_active: bool, - ingress_reply_bypass: bool, - ingress_reply_active: bool, - bypass_candidates: int, - overrides_applied: int, - cgroup_resolved_uids: int, - cgroup_warning: str, - healthy: bool, - ingress_rule_present: bool, - ingress_nft_active: bool, - probe_ok: bool, - probe_message: str, - active_iface: str, - iface_reason: str, - message: str, - ) -> None: - desired = (desired_mode or "").strip().lower() or "selective" - applied = (applied_mode or "").strip().lower() or "direct" - - if healthy: - color = "green" - health_txt = "OK" - else: - color = "red" - health_txt = "MISMATCH" - - text = f"Traffic mode: {desired} (applied: {applied}) [{health_txt}]" - diag_parts = [] - diag_parts.append(f"preferred={preferred_iface or 'auto'}") - diag_parts.append( - f"advanced={'on' if advanced_active else 'off'}" - ) - diag_parts.append( - f"auto_local={'on' if auto_local_bypass else 'off'}" - f"({'active' if auto_local_active else 'saved'})" - ) - diag_parts.append( - f"ingress_reply={'on' if ingress_reply_bypass else 'off'}" - f"({'active' if ingress_reply_active else 'saved'})" - ) - if auto_local_active and bypass_candidates > 0: - diag_parts.append(f"bypass_routes={bypass_candidates}") - diag_parts.append(f"overrides={overrides_applied}") - if cgroup_resolved_uids > 0: - diag_parts.append(f"cgroup_uids={cgroup_resolved_uids}") - if cgroup_warning: - diag_parts.append(f"cgroup_warning={cgroup_warning}") - if active_iface: - diag_parts.append(f"iface={active_iface}") - if iface_reason: - diag_parts.append(f"source={iface_reason}") - diag_parts.append( - f"ingress_diag=rule:{'ok' if ingress_rule_present else 'off'}" - f"/nft:{'ok' if ingress_nft_active else 'off'}" - ) - diag_parts.append(f"probe={'ok' if probe_ok else 'fail'}") - if probe_message: - diag_parts.append(probe_message) - if message: - diag_parts.append(message) - diag = " | ".join(diag_parts) if diag_parts else "—" - - self.lbl_traffic_mode_state.setText(text) - self.lbl_traffic_mode_state.setStyleSheet(f"color: {color};") - self.lbl_traffic_mode_diag.setText(diag) - self.lbl_traffic_mode_diag.setStyleSheet("color: gray;") - - def _update_routes_progress_label(self, view) -> None: - """ - Обновляет прогресс nft по RoutesNftProgressView. - view ожидаем с полями percent, message, active (duck-typing). - """ - if view is None: - # сброс до idle - self._routes_progress_last = 0 - self.routes_progress.setValue(0) - self.lbl_routes_progress.setText("NFT: idle") - self.lbl_routes_progress.setStyleSheet("color: gray;") - return - - # аккуратно ограничим 0..100 - try: - percent = max(0, min(100, int(view.percent))) - except Exception: - percent = 0 - - # не даём прогрессу дёргаться назад, кроме явного сброса (percent==0) - if percent == 0: - self._routes_progress_last = 0 - else: - percent = max(percent, self._routes_progress_last) - self._routes_progress_last = percent - - self.routes_progress.setValue(percent) - - text = f"{percent}% – {view.message}" - if not view.active and percent >= 100: - color = "green" - elif view.active: - color = "orange" - else: - color = "gray" - - self.lbl_routes_progress.setText(text) - self.lbl_routes_progress.setStyleSheet(f"color: {color};") - - def _load_ui_preferences(self) -> None: - raw = self._ui_settings.value("routes/prewarm_aggressive", False) - if isinstance(raw, str): - val = raw.strip().lower() in ("1", "true", "yes", "on") - else: - val = bool(raw) - self.chk_routes_prewarm_aggressive.blockSignals(True) - self.chk_routes_prewarm_aggressive.setChecked(val) - self.chk_routes_prewarm_aggressive.blockSignals(False) - self._update_prewarm_mode_label() - - def _save_ui_preferences(self) -> None: - self._ui_settings.setValue( - "routes/prewarm_aggressive", - bool(self.chk_routes_prewarm_aggressive.isChecked()), - ) - self._ui_settings.sync() - - # ---------------- EVENTS STREAM ---------------- - - def _start_events_stream(self) -> None: - if self.events_thread: - return - self.events_thread = EventThread(self.ctrl, self) - self.events_thread.eventReceived.connect(self._handle_event) - self.events_thread.error.connect(self._handle_event_error) - self.events_thread.start() - - @QtCore.Slot(object) - def _handle_event(self, ev) -> None: - try: - kinds = self.ctrl.classify_event(ev) - except Exception: - kinds = [] - - # Отдельно ловим routes_nft_progress, чтобы обновить лейбл прогресса. - try: - k = (getattr(ev, "kind", "") or "").strip().lower() - except Exception: - k = "" - - if k == "routes_nft_progress": - try: - prog_view = self.ctrl.routes_nft_progress_from_event(ev) - self._update_routes_progress_label(prog_view) - except Exception: - # не роняем UI, просто игнор - pass - - # Простая стратегия: триггерить существующие refresh-функции. - if "status" in kinds: - self.refresh_status_tab() - if "login" in kinds: - self.refresh_login_banner() - if "vpn" in kinds: - self.refresh_vpn_tab() - if "routes" in kinds: - self.refresh_routes_tab() - if "dns" in kinds: - self.refresh_dns_tab() - if "trace" in kinds: - self.refresh_trace_tab() - - - @QtCore.Slot(str) - def _handle_event_error(self, msg: str) -> None: - # Логируем в trace, UI не блокируем. - try: - self.ctrl.log_gui(f"[sse-error] {msg}") - except Exception: - pass - - # ---------------- REFRESH ---------------- - - def refresh_everything(self) -> None: - self.refresh_login_banner() - self.refresh_status_tab() - self.refresh_vpn_tab() - self.refresh_routes_tab() - self.refresh_dns_tab() - self.refresh_domains_tab() - self.refresh_trace_tab() - - def refresh_login_banner(self) -> None: - def work(): - view = self.ctrl.get_login_view() - - self.btn_login_banner.setText(view.text) - self._set_auth_button(view.logged_in) - - # Принудительно: зелёный если залогинен, серый если нет - color = "green" if view.logged_in else "gray" - base_style = "text-align: left; border: none;" - self.btn_login_banner.setStyleSheet( - f"{base_style} color: {color};" - ) - - self._safe(work, title="Login state error") - - def refresh_status_tab(self) -> None: - def work(): - view = self.ctrl.get_status_overview() - self.st_timestamp.setText(view.timestamp) - self.st_counts.setText(view.counts) - self.st_iface.setText(view.iface_table_mark) - - self._set_status_label_color( - self.st_route, view.policy_route, kind="policy" - ) - self._set_status_label_color( - self.st_routes_service, view.routes_service, kind="service" - ) - self._set_status_label_color( - self.st_smartdns_service, view.smartdns_service, kind="service" - ) - self._set_status_label_color( - self.st_vpn_service, view.vpn_service, kind="service" - ) - - self._safe(work, title="Status error") - - def refresh_vpn_tab(self) -> None: - def work(): - view = self.ctrl.vpn_status_view() - txt = [] - if view.desired_location: - txt.append(f"Desired location: {view.desired_location}") - if view.pretty_text: - txt.append(view.pretty_text.rstrip()) - self._set_text(self.txt_vpn, "\n".join(txt).strip() + "\n") - - auto_view = self.ctrl.vpn_autoconnect_view() - self.btn_autoconnect_toggle.setText( - "Disable autoconnect" if auto_view.enabled else "Enable autoconnect" - ) - self.lbl_autoconnect_state.setText(auto_view.unit_text) - self.lbl_autoconnect_state.setStyleSheet( - f"color: {auto_view.color};" - ) - - locs = self.ctrl.vpn_locations_view() - self.cmb_locations.blockSignals(True) - self.cmb_locations.clear() - - current_iso = (view.desired_location or "").strip().upper() - current_index = 0 - - for i, loc in enumerate(locs or []): - self.cmb_locations.addItem(loc.label, loc.iso) - if (loc.iso or "").upper() == current_iso: - current_index = i - - if self.cmb_locations.count() > 0: - self.cmb_locations.setCurrentIndex(current_index) - - self.cmb_locations.blockSignals(False) - - self._safe(work, title="VPN error") - - def refresh_routes_tab(self) -> None: - def work(): - timer_enabled = self.ctrl.routes_timer_enabled() - self.chk_timer.blockSignals(True) - self.chk_timer.setChecked(bool(timer_enabled)) - self.chk_timer.blockSignals(False) - - t = self.ctrl.traffic_mode_view() - self._set_traffic_mode_state( - t.desired_mode, - t.applied_mode, - t.preferred_iface, - bool(t.advanced_active), - bool(t.auto_local_bypass), - bool(t.auto_local_active), - bool(t.ingress_reply_bypass), - bool(t.ingress_reply_active), - int(t.bypass_candidates), - int(t.overrides_applied), - int(t.cgroup_resolved_uids), - t.cgroup_warning, - bool(t.healthy), - bool(t.ingress_rule_present), - bool(t.ingress_nft_active), - bool(t.probe_ok), - t.probe_message, - t.active_iface, - t.iface_reason, - t.message, - ) - rs = self.ctrl.routes_resolve_summary_view() - self.lbl_routes_resolve_summary.setText(rs.text) - self.lbl_routes_resolve_summary.setStyleSheet(f"color: {rs.color};") - self.lbl_routes_recheck_summary.setText(rs.recheck_text) - self.lbl_routes_recheck_summary.setStyleSheet(f"color: {rs.recheck_color};") - self._safe(work, title="Routes error") - - def refresh_dns_tab(self) -> None: - def work(): - self._dns_ui_refresh = True - try: - pool = self.ctrl.dns_upstream_pool_view() - self._set_dns_resolver_summary(getattr(pool, "items", [])) - - st = self.ctrl.dns_status_view() - self.ent_smartdns_addr.setText(st.smartdns_addr or "") - - mode = (getattr(st, "mode", "") or "").strip().lower() - if mode in ("hybrid_wildcard", "hybrid"): - hybrid_enabled = True - mode = "hybrid_wildcard" - else: - hybrid_enabled = False - mode = "direct" - - self.chk_dns_via_smartdns.blockSignals(True) - self.chk_dns_via_smartdns.setChecked(hybrid_enabled) - self.chk_dns_via_smartdns.blockSignals(False) - - unit_state = (st.unit_state or "unknown").strip().lower() - unit_active = unit_state == "active" - self.chk_dns_unit_relay.blockSignals(True) - self.chk_dns_unit_relay.setChecked(unit_active) - self.chk_dns_unit_relay.blockSignals(False) - - self.chk_dns_runtime_nftset.blockSignals(True) - self.chk_dns_runtime_nftset.setChecked(bool(getattr(st, "runtime_nftset", True))) - self.chk_dns_runtime_nftset.blockSignals(False) - self._set_dns_unit_relay_state(unit_active) - self._set_dns_runtime_state( - bool(getattr(st, "runtime_nftset", True)), - str(getattr(st, "wildcard_source", "") or ""), - str(getattr(st, "runtime_config_error", "") or ""), - ) - self._set_dns_mode_state(mode) - finally: - self._dns_ui_refresh = False - self._safe(work, title="DNS error") - - def refresh_domains_tab(self) -> None: - def work(): - # reload currently selected file - self.on_domains_load() - self._safe(work, title="Domains error") - - def refresh_trace_tab(self) -> None: - def work(): - if self.radio_trace_gui.isChecked(): - mode: TraceMode = "gui" - elif self.radio_trace_smartdns.isChecked(): - mode = "smartdns" - else: - mode = "full" - dump = self.ctrl.trace_view(mode) - text = "\n".join(dump.lines).rstrip() - if dump.lines: - text += "\n" - self._set_text(self.txt_trace, text, preserve_scroll=True) - self._safe(work, title="Trace error") - - # ---------------- TOP AUTH / BANNER ---------------- - - def on_auth_button(self) -> None: - def work(): - view = self.ctrl.get_login_view() - if view.logged_in: - self.on_logout() - else: - # при логине всегда переходим на вкладку AdGuardVPN и - # показываем страницу логина - self.tabs.setCurrentWidget(self.tab_vpn) - self._show_vpn_page("login") - self.on_start_login() - self._safe(work, title="Auth error") - - def on_login_banner_clicked(self) -> None: - def work(): - txt = self.ctrl.login_banner_cli_text() - QMessageBox.information(self, "AdGuard VPN", txt) - self._safe(work, title="Login banner error") - - # ---------------- LOGIN FLOW ACTIONS ---------------- - - def on_start_login(self) -> None: - def work(): - self.ctrl.log_gui("Top Login clicked") - self._show_vpn_page("login") - self._login_flow_reset_ui() - - start = self.ctrl.login_flow_start() - - self._login_cursor = int(start.cursor) - self.lbl_login_flow_status.setText( - f"Status: {start.status_text or '—'}" - ) - self.lbl_login_flow_email.setText( - f"User: {start.email}" if start.email else "" - ) - self.edit_login_url.setText(start.url or "") - - self._login_flow_set_buttons( - can_open=start.can_open, - can_check=start.can_check, - can_cancel=start.can_cancel, - ) - - if start.lines: - cleaned = self._clean_ui_lines(start.lines) - if cleaned: - self._append_text(self.txt_login_flow, cleaned + "\n") - - if not start.alive: - self._login_flow_autopoll_stop() - self._login_flow_set_buttons( - can_open=False, can_check=False, can_cancel=False - ) - self.btn_login_stop.setEnabled(False) - QTimer.singleShot(250, self.refresh_login_banner) - return - - self._login_flow_autopoll_start() - - self._safe(work, title="Login start error") - - def _login_flow_reset_ui(self) -> None: - self._login_cursor = 0 - self._login_url_opened = False - self.edit_login_url.setText("") - self.lbl_login_flow_status.setText("Status: —") - self.lbl_login_flow_email.setText("") - self._set_text(self.txt_login_flow, "") - - def _login_flow_set_buttons( - self, - *, - can_open: bool, - can_check: bool, - can_cancel: bool, - ) -> None: - self.btn_login_open.setEnabled(bool(can_open)) - self.btn_login_copy.setEnabled(bool(self.edit_login_url.text().strip())) - self.btn_login_check.setEnabled(bool(can_check)) - self.btn_login_close.setEnabled(bool(can_cancel)) - self.btn_login_stop.setEnabled(True) - - def _login_flow_autopoll_start(self) -> None: - self._login_flow_active = True - if not self.login_poll_timer.isActive(): - self.login_poll_timer.start() - - def _login_flow_autopoll_stop(self) -> None: - self._login_flow_active = False - if self.login_poll_timer.isActive(): - self.login_poll_timer.stop() - - def _login_poll_tick(self) -> None: - if not self._login_flow_active: - return - - def work(): - view = self.ctrl.login_flow_poll(self._login_cursor) - self._login_cursor = int(view.cursor) - - self.lbl_login_flow_status.setText( - f"Status: {view.status_text or '—'}" - ) - self.lbl_login_flow_email.setText( - f"User: {view.email}" if view.email else "" - ) - - if view.url: - self.edit_login_url.setText(view.url) - - self._login_flow_set_buttons( - can_open=view.can_open, - can_check=view.can_check, - can_cancel=view.can_cancel, - ) - - cleaned = self._clean_ui_lines(view.lines) - if cleaned: - self._append_text(self.txt_login_flow, cleaned + "\n") - - if (not self._login_url_opened) and view.url: - self._login_url_opened = True - try: - subprocess.Popen(["xdg-open", view.url]) - except Exception: - pass - - phase = (view.phase or "").strip().lower() - if (not view.alive) or phase in ( - "success", - "failed", - "cancelled", - "already_logged", - ): - self._login_flow_autopoll_stop() - self._login_flow_set_buttons( - can_open=False, can_check=False, can_cancel=False - ) - self.btn_login_stop.setEnabled(False) - QTimer.singleShot(250, self.refresh_login_banner) - - self._safe(work, title="Login flow error") - - def on_login_copy(self) -> None: - def work(): - u = self.edit_login_url.text().strip() - if u: - QApplication.clipboard().setText(u) - self.ctrl.log_gui("Login flow: copy-url") - self._safe(work, title="Login copy error") - - def on_login_open(self) -> None: - def work(): - u = self.edit_login_url.text().strip() - if u: - try: - subprocess.Popen(["xdg-open", u]) - except Exception: - pass - self.ctrl.log_gui("Login flow: open") - self._safe(work, title="Login open error") - - def on_login_check(self) -> None: - def work(): - # если ещё ничего не запущено — считаем это стартом логина - if ( - not self._login_flow_active - and self._login_cursor == 0 - and not self.edit_login_url.text().strip() - and not self.txt_login_flow.toPlainText().strip() - ): - self.on_start_login() - return - - self.ctrl.login_flow_action("check") - self.ctrl.log_gui("Login flow: check") - self._safe(work, title="Login check error") - - def on_login_cancel(self) -> None: - def work(): - self.ctrl.login_flow_action("cancel") - self.ctrl.log_gui("Login flow: cancel") - self._safe(work, title="Login cancel error") - - def on_login_stop(self) -> None: - def work(): - self.ctrl.login_flow_stop() - self.ctrl.log_gui("Login flow: stop") - self._login_flow_autopoll_stop() - QTimer.singleShot(250, self.refresh_login_banner) - self._safe(work, title="Login stop error") - - def on_logout(self) -> None: - def work(): - self.ctrl.log_gui("Top Logout clicked") - res = self.ctrl.vpn_logout() - self._set_text(self.txt_vpn, res.pretty_text or str(res)) - QTimer.singleShot(250, self.refresh_login_banner) - self._safe(work, title="Logout error") - - # ---- VPN actions --------------------------------------------------- - - def on_toggle_autoconnect(self) -> None: - def work(): - current = self.ctrl.vpn_autoconnect_enabled() - enable = not current - self.ctrl.vpn_set_autoconnect(enable) - self.ctrl.log_gui(f"VPN autoconnect set to {enable}") - self.refresh_vpn_tab() - self._safe(work, title="Autoconnect error") - - def on_set_location(self) -> None: - def work(): - idx = self.cmb_locations.currentIndex() - if idx < 0: - return - iso = self.cmb_locations.currentData() - self.ctrl.vpn_set_location(iso) - self.ctrl.log_gui(f"VPN location set to {iso}") - self.refresh_vpn_tab() - self._safe(work, title="Location error") - - # ---- Routes actions ------------------------------------------------ - - def on_routes_action( - self, action: Literal["start", "stop", "restart"] - ) -> None: - def work(): - res = self.ctrl.routes_service_action(action) - self._set_text(self.txt_routes, res.pretty_text or str(res)) - self.refresh_status_tab() - self._safe(work, title="Routes error") - - def _append_routes_log(self, msg: str) -> None: - line = (msg or "").strip() - if not line: - return - self._append_text(self.txt_routes, line + "\n") - self.ctrl.log_gui(line) - - def on_open_traffic_settings(self) -> None: - def work(): - def refresh_all_traffic() -> None: - self.refresh_routes_tab() - self.refresh_status_tab() - - dlg = TrafficModeDialog( - self.ctrl, - log_cb=self._append_routes_log, - refresh_cb=refresh_all_traffic, - parent=self, - ) - dlg.exec() - refresh_all_traffic() - self._safe(work, title="Traffic mode dialog error") - - def on_test_traffic_mode(self) -> None: - def work(): - view = self.ctrl.traffic_mode_test() - msg = ( - f"Traffic mode test: desired={view.desired_mode}, applied={view.applied_mode}, " - f"iface={view.active_iface or '-'}, probe_ok={view.probe_ok}, " - f"healthy={view.healthy}, auto_local_bypass={view.auto_local_bypass}, " - f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, " - f"cgroup_uids={view.cgroup_resolved_uids}, cgroup_warning={view.cgroup_warning or '-'}, " - f"message={view.message}, probe={view.probe_message}" - ) - self._append_routes_log(msg) - self.refresh_routes_tab() - self.refresh_status_tab() - self._safe(work, title="Traffic mode test error") - - def on_routes_precheck_debug(self) -> None: - def work(): - res = self.ctrl.routes_precheck_debug(run_now=True) - txt = (res.pretty_text or "").strip() - if res.ok: - QMessageBox.information(self, "Resolve precheck debug", txt or "OK") - else: - QMessageBox.critical(self, "Resolve precheck debug", txt or "ERROR") - self.refresh_routes_tab() - self.refresh_status_tab() - self.refresh_trace_tab() - self._safe(work, title="Resolve precheck debug error") - - def on_toggle_timer(self) -> None: - def work(): - enabled = self.chk_timer.isChecked() - res = self.ctrl.routes_timer_set(enabled) - self.ctrl.log_gui(f"Routes timer set to {enabled}") - self._set_text(self.txt_routes, res.pretty_text or str(res)) - self.refresh_routes_tab() - self._safe(work, title="Timer error") - - def on_fix_policy_route(self) -> None: - def work(): - res = self.ctrl.routes_fix_policy_route() - self._set_text(self.txt_routes, res.pretty_text or str(res)) - self.refresh_status_tab() - self._safe(work, title="Policy route error") - - # ---- DNS actions --------------------------------------------------- - - def _schedule_dns_autosave(self, _text: str = "") -> None: - if self._dns_ui_refresh: - return - self.dns_save_timer.start() - - def _apply_dns_autosave(self) -> None: - def work(): - if self._dns_ui_refresh: - return - self.ctrl.dns_mode_set( - self.chk_dns_via_smartdns.isChecked(), - self.ent_smartdns_addr.text().strip(), - ) - self.ctrl.log_gui("DNS settings autosaved") - self._safe(work, title="DNS save error") - - def on_open_dns_benchmark(self) -> None: - def work(): - dlg = DNSBenchmarkDialog( - self.ctrl, - settings=self._ui_settings, - refresh_cb=self.refresh_dns_tab, - parent=self, - ) - dlg.exec() - self.refresh_dns_tab() - self._safe(work, title="DNS benchmark error") - - def on_dns_mode_toggle(self) -> None: - def work(): - via = self.chk_dns_via_smartdns.isChecked() - self.ctrl.dns_mode_set(via, self.ent_smartdns_addr.text().strip()) - mode = "hybrid_wildcard" if via else "direct" - self.ctrl.log_gui(f"DNS mode changed: mode={mode}") - self.refresh_dns_tab() - self._safe(work, title="DNS mode error") - - def on_smartdns_unit_toggle(self) -> None: - def work(): - enable = self.chk_dns_unit_relay.isChecked() - action = "start" if enable else "stop" - self.ctrl.smartdns_service_action(action) - self.ctrl.log_smartdns(f"SmartDNS unit action from GUI: {action}") - self.refresh_dns_tab() - self.refresh_status_tab() - self._safe(work, title="SmartDNS error") - - def on_smartdns_runtime_toggle(self) -> None: - def work(): - if self._dns_ui_refresh: - return - enable = self.chk_dns_runtime_nftset.isChecked() - st = self.ctrl.smartdns_runtime_set(enabled=enable, restart=True) - self.ctrl.log_smartdns( - f"SmartDNS runtime accelerator set from GUI: enabled={enable} changed={st.changed} restarted={st.restarted} source={st.wildcard_source}" - ) - self.refresh_dns_tab() - self.refresh_trace_tab() - self._safe(work, title="SmartDNS runtime error") - - def on_smartdns_prewarm(self) -> None: - def work(): - aggressive = bool(self.chk_routes_prewarm_aggressive.isChecked()) - result = self.ctrl.smartdns_prewarm(aggressive_subs=aggressive) - mode_txt = "aggressive_subs=on" if aggressive else "aggressive_subs=off" - self.ctrl.log_smartdns(f"SmartDNS prewarm requested from GUI: {mode_txt}") - txt = (result.pretty_text or "").strip() - if result.ok: - QMessageBox.information(self, "SmartDNS prewarm", txt or "OK") - else: - QMessageBox.critical(self, "SmartDNS prewarm", txt or "ERROR") - self.refresh_trace_tab() - self._safe(work, title="SmartDNS prewarm error") - - def _update_prewarm_mode_label(self, _state: int = 0) -> None: - aggressive = bool(self.chk_routes_prewarm_aggressive.isChecked()) - if aggressive: - self.lbl_routes_prewarm_mode.setText("Prewarm mode: aggressive (subs enabled)") - self.lbl_routes_prewarm_mode.setStyleSheet("color: orange;") - else: - self.lbl_routes_prewarm_mode.setText("Prewarm mode: wildcard-only") - self.lbl_routes_prewarm_mode.setStyleSheet("color: gray;") - - def _on_prewarm_aggressive_changed(self, _state: int = 0) -> None: - self._update_prewarm_mode_label(_state) - self._save_ui_preferences() - - # ---- Domains actions ----------------------------------------------- - - def on_domains_load(self) -> None: - def work(): - name = self._get_selected_domains_file() - content, source, path = self._load_file_content(name) - is_readonly = name in ("last-ips-map-direct", "last-ips-map-wildcard", "wildcard-observed-hosts") - self.txt_domains.setReadOnly(is_readonly) - self.btn_domains_save.setEnabled(not is_readonly) - self._set_text(self.txt_domains, content) - ro = "read-only" if is_readonly else "editable" - self.lbl_domains_info.setText(f"{name} ({source}, {ro}) [{path}]") - self._safe(work, title="Domains load error") - - def on_domains_save(self) -> None: - def work(): - name = self._get_selected_domains_file() - content = self.txt_domains.toPlainText() - self._save_file_content(name, content) - self.ctrl.log_gui(f"Domains file saved: {name}") - self._safe(work, title="Domains save error") - - # ---- close event --------------------------------------------------- - - def closeEvent(self, event) -> None: # pragma: no cover - GUI - try: - self._save_ui_preferences() - self._login_flow_autopoll_stop() - if self.events_thread: - self.events_thread.stop() - self.events_thread.wait(1500) - finally: - super().closeEvent(event) def main(argv: list[str] | None = None) -> int: diff --git a/selective-vpn-web/.env.example b/selective-vpn-web/.env.example new file mode 100644 index 0000000..787455c --- /dev/null +++ b/selective-vpn-web/.env.example @@ -0,0 +1,6 @@ +# Optional absolute API base URL. +# Leave empty to use same-origin requests. +VITE_API_BASE_URL= + +# Dev-server proxy target (used by vite.config.ts). +VITE_DEV_PROXY_TARGET=http://127.0.0.1:8080 diff --git a/selective-vpn-web/.gitignore b/selective-vpn-web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/selective-vpn-web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/selective-vpn-web/README.md b/selective-vpn-web/README.md new file mode 100644 index 0000000..ef93f42 --- /dev/null +++ b/selective-vpn-web/README.md @@ -0,0 +1,36 @@ +# Selective VPN Web (Foundation) + +Web prototype foundation for Selective VPN control-plane. + +## Stack +- Vite +- React + TypeScript +- React Router +- TanStack Query +- Zustand + +## Current scope +- App shell (navigation + layout). +- Read-only overview (`/healthz`, `/api/v1/status`, `/api/v1/vpn/status`, `/api/v1/vpn/login-state`). +- SSE connectivity indicator for `/api/v1/events/stream`. +- Placeholder pages for upcoming VPN/Routes/DNS/Transport/Trace screens. + +No mutating controls are enabled at this stage. + +## Run +```bash +npm install +npm run dev +``` + +## Build +```bash +npm run build +``` + +## Environment +Use `.env.example` as a base: +- `VITE_API_BASE_URL` — absolute API base URL (optional). +- `VITE_DEV_PROXY_TARGET` — Vite dev proxy target (default `http://127.0.0.1:8080`). + +By default, frontend uses same-origin URLs and relies on Vite proxy in dev mode. diff --git a/selective-vpn-web/eslint.config.js b/selective-vpn-web/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/selective-vpn-web/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/selective-vpn-web/index.html b/selective-vpn-web/index.html new file mode 100644 index 0000000..43e7a2f --- /dev/null +++ b/selective-vpn-web/index.html @@ -0,0 +1,13 @@ + + + + + + + selective-vpn-web + + +
+ + + diff --git a/selective-vpn-web/package-lock.json b/selective-vpn-web/package-lock.json new file mode 100644 index 0000000..7d3fff2 --- /dev/null +++ b/selective-vpn-web/package-lock.json @@ -0,0 +1,3159 @@ +{ + "name": "selective-vpn-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "selective-vpn-web", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.90.10", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.2", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "8.48.0", + "vite": "^6.4.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", + "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/selective-vpn-web/package.json b/selective-vpn-web/package.json new file mode 100644 index 0000000..73f97b0 --- /dev/null +++ b/selective-vpn-web/package.json @@ -0,0 +1,33 @@ +{ + "name": "selective-vpn-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.10", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.2", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "8.48.0", + "vite": "^6.4.1" + } +} diff --git a/selective-vpn-web/src/app/App.tsx b/selective-vpn-web/src/app/App.tsx new file mode 100644 index 0000000..e595cc1 --- /dev/null +++ b/selective-vpn-web/src/app/App.tsx @@ -0,0 +1,14 @@ +import { BrowserRouter } from 'react-router-dom' + +import { AppProviders } from './providers' +import { AppRoutes } from './routes' + +export function App() { + return ( + + + + + + ) +} diff --git a/selective-vpn-web/src/app/providers.tsx b/selective-vpn-web/src/app/providers.tsx new file mode 100644 index 0000000..fdbb213 --- /dev/null +++ b/selective-vpn-web/src/app/providers.tsx @@ -0,0 +1,15 @@ +import type { PropsWithChildren } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) + +export function AppProviders({ children }: PropsWithChildren) { + return {children} +} diff --git a/selective-vpn-web/src/app/routes.tsx b/selective-vpn-web/src/app/routes.tsx new file mode 100644 index 0000000..8e1fb3d --- /dev/null +++ b/selective-vpn-web/src/app/routes.tsx @@ -0,0 +1,27 @@ +import { Navigate, Route, Routes } from 'react-router-dom' + +import { DnsPage } from '../pages/dns/DnsPage' +import { NotFoundPage } from '../pages/not-found/NotFoundPage' +import { OverviewPage } from '../pages/overview/OverviewPage' +import { RoutesPage } from '../pages/routes/RoutesPage' +import { TracePage } from '../pages/trace/TracePage' +import { TransportPage } from '../pages/transport/TransportPage' +import { VpnPage } from '../pages/vpn/VpnPage' +import { AppLayout } from '../shared/ui/AppLayout' + +export function AppRoutes() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ) +} diff --git a/selective-vpn-web/src/app/store/useUiStore.ts b/selective-vpn-web/src/app/store/useUiStore.ts new file mode 100644 index 0000000..aca1903 --- /dev/null +++ b/selective-vpn-web/src/app/store/useUiStore.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand' + +type UiState = { + sidebarCollapsed: boolean + toggleSidebar: () => void +} + +export const useUiStore = create((set) => ({ + sidebarCollapsed: false, + toggleSidebar: () => { + set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })) + }, +})) diff --git a/selective-vpn-web/src/index.css b/selective-vpn-web/src/index.css new file mode 100644 index 0000000..f7bb5a9 --- /dev/null +++ b/selective-vpn-web/src/index.css @@ -0,0 +1,256 @@ +:root { + --bg: #eef3f7; + --bg-soft: #f7fbff; + --surface: #ffffff; + --surface-soft: #f6f9fc; + --text: #122034; + --muted: #607189; + --border: #dbe5ef; + --accent: #0d8b63; + --warn: #d4a200; + --danger: #bb2d3b; + + font-family: "IBM Plex Sans", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + color: var(--text); + background: linear-gradient(150deg, var(--bg) 0%, var(--bg-soft) 100%); +} + +#root { + min-height: 100vh; +} + +a { + color: inherit; + text-decoration: none; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 248px 1fr; +} + +.app-sidebar { + border-right: 1px solid var(--border); + background: var(--surface); + padding: 20px 16px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.app-sidebar.is-collapsed { + width: 90px; + padding: 20px 10px; +} + +.app-brand { + display: flex; + flex-direction: column; + gap: 2px; +} + +.app-brand-title { + font-size: 18px; + font-weight: 700; +} + +.app-brand-subtitle { + color: var(--muted); + font-size: 12px; +} + +.app-nav { + display: flex; + flex-direction: column; + gap: 6px; +} + +.app-nav-link { + border: 1px solid transparent; + border-radius: 10px; + padding: 8px 10px; + color: var(--muted); + transition: 0.15s ease; +} + +.app-nav-link:hover { + background: var(--surface-soft); + color: var(--text); +} + +.app-nav-link.is-active { + border-color: #c5d8cc; + background: #edf8f3; + color: #0d5f44; + font-weight: 600; +} + +.app-main { + display: flex; + flex-direction: column; + min-width: 0; +} + +.app-header { + border-bottom: 1px solid var(--border); + background: var(--surface); + min-height: 64px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.app-header-left, +.app-header-right { + display: flex; + align-items: center; + gap: 8px; +} + +.app-content { + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.page-title { + margin: 0; + font-size: 26px; +} + +.page-subtitle { + margin: 6px 0 0; + color: var(--muted); +} + +.panel-grid { + margin-top: 14px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; +} + +.panel { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + padding: 14px; +} + +.panel h2 { + margin: 0 0 10px; + font-size: 16px; +} + +.kv-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.kv-row { + display: flex; + justify-content: space-between; + gap: 10px; + font-size: 14px; +} + +.kv-row span:first-child { + color: var(--muted); +} + +.status-chip { + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + white-space: nowrap; +} + +.status-ok { + border-color: #9fd4be; + background: #eaf8f1; + color: #0a6345; +} + +.status-warn { + border-color: #ebd58d; + background: #fff8de; + color: #996f00; +} + +.status-bad { + border-color: #e9b0b8; + background: #fff1f3; + color: #9f2331; +} + +.status-neutral { + border-color: var(--border); + background: #f3f7fb; + color: #37506a; +} + +.ghost-button { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--text); + padding: 8px 12px; + cursor: pointer; +} + +.ghost-button:hover { + background: var(--surface-soft); +} + +.inline-alert { + border-radius: 10px; + padding: 8px 10px; + font-size: 13px; +} + +.inline-alert-warn { + border: 1px solid #f0d58a; + background: #fff8e0; + color: #9e7100; +} + +.text-bad { + color: var(--danger); +} + +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } + + .app-sidebar { + border-right: none; + border-bottom: 1px solid var(--border); + } + + .app-sidebar.is-collapsed { + width: auto; + padding: 20px 16px; + } +} diff --git a/selective-vpn-web/src/main.tsx b/selective-vpn-web/src/main.tsx new file mode 100644 index 0000000..21fbf31 --- /dev/null +++ b/selective-vpn-web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import { App } from './app/App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/selective-vpn-web/src/pages/dns/DnsPage.tsx b/selective-vpn-web/src/pages/dns/DnsPage.tsx new file mode 100644 index 0000000..442f7ac --- /dev/null +++ b/selective-vpn-web/src/pages/dns/DnsPage.tsx @@ -0,0 +1,10 @@ +import { PlaceholderPage } from '../placeholder/PlaceholderPage' + +export function DnsPage() { + return ( + + ) +} diff --git a/selective-vpn-web/src/pages/not-found/NotFoundPage.tsx b/selective-vpn-web/src/pages/not-found/NotFoundPage.tsx new file mode 100644 index 0000000..85b8a20 --- /dev/null +++ b/selective-vpn-web/src/pages/not-found/NotFoundPage.tsx @@ -0,0 +1,15 @@ +import { Link } from 'react-router-dom' + +export function NotFoundPage() { + return ( +
+

Page Not Found

+

+ The requested route does not exist in the web prototype. +

+ + Go to Overview + +
+ ) +} diff --git a/selective-vpn-web/src/pages/overview/OverviewPage.tsx b/selective-vpn-web/src/pages/overview/OverviewPage.tsx new file mode 100644 index 0000000..8909c02 --- /dev/null +++ b/selective-vpn-web/src/pages/overview/OverviewPage.tsx @@ -0,0 +1,158 @@ +import { useQuery } from '@tanstack/react-query' + +import { api } from '../../shared/api/endpoints' + +function boolView(value: boolean | undefined): string { + if (value === undefined) { + return 'n/a' + } + return value ? 'ok' : 'missing' +} + +function queryErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + return 'unknown error' +} + +export function OverviewPage() { + const healthQuery = useQuery({ + queryKey: ['healthz'], + queryFn: api.healthz, + refetchInterval: 5000, + }) + + const statusQuery = useQuery({ + queryKey: ['status'], + queryFn: api.status, + refetchInterval: 5000, + }) + + const vpnQuery = useQuery({ + queryKey: ['vpn-status'], + queryFn: api.vpnStatus, + refetchInterval: 5000, + }) + + const loginQuery = useQuery({ + queryKey: ['vpn-login-state'], + queryFn: api.vpnLoginState, + refetchInterval: 5000, + }) + + return ( +
+

Overview

+

+ Foundation mode: read-only checks and realtime connectivity indicators. +

+ +
+
+

Core Health

+ {healthQuery.isLoading ?

Loading...

: null} + {healthQuery.error ? ( +

Error: {queryErrorMessage(healthQuery.error)}

+ ) : null} + {healthQuery.data ? ( +
+
+ Status + {healthQuery.data.status} +
+
+ Time + {healthQuery.data.time} +
+
+ ) : null} +
+ +
+

Routes Snapshot

+ {statusQuery.isLoading ?

Loading...

: null} + {statusQuery.error ? ( +

Error: {queryErrorMessage(statusQuery.error)}

+ ) : null} + {statusQuery.data ? ( +
+
+ iface + {statusQuery.data.iface || '—'} +
+
+ table + {statusQuery.data.table || '—'} +
+
+ mark + {statusQuery.data.mark || '—'} +
+
+ ip_count + {statusQuery.data.ip_count} +
+
+ domain_count + {statusQuery.data.domain_count} +
+
+ policy_route + {boolView(statusQuery.data.policy_route_ok)} +
+
+ ) : null} +
+ +
+

VPN Snapshot

+ {vpnQuery.isLoading ?

Loading...

: null} + {vpnQuery.error ? ( +

Error: {queryErrorMessage(vpnQuery.error)}

+ ) : null} + {vpnQuery.data ? ( +
+
+ desired_location + {vpnQuery.data.desired_location || '—'} +
+
+ status_word + {vpnQuery.data.status_word || '—'} +
+
+ unit_state + {vpnQuery.data.unit_state || '—'} +
+
+ ) : null} +
+ +
+

Login Snapshot

+ {loginQuery.isLoading ?

Loading...

: null} + {loginQuery.error ? ( +

Error: {queryErrorMessage(loginQuery.error)}

+ ) : null} + {loginQuery.data ? ( +
+
+ state + {loginQuery.data.state || '—'} +
+
+ email + {loginQuery.data.email || '—'} +
+
+ message + {loginQuery.data.msg || '—'} +
+
+ ) : null} +
+
+
+ ) +} diff --git a/selective-vpn-web/src/pages/placeholder/PlaceholderPage.tsx b/selective-vpn-web/src/pages/placeholder/PlaceholderPage.tsx new file mode 100644 index 0000000..b82d22b --- /dev/null +++ b/selective-vpn-web/src/pages/placeholder/PlaceholderPage.tsx @@ -0,0 +1,16 @@ +type PlaceholderPageProps = { + title: string + description: string +} + +export function PlaceholderPage({ title, description }: PlaceholderPageProps) { + return ( +
+

{title}

+

{description}

+
+

This section is scaffolded for the next implementation phase.

+
+
+ ) +} diff --git a/selective-vpn-web/src/pages/routes/RoutesPage.tsx b/selective-vpn-web/src/pages/routes/RoutesPage.tsx new file mode 100644 index 0000000..a68a197 --- /dev/null +++ b/selective-vpn-web/src/pages/routes/RoutesPage.tsx @@ -0,0 +1,10 @@ +import { PlaceholderPage } from '../placeholder/PlaceholderPage' + +export function RoutesPage() { + return ( + + ) +} diff --git a/selective-vpn-web/src/pages/trace/TracePage.tsx b/selective-vpn-web/src/pages/trace/TracePage.tsx new file mode 100644 index 0000000..ba40a8e --- /dev/null +++ b/selective-vpn-web/src/pages/trace/TracePage.tsx @@ -0,0 +1,10 @@ +import { PlaceholderPage } from '../placeholder/PlaceholderPage' + +export function TracePage() { + return ( + + ) +} diff --git a/selective-vpn-web/src/pages/transport/TransportPage.tsx b/selective-vpn-web/src/pages/transport/TransportPage.tsx new file mode 100644 index 0000000..dd9dcef --- /dev/null +++ b/selective-vpn-web/src/pages/transport/TransportPage.tsx @@ -0,0 +1,10 @@ +import { PlaceholderPage } from '../placeholder/PlaceholderPage' + +export function TransportPage() { + return ( + + ) +} diff --git a/selective-vpn-web/src/pages/vpn/VpnPage.tsx b/selective-vpn-web/src/pages/vpn/VpnPage.tsx new file mode 100644 index 0000000..1aad0b2 --- /dev/null +++ b/selective-vpn-web/src/pages/vpn/VpnPage.tsx @@ -0,0 +1,10 @@ +import { PlaceholderPage } from '../placeholder/PlaceholderPage' + +export function VpnPage() { + return ( + + ) +} diff --git a/selective-vpn-web/src/shared/api/contracts.ts b/selective-vpn-web/src/shared/api/contracts.ts new file mode 100644 index 0000000..b5b4bc5 --- /dev/null +++ b/selective-vpn-web/src/shared/api/contracts.ts @@ -0,0 +1,37 @@ +export type HealthzResponse = { + status: string + time: string +} + +export type StatusResponse = { + timestamp: string + ip_count: number + domain_count: number + iface: string + table: string + mark: string + policy_route_ok?: boolean + route_ok?: boolean +} + +export type VpnStatusResponse = { + desired_location: string + status_word: string + raw_text: string + unit_state: string +} + +export type VpnLoginStateResponse = { + state: string + email?: string + msg?: string + text?: string + color?: string +} + +export type SseEnvelope = { + id?: number + kind?: string + ts?: string + data?: unknown +} diff --git a/selective-vpn-web/src/shared/api/endpoints.ts b/selective-vpn-web/src/shared/api/endpoints.ts new file mode 100644 index 0000000..8015e61 --- /dev/null +++ b/selective-vpn-web/src/shared/api/endpoints.ts @@ -0,0 +1,14 @@ +import type { + HealthzResponse, + StatusResponse, + VpnLoginStateResponse, + VpnStatusResponse, +} from './contracts' +import { requestJson } from './http' + +export const api = { + healthz: () => requestJson('/healthz'), + status: () => requestJson('/api/v1/status'), + vpnStatus: () => requestJson('/api/v1/vpn/status'), + vpnLoginState: () => requestJson('/api/v1/vpn/login-state'), +} diff --git a/selective-vpn-web/src/shared/api/http.ts b/selective-vpn-web/src/shared/api/http.ts new file mode 100644 index 0000000..2164e1a --- /dev/null +++ b/selective-vpn-web/src/shared/api/http.ts @@ -0,0 +1,62 @@ +import { env } from '../config/env' + +type RequestJsonOptions = Omit & { + timeoutMs?: number + body?: unknown +} + +export class HttpError extends Error { + public readonly status: number + public readonly payload: string + + constructor(message: string, status: number, payload: string) { + super(message) + this.status = status + this.payload = payload + } +} + +export function apiUrl(path: string): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}` + if (!env.apiBaseUrl) { + return normalizedPath + } + return `${env.apiBaseUrl}${normalizedPath}` +} + +export async function requestJson(path: string, options: RequestJsonOptions = {}): Promise { + const controller = new AbortController() + const timeoutMs = options.timeoutMs ?? 8000 + + const headers = new Headers(options.headers || {}) + const hasBody = options.body !== undefined + if (hasBody && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + + const timeout = setTimeout(() => { + controller.abort() + }, timeoutMs) + + try { + const response = await fetch(apiUrl(path), { + ...options, + headers, + body: hasBody ? JSON.stringify(options.body) : undefined, + signal: controller.signal, + }) + + const text = await response.text() + if (!response.ok) { + throw new HttpError(`Request failed: ${response.status}`, response.status, text) + } + + if (!text) { + return {} as T + } + + return JSON.parse(text) as T + } finally { + clearTimeout(timeout) + } +} diff --git a/selective-vpn-web/src/shared/config/env.ts b/selective-vpn-web/src/shared/config/env.ts new file mode 100644 index 0000000..fb66a78 --- /dev/null +++ b/selective-vpn-web/src/shared/config/env.ts @@ -0,0 +1,11 @@ +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, '') +} + +const rawApiBaseUrl = String(import.meta.env.VITE_API_BASE_URL || '').trim() +const apiBaseUrl = rawApiBaseUrl ? trimTrailingSlash(rawApiBaseUrl) : '' + +export const env = { + apiBaseUrl, + apiTargetLabel: apiBaseUrl || 'same-origin', +} as const diff --git a/selective-vpn-web/src/shared/sse/useEventsStream.ts b/selective-vpn-web/src/shared/sse/useEventsStream.ts new file mode 100644 index 0000000..8506cc5 --- /dev/null +++ b/selective-vpn-web/src/shared/sse/useEventsStream.ts @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react' + +import type { SseEnvelope } from '../api/contracts' +import { apiUrl } from '../api/http' + +const EVENT_NAMES = [ + 'status_changed', + 'login_state_changed', + 'trace_changed', + 'trace_append', + 'autoloop_status_changed', + 'unit_state_changed', + 'routes_nft_progress', + 'vpn_locations_changed', + 'transport_client_state_changed', + 'transport_client_provisioned', + 'transport_policy_validated', + 'transport_policy_applied', + 'transport_conflict_detected', + 'traffic_mode_changed', + 'traffic_profiles_changed', + 'status_error', +] as const + +export type StreamState = 'connecting' | 'open' | 'error' | 'closed' + +export type StreamEvent = { + kind: string + ts: string + envelope: SseEnvelope +} + +function parseEnvelope(raw: string): SseEnvelope { + try { + return JSON.parse(raw) as SseEnvelope + } catch { + return { data: raw } + } +} + +export function useEventsStream(path = '/api/v1/events/stream') { + const [state, setState] = useState('connecting') + const [error, setError] = useState(null) + const [lastEvent, setLastEvent] = useState(null) + const [eventCount, setEventCount] = useState(0) + + useEffect(() => { + let stopped = false + + const source = new EventSource(apiUrl(path)) + const listenerEntries: Array<{ name: string; fn: EventListener }> = [] + + const handleMessage = (event: MessageEvent) => { + if (stopped) { + return + } + + const envelope = parseEnvelope(event.data) + const kind = String(envelope.kind || event.type || 'message') + const ts = String(envelope.ts || new Date().toISOString()) + + setLastEvent({ + kind, + ts, + envelope, + }) + setEventCount((prev) => prev + 1) + } + + for (const name of EVENT_NAMES) { + const fn = (event: Event) => { + handleMessage(event as MessageEvent) + } + source.addEventListener(name, fn) + listenerEntries.push({ name, fn }) + } + + source.onopen = () => { + if (stopped) { + return + } + setState('open') + setError(null) + } + + source.onerror = () => { + if (stopped) { + return + } + setState('error') + setError('stream disconnected, auto-retrying') + } + + return () => { + stopped = true + setState('closed') + for (const entry of listenerEntries) { + source.removeEventListener(entry.name, entry.fn) + } + source.close() + } + }, [path]) + + return { state, error, lastEvent, eventCount } +} diff --git a/selective-vpn-web/src/shared/ui/AppLayout.tsx b/selective-vpn-web/src/shared/ui/AppLayout.tsx new file mode 100644 index 0000000..93dd5a6 --- /dev/null +++ b/selective-vpn-web/src/shared/ui/AppLayout.tsx @@ -0,0 +1,89 @@ +import { NavLink, Outlet } from 'react-router-dom' + +import { useUiStore } from '../../app/store/useUiStore' +import { env } from '../config/env' +import { useEventsStream } from '../sse/useEventsStream' + +type NavItem = { + to: string + label: string +} + +const NAV_ITEMS: NavItem[] = [ + { to: '/overview', label: 'Overview' }, + { to: '/vpn', label: 'VPN' }, + { to: '/routes', label: 'Routes' }, + { to: '/dns', label: 'DNS' }, + { to: '/transport', label: 'Transport' }, + { to: '/trace', label: 'Trace' }, +] + +function streamClassName(state: string): string { + if (state === 'open') { + return 'status-chip status-ok' + } + if (state === 'connecting') { + return 'status-chip status-warn' + } + return 'status-chip status-bad' +} + +export function AppLayout() { + const collapsed = useUiStore((state) => state.sidebarCollapsed) + const toggleSidebar = useUiStore((state) => state.toggleSidebar) + const stream = useEventsStream('/api/v1/events/stream') + + return ( +
+ + +
+
+
+ +
+ +
+ + SSE: {stream.state} + + + API: {env.apiTargetLabel} + + + Last: {stream.lastEvent?.kind || '—'} + +
+
+ +
+ {stream.error ? ( +
{stream.error}
+ ) : null} + +
+
+
+ ) +} diff --git a/selective-vpn-web/tsconfig.app.json b/selective-vpn-web/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/selective-vpn-web/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/selective-vpn-web/tsconfig.json b/selective-vpn-web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/selective-vpn-web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/selective-vpn-web/tsconfig.node.json b/selective-vpn-web/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/selective-vpn-web/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/selective-vpn-web/vite.config.ts b/selective-vpn-web/vite.config.ts new file mode 100644 index 0000000..1b966ff --- /dev/null +++ b/selective-vpn-web/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, loadEnv } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const target = (env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:8080').trim() + + return { + plugins: [react()], + server: { + host: '127.0.0.1', + port: 5173, + proxy: { + '/api': { + target, + changeOrigin: true, + }, + '/healthz': { + target, + changeOrigin: true, + }, + }, + }, + } +}) diff --git a/smartdns.conf b/smartdns.conf index aa05455..4d8bd56 100644 --- a/smartdns.conf +++ b/smartdns.conf @@ -1,16 +1,12 @@ # ---- basic listen ---- -bind 127.0.0.1:6053 -no-speed-check -no-cache - -# ---- upstream: Meta DNS (VPN-only) ---- -server 46.243.231.30 -server 46.243.231.41 +bind 127.0.0.1:6053 # ---- upstream: AdGuard Home на PVE ---- # обычный UDP DNS-сервер +server 192.168.50.10 # включим простой лог в stdout (чтоб увидеть хоть что-то через journalctl) log-level info -response-mode fastest-response # набор доменов для автотуннеля domain-set -name agvpn_wild -file /etc/selective-vpn/smartdns.conf @@ -19,6 +15,5 @@ domain-set -name agvpn_wild -file /etc/selective-vpn/smartdns.conf nftset /domain-set:agvpn_wild/#4:inet#agvpn#agvpn_dyn4 # (опционально) включить таймауты и дебаг nftset -nftset-timeout yes +nftset-timeout no nftset-debug yes - diff --git a/tests/api_sanity.sh b/tests/api_sanity.sh new file mode 100755 index 0000000..9f6dd08 --- /dev/null +++ b/tests/api_sanity.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +API_URL="${API_URL:-http://127.0.0.1:8080}" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +check_json_get() { + local path="$1" + local keys_csv="$2" + local out_file="$TMP_DIR/out.json" + local code + code="$(curl -sS --max-time 15 -o "$out_file" -w "%{http_code}" "${API_URL}${path}")" + if [[ "$code" != "200" ]]; then + echo "[sanity] ${path} -> HTTP ${code}" >&2 + cat "$out_file" >&2 || true + return 1 + fi + python3 - "$out_file" "$keys_csv" "$path" <<'PY' +import json +import sys + +file_path, keys_csv, path = sys.argv[1], sys.argv[2], sys.argv[3] +keys = [k for k in keys_csv.split(",") if k] +with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) +for key in keys: + if key not in data: + print(f"[sanity] {path}: missing key '{key}'", file=sys.stderr) + sys.exit(1) +print(f"[sanity] {path}: OK") +PY +} + +echo "[sanity] API_URL=${API_URL}" +check_json_get "/healthz" "status,time" +check_json_get "/api/v1/status" "timestamp,iface,table" +check_json_get "/api/v1/routes/status" "timestamp,iface,table" +check_json_get "/api/v1/traffic/mode" "mode,healthy,message" +check_json_get "/api/v1/traffic/interfaces" "interfaces,preferred_iface,active_iface" +check_json_get "/api/v1/dns/status" "mode,smartdns_addr,via_smartdns" +check_json_get "/api/v1/vpn/status" "status_word,unit_state" +check_json_get "/api/v1/vpn/autoloop-status" "status_word,raw_text" +check_json_get "/api/v1/routes/timer" "enabled" + +# Method check: POST-only endpoint should reject GET. +code="$(curl -sS --max-time 10 -o /dev/null -w "%{http_code}" "${API_URL}/api/v1/routes/update")" +if [[ "$code" != "405" ]]; then + echo "[sanity] /api/v1/routes/update GET expected 405, got ${code}" >&2 + exit 1 +fi +echo "[sanity] method guard OK (/api/v1/routes/update GET => 405)" + +# Quick stream availability: open SSE briefly; timeout is expected. +headers_file="$TMP_DIR/events.headers" +set +e +http_code="$(curl -sS --max-time 2 -D "$headers_file" -o /dev/null -w "%{http_code}" "${API_URL}/api/v1/events/stream")" +curl_rc=$? +set -e +if [[ "$http_code" != "200" ]]; then + echo "[sanity] /api/v1/events/stream -> HTTP ${http_code}" >&2 + cat "$headers_file" >&2 || true + exit 1 +fi +if [[ $curl_rc -ne 0 && $curl_rc -ne 28 ]]; then + echo "[sanity] unexpected curl rc=${curl_rc} for events stream" >&2 + exit 1 +fi +if ! grep -qi '^Content-Type: text/event-stream' "$headers_file"; then + echo "[sanity] /api/v1/events/stream missing text/event-stream content-type" >&2 + cat "$headers_file" >&2 || true + exit 1 +fi +echo "[sanity] events stream headers OK (curl rc=${curl_rc})" +echo "[sanity] all checks passed" diff --git a/tests/events_stream.py b/tests/events_stream.py new file mode 100755 index 0000000..4a5be1b --- /dev/null +++ b/tests/events_stream.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""SSE smoke test with active event trigger via /api/v1/trace/append.""" + +import http.client +import json +import os +import sys +import threading +import time +from urllib.parse import urlparse + +API_URL = os.environ.get("API_URL", "http://127.0.0.1:8080") +TIMEOUT = float(os.environ.get("EVENTS_TIMEOUT_SEC", "12")) +EVENT_REQUIRED = "trace_append" + + +def parse_base(api_url: str): + parsed = urlparse(api_url) + if parsed.scheme != "http": + raise ValueError("only http API_URL is supported for this smoke test") + host = parsed.hostname or "127.0.0.1" + port = parsed.port or 80 + base_path = parsed.path.rstrip("/") + return host, port, base_path + + +def post_trace_append(host: str, port: int, base_path: str): + # Small delay to ensure SSE subscription is active before trigger. + time.sleep(1.0) + conn = http.client.HTTPConnection(host, port, timeout=8) + body = json.dumps({"kind": "gui", "line": f"sse-probe-{int(time.time())}"}) + path = f"{base_path}/api/v1/trace/append" + conn.request("POST", path, body=body, headers={"Content-Type": "application/json"}) + resp = conn.getresponse() + payload = resp.read().decode("utf-8", errors="ignore") + if resp.status != 200: + raise RuntimeError(f"trace/append failed: HTTP {resp.status}, body={payload}") + conn.close() + + +def main(): + host, port, base_path = parse_base(API_URL) + stream_path = f"{base_path}/api/v1/events/stream" + conn = http.client.HTTPConnection(host, port, timeout=TIMEOUT) + conn.putrequest("GET", stream_path) + conn.putheader("Accept", "text/event-stream") + conn.putheader("Cache-Control", "no-cache") + conn.putheader("Connection", "keep-alive") + conn.endheaders() + + resp = conn.getresponse() + if resp.status != 200: + print(f"[events] unexpected HTTP {resp.status}", file=sys.stderr) + sys.exit(1) + content_type = resp.getheader("Content-Type", "") + if "text/event-stream" not in content_type: + print(f"[events] bad Content-Type: {content_type}", file=sys.stderr) + sys.exit(1) + + trigger_err = [] + + def trigger(): + try: + post_trace_append(host, port, base_path) + except Exception as exc: + trigger_err.append(str(exc)) + + t = threading.Thread(target=trigger, daemon=True) + t.start() + + got_id = False + got_required = False + deadline = time.time() + TIMEOUT + while time.time() < deadline: + raw = resp.readline() + if not raw: + break + line = raw.decode("utf-8", errors="ignore").strip() + if line.startswith("id:"): + got_id = True + if line.startswith("event:"): + event = line.split(":", 1)[1].strip() + if event == EVENT_REQUIRED: + got_required = True + print(f"[events] got required event: {event}") + break + + resp.close() + conn.close() + t.join(timeout=0.5) + + if trigger_err: + print(f"[events] trigger failed: {trigger_err[0]}", file=sys.stderr) + sys.exit(1) + if not got_id: + print("[events] no SSE event id observed", file=sys.stderr) + sys.exit(1) + if not got_required: + print(f"[events] missing required event: {EVENT_REQUIRED}", file=sys.stderr) + sys.exit(1) + print("[events] stream smoke passed") + + +if __name__ == "__main__": + main() diff --git a/tests/run_all.sh b/tests/run_all.sh new file mode 100755 index 0000000..372e6bb --- /dev/null +++ b/tests/run_all.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +API_URL="${API_URL:-http://127.0.0.1:8080}" +echo "[run_all] API_URL=${API_URL}" + +run() { + local name="$1" + shift + echo "[run_all] >>> ${name}" + "$@" + echo "[run_all] <<< ${name}: OK" +} + +run "api_sanity" env API_URL="${API_URL}" ./tests/api_sanity.sh +run "transport_packaging_smoke" ./tests/transport_packaging_smoke.sh +run "transport_packaging_auto_update" ./tests/transport_packaging_auto_update.sh +run "transport_packaging_policy_rollout" ./tests/transport_packaging_policy_rollout.sh +run "vpn_locations_swr" env API_URL="${API_URL}" ./tests/vpn_locations_swr.sh +run "trace_append" env API_URL="${API_URL}" ./tests/trace_append.sh +run "events_stream" env API_URL="${API_URL}" ./tests/events_stream.py +run "vpn_login_flow" env API_URL="${API_URL}" ./tests/vpn_login_flow.py +run "transport_flow" env API_URL="${API_URL}" ./tests/transport_flow_smoke.py +run "transport_platform_compatibility" env API_URL="${API_URL}" ./tests/transport_platform_compatibility_smoke.py +run "transport_runbook_cli" env API_URL="${API_URL}" ./tests/transport_runbook_cli_smoke.sh +run "transport_recovery_runbook" env API_URL="${API_URL}" ./tests/transport_recovery_runbook_smoke.sh +run "transport_systemd_real_e2e" env API_URL="${API_URL}" ./tests/transport_systemd_real_e2e.py +run "transport_production_like_e2e" env API_URL="${API_URL}" ./tests/transport_production_like_e2e.py +run "transport_singbox_e2e" env API_URL="${API_URL}" ./tests/transport_singbox_e2e.py +run "transport_dnstt_e2e" env API_URL="${API_URL}" ./tests/transport_dnstt_e2e.py +run "transport_phoenix_e2e" env API_URL="${API_URL}" ./tests/transport_phoenix_e2e.py + +echo "[run_all] all smoke tests passed" diff --git a/tests/trace_append.sh b/tests/trace_append.sh new file mode 100755 index 0000000..cf508b0 --- /dev/null +++ b/tests/trace_append.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +API_URL="${API_URL:-http://127.0.0.1:8080}" +SENT_LINE="test-trace-$(date +%s)-$RANDOM" +TMP_JSON="$(mktemp)" +trap 'rm -f "$TMP_JSON"' EXIT + +resp="$(curl -sS --max-time 15 "${API_URL}/api/v1/trace/append" \ + -H "Content-Type: application/json" \ + -d "{\"kind\":\"gui\",\"line\":\"${SENT_LINE}\"}")" +python3 - "$resp" <<'PY' +import json +import sys +obj = json.loads(sys.argv[1]) +if obj.get("ok") is not True: + print(f"[trace] append response not ok: {obj}", file=sys.stderr) + sys.exit(1) +PY + +plain="$(curl -sS --max-time 15 "${API_URL}/api/v1/trace")" +if ! grep -q -- "${SENT_LINE}" <<<"${plain}"; then + echo "[trace] appended line not found in /api/v1/trace" >&2 + exit 1 +fi + +curl -sS --max-time 15 "${API_URL}/api/v1/trace-json?mode=full" >"$TMP_JSON" +python3 - "$TMP_JSON" "$SENT_LINE" <<'PY' +import json +import sys +file_path, needle = sys.argv[1], sys.argv[2] +with open(file_path, "r", encoding="utf-8") as f: + obj = json.load(f) +lines = obj.get("lines") +if not isinstance(lines, list): + print("[trace] /trace-json invalid payload: no lines[]", file=sys.stderr) + sys.exit(1) +if not any(needle in line for line in lines if isinstance(line, str)): + print("[trace] appended line not found in /trace-json", file=sys.stderr) + sys.exit(1) +PY + +echo "[trace] append and readback OK" diff --git a/tests/transport_dnstt_e2e.py b/tests/transport_dnstt_e2e.py new file mode 100755 index 0000000..9f27f1f --- /dev/null +++ b/tests/transport_dnstt_e2e.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import time +from typing import Dict, Optional, Tuple +import urllib.error +import urllib.request + + +def fail(msg: str) -> int: + print(f"[transport_dnstt_e2e] ERROR: {msg}") + return 1 + + +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=20.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: + return 0, {} + + try: + data_json = json.loads(raw) if raw else {} + except Exception: + data_json = {} + if not isinstance(data_json, dict): + data_json = {} + return status, data_json + + +def ensure_client_deleted(api_url: str, client_id: str) -> None: + request_json(api_url, "DELETE", f"/api/v1/transport/clients/{client_id}?force=true") + + +def create_client(api_url: str, payload: Dict) -> Tuple[int, Dict]: + return request_json(api_url, "POST", "/api/v1/transport/clients", payload) + + +def main() -> int: + api_url = os.environ.get("API_URL", "http://127.0.0.1:8080").strip() + if not api_url: + return fail("empty API_URL") + + print(f"[transport_dnstt_e2e] API_URL={api_url}") + status, caps = request_json(api_url, "GET", "/api/v1/transport/capabilities") + if status == 404: + print("[transport_dnstt_e2e] SKIP: transport endpoints are not available on this backend") + return 0 + if status != 200 or not bool(caps.get("ok", False)): + return fail(f"capabilities failed status={status} payload={caps}") + + clients_caps = caps.get("clients") or {} + if not isinstance(clients_caps, dict) or "dnstt" not in clients_caps: + return fail(f"dnstt capability is missing: {caps}") + runtime_modes = caps.get("runtime_modes") or {} + if isinstance(runtime_modes, dict) and runtime_modes: + if not bool(runtime_modes.get("exec", False)): + return fail(f"runtime_modes.exec is not supported: {caps}") + else: + print("[transport_dnstt_e2e] WARN: runtime_modes are not advertised by current backend build") + + ts = int(time.time()) + pid = os.getpid() + + # Case 1: successful lifecycle on mock runner. + client_ok = f"e2e-dnstt-ok-{ts}-{pid}" + ensure_client_deleted(api_url, client_ok) + status, create_ok = create_client( + api_url, + { + "id": client_ok, + "name": "E2E DNSTT Mock", + "kind": "dnstt", + "enabled": False, + "config": { + "runner": "mock", + "runtime_mode": "exec", + }, + }, + ) + if status != 200 or not bool(create_ok.get("ok", False)): + return fail(f"create mock dnstt failed status={status} payload={create_ok}") + try: + status, provision = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/provision") + if status == 404: + print("[transport_dnstt_e2e] SKIP: provision endpoint is not available on current backend build") + return 0 + if status != 200 or not bool(provision.get("ok", False)): + return fail(f"provision mock dnstt failed status={status} payload={provision}") + + status, start = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/start") + if status != 200 or not bool(start.get("ok", False)): + return fail(f"start mock dnstt failed status={status} payload={start}") + if str(start.get("status_after") or "").strip().lower() != "up": + return fail(f"start did not set status_after=up: {start}") + + status, health = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_ok}/health") + if status != 200 or not bool(health.get("ok", False)): + return fail(f"health mock dnstt failed status={status} payload={health}") + if str(health.get("status") or "").strip().lower() not in ("up", "degraded"): + return fail(f"unexpected health status after start: {health}") + + status, restart = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/restart") + if status != 200 or not bool(restart.get("ok", False)): + return fail(f"restart mock dnstt failed status={status} payload={restart}") + + status, stop = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/stop") + if status != 200 or not bool(stop.get("ok", False)): + return fail(f"stop mock dnstt failed status={status} payload={stop}") + if str(stop.get("status_after") or "").strip().lower() != "down": + return fail(f"stop did not set status_after=down: {stop}") + + status, metrics = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_ok}/metrics") + if status == 404: + print("[transport_dnstt_e2e] WARN: metrics endpoint is not available on current backend build") + else: + if status != 200 or not bool(metrics.get("ok", False)): + return fail(f"metrics mock dnstt failed status={status} payload={metrics}") + metrics_obj = metrics.get("metrics") or {} + if not isinstance(metrics_obj, dict): + return fail(f"metrics payload is invalid: {metrics}") + if int(metrics_obj.get("state_changes", 0) or 0) < 2: + return fail(f"state_changes must be >=2 after lifecycle sequence: {metrics}") + print("[transport_dnstt_e2e] case1 mock lifecycle: ok") + finally: + ensure_client_deleted(api_url, client_ok) + + # Case 2: ssh overlay requires ssh_host. + client_ssh = f"e2e-dnstt-ssh-host-{ts}-{pid}" + ensure_client_deleted(api_url, client_ssh) + status, create_ssh = create_client( + api_url, + { + "id": client_ssh, + "name": "E2E DNSTT SSH Overlay", + "kind": "dnstt", + "enabled": False, + "config": { + "runner": "systemd", + "runtime_mode": "exec", + "unit": f"{client_ssh}.service", + "exec_start": "/usr/bin/true", + "ssh_tunnel": True, + }, + }, + ) + if status != 200 or not bool(create_ssh.get("ok", False)): + return fail(f"create dnstt ssh overlay failed status={status} payload={create_ssh}") + try: + status, provision_ssh = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ssh}/provision") + if status != 200 or bool(provision_ssh.get("ok", True)): + return fail(f"dnstt ssh overlay provision must fail status={status} payload={provision_ssh}") + if str(provision_ssh.get("code") or "").strip() != "TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED": + return fail(f"dnstt ssh overlay wrong code: {provision_ssh}") + msg = str(provision_ssh.get("message") or "").strip().lower() + if "ssh_host" not in msg: + return fail(f"dnstt ssh overlay message must reference ssh_host: {provision_ssh}") + print("[transport_dnstt_e2e] case2 ssh overlay host guard: ok") + finally: + ensure_client_deleted(api_url, client_ssh) + + # Case 3: ssh overlay requires valid ssh_unit when provided. + client_ssh_unit = f"e2e-dnstt-ssh-unit-{ts}-{pid}" + ensure_client_deleted(api_url, client_ssh_unit) + status, create_ssh_unit = create_client( + api_url, + { + "id": client_ssh_unit, + "name": "E2E DNSTT SSH Unit Validation", + "kind": "dnstt", + "enabled": False, + "config": { + "runner": "systemd", + "runtime_mode": "exec", + "unit": f"{client_ssh_unit}.service", + "exec_start": "/usr/bin/true", + "ssh_tunnel": True, + "ssh_host": "127.0.0.1", + "ssh_unit": "invalid unit name", + }, + }, + ) + if status != 200 or not bool(create_ssh_unit.get("ok", False)): + return fail(f"create dnstt ssh unit validation client failed status={status} payload={create_ssh_unit}") + try: + status, provision_ssh_unit = request_json( + api_url, "POST", f"/api/v1/transport/clients/{client_ssh_unit}/provision" + ) + if status != 200 or bool(provision_ssh_unit.get("ok", True)): + return fail(f"dnstt ssh unit validation provision must fail status={status} payload={provision_ssh_unit}") + if str(provision_ssh_unit.get("code") or "").strip() != "TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED": + return fail(f"dnstt ssh unit validation wrong code: {provision_ssh_unit}") + msg = str(provision_ssh_unit.get("message") or "").strip().lower() + if "ssh_unit" not in msg: + return fail(f"dnstt ssh unit validation message must reference ssh_unit: {provision_ssh_unit}") + print("[transport_dnstt_e2e] case3 ssh overlay unit guard: ok") + finally: + ensure_client_deleted(api_url, client_ssh_unit) + + # Case 4: dnstt template validation must reject incomplete config. + client_tpl = f"e2e-dnstt-template-{ts}-{pid}" + ensure_client_deleted(api_url, client_tpl) + status, create_tpl = create_client( + api_url, + { + "id": client_tpl, + "name": "E2E DNSTT Template Validation", + "kind": "dnstt", + "enabled": False, + "config": { + "runner": "systemd", + "runtime_mode": "exec", + "unit": f"{client_tpl}.service", + # no exec_start, missing template fields on purpose. + }, + }, + ) + if status != 200 or not bool(create_tpl.get("ok", False)): + return fail(f"create dnstt template client failed status={status} payload={create_tpl}") + try: + status, provision_tpl = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_tpl}/provision") + if status != 200 or bool(provision_tpl.get("ok", True)): + return fail(f"dnstt template provision must fail status={status} payload={provision_tpl}") + if str(provision_tpl.get("code") or "").strip() != "TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED": + return fail(f"dnstt template wrong code: {provision_tpl}") + msg = str(provision_tpl.get("message") or "").strip().lower() + if "dnstt template requires" not in msg: + return fail(f"dnstt template message must mention required fields: {provision_tpl}") + print("[transport_dnstt_e2e] case4 template validation guard: ok") + finally: + ensure_client_deleted(api_url, client_tpl) + + print("[transport_dnstt_e2e] passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/transport_flow_smoke.py b/tests/transport_flow_smoke.py new file mode 100755 index 0000000..5144dd2 --- /dev/null +++ b/tests/transport_flow_smoke.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import sys +import time +from typing import Dict, Optional, Tuple +import urllib.error +import urllib.request + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GUI_DIR = os.path.join(REPO_ROOT, "selective-vpn-gui") +if GUI_DIR not in sys.path: + sys.path.insert(0, GUI_DIR) + +from api_client import ApiClient, ApiError +from dashboard_controller import DashboardController + + +def fail(msg: str) -> int: + print(f"[transport] ERROR: {msg}") + return 1 + + +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=15.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: + return 0, {} + + try: + data_json = json.loads(raw) if raw else {} + except Exception: + data_json = {} + if not isinstance(data_json, dict): + data_json = {} + return status, data_json + + +def main() -> int: + api_url = os.environ.get("API_URL", "http://127.0.0.1:8080").strip() + if not api_url: + return fail("empty API_URL") + + print(f"[transport] API_URL={api_url}") + client = ApiClient(api_url) + ctrl = DashboardController(client) + + try: + caps = ctrl.transport_capabilities() + except ApiError as e: + if int(getattr(e, "status_code", 0) or 0) == 404: + print("[transport] SKIP: running backend has no /api/v1/transport/* endpoints") + return 0 + return fail(str(e)) + if not caps.clients: + return fail("empty transport capabilities") + print(f"[transport] capabilities: {', '.join(sorted(caps.clients.keys()))}") + status, caps_raw = request_json(api_url, "GET", "/api/v1/transport/capabilities") + if status == 200 and bool(caps_raw.get("ok", False)): + clients_raw = caps_raw.get("clients") or {} + dnstt_caps = clients_raw.get("dnstt") if isinstance(clients_raw, dict) else {} + if isinstance(dnstt_caps, dict): + if bool(dnstt_caps.get("ssh_tunnel", False)): + print("[transport] dnstt ssh_tunnel capability detected") + else: + print("[transport] WARN: dnstt ssh_tunnel capability is not advertised") + runtime_modes_raw = caps_raw.get("runtime_modes") or {} + if isinstance(runtime_modes_raw, dict): + if bool(runtime_modes_raw.get("exec", False)): + print("[transport] runtime_mode exec supported") + else: + print("[transport] WARN: runtime_mode exec is not advertised") + if "embedded" in runtime_modes_raw or "sidecar" in runtime_modes_raw: + print( + "[transport] runtime_modes map: " + + ", ".join(f"{k}={v}" for k, v in sorted(runtime_modes_raw.items())) + ) + packaging_profiles = caps_raw.get("packaging_profiles") or {} + if isinstance(packaging_profiles, dict): + if bool(packaging_profiles.get("system", False)): + print("[transport] packaging profile system supported") + else: + print("[transport] WARN: packaging profile system is not advertised") + if isinstance(caps_raw.get("error_codes"), list): + print(f"[transport] capabilities error_codes={len(caps_raw.get('error_codes') or [])}") + + # D4.1 contract smoke: lifecycle + health + metrics + unified runtime/error fields. + client_id = f"smoke-{int(time.time())}-{os.getpid()}" + status, create_data = request_json( + api_url, + "POST", + "/api/v1/transport/clients", + { + "id": client_id, + "name": "Smoke Transport", + "kind": "singbox", + "enabled": False, + }, + ) + if status != 200 or not bool(create_data.get("ok", False)): + return fail(f"create client failed status={status} payload={create_data}") + + status, provision_data = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/provision") + if status == 404: + print("[transport] WARN: provision endpoint not available on current backend build") + elif status != 200: + return fail(f"provision failed status={status} payload={provision_data}") + elif not bool(provision_data.get("ok", False)): + print(f"[transport] WARN: provision returned ok=false payload={provision_data}") + else: + print("[transport] provision action ok") + + status, start_data = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/start") + if status != 200 or not bool(start_data.get("ok", False)): + return fail(f"start failed status={status} payload={start_data}") + status_after = str(start_data.get("status_after") or "").strip().lower() + if status_after and status_after != "up": + return fail(f"start did not set status_up: {start_data}") + + status, health_data = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_id}/health") + if status != 200 or not bool(health_data.get("ok", False)): + return fail(f"health failed status={status} payload={health_data}") + if status_after == "" and str(health_data.get("status") or "").strip().lower() not in ("up", "degraded"): + return fail(f"health status is not up/degraded after start: {health_data}") + health_client_id = str(health_data.get("client_id") or "").strip() + if health_client_id and health_client_id != client_id: + return fail(f"health client_id mismatch: {health_data}") + runtime = health_data.get("runtime") or {} + if isinstance(runtime, dict) and isinstance(runtime.get("metrics"), dict): + print("[transport] health runtime.metrics found") + else: + print("[transport] WARN: legacy health payload without runtime.metrics") + + status, metrics_data = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_id}/metrics") + if status == 404: + print("[transport] WARN: metrics endpoint not available on current backend build") + else: + if status != 200 or not bool(metrics_data.get("ok", False)): + return fail(f"metrics failed status={status} payload={metrics_data}") + metrics = metrics_data.get("metrics") or {} + if not isinstance(metrics, dict) or "state_changes" not in metrics: + return fail(f"metrics payload missing state_changes: {metrics_data}") + + status, stop_data = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/stop") + if status != 200 or not bool(stop_data.get("ok", False)): + return fail(f"stop failed status={status} payload={stop_data}") + + status, _ = request_json(api_url, "DELETE", f"/api/v1/transport/clients/{client_id}?force=true") + if status != 200: + return fail(f"cleanup delete failed status={status}") + print("[transport] backend-contract smoke: lifecycle/health/metrics ok") + + pol = ctrl.transport_policy() + print(f"[transport] current revision={pol.revision} intents={len(pol.intents)}") + + flow = ctrl.transport_flow_draft(pol.intents, base_revision=pol.revision) + flow = ctrl.transport_flow_validate(flow) + print( + f"[transport] validate phase={flow.phase} valid={flow.valid} " + f"blocks={flow.block_count} warns={flow.warn_count}" + ) + + if flow.phase == "risky": + flow = ctrl.transport_flow_confirm(flow) + flow = ctrl.transport_flow_apply(flow, force_override=True) + else: + flow = ctrl.transport_flow_apply(flow, force_override=False) + + if flow.phase != "applied": + return fail(f"apply phase={flow.phase} code={flow.code} message={flow.message}") + print(f"[transport] apply ok revision={flow.applied_revision} apply_id={flow.apply_id}") + + flow = ctrl.transport_flow_rollback(flow) + if flow.phase != "applied": + return fail(f"rollback phase={flow.phase} code={flow.code} message={flow.message}") + print(f"[transport] rollback ok revision={flow.applied_revision} apply_id={flow.apply_id}") + + conflicts = ctrl.transport_conflicts() + print( + f"[transport] conflicts: count={len(conflicts.items)} has_blocking={conflicts.has_blocking}" + ) + print("[transport] flow smoke passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/transport_packaging_auto_update.sh b/tests/transport_packaging_auto_update.sh new file mode 100755 index 0000000..041d8c1 --- /dev/null +++ b/tests/transport_packaging_auto_update.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +AUTO_UPDATER="${ROOT_DIR}/scripts/transport-packaging/auto_update.sh" + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "[transport_packaging_auto_update] missing command: ${cmd}" >&2 + exit 1 + fi +} + +require_cmd bash +require_cmd sha256sum +require_cmd flock + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +assets_dir="${tmp_dir}/assets" +bin_root="${tmp_dir}/bin-root" +state_dir="${tmp_dir}/state" +mkdir -p "$assets_dir" "$bin_root" "$state_dir" + +asset_v1="${assets_dir}/sing-box-v1" +asset_v2="${assets_dir}/sing-box-v2" + +cat >"$asset_v1" <<'EOF' +#!/usr/bin/env bash +echo "auto sing-box v1" +EOF +chmod +x "$asset_v1" + +cat >"$asset_v2" <<'EOF' +#!/usr/bin/env bash +echo "auto sing-box v2" +EOF +chmod +x "$asset_v2" + +sha_v1="$(sha256sum "$asset_v1" | awk '{print $1}')" +sha_v2="$(sha256sum "$asset_v2" | awk '{print $1}')" + +manifest_v1="${tmp_dir}/manifest-v1.json" +manifest_v2="${tmp_dir}/manifest-v2.json" + +cat >"$manifest_v1" <"$manifest_v2" < skip" +"$AUTO_UPDATER" \ + --enabled false \ + --manifest "$manifest_v1" \ + --bin-root "$bin_root" \ + --state-dir "$state_dir" \ + --component singbox \ + --target linux-amd64 \ + --min-interval-sec 3600 +if [[ -e "${bin_root}/sing-box" ]]; then + echo "[transport_packaging_auto_update] expected no install when disabled" >&2 + exit 1 +fi + +echo "[transport_packaging_auto_update] enabled -> install v1" +"$AUTO_UPDATER" \ + --enabled true \ + --manifest "$manifest_v1" \ + --bin-root "$bin_root" \ + --state-dir "$state_dir" \ + --component singbox \ + --target linux-amd64 \ + --min-interval-sec 3600 +out_v1="$("${bin_root}/sing-box")" +if [[ "$out_v1" != "auto sing-box v1" ]]; then + echo "[transport_packaging_auto_update] expected v1 output, got: ${out_v1}" >&2 + exit 1 +fi + +echo "[transport_packaging_auto_update] interval gate -> skip update to v2" +"$AUTO_UPDATER" \ + --enabled true \ + --manifest "$manifest_v2" \ + --bin-root "$bin_root" \ + --state-dir "$state_dir" \ + --component singbox \ + --target linux-amd64 \ + --min-interval-sec 3600 +out_after_gate="$("${bin_root}/sing-box")" +if [[ "$out_after_gate" != "auto sing-box v1" ]]; then + echo "[transport_packaging_auto_update] expected interval-gated v1 output, got: ${out_after_gate}" >&2 + exit 1 +fi + +echo "[transport_packaging_auto_update] force-now -> install v2" +"$AUTO_UPDATER" \ + --enabled true \ + --manifest "$manifest_v2" \ + --bin-root "$bin_root" \ + --state-dir "$state_dir" \ + --component singbox \ + --target linux-amd64 \ + --min-interval-sec 3600 \ + --force-now +out_v2="$("${bin_root}/sing-box")" +if [[ "$out_v2" != "auto sing-box v2" ]]; then + echo "[transport_packaging_auto_update] expected forced v2 output, got: ${out_v2}" >&2 + exit 1 +fi + +if [[ ! -s "${state_dir}/last_success_epoch" ]]; then + echo "[transport_packaging_auto_update] expected last_success_epoch file" >&2 + exit 1 +fi + +echo "[transport_packaging_auto_update] passed" diff --git a/tests/transport_packaging_policy_rollout.sh b/tests/transport_packaging_policy_rollout.sh new file mode 100755 index 0000000..9d45f01 --- /dev/null +++ b/tests/transport_packaging_policy_rollout.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UPDATER="${ROOT_DIR}/scripts/transport-packaging/update.sh" + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "[transport_packaging_policy_rollout] missing command: ${cmd}" >&2 + exit 1 + fi +} + +require_cmd bash +require_cmd openssl +require_cmd sha256sum +require_cmd jq + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +assets_dir="${tmp_dir}/assets" +bin_root="${tmp_dir}/bin-root" +keys_dir="${tmp_dir}/keys" +mkdir -p "$assets_dir" "$bin_root" "$keys_dir" + +asset_v1="${assets_dir}/sing-box-v1" +asset_v2="${assets_dir}/sing-box-v2" +sig_v1="${assets_dir}/sing-box-v1.sig" +sig_v2="${assets_dir}/sing-box-v2.sig" +sig_bad="${assets_dir}/sing-box-v2.bad.sig" + +cat >"$asset_v1" <<'EOF' +#!/usr/bin/env bash +echo "signed sing-box v1" +EOF +chmod +x "$asset_v1" + +cat >"$asset_v2" <<'EOF' +#!/usr/bin/env bash +echo "signed sing-box v2" +EOF +chmod +x "$asset_v2" + +sha_v1="$(sha256sum "$asset_v1" | awk '{print $1}')" +sha_v2="$(sha256sum "$asset_v2" | awk '{print $1}')" + +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "${keys_dir}/release-private.pem" >/dev/null 2>&1 +openssl rsa -pubout -in "${keys_dir}/release-private.pem" -out "${keys_dir}/release-public.pem" >/dev/null 2>&1 + +openssl dgst -sha256 -sign "${keys_dir}/release-private.pem" -out "$sig_v1" "$asset_v1" +openssl dgst -sha256 -sign "${keys_dir}/release-private.pem" -out "$sig_v2" "$asset_v2" +cp "$sig_v1" "$sig_bad" + +sig_sha_v1="$(sha256sum "$sig_v1" | awk '{print $1}')" +sig_sha_v2="$(sha256sum "$sig_v2" | awk '{print $1}')" +sig_sha_bad="$(sha256sum "$sig_bad" | awk '{print $1}')" + +policy="${tmp_dir}/source-policy.json" +cat >"$policy" <<'EOF' +{ + "schema_version": 1, + "require_https": false, + "allow_file_scheme": true, + "signature": { + "default_mode": "required", + "allowed_types": ["openssl-sha256"] + }, + "components": { + "singbox": { + "allowed_url_prefixes": ["file://"], + "signature_mode": "required" + } + } +} +EOF + +manifest="${tmp_dir}/manifest-canary.json" +cat >"$manifest" < skip" +"$UPDATER" --manifest "$manifest" --source-policy "$policy" --bin-root "$bin_root" --component singbox --target linux-amd64 --rollout-stage stable --cohort-id 5 +if [[ -e "${bin_root}/sing-box" ]]; then + echo "[transport_packaging_policy_rollout] expected no install on stage mismatch" >&2 + exit 1 +fi + +echo "[transport_packaging_policy_rollout] canary gated by cohort -> skip" +"$UPDATER" --manifest "$manifest" --source-policy "$policy" --bin-root "$bin_root" --component singbox --target linux-amd64 --rollout-stage canary --cohort-id 55 +if [[ -e "${bin_root}/sing-box" ]]; then + echo "[transport_packaging_policy_rollout] expected no install when cohort is out of rollout percent" >&2 + exit 1 +fi + +echo "[transport_packaging_policy_rollout] canary in cohort -> install" +"$UPDATER" --manifest "$manifest" --source-policy "$policy" --bin-root "$bin_root" --component singbox --target linux-amd64 --rollout-stage canary --cohort-id 5 +out_v1="$("${bin_root}/sing-box")" +if [[ "$out_v1" != "signed sing-box v1" ]]; then + echo "[transport_packaging_policy_rollout] expected signed v1 output, got: ${out_v1}" >&2 + exit 1 +fi + +echo "[transport_packaging_policy_rollout] untrusted source must fail" +manifest_untrusted="${tmp_dir}/manifest-untrusted.json" +jq \ + '.components.singbox.targets["linux-amd64"].version = "1.1.0" | + .components.singbox.targets["linux-amd64"].url = "https://example.com/sing-box" | + .components.singbox.targets["linux-amd64"].sha256 = "0000000000000000000000000000000000000000000000000000000000000000" | + .components.singbox.targets["linux-amd64"].rollout.stage = "stable" | + .components.singbox.targets["linux-amd64"].rollout.percent = 100' \ + "$manifest" >"$manifest_untrusted" +if "$UPDATER" --manifest "$manifest_untrusted" --source-policy "$policy" --bin-root "$bin_root" --component singbox --target linux-amd64 --rollout-stage stable --cohort-id 5 --dry-run; then + echo "[transport_packaging_policy_rollout] expected failure for untrusted source" >&2 + exit 1 +fi + +echo "[transport_packaging_policy_rollout] bad signature must fail" +manifest_bad_sig="${tmp_dir}/manifest-bad-sig.json" +cat >"$manifest_bad_sig" <&2 + exit 1 +fi + +echo "[transport_packaging_policy_rollout] valid signature update -> install v2" +manifest_good_sig="${tmp_dir}/manifest-good-sig.json" +cat >"$manifest_good_sig" <&2 + exit 1 +fi + +echo "[transport_packaging_policy_rollout] passed" diff --git a/tests/transport_packaging_smoke.sh b/tests/transport_packaging_smoke.sh new file mode 100755 index 0000000..8ab748d --- /dev/null +++ b/tests/transport_packaging_smoke.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UPDATER="${ROOT_DIR}/scripts/transport-packaging/update.sh" +ROLLBACK="${ROOT_DIR}/scripts/transport-packaging/rollback.sh" + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "[transport_packaging_smoke] missing command: ${cmd}" >&2 + exit 1 + fi +} + +require_cmd bash +require_cmd python3 +require_cmd curl +require_cmd sha256sum + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +assets_dir="${tmp_dir}/assets" +bin_root="${tmp_dir}/bin-root" +mkdir -p "$assets_dir" "$bin_root" + +asset_v1="${assets_dir}/sing-box-v1" +asset_v2="${assets_dir}/sing-box-v2" + +cat >"$asset_v1" <<'EOF' +#!/usr/bin/env bash +echo "sing-box v1" +EOF +chmod +x "$asset_v1" + +cat >"$asset_v2" <<'EOF' +#!/usr/bin/env bash +echo "sing-box v2" +EOF +chmod +x "$asset_v2" + +sha_v1="$(sha256sum "$asset_v1" | awk '{print $1}')" +sha_v2="$(sha256sum "$asset_v2" | awk '{print $1}')" + +manifest_v1="${tmp_dir}/manifest-v1.json" +manifest_v2="${tmp_dir}/manifest-v2.json" + +cat >"$manifest_v1" <"$manifest_v2" <&2 + exit 1 +fi + +echo "[transport_packaging_smoke] update to v2" +"$UPDATER" --manifest "$manifest_v2" --bin-root "$bin_root" --component singbox --target linux-amd64 +out_v2="$("$bin_root/sing-box")" +if [[ "$out_v2" != "sing-box v2" ]]; then + echo "[transport_packaging_smoke] expected v2 output, got: ${out_v2}" >&2 + exit 1 +fi + +echo "[transport_packaging_smoke] rollback to v1" +"$ROLLBACK" --bin-root "$bin_root" --component singbox +out_after_rb="$("$bin_root/sing-box")" +if [[ "$out_after_rb" != "sing-box v1" ]]; then + echo "[transport_packaging_smoke] expected rollback to v1, got: ${out_after_rb}" >&2 + exit 1 +fi + +echo "[transport_packaging_smoke] passed" diff --git a/tests/transport_phoenix_e2e.py b/tests/transport_phoenix_e2e.py new file mode 100755 index 0000000..f204704 --- /dev/null +++ b/tests/transport_phoenix_e2e.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import time +from typing import Dict, Optional, Tuple +import urllib.error +import urllib.request + + +def fail(msg: str) -> int: + print(f"[transport_phoenix_e2e] ERROR: {msg}") + return 1 + + +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=20.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: + return 0, {} + + try: + data_json = json.loads(raw) if raw else {} + except Exception: + data_json = {} + if not isinstance(data_json, dict): + data_json = {} + return status, data_json + + +def ensure_client_deleted(api_url: str, client_id: str) -> None: + request_json(api_url, "DELETE", f"/api/v1/transport/clients/{client_id}?force=true") + + +def create_client(api_url: str, payload: Dict) -> Tuple[int, Dict]: + return request_json(api_url, "POST", "/api/v1/transport/clients", payload) + + +def main() -> int: + api_url = os.environ.get("API_URL", "http://127.0.0.1:8080").strip() + if not api_url: + return fail("empty API_URL") + + print(f"[transport_phoenix_e2e] API_URL={api_url}") + status, caps = request_json(api_url, "GET", "/api/v1/transport/capabilities") + if status == 404: + print("[transport_phoenix_e2e] SKIP: transport endpoints are not available on this backend") + return 0 + if status != 200 or not bool(caps.get("ok", False)): + return fail(f"capabilities failed status={status} payload={caps}") + + clients_caps = caps.get("clients") or {} + if not isinstance(clients_caps, dict) or "phoenix" not in clients_caps: + return fail(f"phoenix capability is missing: {caps}") + runtime_modes = caps.get("runtime_modes") or {} + if isinstance(runtime_modes, dict) and runtime_modes: + if not bool(runtime_modes.get("exec", False)): + return fail(f"runtime_modes.exec is not supported: {caps}") + else: + print("[transport_phoenix_e2e] WARN: runtime_modes are not advertised by current backend build") + + ts = int(time.time()) + pid = os.getpid() + + # Case 1: successful lifecycle on mock runner. + client_ok = f"e2e-phoenix-ok-{ts}-{pid}" + ensure_client_deleted(api_url, client_ok) + status, create_ok = create_client( + api_url, + { + "id": client_ok, + "name": "E2E Phoenix Mock", + "kind": "phoenix", + "enabled": False, + "config": { + "runner": "mock", + "runtime_mode": "exec", + }, + }, + ) + if status != 200 or not bool(create_ok.get("ok", False)): + return fail(f"create mock phoenix failed status={status} payload={create_ok}") + try: + status, provision = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/provision") + if status == 404: + print("[transport_phoenix_e2e] SKIP: provision endpoint is not available on current backend build") + return 0 + if status != 200 or not bool(provision.get("ok", False)): + return fail(f"provision mock phoenix failed status={status} payload={provision}") + + status, start = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/start") + if status != 200 or not bool(start.get("ok", False)): + return fail(f"start mock phoenix failed status={status} payload={start}") + if str(start.get("status_after") or "").strip().lower() != "up": + return fail(f"start did not set status_after=up: {start}") + + status, health = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_ok}/health") + if status != 200 or not bool(health.get("ok", False)): + return fail(f"health mock phoenix failed status={status} payload={health}") + if str(health.get("status") or "").strip().lower() not in ("up", "degraded"): + return fail(f"unexpected health status after start: {health}") + + status, restart = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/restart") + if status != 200 or not bool(restart.get("ok", False)): + return fail(f"restart mock phoenix failed status={status} payload={restart}") + + status, stop = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/stop") + if status != 200 or not bool(stop.get("ok", False)): + return fail(f"stop mock phoenix failed status={status} payload={stop}") + if str(stop.get("status_after") or "").strip().lower() != "down": + return fail(f"stop did not set status_after=down: {stop}") + + status, metrics = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_ok}/metrics") + if status == 404: + print("[transport_phoenix_e2e] WARN: metrics endpoint is not available on current backend build") + else: + if status != 200 or not bool(metrics.get("ok", False)): + return fail(f"metrics mock phoenix failed status={status} payload={metrics}") + metrics_obj = metrics.get("metrics") or {} + if not isinstance(metrics_obj, dict): + return fail(f"metrics payload is invalid: {metrics}") + if int(metrics_obj.get("state_changes", 0) or 0) < 2: + return fail(f"state_changes must be >=2 after lifecycle sequence: {metrics}") + print("[transport_phoenix_e2e] case1 mock lifecycle: ok") + finally: + ensure_client_deleted(api_url, client_ok) + + # Case 2: embedded runtime mode must be rejected. + client_emb = f"e2e-phoenix-embedded-{ts}-{pid}" + ensure_client_deleted(api_url, client_emb) + status, create_emb = create_client( + api_url, + { + "id": client_emb, + "name": "E2E Phoenix Embedded", + "kind": "phoenix", + "enabled": False, + "config": { + "runner": "mock", + "runtime_mode": "embedded", + }, + }, + ) + if status != 200 or not bool(create_emb.get("ok", False)): + return fail(f"create embedded phoenix failed status={status} payload={create_emb}") + try: + status, provision_emb = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_emb}/provision") + if status != 200 or bool(provision_emb.get("ok", True)): + return fail(f"embedded provision must fail status={status} payload={provision_emb}") + if str(provision_emb.get("code") or "").strip() != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED": + return fail(f"embedded provision wrong code: {provision_emb}") + + status, start_emb = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_emb}/start") + if status != 200 or bool(start_emb.get("ok", True)): + return fail(f"embedded start must fail status={status} payload={start_emb}") + if str(start_emb.get("code") or "").strip() != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED": + return fail(f"embedded start wrong code: {start_emb}") + + status, health_emb = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_emb}/health") + if status != 200 or not bool(health_emb.get("ok", False)): + return fail(f"embedded health request failed status={status} payload={health_emb}") + if str(health_emb.get("code") or "").strip() != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED": + return fail(f"embedded health wrong code: {health_emb}") + print("[transport_phoenix_e2e] case2 runtime_mode=embedded guard: ok") + finally: + ensure_client_deleted(api_url, client_emb) + + # Case 3: require_binary fail-fast for missing phoenix binary. + client_req = f"e2e-phoenix-requirebin-{ts}-{pid}" + ensure_client_deleted(api_url, client_req) + status, create_req = create_client( + api_url, + { + "id": client_req, + "name": "E2E Phoenix RequireBinary", + "kind": "phoenix", + "enabled": False, + "config": { + "runner": "systemd", + "runtime_mode": "exec", + "unit": f"{client_req}.service", + "packaging_profile": "bundled", + "bin_root": "/opt/selective-vpn/bin", + "require_binary": True, + "phoenix_bin": "/tmp/definitely-missing-phoenix-binary", + "phoenix_config_path": "/etc/phoenix/client.toml", + }, + }, + ) + if status != 200 or not bool(create_req.get("ok", False)): + return fail(f"create require_binary phoenix failed status={status} payload={create_req}") + try: + status, provision_req = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_req}/provision") + if status != 200 or bool(provision_req.get("ok", True)): + return fail(f"require_binary provision must fail status={status} payload={provision_req}") + if str(provision_req.get("code") or "").strip() != "TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED": + return fail(f"require_binary provision wrong code: {provision_req}") + msg = str(provision_req.get("message") or "").strip().lower() + if "required phoenix binary not found" not in msg: + return fail(f"require_binary provision wrong message: {provision_req}") + print("[transport_phoenix_e2e] case3 require_binary fail-fast: ok") + finally: + ensure_client_deleted(api_url, client_req) + + print("[transport_phoenix_e2e] passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/transport_platform_compatibility_smoke.py b/tests/transport_platform_compatibility_smoke.py new file mode 100755 index 0000000..1ccb872 --- /dev/null +++ b/tests/transport_platform_compatibility_smoke.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request + + +def fail(msg: str) -> int: + print(f"[transport_platform_compat] ERROR: {msg}") + return 1 + + +def request_json(api_url: str, method: str, path: str, payload: dict | None = 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=20.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: + return 0, {} + + try: + body = json.loads(raw) if raw else {} + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + return status, body + + +def assert_capability_true(caps: dict, section: str, key: str) -> tuple[bool, str]: + obj = caps.get(section) or {} + if not isinstance(obj, dict): + return False, f"section `{section}` is missing in capabilities payload" + if key not in obj: + return False, f"`{section}.{key}` is missing in capabilities payload" + if not bool(obj.get(key)): + return False, f"`{section}.{key}` must be true for cross-platform contract" + return True, "" + + +def main() -> int: + api_url = os.environ.get("API_URL", "http://127.0.0.1:8080").strip() + if not api_url: + return fail("empty API_URL") + + print(f"[transport_platform_compat] API_URL={api_url}") + + status, caps = request_json(api_url, "GET", "/api/v1/transport/capabilities") + if status == 404: + print("[transport_platform_compat] SKIP: /api/v1/transport/* is unavailable on current backend build") + return 0 + if status != 200 or not bool(caps.get("ok", False)): + return fail(f"capabilities failed status={status} payload={caps}") + + clients = caps.get("clients") or {} + if not isinstance(clients, dict): + return fail(f"clients map is invalid: {caps}") + required_clients = ("singbox", "dnstt", "phoenix") + for kind in required_clients: + if kind not in clients: + return fail(f"missing transport client `{kind}` in capabilities") + if not isinstance(clients.get(kind), dict): + return fail(f"client capability `{kind}` must be an object") + + ok, msg = assert_capability_true(caps, "runtime_modes", "exec") + if not ok: + return fail(msg) + ok, msg = assert_capability_true(caps, "packaging_profiles", "system") + if not ok: + return fail(msg) + ok, msg = assert_capability_true(caps, "packaging_profiles", "bundled") + if not ok: + return fail(msg) + + # Базовый policy-контракт должен быть одинаково доступен для web/iOS/Android клиентов. + status, policy = request_json(api_url, "GET", "/api/v1/transport/policies") + if status != 200 or not bool(policy.get("ok", False)): + return fail(f"transport/policies failed status={status} payload={policy}") + revision = int(policy.get("policy_revision") or 0) + intents = policy.get("intents") or [] + if not isinstance(intents, list): + return fail(f"policy intents must be array: {policy}") + + status, validated = request_json( + api_url, + "POST", + "/api/v1/transport/policies/validate", + {"base_revision": revision, "intents": intents}, + ) + if status != 200 or not bool(validated.get("ok", False)): + return fail(f"transport/policies/validate failed status={status} payload={validated}") + if int(validated.get("base_revision") or 0) <= 0: + return fail(f"validate response has invalid base_revision: {validated}") + + status, conflicts = request_json(api_url, "GET", "/api/v1/transport/conflicts") + if status != 200 or not bool(conflicts.get("ok", False)): + return fail(f"transport/conflicts failed status={status} payload={conflicts}") + + print("[transport_platform_compat] capabilities + policy contract are compatible with web/iOS/Android clients") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/transport_production_like_e2e.py b/tests/transport_production_like_e2e.py new file mode 100755 index 0000000..f3fe54d --- /dev/null +++ b/tests/transport_production_like_e2e.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import tempfile +import time +from pathlib import Path +from typing import Dict, List, Optional, Tuple +import urllib.error +import urllib.request + +SINGBOX_INSTANCE_DROPIN = "10-selective-vpn.conf" + + +def fail(msg: str) -> int: + print(f"[transport_production_like_e2e] ERROR: {msg}") + return 1 + + +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: + return 0, {} + + try: + data_json = json.loads(raw) if raw else {} + except Exception: + data_json = {} + if not isinstance(data_json, dict): + data_json = {} + return status, data_json + + +def ensure_client_deleted(api_url: str, client_id: str) -> None: + request_json(api_url, "DELETE", f"/api/v1/transport/clients/{client_id}?force=true") + + +def is_systemd_unavailable(resp: Dict) -> bool: + text = ( + str(resp.get("message") or "") + + " " + + str(resp.get("stderr") or "") + + " " + + str(resp.get("stdout") or "") + ).lower() + checks = ( + "not been booted with systemd", + "failed to connect to bus", + "systemctl daemon-reload failed", + "operation not permitted", + ) + return any(c in text for c in checks) + + +def write_fake_binary(path: Path) -> None: + body = "#!/usr/bin/env bash\nexec /usr/bin/sleep 120\n" + path.write_text(body, encoding="utf-8") + path.chmod(0o755) + + +def unit_file_path(unit: str) -> Path: + return Path("/etc/systemd/system") / unit + + +def unit_dropin_path(unit: str, file_name: str = SINGBOX_INSTANCE_DROPIN) -> Path: + return Path("/etc/systemd/system") / f"{unit}.d" / file_name + + +def read_managed_unit_text(unit: str) -> str: + unit_path = unit_file_path(unit) + dropin_path = unit_dropin_path(unit) + chunks: List[str] = [] + if unit_path.exists(): + chunks.append(unit_path.read_text(encoding="utf-8", errors="replace")) + if dropin_path.exists(): + chunks.append(dropin_path.read_text(encoding="utf-8", errors="replace")) + if not chunks: + raise AssertionError(f"unit artifacts are missing: {unit_path} {dropin_path}") + return "\n".join(chunks) + + +def assert_unit_contains(unit: str, expected_parts: List[str]) -> None: + text = read_managed_unit_text(unit) + for part in expected_parts: + if part not in text: + raise AssertionError(f"unit {unit} missing {part!r}") + + +def assert_unit_removed(unit: str, client_id: str) -> None: + marker = f"Environment=SVPN_TRANSPORT_ID={client_id}" + unit_path = unit_file_path(unit) + dropin_path = unit_dropin_path(unit) + if dropin_path.exists(): + raise AssertionError(f"drop-in file still exists after cleanup: {dropin_path}") + if unit_path.exists(): + text = unit_path.read_text(encoding="utf-8", errors="replace") + if marker in text: + raise AssertionError(f"owned unit still exists after cleanup: {unit_path}") + + +def assert_file_exists(path: str) -> None: + p = Path(path) + if not p.exists(): + raise AssertionError(f"expected file missing: {p}") + + +def run_case( + api_url: str, + *, + client_id: str, + kind: str, + cfg: Dict, + units: List[str], + expected_unit_parts: List[str], + template_units: Optional[List[str]] = None, +) -> Tuple[bool, str]: + ensure_client_deleted(api_url, client_id) + status, created = request_json( + api_url, + "POST", + "/api/v1/transport/clients", + { + "id": client_id, + "name": f"E2E ProductionLike {kind}", + "kind": kind, + "enabled": False, + "config": cfg, + }, + ) + if status != 200 or not bool(created.get("ok", False)): + raise AssertionError(f"create failed status={status} payload={created}") + + try: + status, provision = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/provision") + if status == 404: + return False, "provision endpoint is not available on current backend build" + if status != 200: + raise AssertionError(f"provision failed status={status} payload={provision}") + if not bool(provision.get("ok", False)): + if is_systemd_unavailable(provision): + return False, f"systemd is unavailable: {provision}" + raise AssertionError(f"provision returned ok=false payload={provision}") + + for unit in units: + assert_unit_contains(unit, [f"Environment=SVPN_TRANSPORT_ID={client_id}"]) + assert_unit_contains(units[0], expected_unit_parts) + for t_unit in (template_units or []): + assert_file_exists(str(unit_file_path(t_unit))) + + status, started = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/start") + if status != 200 or not bool(started.get("ok", False)): + raise AssertionError(f"start failed status={status} payload={started}") + if str(started.get("status_after") or "").strip().lower() != "up": + raise AssertionError(f"start did not set status_after=up payload={started}") + + status, health = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_id}/health") + if status != 200 or not bool(health.get("ok", False)): + raise AssertionError(f"health failed status={status} payload={health}") + + status, metrics = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_id}/metrics") + if status != 200 or not bool(metrics.get("ok", False)): + raise AssertionError(f"metrics failed status={status} payload={metrics}") + m = metrics.get("metrics") or {} + if int(m.get("state_changes", 0) or 0) < 1: + raise AssertionError(f"state_changes must be >=1 payload={metrics}") + + status, restarted = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/restart") + if status != 200 or not bool(restarted.get("ok", False)): + raise AssertionError(f"restart failed status={status} payload={restarted}") + + status, stopped = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/stop") + if status != 200 or not bool(stopped.get("ok", False)): + raise AssertionError(f"stop failed status={status} payload={stopped}") + finally: + status, deleted = request_json(api_url, "DELETE", f"/api/v1/transport/clients/{client_id}?force=true") + if status != 200 or not bool(deleted.get("ok", False)): + raise AssertionError(f"delete failed status={status} payload={deleted}") + for unit in units: + assert_unit_removed(unit, client_id) + for t_unit in (template_units or []): + assert_file_exists(str(unit_file_path(t_unit))) + + return True, "ok" + + +def main() -> int: + api_url = os.environ.get("API_URL", "http://127.0.0.1:8080").strip() + if not api_url: + return fail("empty API_URL") + print(f"[transport_production_like_e2e] API_URL={api_url}") + + status, caps = request_json(api_url, "GET", "/api/v1/transport/capabilities") + if status == 404: + print("[transport_production_like_e2e] SKIP: transport endpoints are not available on this backend") + return 0 + if status != 200 or not bool(caps.get("ok", False)): + return fail(f"capabilities failed status={status} payload={caps}") + + runtime_modes = caps.get("runtime_modes") or {} + if isinstance(runtime_modes, dict) and runtime_modes: + if not bool(runtime_modes.get("exec", False)): + return fail(f"runtime_modes.exec is not supported: {caps}") + packaging_profiles = caps.get("packaging_profiles") or {} + if isinstance(packaging_profiles, dict) and packaging_profiles: + if not bool(packaging_profiles.get("bundled", False)): + return fail(f"packaging_profiles.bundled is not supported: {caps}") + if not bool(packaging_profiles.get("system", False)): + return fail(f"packaging_profiles.system is not supported: {caps}") + + ts = int(time.time()) + pid = os.getpid() + tag = f"{ts}-{pid}" + + with tempfile.TemporaryDirectory(prefix="svpn-prodlike-") as tmp: + root = Path(tmp) + bin_root = root / "bin" + bin_root.mkdir(parents=True, exist_ok=True) + + singbox_bin = bin_root / "sing-box" + phoenix_bin = bin_root / "phoenix-client" + dnstt_bin = bin_root / "dnstt-client" + write_fake_binary(singbox_bin) + write_fake_binary(phoenix_bin) + write_fake_binary(dnstt_bin) + + singbox_cfg = root / "singbox.json" + phoenix_cfg = root / "phoenix.toml" + singbox_cfg.write_text("{}", encoding="utf-8") + phoenix_cfg.write_text("{}", encoding="utf-8") + + phoenix_unit = f"svpn-prodlike-phoenix-{tag}.service" + dnstt_unit = f"svpn-prodlike-dnstt-{tag}.service" + dnstt_ssh_unit = f"svpn-prodlike-dnstt-ssh-{tag}.service" + + singbox_client_id = f"e2e-prodlike-singbox-{tag}" + singbox_unit = f"singbox@{singbox_client_id}.service" + + cases = [ + { + "client_id": singbox_client_id, + "kind": "singbox", + "cfg": { + "runner": "systemd", + "runtime_mode": "exec", + "packaging_profile": "bundled", + "bin_root": str(bin_root), + "packaging_system_fallback": False, + "require_binary": True, + "singbox_config_path": str(singbox_cfg), + "hardening_enabled": False, + }, + "units": [singbox_unit], + "template_units": ["singbox@.service"], + "expected": [str(singbox_bin), "run", str(singbox_cfg)], + }, + { + "client_id": f"e2e-prodlike-phoenix-{tag}", + "kind": "phoenix", + "cfg": { + "runner": "systemd", + "runtime_mode": "exec", + "unit": phoenix_unit, + "packaging_profile": "bundled", + "bin_root": str(bin_root), + "packaging_system_fallback": False, + "require_binary": True, + "phoenix_config_path": str(phoenix_cfg), + "hardening_enabled": False, + }, + "units": [phoenix_unit], + "expected": [str(phoenix_bin), "-config", str(phoenix_cfg)], + }, + { + "client_id": f"e2e-prodlike-dnstt-{tag}", + "kind": "dnstt", + "cfg": { + "runner": "systemd", + "runtime_mode": "exec", + "unit": dnstt_unit, + "packaging_profile": "bundled", + "bin_root": str(bin_root), + "packaging_system_fallback": False, + "require_binary": True, + "resolver_mode": "doh", + "doh_url": "https://dns.google/dns-query", + "pubkey": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "domain": "tunnel.example.com", + "local_addr": "127.0.0.1:7005", + "ssh_tunnel": True, + "ssh_unit": dnstt_ssh_unit, + "ssh_exec_start": "/usr/bin/sleep 120", + "hardening_enabled": False, + "ssh_hardening_enabled": False, + }, + "units": [dnstt_unit, dnstt_ssh_unit], + "expected": [str(dnstt_bin), "-doh", "dns.google", "tunnel.example.com", "127.0.0.1:7005"], + }, + ] + + for case in cases: + try: + ok, reason = run_case( + api_url, + client_id=case["client_id"], + kind=case["kind"], + cfg=case["cfg"], + units=case["units"], + expected_unit_parts=case["expected"], + template_units=case.get("template_units"), + ) + except AssertionError as e: + return fail(f"{case['kind']} failed: {e}") + if not ok: + print(f"[transport_production_like_e2e] SKIP: {reason}") + return 0 + print(f"[transport_production_like_e2e] {case['kind']} production-like lifecycle: ok") + + print("[transport_production_like_e2e] passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/transport_recovery_runbook_smoke.sh b/tests/transport_recovery_runbook_smoke.sh new file mode 100755 index 0000000..9c2e636 --- /dev/null +++ b/tests/transport_recovery_runbook_smoke.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +API_URL="${API_URL:-http://127.0.0.1:8080}" +RUNBOOK="${ROOT_DIR}/scripts/transport_runbook.py" +RECOVERY="${ROOT_DIR}/scripts/transport_recovery_runbook.py" + +for f in "$RUNBOOK" "$RECOVERY"; do + if [[ ! -x "$f" ]]; then + if [[ -f "$f" ]]; then + chmod +x "$f" + else + echo "[transport_recovery_runbook_smoke] missing script: $f" >&2 + exit 1 + fi + fi +done + +ts="$(date +%s)" +pid="$$" +ok_id="smoke-recovery-ok-${ts}-${pid}" +fail_id="smoke-recovery-fail-${ts}-${pid}" +diag_ok="/tmp/${ok_id}.json" +diag_fail="/tmp/${fail_id}.json" +trap 'rm -f "$diag_ok" "$diag_fail"' EXIT + +echo "[transport_recovery_runbook_smoke] API_URL=${API_URL}" + +echo "[transport_recovery_runbook_smoke] case1: recovery success" +env API_URL="${API_URL}" "$RUNBOOK" \ + --api-url "${API_URL}" \ + --client-id "${ok_id}" \ + --kind singbox \ + --name "Recovery OK ${ok_id}" \ + --config-json '{"runner":"mock","runtime_mode":"exec"}' \ + --actions "create" + +env API_URL="${API_URL}" "$RECOVERY" \ + --api-url "${API_URL}" \ + --client-id "${ok_id}" \ + --max-restarts 1 \ + --provision-if-needed \ + --diagnostics-json "$diag_ok" + +env API_URL="${API_URL}" "$RUNBOOK" \ + --api-url "${API_URL}" \ + --client-id "${ok_id}" \ + --actions "delete" \ + --force-delete + +echo "[transport_recovery_runbook_smoke] case2: recovery fail-path with diagnostics" +env API_URL="${API_URL}" "$RUNBOOK" \ + --api-url "${API_URL}" \ + --client-id "${fail_id}" \ + --kind phoenix \ + --name "Recovery FAIL ${fail_id}" \ + --config-json '{"runner":"mock","runtime_mode":"embedded"}' \ + --actions "create" + +set +e +env API_URL="${API_URL}" "$RECOVERY" \ + --api-url "${API_URL}" \ + --client-id "${fail_id}" \ + --max-restarts 1 \ + --provision-if-needed \ + --diagnostics-json "$diag_fail" +rc=$? +set -e +if [[ "$rc" -eq 0 ]]; then + echo "[transport_recovery_runbook_smoke] expected non-zero for fail-path case" >&2 + exit 1 +fi +if [[ "$rc" -ne 2 ]]; then + echo "[transport_recovery_runbook_smoke] expected rc=2 for unrecovered case, got rc=${rc}" >&2 + exit 1 +fi +if [[ ! -s "$diag_fail" ]]; then + echo "[transport_recovery_runbook_smoke] diagnostics file was not produced: $diag_fail" >&2 + exit 1 +fi + +env API_URL="${API_URL}" "$RUNBOOK" \ + --api-url "${API_URL}" \ + --client-id "${fail_id}" \ + --actions "delete" \ + --force-delete + +echo "[transport_recovery_runbook_smoke] passed" diff --git a/tests/transport_runbook_cli_smoke.sh b/tests/transport_runbook_cli_smoke.sh new file mode 100755 index 0000000..4836e40 --- /dev/null +++ b/tests/transport_runbook_cli_smoke.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNBOOK="${ROOT_DIR}/scripts/transport_runbook.py" +API_URL="${API_URL:-http://127.0.0.1:8080}" + +if [[ ! -x "$RUNBOOK" ]]; then + if [[ -f "$RUNBOOK" ]]; then + chmod +x "$RUNBOOK" + else + echo "[transport_runbook_cli_smoke] missing runbook script: $RUNBOOK" >&2 + exit 1 + fi +fi + +ts="$(date +%s)" +pid="$$" +client_id="smoke-runbook-${ts}-${pid}" +cfg='{"runner":"mock","runtime_mode":"exec"}' + +echo "[transport_runbook_cli_smoke] API_URL=${API_URL}" +echo "[transport_runbook_cli_smoke] client_id=${client_id}" + +env API_URL="${API_URL}" "$RUNBOOK" \ + --api-url "${API_URL}" \ + --client-id "${client_id}" \ + --kind singbox \ + --name "Runbook Smoke ${client_id}" \ + --config-json "${cfg}" \ + --actions "create,provision,start,health,metrics,restart,stop,delete" + +echo "[transport_runbook_cli_smoke] passed" diff --git a/tests/transport_singbox_e2e.py b/tests/transport_singbox_e2e.py new file mode 100755 index 0000000..f5a903e --- /dev/null +++ b/tests/transport_singbox_e2e.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import sys +import time +from typing import Dict, Optional, Tuple +import urllib.error +import urllib.request + + +def fail(msg: str) -> int: + print(f"[transport_singbox_e2e] ERROR: {msg}") + return 1 + + +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=20.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: + return 0, {} + + try: + data_json = json.loads(raw) if raw else {} + except Exception: + data_json = {} + if not isinstance(data_json, dict): + data_json = {} + return status, data_json + + +def ensure_client_deleted(api_url: str, client_id: str) -> None: + request_json(api_url, "DELETE", f"/api/v1/transport/clients/{client_id}?force=true") + + +def create_client(api_url: str, payload: Dict) -> Tuple[int, Dict]: + return request_json(api_url, "POST", "/api/v1/transport/clients", payload) + + +def main() -> int: + api_url = os.environ.get("API_URL", "http://127.0.0.1:8080").strip() + if not api_url: + return fail("empty API_URL") + + print(f"[transport_singbox_e2e] API_URL={api_url}") + + status, caps = request_json(api_url, "GET", "/api/v1/transport/capabilities") + if status == 404: + print("[transport_singbox_e2e] SKIP: transport endpoints are not available on this backend") + return 0 + if status != 200 or not bool(caps.get("ok", False)): + return fail(f"capabilities failed status={status} payload={caps}") + + clients_caps = caps.get("clients") or {} + if not isinstance(clients_caps, dict) or "singbox" not in clients_caps: + return fail(f"singbox capability is missing: {caps}") + runtime_modes = caps.get("runtime_modes") or {} + if isinstance(runtime_modes, dict) and runtime_modes: + if not bool(runtime_modes.get("exec", False)): + return fail(f"runtime_modes.exec is not supported: {caps}") + else: + print("[transport_singbox_e2e] WARN: runtime_modes are not advertised by current backend build") + + ts = int(time.time()) + pid = os.getpid() + + # Case 1: successful lifecycle on mock runner. + client_ok = f"e2e-singbox-ok-{ts}-{pid}" + ensure_client_deleted(api_url, client_ok) + status, create_ok = create_client( + api_url, + { + "id": client_ok, + "name": "E2E Singbox Mock", + "kind": "singbox", + "enabled": False, + "config": { + "runner": "mock", + "runtime_mode": "exec", + "packaging_profile": "bundled", + "bin_root": "/opt/selective-vpn/bin", + "require_binary": False, + "singbox_config_path": "/etc/singbox/e2e.json", + }, + }, + ) + if status != 200 or not bool(create_ok.get("ok", False)): + return fail(f"create mock singbox failed status={status} payload={create_ok}") + try: + status, provision = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/provision") + if status == 404: + print("[transport_singbox_e2e] SKIP: provision endpoint is not available on current backend build") + return 0 + if status != 200 or not bool(provision.get("ok", False)): + return fail(f"provision mock singbox failed status={status} payload={provision}") + + status, start = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/start") + if status != 200 or not bool(start.get("ok", False)): + return fail(f"start mock singbox failed status={status} payload={start}") + if str(start.get("status_after") or "").strip().lower() != "up": + return fail(f"start did not set status_after=up: {start}") + + status, health = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_ok}/health") + if status != 200 or not bool(health.get("ok", False)): + return fail(f"health mock singbox failed status={status} payload={health}") + if str(health.get("status") or "").strip().lower() not in ("up", "degraded"): + return fail(f"unexpected health status after start: {health}") + + status, restart = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/restart") + if status != 200 or not bool(restart.get("ok", False)): + return fail(f"restart mock singbox failed status={status} payload={restart}") + + status, stop = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_ok}/stop") + if status != 200 or not bool(stop.get("ok", False)): + return fail(f"stop mock singbox failed status={status} payload={stop}") + if str(stop.get("status_after") or "").strip().lower() != "down": + return fail(f"stop did not set status_after=down: {stop}") + + status, metrics = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_ok}/metrics") + if status == 404: + print("[transport_singbox_e2e] WARN: metrics endpoint is not available on current backend build") + else: + if status != 200 or not bool(metrics.get("ok", False)): + return fail(f"metrics mock singbox failed status={status} payload={metrics}") + metrics_obj = metrics.get("metrics") or {} + if not isinstance(metrics_obj, dict): + return fail(f"metrics payload is invalid: {metrics}") + if int(metrics_obj.get("state_changes", 0) or 0) < 2: + return fail(f"state_changes must be >=2 after lifecycle sequence: {metrics}") + print("[transport_singbox_e2e] case1 mock lifecycle: ok") + finally: + ensure_client_deleted(api_url, client_ok) + + # Case 2: embedded runtime mode must be rejected. + client_emb = f"e2e-singbox-embedded-{ts}-{pid}" + ensure_client_deleted(api_url, client_emb) + status, create_emb = create_client( + api_url, + { + "id": client_emb, + "name": "E2E Singbox Embedded", + "kind": "singbox", + "enabled": False, + "config": { + "runner": "mock", + "runtime_mode": "embedded", + }, + }, + ) + if status != 200 or not bool(create_emb.get("ok", False)): + return fail(f"create embedded singbox failed status={status} payload={create_emb}") + try: + status, provision_emb = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_emb}/provision") + if status != 200 or bool(provision_emb.get("ok", True)): + return fail(f"embedded provision must fail status={status} payload={provision_emb}") + if str(provision_emb.get("code") or "").strip() != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED": + return fail(f"embedded provision wrong code: {provision_emb}") + + status, start_emb = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_emb}/start") + if status != 200 or bool(start_emb.get("ok", True)): + return fail(f"embedded start must fail status={status} payload={start_emb}") + if str(start_emb.get("code") or "").strip() != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED": + return fail(f"embedded start wrong code: {start_emb}") + + status, health_emb = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_emb}/health") + if status != 200 or not bool(health_emb.get("ok", False)): + return fail(f"embedded health request failed status={status} payload={health_emb}") + if str(health_emb.get("code") or "").strip() != "TRANSPORT_BACKEND_RUNTIME_MODE_UNSUPPORTED": + return fail(f"embedded health wrong code: {health_emb}") + print("[transport_singbox_e2e] case2 runtime_mode=embedded guard: ok") + finally: + ensure_client_deleted(api_url, client_emb) + + # Case 3: require_binary fail-fast for missing singbox binary. + client_req = f"e2e-singbox-requirebin-{ts}-{pid}" + ensure_client_deleted(api_url, client_req) + status, create_req = create_client( + api_url, + { + "id": client_req, + "name": "E2E Singbox RequireBinary", + "kind": "singbox", + "enabled": False, + "config": { + "runner": "systemd", + "runtime_mode": "exec", + "unit": f"{client_req}.service", + "packaging_profile": "bundled", + "bin_root": "/opt/selective-vpn/bin", + "require_binary": True, + "singbox_bin": "/tmp/definitely-missing-sing-box-binary", + "singbox_config_path": "/etc/singbox/e2e.json", + }, + }, + ) + if status != 200 or not bool(create_req.get("ok", False)): + return fail(f"create require_binary singbox failed status={status} payload={create_req}") + try: + status, provision_req = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_req}/provision") + if status != 200 or bool(provision_req.get("ok", True)): + return fail(f"require_binary provision must fail status={status} payload={provision_req}") + if str(provision_req.get("code") or "").strip() != "TRANSPORT_BACKEND_PROVISION_CONFIG_REQUIRED": + return fail(f"require_binary provision wrong code: {provision_req}") + msg = str(provision_req.get("message") or "").strip().lower() + if "required singbox binary not found" not in msg: + return fail(f"require_binary provision wrong message: {provision_req}") + print("[transport_singbox_e2e] case3 require_binary fail-fast: ok") + finally: + ensure_client_deleted(api_url, client_req) + + print("[transport_singbox_e2e] passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/transport_systemd_real_e2e.py b/tests/transport_systemd_real_e2e.py new file mode 100755 index 0000000..f5adc71 --- /dev/null +++ b/tests/transport_systemd_real_e2e.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Dict, List, Optional, Tuple +import urllib.error +import urllib.request + +SINGBOX_INSTANCE_DROPIN = "10-selective-vpn.conf" + + +def fail(msg: str) -> int: + print(f"[transport_systemd_real_e2e] ERROR: {msg}") + return 1 + + +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: + return 0, {} + + try: + data_json = json.loads(raw) if raw else {} + except Exception: + data_json = {} + if not isinstance(data_json, dict): + data_json = {} + return status, data_json + + +def ensure_client_deleted(api_url: str, client_id: str) -> None: + request_json(api_url, "DELETE", f"/api/v1/transport/clients/{client_id}?force=true") + + +def is_systemd_unavailable(resp: Dict) -> bool: + text = ( + str(resp.get("message") or "") + + " " + + str(resp.get("stderr") or "") + + " " + + str(resp.get("stdout") or "") + ).lower() + checks = ( + "not been booted with systemd", + "failed to connect to bus", + "systemctl daemon-reload failed", + "operation not permitted", + ) + return any(c in text for c in checks) + + +def unit_file_path(unit: str) -> Path: + return Path("/etc/systemd/system") / unit + + +def unit_dropin_path(unit: str, file_name: str = SINGBOX_INSTANCE_DROPIN) -> Path: + return Path("/etc/systemd/system") / f"{unit}.d" / file_name + + +def assert_unit_owned(unit: str, client_id: str) -> None: + marker = f"Environment=SVPN_TRANSPORT_ID={client_id}" + unit_path = unit_file_path(unit) + if unit_path.exists(): + body = unit_path.read_text(encoding="utf-8", errors="replace") + if marker in body: + return + + dropin_path = unit_dropin_path(unit) + if dropin_path.exists(): + body = dropin_path.read_text(encoding="utf-8", errors="replace") + if marker in body: + return + + if unit_path.exists() and not dropin_path.exists(): + raise AssertionError(f"ownership marker {marker} not found in unit: {unit_path}") + raise AssertionError(f"unit artifacts are missing for ownership check: {unit_path} {dropin_path}") + + +def assert_unit_removed(unit: str, client_id: str) -> None: + marker = f"Environment=SVPN_TRANSPORT_ID={client_id}" + unit_path = unit_file_path(unit) + dropin_path = unit_dropin_path(unit) + if dropin_path.exists(): + raise AssertionError(f"drop-in file still exists after cleanup: {dropin_path}") + if unit_path.exists(): + body = unit_path.read_text(encoding="utf-8", errors="replace") + if marker in body: + raise AssertionError(f"owned unit file still exists after cleanup: {unit_path}") + + +def assert_file_exists(path: str) -> None: + p = Path(path) + if not p.exists(): + raise AssertionError(f"expected file missing: {p}") + + +def create_client(api_url: str, payload: Dict) -> Tuple[int, Dict]: + return request_json(api_url, "POST", "/api/v1/transport/clients", payload) + + +def run_case( + api_url: str, + *, + client_id: str, + name: str, + kind: str, + cfg: Dict, + units: List[str], + template_units: Optional[List[str]] = None, +) -> Tuple[bool, str]: + ensure_client_deleted(api_url, client_id) + status, created = create_client( + api_url, + { + "id": client_id, + "name": name, + "kind": kind, + "enabled": False, + "config": cfg, + }, + ) + if status != 200 or not bool(created.get("ok", False)): + raise AssertionError(f"create failed status={status} payload={created}") + + try: + status, provision = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/provision") + if status == 404: + return False, "provision endpoint is not available on current backend build" + if status != 200: + raise AssertionError(f"provision failed status={status} payload={provision}") + if not bool(provision.get("ok", False)): + if is_systemd_unavailable(provision): + return False, f"systemd is unavailable: {provision}" + raise AssertionError(f"provision returned ok=false payload={provision}") + + for unit in units: + assert_unit_owned(unit, client_id) + for t_unit in (template_units or []): + assert_file_exists(str(unit_file_path(t_unit))) + + status, started = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/start") + if status != 200 or not bool(started.get("ok", False)): + raise AssertionError(f"start failed status={status} payload={started}") + if str(started.get("status_after") or "").strip().lower() != "up": + raise AssertionError(f"start did not set status_after=up payload={started}") + + status, health = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_id}/health") + if status != 200 or not bool(health.get("ok", False)): + raise AssertionError(f"health failed status={status} payload={health}") + if str(health.get("status") or "").strip().lower() not in ("up", "degraded"): + raise AssertionError(f"health status is unexpected payload={health}") + + status, metrics = request_json(api_url, "GET", f"/api/v1/transport/clients/{client_id}/metrics") + if status != 200 or not bool(metrics.get("ok", False)): + raise AssertionError(f"metrics failed status={status} payload={metrics}") + m = metrics.get("metrics") or {} + if not isinstance(m, dict): + raise AssertionError(f"metrics payload is invalid: {metrics}") + if int(m.get("state_changes", 0) or 0) < 1: + raise AssertionError(f"state_changes must be >=1 after start: {metrics}") + + status, restarted = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/restart") + if status != 200 or not bool(restarted.get("ok", False)): + raise AssertionError(f"restart failed status={status} payload={restarted}") + + status, stopped = request_json(api_url, "POST", f"/api/v1/transport/clients/{client_id}/stop") + if status != 200 or not bool(stopped.get("ok", False)): + raise AssertionError(f"stop failed status={status} payload={stopped}") + if str(stopped.get("status_after") or "").strip().lower() != "down": + raise AssertionError(f"stop did not set status_after=down payload={stopped}") + finally: + status, deleted = request_json(api_url, "DELETE", f"/api/v1/transport/clients/{client_id}?force=true") + if status != 200 or not bool(deleted.get("ok", False)): + raise AssertionError(f"delete failed status={status} payload={deleted}") + for unit in units: + assert_unit_removed(unit, client_id) + for t_unit in (template_units or []): + assert_file_exists(str(unit_file_path(t_unit))) + + return True, "ok" + + +def main() -> int: + api_url = os.environ.get("API_URL", "http://127.0.0.1:8080").strip() + if not api_url: + return fail("empty API_URL") + print(f"[transport_systemd_real_e2e] API_URL={api_url}") + + status, caps = request_json(api_url, "GET", "/api/v1/transport/capabilities") + if status == 404: + print("[transport_systemd_real_e2e] SKIP: transport endpoints are not available on this backend") + return 0 + if status != 200 or not bool(caps.get("ok", False)): + return fail(f"capabilities failed status={status} payload={caps}") + runtime_modes = caps.get("runtime_modes") or {} + if isinstance(runtime_modes, dict) and runtime_modes: + if not bool(runtime_modes.get("exec", False)): + return fail(f"runtime_modes.exec is not supported: {caps}") + + ts = int(time.time()) + pid = os.getpid() + tag = f"{ts}-{pid}" + + cases = [ + { + "client_id": f"e2e-sys-singbox-{tag}", + "name": "E2E Systemd Singbox", + "kind": "singbox", + "cfg": { + "runner": "systemd", + "runtime_mode": "exec", + "exec_start": "/usr/bin/sleep 120", + "hardening_enabled": False, + }, + "units": [f"singbox@e2e-sys-singbox-{tag}.service"], + "template_units": ["singbox@.service"], + }, + { + "client_id": f"e2e-sys-phoenix-{tag}", + "name": "E2E Systemd Phoenix", + "kind": "phoenix", + "cfg": { + "runner": "systemd", + "runtime_mode": "exec", + "unit": f"svpn-e2e-phoenix-{tag}.service", + "exec_start": "/usr/bin/sleep 120", + "hardening_enabled": False, + }, + "units": [f"svpn-e2e-phoenix-{tag}.service"], + }, + { + "client_id": f"e2e-sys-dnstt-{tag}", + "name": "E2E Systemd DNSTT", + "kind": "dnstt", + "cfg": { + "runner": "systemd", + "runtime_mode": "exec", + "unit": f"svpn-e2e-dnstt-{tag}.service", + "exec_start": "/usr/bin/sleep 120", + "ssh_tunnel": True, + "ssh_unit": f"svpn-e2e-dnstt-ssh-{tag}.service", + "ssh_exec_start": "/usr/bin/sleep 120", + "hardening_enabled": False, + "ssh_hardening_enabled": False, + }, + "units": [ + f"svpn-e2e-dnstt-{tag}.service", + f"svpn-e2e-dnstt-ssh-{tag}.service", + ], + }, + ] + + for case in cases: + try: + ok, reason = run_case( + api_url, + client_id=case["client_id"], + name=case["name"], + kind=case["kind"], + cfg=case["cfg"], + units=case["units"], + template_units=case.get("template_units"), + ) + except AssertionError as e: + return fail(f"{case['kind']} failed: {e}") + if not ok: + print(f"[transport_systemd_real_e2e] SKIP: {reason}") + return 0 + print(f"[transport_systemd_real_e2e] {case['kind']} real-systemd lifecycle: ok") + + print("[transport_systemd_real_e2e] passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/vpn_locations_swr.sh b/tests/vpn_locations_swr.sh new file mode 100755 index 0000000..a28136b --- /dev/null +++ b/tests/vpn_locations_swr.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +API_URL="${API_URL:-http://127.0.0.1:8080}" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +req_json() { + local path="$1" + local out_file="$2" + local code + local total + local metrics + metrics="$(curl -sS --max-time 6 -o "$out_file" -w "%{http_code} %{time_total}" "${API_URL}${path}")" + code="${metrics%% *}" + total="${metrics#* }" + if [[ "$code" != "200" ]]; then + echo "[vpn_locations] ${path} -> HTTP ${code}" >&2 + cat "$out_file" >&2 || true + return 1 + fi + printf "%s\n" "$total" +} + +echo "[vpn_locations] API_URL=${API_URL}" + +# Trigger background refresh (handler must respond immediately from cache/SWR path). +t1="$(req_json "/api/v1/vpn/locations?refresh=1" "$TMP_DIR/refresh.json")" +echo "[vpn_locations] refresh request time=${t1}s" + +# Read current state snapshot. +t2="$(req_json "/api/v1/vpn/locations" "$TMP_DIR/state.json")" +echo "[vpn_locations] snapshot request time=${t2}s" + +python3 - "$TMP_DIR/state.json" <<'PY' +import json +import sys + +path = sys.argv[1] +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + +required = ("locations", "stale", "refresh_in_progress") +for k in required: + if k not in data: + raise SystemExit(f"[vpn_locations] missing key: {k}") + +if not isinstance(data["locations"], list): + raise SystemExit("[vpn_locations] locations must be array") +if not isinstance(data["stale"], bool): + raise SystemExit("[vpn_locations] stale must be bool") +if not isinstance(data["refresh_in_progress"], bool): + raise SystemExit("[vpn_locations] refresh_in_progress must be bool") + +print( + "[vpn_locations] keys OK:", + f"count={len(data['locations'])}", + f"stale={data['stale']}", + f"refresh_in_progress={data['refresh_in_progress']}", +) +PY + +# Poll short window: refresh should eventually finish or provide retry metadata. +ok=0 +for _ in $(seq 1 12); do + req_json "/api/v1/vpn/locations" "$TMP_DIR/poll.json" >/dev/null + if python3 - "$TMP_DIR/poll.json" <<'PY' +import json +import sys +with open(sys.argv[1], "r", encoding="utf-8") as f: + data = json.load(f) +if not data.get("refresh_in_progress", False): + raise SystemExit(0) +if data.get("next_retry_at"): + raise SystemExit(0) +raise SystemExit(1) +PY + then + ok=1 + break + fi + sleep 1 +done + +if [[ "$ok" != "1" ]]; then + echo "[vpn_locations] refresh state did not settle in expected window" >&2 + cat "$TMP_DIR/poll.json" >&2 || true + exit 1 +fi + +echo "[vpn_locations] SWR checks passed" diff --git a/tests/vpn_login_flow.py b/tests/vpn_login_flow.py new file mode 100755 index 0000000..6da2e73 --- /dev/null +++ b/tests/vpn_login_flow.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""VPN login session smoke: start -> state polling -> optional action -> stop.""" + +import json +import os +import sys +import time +from urllib import request + +API_BASE = os.environ.get("API_URL", "http://127.0.0.1:8080") +TIMEOUT = int(os.environ.get("VPN_FLOW_TIMEOUT_SEC", "20")) + + +def call(method, path, data=None, timeout=10): + url = f"{API_BASE}{path}" + payload = json.dumps(data).encode("utf-8") if data is not None else None + req = request.Request(url, data=payload, method=method) + if payload is not None: + req.add_header("Content-Type", "application/json") + try: + with request.urlopen(req, timeout=timeout) as resp: + body = resp.read() + except Exception as err: + print(f"[vpn] request {path} failed: {err}", file=sys.stderr) + sys.exit(1) + try: + return json.loads(body) + except json.JSONDecodeError: + print(f"[vpn] non-json response for {path}: {body[:200]!r}", file=sys.stderr) + sys.exit(1) + + +def main(): + print(f"[vpn] API_BASE={API_BASE}") + start = call("POST", "/api/v1/vpn/login/session/start") + if "ok" not in start or "phase" not in start or "level" not in start: + print(f"[vpn] invalid start payload: {start}", file=sys.stderr) + sys.exit(1) + print(f"[vpn] start phase={start.get('phase')} ok={start.get('ok')}") + + cursor = 0 + saw_state = False + tried_action = False + deadline = time.time() + TIMEOUT + + while time.time() < deadline: + state = call("GET", f"/api/v1/vpn/login/session/state?since={cursor}") + saw_state = True + if "cursor" in state: + cursor = int(state["cursor"]) + phase = state.get("phase") + alive = bool(state.get("alive")) + can_check = bool(state.get("can_check")) + print(f"[vpn] state phase={phase} alive={alive} cursor={cursor}") + + if can_check and alive and not tried_action: + action = call("POST", "/api/v1/vpn/login/session/action", {"action": "check"}) + if not action.get("ok", False): + print(f"[vpn] action check failed: {action}", file=sys.stderr) + sys.exit(1) + tried_action = True + print("[vpn] action=check sent") + + if phase in ("success", "already_logged", "failed", "cancelled"): + break + time.sleep(1) + + if not saw_state: + print("[vpn] no state response received", file=sys.stderr) + sys.exit(1) + + stop = call("POST", "/api/v1/vpn/login/session/stop") + if not stop.get("ok", False): + print(f"[vpn] stop failed: {stop}", file=sys.stderr) + sys.exit(1) + print("[vpn] flow smoke passed") + + +if __name__ == "__main__": + main()