baseline: api+gui traffic mode + candidates picker

Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
This commit is contained in:
beckline
2026-02-14 15:32:25 +03:00
parent 50e2999cad
commit 10a10f44a8
55 changed files with 16488 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
# Ops Checklist (Selective VPN) / Боевой чеклист
RU: Практический чеклист для проверки и восстановления работы маршрутизации и DNS.
EN: Practical runbook/checklist for validating and recovering routing + DNS behavior.
RU: DNS mode и Traffic mode это две независимые оси.
EN: DNS mode and Traffic mode are independent.
## 0) Safety First / Безопасность
RU:
- Лучше тестировать изменения, пока у тебя есть стабильный SSH.
- `direct` traffic mode это аварийный режим: он убирает базовые policy rules.
- Если ты сидишь удаленно и доступ только через VPN, избегай `direct` без плана отката.
EN:
- Prefer to test changes while you still have a stable SSH session.
- `direct` traffic mode is an emergency option: it removes base policy rules.
- If VPN is your only access, avoid switching to `direct` without a rollback plan.
## 1) Quick Health (API)
```bash
curl -s http://127.0.0.1:8080/healthz
curl -s http://127.0.0.1:8080/api/v1/status
curl -s http://127.0.0.1:8080/api/v1/dns/status
curl -s http://127.0.0.1:8080/api/v1/traffic/mode/test
```
RU: смотри, чтобы `traffic/mode/test` вернул `healthy=true`, `probe_ok=true`.
EN: make sure `traffic/mode/test` returns `healthy=true`, `probe_ok=true`.
## 2) Quick Health (Linux)
```bash
ip rule show
ip -4 route show table agvpn
ip -4 route get 1.1.1.1
ip -4 route get 1.1.1.1 mark 0x66
```
RU: ожидаемые паттерны:
- Selective: есть правило типа `pref 12000 fwmark 0x66 lookup agvpn`.
- Full tunnel: есть правило типа `pref 11900 lookup agvpn`.
- В VPN-режимах в `agvpn` таблице есть `default dev <vpn-iface>`.
EN: expected patterns:
- Selective mode: rule like `pref 12000 fwmark 0x66 lookup agvpn`.
- Full tunnel: rule like `pref 11900 lookup agvpn`.
- In VPN modes `agvpn` has `default dev <vpn-iface>`.
## 3) Validate LAN and Containers / Проверка локалки и Docker
RU: цель: в `full_tunnel` обычно нужно, чтобы LAN и Docker продолжали работать.
EN: goal: in `full_tunnel` you usually want LAN and Docker networks to keep working.
RU: если в `full_tunnel` ломается доступ к LAN/docker:
- включи `auto_local_bypass`.
- если нужно, чтобы контейнеры ходили в интернет direct (а хост через VPN), добавь docker CIDR в `Force Direct subnets`.
EN: if LAN/docker break in `full_tunnel`:
- enable `auto_local_bypass`.
- if you want containers direct in full tunnel, add docker CIDRs to `Force Direct subnets`.
## 4) Validate nft sets / Проверка nft
RU: обычно используются два сета:
- `agvpn4`: direct-resolved IPs + static
- `agvpn_dyn4`: wildcard/smartdns dynamic IPs
```bash
nft list table inet agvpn
nft list set inet agvpn agvpn4
nft list set inet agvpn agvpn_dyn4
```
## 5) Wildcard DNS / SmartDNS
RU: state и артефакты:
- Canonical wildcard state: `/var/lib/selective-vpn/smartdns-wildcards.json`
- Generated rules file: `/etc/selective-vpn/smartdns.conf`
RU: runtime accelerator (опционально):
- когда включен, SmartDNS конфиг может содержать `nftset ... agvpn_dyn4`.
- когда выключен, wildcard все равно работает через resolver job + prewarm.
```bash
systemctl is-active smartdns-local.service
ls -la /etc/selective-vpn/smartdns.conf /var/lib/selective-vpn/smartdns-wildcards.json
```
## 6) Safe Recovery / Безопасный откат
### A) Clear routes (save cache) / Clear с сохранением снапшота
GUI: `Clear routes (save cache)`.
RU: очищает routes/nft, но сохраняет снапшот для восстановления.
EN: clears routes/nft but saves a snapshot for restore.
### B) Restore cached routes / Восстановление снапшота
GUI: `Restore cached routes`.
RU:
- часть маршрутов может быть `linkdown` (docker bridge). Restore пропускает некритичные ошибки.
EN:
- some routes can be `linkdown` (docker bridges). Restore skips non-critical failures.
### C) Restart services / Рестарт сервисов
```bash
sudo systemctl restart selective-vpn-api.service
```
## 7) Logs / Логи
```bash
journalctl -u selective-vpn-api.service -n 200 --no-pager
journalctl -u selective-vpn-api.service -f
```
## 8) Common Pitfalls / Частые грабли
- Docker bridge маршруты могут существовать, но быть `linkdown` (best-effort).
- UID/cgroup overrides влияют на процессы хоста (OUTPUT) и обычно не управляют forwarded Docker-трафиком.
- Если overrides списки слишком большие, backend отвергнет их (лимит на каждый тип).

View File

@@ -0,0 +1,173 @@
# Traffic Mode and Overrides Usage
This document describes how to use traffic mode extensions in the current build:
- traffic modes: `selective`, `full_tunnel`, `direct`
- `auto_local_bypass`
- policy overrides by source subnet / UID / cgroup (systemd)
- detected candidates UI (`Add detected...`)
## 1) Modes
- `selective`: only marked traffic goes to VPN table (`agvpn`)
- `full_tunnel`: all traffic goes to VPN table
- `direct`: base VPN routing rules are removed
Notes:
- DNS mode is independent from traffic mode.
- Modes are controlled from GUI dialog `Traffic mode settings`.
## 2) Auto-local bypass
Option: `Auto-local bypass (LAN/container subnets)`.
When enabled, backend mirrors local routes from `main` table into `agvpn` table:
- link-scope routes
- private/local ranges
- common container interfaces (`docker*`, `br-*`, `veth*`, `cni*`)
Purpose: reduce LAN/container breakage in `full_tunnel`.
Important:
- `auto_local_bypass` does NOT make containers use direct internet in `full_tunnel`.
- If you want containers to be `direct` in `full_tunnel`, use `Force Direct subnets`.
## 3) Policy overrides (Advanced)
Configured in dialog tab `Policy overrides (Advanced)`.
Layout:
- `Force VPN` column
- `Force Direct` column
Each column provides the same types of overrides:
- `Source subnets`
- `UIDs`
- `Cgroups / services`
### 3.1) Source subnets
Meaning: force routing for traffic **by source subnet**.
Input format:
- subnet: `172.18.0.0/16`
- single IP is accepted and normalized to `/32`
- one value per line (comma/semicolon separated values are also accepted)
Practical usage:
- Docker/bridge networks are best controlled via `Source subnets`.
### 3.2) UIDs
Meaning: force routing for **host-local processes** by UID/uidrange.
Input format:
- UID: `1000`
- UID range: `1000-1010`
- one value per line
Important limitation:
- UID rules generally affect host OUTPUT traffic, not forwarded traffic from Docker bridges.
### 3.3) Cgroups / services
Meaning: select workloads by systemd cgroup, backend resolves them to UID rules at apply time.
Input format:
- cgroup path or cgroup name, one per line
- examples:
- `/system.slice/jellyfin.service`
- `system.slice/docker.service`
Current implementation model:
1. backend scans matching cgroup directory (recursively) and reads `cgroup.procs`
2. resolves each PID owner UID from `/proc/<pid>/status`
3. creates `uidrange` policy rules from those UIDs
Important limitations:
- cgroup override is currently UID-based after resolution.
- if multiple workloads run under same UID (for example `root`), they cannot be separated by UID policy rules.
- if cgroup has no running processes at apply time, no UID rules are created from that cgroup.
## 4) Detected candidates (`Add detected...`)
Button: `Add detected...` (in `Policy overrides (Advanced)`).
This opens a selector populated by the backend endpoint:
- `GET /api/v1/traffic/candidates`
Tabs:
- `Subnets`: LAN + docker/bridge subnets detected from `ip -4 route show table main`
- `Services`: running systemd units -> mapped to cgroup like `system.slice/<unit>.service`
- `UIDs`: UIDs detected from running processes (`ps -eo uid,user,comm`)
Presets (Subnets tab):
- `Keep LAN direct`
- `Keep Docker direct`
Safety model:
- Selecting items only fills the text fields.
- Nothing changes on the host until you click `Apply overrides`.
## 5) Rule priority and precedence
Managed `ip rule` priorities:
- direct subnet overrides: `11600+`
- direct UID overrides: `11680+`
- VPN subnet overrides: `11720+`
- VPN UID overrides: `11800+`
- full tunnel base rule: `11900`
- selective base rule: `12000`
This means direct overrides are evaluated before VPN overrides and before base mode rules.
## 6) Recommended workflow
1. Select traffic mode.
2. Select preferred iface (or `auto`).
3. Toggle `auto_local_bypass` as needed.
4. Fill overrides (subnet/UID/cgroup), optionally using `Add detected...`.
5. Click `Apply overrides`.
6. Click `Test mode` (on Routes tab).
7. If needed, click `Clear routes (save cache)` and/or `Restore cached routes`.
## 7) Observability
GUI status line shows:
- desired/applied mode
- bypass route count
- override count
- resolved cgroup UID count
- cgroup warning text (if any)
## 8) Troubleshooting quick checks
```bash
ip rule show
ip -4 route show table agvpn
nft list ruleset | sed -n '/table inet agvpn/,$p'
```
If mode health is not OK:
- verify selected iface exists and is up
- verify `agvpn` table has default route in VPN modes
- verify subnet/UID/cgroup entries are valid and currently active

View File

@@ -0,0 +1,2 @@
###
# Default bases list (seed). Add domains here; one per line.

View File

@@ -0,0 +1 @@
# meta domains (seed)

View File

@@ -0,0 +1 @@
# static IPs (seed)

View File

@@ -0,0 +1,3 @@
www
api
static

View File

@@ -0,0 +1,204 @@
package app
import (
"fmt"
"log"
"os"
"regexp"
"strings"
"time"
)
// ---------------------------------------------------------------------
// autoloop
// ---------------------------------------------------------------------
// EN: Long-running VPN autoloop worker that keeps the tunnel connected,
// EN: updates login/license state, enforces policy route defaults, and emits events.
// RU: Долгоживущий воркер VPN autoloop, который поддерживает соединение,
// RU: обновляет login/license state, чинит policy route и публикует события.
func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string) {
locFile := stateDirPath + "/adguard-location.txt"
logFile := stateDirPath + "/adguard-autoloop.log"
loginStateFile := stateDirPath + "/adguard-login.json"
licenseTTL := 3600 * time.Second
statusTimeout := 8 * time.Second
connectTimeout := 25 * time.Second
disconnectTimeout := 8 * time.Second
licenseTimeout := 10 * time.Second
lastLicense := time.Time{}
_ = os.MkdirAll(stateDirPath, 0o755)
log.Printf("autoloop: start iface=%s table=%s mtu=%d", iface, table, mtu)
logLine := func(msg string) {
line := fmt.Sprintf("%s autoloop: %s\n", time.Now().Format(time.RFC3339), msg)
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err == nil {
defer f.Close()
_, _ = f.WriteString(line)
}
fmt.Print(line)
}
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")
}
fixPolicy := func() {
_, stderr, _, err := runCommandTimeout(5*time.Second,
"ip", "-4", "route", "replace",
"default", "dev", iface,
"table", table,
"mtu", fmt.Sprintf("%d", mtu),
)
if err != nil {
logLine("route: FAILED to set default dev " + iface +
" table " + table + ": " + stderr)
} else {
logLine("route: default dev " + iface + " table " + table +
" 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
}
}
updateLicense := func() {
now := time.Now()
if !lastLicense.IsZero() && now.Sub(lastLicense) < licenseTTL {
return
}
lastLicense = now
out, _, _, _ := runCommandTimeout(licenseTimeout, adgvpnCLI, "license")
out = stripANSI(out)
updateLoginStateFromText(out)
}
writeLoginState("unknown", "", "not checked yet")
updateLicense()
for {
statusOut, _, exitCode, err := runCommandTimeout(statusTimeout, adgvpnCLI, "status")
statusOut = stripANSI(statusOut)
if err != nil {
logLine(fmt.Sprintf("status: ERROR exit=%d err=%v raw=%q", exitCode, err, statusOut))
}
if isConnected(statusOut) {
logLine("status: CONNECTED; raw: " + statusOut)
fixPolicy()
updateLicense()
events.push("autoloop_status_changed", map[string]string{
"status_word": "CONNECTED",
"raw_text": statusOut,
})
time.Sleep(20 * time.Second)
continue
}
logLine("status: DISCONNECTED; raw: " + statusOut)
events.push("autoloop_status_changed", map[string]string{
"status_word": "DISCONNECTED",
"raw_text": statusOut,
})
updateLoginStateFromText(statusOut)
loc := getLocation()
logLine("reconnecting to " + loc)
_, _, _, _ = runCommandTimeout(disconnectTimeout, adgvpnCLI, "disconnect")
connectOut, _, _, _ := runCommandTimeout(connectTimeout, adgvpnCLI, "connect", "-l", loc, "--log-to-file")
connectOut = stripANSI(connectOut)
logLine("connect raw: " + connectOut)
updateLoginStateFromText(connectOut)
statusAfter, _, _, _ := runCommandTimeout(statusTimeout, adgvpnCLI, "status")
statusAfter = stripANSI(statusAfter)
if isConnected(statusAfter) {
logLine("after connect: CONNECTED; raw: " + statusAfter)
fixPolicy()
updateLicense()
events.push("autoloop_status_changed", map[string]string{
"status_word": "CONNECTED",
"raw_text": statusAfter,
})
time.Sleep(20 * time.Second)
continue
}
logLine("after connect: STILL DISCONNECTED; raw: " + statusAfter)
time.Sleep(10 * time.Second)
}
}
// ---------------------------------------------------------------------
// autoloop helpers
// ---------------------------------------------------------------------
func escapeJSON(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\\"`)
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
return s
}

View File

@@ -0,0 +1,109 @@
package app
import "embed"
// EN: Centralized runtime configuration constants and embedded seed assets used
// EN: across the API server, route updater, VPN helpers, and background workers.
// RU: Централизованные runtime-константы и встроенные seed-ресурсы,
// RU: используемые API-сервером, апдейтером маршрутов, VPN-хелперами и воркерами.
// ---------------------------------------------------------------------
// runtime constants
// ---------------------------------------------------------------------
const (
stateDir = "/var/lib/selective-vpn"
statusFilePath = stateDir + "/status.json"
dnsModePath = stateDir + "/dns-mode.json"
trafficModePath = stateDir + "/traffic-mode.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"
routesCacheRT = stateDir + "/routes-clear-cache-routes.txt"
autoloopLogPath = stateDir + "/adguard-autoloop.log"
loginStatePath = stateDir + "/adguard-login.json"
dnsUpstreamsPath = stateDir + "/dns-upstreams.json"
smartdnsWLPath = stateDir + "/smartdns-wildcards.json"
smartdnsRTPath = stateDir + "/smartdns-runtime.json"
desiredLocation = stateDir + "/adguard-location.txt"
adgvpnCLI = "/usr/local/bin/adguardvpn-cli-root"
// маршруты v2
routesServiceTemplate = "selective-vpn2@%s.service"
routesTimerTemplate = "selective-vpn2@%s.timer"
routesServiceEnv = "SELECTIVE_VPN_ROUTES_UNIT"
routesTimerEnv = "SELECTIVE_VPN_ROUTES_TIMER"
// юнит автоконнекта AdGuard VPN
adgvpnUnit = "adguardvpn-autoconnect.service"
// доменные файлы / пути
domainDir = "/etc/selective-vpn/domains"
dnsUpstreamsConf = "/etc/selective-vpn/dns-upstreams.conf"
smartdnsDomainsFile = "/etc/selective-vpn/smartdns.conf"
smartdnsMainConfig = "/opt/stack/adguardapp/smartdns.conf"
staticIPsFile = "/etc/selective-vpn/static-ips.txt"
heartbeatFile = stateDir + "/heartbeat"
lockFile = "/run/lock/selective-vpn.lock"
MARK = "0x66"
defaultDNS1 = "94.140.14.14"
defaultDNS2 = "94.140.15.15"
defaultMeta1 = "46.243.231.30"
defaultMeta2 = "46.243.231.41"
smartDNSDefaultAddr = "127.0.0.1#6053"
smartDNSAddrEnv = "SVPN_SMARTDNS_ADDR"
smartDNSForceEnv = "SVPN_SMARTDNS_FORCE"
policyRouteMTU = "1380"
defaultTraceTailMax = 800
defaultEventsCapacity = 512
defaultPollStatusMs = 2000
defaultPollLoginMs = 2500
defaultPollAutoloopMs = 2500
defaultPollSystemdMs = 3000
defaultPollTraceMs = 1500
defaultHeartbeatSeconds = 15
)
// ---------------------------------------------------------------------
// domain expansion lists
// ---------------------------------------------------------------------
// EN: Domain expansion lists used by routes update to build selective targets.
// RU: Списки доменов для расширения селективных целей при обновлении маршрутов.
var googleLikeDomains = []string{
"google.com", "googleapis.com", "gstatic.com", "googleusercontent.com",
"1e100.net", "gvt1.com", "gvt2.com", "gvt3.com",
}
// EN: Extra Twitter subdomains that should be forced through selective routing.
// RU: Дополнительные поддомены Twitter, которые принудительно идут через селективный маршрут.
var twitterSpecial = []string{
"ton", "pay", "caps", "sms", "cert", "tdweb", "p", "ma-0.twimg", "si0.twimg",
"syndication", "tweetdeck", "stream", "userstream", "sitestream", "betastream",
"music", "ms1", "ms3", "urls-real.api", "music-partner", "partner-stream",
}
// ---------------------------------------------------------------------
// embedded assets
// ---------------------------------------------------------------------
// EN: Embedded default domain files used as seed content when runtime files are absent.
// RU: Встроенные файлы доменов по умолчанию для первичного seed, если runtime-файлы отсутствуют.
//
//go:embed assets/domains/*
var embeddedDomains embed.FS

View File

@@ -0,0 +1,886 @@
package app
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"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)
}
}
// ---------------------------------------------------------------------
// 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))
}
// ---------------------------------------------------------------------
// 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: +%d domains omitted", len(domains)-loggedHosts))
}
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
}
}
// ---------------------------------------------------------------------
// EN: `loadDNSUpstreamsConf` loads dns upstreams conf from storage or config.
// RU: `loadDNSUpstreamsConf` - загружает dns upstreams conf из хранилища или конфига.
// ---------------------------------------------------------------------
func loadDNSUpstreamsConf() 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
}
// ---------------------------------------------------------------------
// EN: `saveDNSUpstreamsConf` saves dns upstreams conf to persistent storage.
// RU: `saveDNSUpstreamsConf` - сохраняет dns upstreams conf в постоянное хранилище.
// ---------------------------------------------------------------------
func saveDNSUpstreamsConf(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: `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"
}

View File

@@ -0,0 +1,184 @@
package app
import (
"encoding/json"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
)
// ---------------------------------------------------------------------
// domains editor + smartdns wildcards
// ---------------------------------------------------------------------
// EN: Domain and SmartDNS configuration endpoints.
// EN: Provides CRUD-style file access for domain lists, current nft/ipset table dump,
// EN: and persisted SmartDNS wildcard configuration.
// RU: Эндпоинты конфигурации доменов и SmartDNS.
// RU: Предоставляет доступ к файлам списков доменов, дамп текущей таблицы nft/ipset
// RU: и сохранение конфигурации wildcard-доменов SmartDNS.
var domainFiles = map[string]string{
"bases": domainDir + "/bases.txt",
"meta": domainDir + "/meta-special.txt",
"subs": domainDir + "/subs.txt",
"static": staticIPsFile,
"last-ips-map": lastIPsMapPath,
"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
}
stdout, _, _, err := runCommand("ipset", "list", "agvpn4")
lines := []string{}
if err == nil {
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
// 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
}
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" {
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)
}
}
// ---------------------------------------------------------------------
// smartdns wildcards
// ---------------------------------------------------------------------
func handleSmartdnsWildcards(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
payload := struct {
Domains []string `json:"domains"`
}{Domains: readSmartDNSWildcardDomains()}
writeJSON(w, http.StatusOK, payload)
case http.MethodPost:
var payload struct {
Domains []string `json:"domains"`
}
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&payload); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
if err := saveSmartDNSWildcardDomainsState(payload.Domains); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func readSmartDNSWildcardDomains() []string {
domains, _ := loadSmartDNSWildcardDomainsState(nil)
return domains
}

View File

@@ -0,0 +1,109 @@
package app
import (
"os"
"strconv"
"strings"
"sync"
"time"
)
// ---------------------------------------------------------------------
// события / 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
}
// ---------------------------------------------------------------------
// 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
}
// ---------------------------------------------------------------------
// 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
}
// ---------------------------------------------------------------------
// 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 {
return nil
}
var out []Event
for _, ev := range b.buf {
if ev.ID > id {
out = append(out, ev)
}
}
return out
}
// ---------------------------------------------------------------------
// env helpers
// ---------------------------------------------------------------------
// EN: Positive integer env reader with safe default fallback.
// RU: Чтение положительного целого из env с безопасным fallback на дефолт.
func envInt(key string, def int) int {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return def
}
var events = newEventBus(envInt("SVPN_EVENTS_CAP", defaultEventsCapacity))

View File

@@ -0,0 +1,111 @@
package app
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"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
}
// ---------------------------------------------------------------------
// 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
}
for _, ev := range evs {
if err := send(ev); err != nil {
return
}
since = ev.ID
}
}
}
}

View File

@@ -0,0 +1,59 @@
package app
import (
"encoding/json"
"log"
"net/http"
"time"
)
// ---------------------------------------------------------------------
// 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))
})
}
// ---------------------------------------------------------------------
// 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)
}
}
// ---------------------------------------------------------------------
// 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),
})
}

View File

@@ -0,0 +1,400 @@
package app
import (
"bytes"
"context"
"errors"
"fmt"
"net/netip"
"os/exec"
"sort"
"strings"
"time"
"github.com/cenkalti/backoff/v4"
)
// ---------------------------------------------------------------------
// 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)
}
// 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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
package app
import (
"os"
"path/filepath"
"testing"
)
func TestDomainCacheLegacyMigrationToDirectBucket(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "domain-cache.json")
legacy := `{
"example.com": {"ips": ["1.1.1.1", "10.0.0.1"], "last_resolved": 100},
"bad.com": {"ips": [], "last_resolved": 100}
}`
if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil {
t.Fatalf("write legacy cache: %v", err)
}
st := loadDomainCacheState(path, nil)
if _, ok := st.get("example.com", domainCacheSourceDirect, 150, 100); !ok {
t.Fatalf("expected direct cache hit after migration")
}
if _, ok := st.get("example.com", domainCacheSourceWildcard, 150, 100); ok {
t.Fatalf("did not expect wildcard cache hit for migrated legacy entry")
}
if ips, ok := st.get("example.com", domainCacheSourceDirect, 150, 100); !ok || len(ips) != 1 || ips[0] != "1.1.1.1" {
t.Fatalf("unexpected migrated ips: ok=%v ips=%v", ok, ips)
}
}
func TestDomainCacheSplitBucketsAreIndependent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "domain-cache.json")
v2 := `{
"version": 2,
"domains": {
"example.com": {
"direct": {"ips": ["1.1.1.1"], "last_resolved": 100},
"wildcard": {"ips": ["2.2.2.2"], "last_resolved": 100}
}
}
}`
if err := os.WriteFile(path, []byte(v2), 0o644); err != nil {
t.Fatalf("write v2 cache: %v", err)
}
st := loadDomainCacheState(path, nil)
direct, ok := st.get("example.com", domainCacheSourceDirect, 150, 100)
if !ok || len(direct) != 1 || direct[0] != "1.1.1.1" {
t.Fatalf("unexpected direct lookup: ok=%v ips=%v", ok, direct)
}
wild, ok := st.get("example.com", domainCacheSourceWildcard, 150, 100)
if !ok || len(wild) != 1 || wild[0] != "2.2.2.2" {
t.Fatalf("unexpected wildcard lookup: ok=%v ips=%v", ok, wild)
}
}
func TestDomainCacheSetAndTTL(t *testing.T) {
st := newDomainCacheState()
st.set("example.com", domainCacheSourceDirect, []string{"1.1.1.1", "1.1.1.1", "10.0.0.1"}, 100)
if _, ok := st.get("example.com", domainCacheSourceDirect, 201, 100); ok {
t.Fatalf("expected cache miss due ttl expiry")
}
ips, ok := st.get("example.com", domainCacheSourceDirect, 200, 100)
if !ok || len(ips) != 1 || ips[0] != "1.1.1.1" {
t.Fatalf("unexpected ttl hit result: ok=%v ips=%v", ok, ips)
}
}

View File

@@ -0,0 +1,399 @@
package app
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
)
// ---------------------------------------------------------------------
// routes clear cache (safe clear / fast restore)
// ---------------------------------------------------------------------
// EN: Snapshot data persisted before routes clear to support fast restore
// EN: without running full domain resolve again.
// RU: Снимок данных, который сохраняется перед routes clear для быстрого
// RU: восстановления без повторного полного резолва доменов.
type routesClearCacheMeta struct {
CreatedAt string `json:"created_at"`
Iface string `json:"iface,omitempty"`
RouteCount int `json:"route_count"`
IPCount int `json:"ip_count"`
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))
}
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 {
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")
}
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
}

View File

@@ -0,0 +1,405 @@
package app
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"syscall"
)
// ---------------------------------------------------------------------
// routes handlers
// ---------------------------------------------------------------------
// EN: HTTP handlers for selective routing control plane operations:
// EN: status, systemd service/timer control, route cleanup, policy fix, and async update trigger.
// 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
// ---------------------------------------------------------------------
func makeCmdHandler(name string, args ...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
}
stdout, stderr, code, err := runCommand(name, args...)
res := cmdResult{
OK: err == nil && code == 0,
ExitCode: code,
Stdout: stdout,
Stderr: stderr,
}
if err != nil {
res.Message = err.Error()
}
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 {
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")
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
}
// ---------------------------------------------------------------------
// 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",
})
}

View File

@@ -0,0 +1,52 @@
package app
import (
"fmt"
"os"
"strings"
)
// ---------------------------------------------------------------------
// routes systemd unit name resolution
// ---------------------------------------------------------------------
// EN: Resolve routes service/timer unit names from preferred/active iface.
// EN: Env overrides still have top priority for custom deployments.
// RU: Вычисление имен unit для routes service/timer по preferred/active iface.
// RU: Для кастомных окружений сохраняется приоритет переменных окружения.
func resolveRoutesUnitIface() (string, string) {
st := loadTrafficModeState()
if pref := normalizePreferredIface(st.PreferredIface); pref != "" {
return pref, "preferred"
}
if statusIface := statusIfaceFromFile(); statusIface != "" && statusIface != "-" {
return statusIface, "status"
}
if active, reason := resolveTrafficIface(""); active != "" {
return active, reason
}
return "", "iface-not-found"
}
func routesServiceUnitName() string {
if forced := strings.TrimSpace(os.Getenv(routesServiceEnv)); forced != "" {
return forced
}
iface, _ := resolveRoutesUnitIface()
if iface == "" {
return ""
}
return fmt.Sprintf(routesServiceTemplate, iface)
}
func routesTimerUnitName() string {
if forced := strings.TrimSpace(os.Getenv(routesTimerEnv)); forced != "" {
return forced
}
iface, _ := resolveRoutesUnitIface()
if iface == "" {
return ""
}
return fmt.Sprintf(routesTimerTemplate, iface)
}

View File

@@ -0,0 +1,703 @@
package app
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"net"
"os"
"os/user"
"sort"
"strconv"
"strings"
"time"
)
// ---------------------------------------------------------------------
// основной routesUpdate
// ---------------------------------------------------------------------
// EN: Core selective-routes orchestration pipeline.
// EN: This unit prepares policy routing, nftables objects, domain expansion,
// EN: resolver execution, status artifacts, and GUI-facing progress events.
// RU: Основной orchestration-пайплайн selective-routes.
// RU: Модуль готовит policy routing, nftables-объекты, расширение доменов,
// RU: запуск резолвера, статусные артефакты и события прогресса для GUI.
// ---------------------------------------------------------------------
// EN: `routesUpdate` contains core logic for routes update.
// RU: `routesUpdate` - содержит основную логику для routes update.
// ---------------------------------------------------------------------
func routesUpdate(iface string) cmdResult {
logp := func(format string, args ...any) {
appendTraceLine("routes", fmt.Sprintf(format, args...))
}
heartbeat := func() {
_ = os.WriteFile(heartbeatFile, []byte{}, 0o644)
}
res := cmdResult{OK: false}
iface = normalizePreferredIface(iface)
if iface == "" {
iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface)
}
if iface == "" {
logp("no active vpn iface, exit 0")
res.OK = true
res.Message = "interface not found, skipped"
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", ";", "}")
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}")
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output")
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "ip", "daddr", "@agvpn4", "meta", "mark", "set", MARK)
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "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)
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{}{}
if !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)
}
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)
if err != nil {
logp("Go resolver FAILED: %v", err)
res.Message = fmt.Sprintf("resolver failed: %v", err)
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)
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)
heartbeat()
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
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)
appendTraceLineTo(
smartdnsLogPath,
"smartdns",
fmt.Sprintf("wildcard sync: mode=%s source=%s domains=%d ips=%d", mode.Mode, source, len(hosts), wildcardIPCount),
)
const maxHostsLog = 200
for i, host := range hosts {
if i >= maxHostsLog {
appendTraceLineTo(
smartdnsLogPath,
"smartdns",
fmt.Sprintf("wildcard sync: +%d domains omitted", len(hosts)-maxHostsLog),
)
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)
}

View File

@@ -0,0 +1,47 @@
package app
import (
"fmt"
"io/fs"
"os"
)
// ---------------------------------------------------------------------
// seed-файлы и bootstrap
// ---------------------------------------------------------------------
// EN: Bootstrap seed files on first run so the API can start with sane defaults
// EN: even when runtime configuration files do not exist yet.
// RU: Инициализация seed-файлов при первом запуске, чтобы API стартовал
// RU: с корректными значениями по умолчанию при отсутствии runtime-конфигов.
// ---------------------------------------------------------------------
// seed initializer
// ---------------------------------------------------------------------
func ensureSeeds() {
_ = os.MkdirAll(domainDir, 0o755)
_ = os.MkdirAll("/etc/selective-vpn", 0o755)
_ = os.MkdirAll(stateDir, 0o755)
seedFile := func(name string, path string) {
if _, err := os.Stat(path); err == nil {
return
}
data, err := fs.ReadFile(embeddedDomains, "assets/domains/"+name)
if err != nil {
data = []byte{}
}
_ = os.WriteFile(path, data, 0o644)
}
seedFile("bases.txt", domainDir+"/bases.txt")
seedFile("subs.txt", domainDir+"/subs.txt")
seedFile("meta-special.txt", domainDir+"/meta-special.txt")
seedFile("static-ips.txt", staticIPsFile)
if _, err := os.Stat(dnsUpstreamsConf); err != nil {
content := fmt.Sprintf("default %s %s\nmeta %s %s\n", defaultDNS1, defaultDNS2, defaultMeta1, defaultMeta2)
_ = os.WriteFile(dnsUpstreamsConf, []byte(content), 0o644)
}
}

View File

@@ -0,0 +1,204 @@
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()
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/interfaces", handleTrafficInterfaces)
mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates)
// 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/status", handleDNSStatus)
mux.HandleFunc("/api/v1/dns/mode", handleDNSModeSet)
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)
}
}

View File

@@ -0,0 +1,72 @@
package app
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"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...)
}
// ---------------------------------------------------------------------
// 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
}
// ---------------------------------------------------------------------
// 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
}

View File

@@ -0,0 +1,224 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ---------------------------------------------------------------------
// smartdns runtime accelerator (nftset -> agvpn_dyn4)
// ---------------------------------------------------------------------
// EN: Runtime accelerator state is persisted separately from DNS mode.
// EN: This allows enabling/disabling SmartDNS nftset hook without changing
// EN: resolver primary behavior.
// RU: Состояние runtime-ускорителя хранится отдельно от DNS mode.
// RU: Это позволяет включать/выключать SmartDNS nftset-hook независимо от
// RU: основного пути через резолвер.
const (
smartdnsRuntimeDomainSetLine = "domain-set -name agvpn_wild -file /etc/selective-vpn/smartdns.conf"
smartdnsRuntimeNftsetLine = "nftset /domain-set:agvpn_wild/#4:inet#agvpn#agvpn_dyn4"
smartdnsRuntimeStateVersion = 1
)
type smartDNSRuntimeState struct {
Version int `json:"version"`
Enabled bool `json:"enabled"`
UpdatedAt string `json:"updated_at"`
}
func wildcardFillSource(runtimeEnabled bool) string {
if runtimeEnabled {
return "both"
}
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()
msg := ""
if err != nil {
msg = fmt.Sprintf("config read error: %v", err)
}
return SmartDNSRuntimeStatusResponse{
Enabled: st.Enabled,
AppliedEnable: appliedEnabled,
WildcardSource: wildcardFillSource(st.Enabled),
UnitState: smartdnsUnitState(),
ConfigPath: smartdnsMainConfig,
Message: msg,
}
}

View File

@@ -0,0 +1,132 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
)
// ---------------------------------------------------------------------
// smartdns wildcard canonical store
// ---------------------------------------------------------------------
// EN: Canonical SmartDNS wildcard storage is JSON in stateDir.
// EN: `/etc/selective-vpn/smartdns.conf` is generated as a runtime artifact.
// RU: Каноничное хранилище wildcard-доменов SmartDNS — JSON в stateDir.
// RU: `/etc/selective-vpn/smartdns.conf` генерируется как runtime-артефакт.
type smartDNSWildcardState struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
Domains []string `json:"domains"`
}
func normalizeWildcardDomains(raw []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(raw))
for _, ln := range raw {
d := normalizeWildcardDomain(ln)
if d == "" {
continue
}
if _, ok := seen[d]; ok {
continue
}
seen[d] = struct{}{}
out = append(out, d)
}
return out
}
func parseSmartDNSDomainsContent(content string) []string {
return normalizeWildcardDomains(strings.Split(content, "\n"))
}
func renderSmartDNSDomainsContent(domains []string) string {
header := strings.TrimSpace(`
# Auto-generated by selective-vpn API.
# SmartDNS wildcard rules for selective VPN / AGVPN.
`) + "\n"
if len(domains) == 0 {
return header
}
return header + "\n" + strings.Join(domains, "\n") + "\n"
}
func loadSmartDNSWildcardDomainsState(logf func(string, ...any)) ([]string, string) {
if data, err := os.ReadFile(smartdnsWLPath); err == nil {
// preferred shape: object with metadata
var st smartDNSWildcardState
if json.Unmarshal(data, &st) == nil {
domains := normalizeWildcardDomains(st.Domains)
_ = writeSmartDNSDomainsArtifact(domains)
return domains, "state"
}
// backward-compat shape: plain []string
var arr []string
if json.Unmarshal(data, &arr) == nil {
domains := normalizeWildcardDomains(arr)
_ = saveSmartDNSWildcardDomainsState(domains)
return domains, "state-legacy"
}
if logf != nil {
logf("smartdns wildcards: invalid state json at %s, fallback to conf", smartdnsWLPath)
}
}
// migration path: parse legacy .conf file if state json is missing/broken.
confData, err := os.ReadFile(smartdnsDomainsFile)
if err == nil {
domains := parseSmartDNSDomainsContent(string(confData))
if saveErr := saveSmartDNSWildcardDomainsState(domains); saveErr != nil && logf != nil {
logf("smartdns wildcards: migration from conf failed: %v", saveErr)
}
return domains, "migrated-conf"
}
// bootstrap empty canonical state + artifact.
_ = saveSmartDNSWildcardDomainsState(nil)
return nil, "default"
}
func saveSmartDNSWildcardDomainsState(domains []string) error {
normalized := normalizeWildcardDomains(domains)
state := smartDNSWildcardState{
Version: 1,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
Domains: normalized,
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(smartdnsWLPath), 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(smartdnsDomainsFile), 0o755); err != nil {
return err
}
stateTmp := smartdnsWLPath + ".tmp"
if err := os.WriteFile(stateTmp, data, 0o644); err != nil {
return err
}
if err := os.Rename(stateTmp, smartdnsWLPath); err != nil {
return err
}
return writeSmartDNSDomainsArtifact(normalized)
}
func writeSmartDNSDomainsArtifact(domains []string) error {
content := renderSmartDNSDomainsContent(domains)
tmp := smartdnsDomainsFile + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
return err
}
return os.Rename(tmp, smartdnsDomainsFile)
}

View File

@@ -0,0 +1,261 @@
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
}

View File

@@ -0,0 +1,225 @@
package app
import (
"net/http"
"sort"
"strconv"
"strings"
"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
}
resp := TrafficCandidatesResponse{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Subnets: trafficCandidateSubnets(),
Units: trafficCandidateUnits(),
UIDs: trafficCandidateUIDs(),
}
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,250 @@
package app
// ---------------------------------------------------------------------
// структуры
// ---------------------------------------------------------------------
// EN: Shared DTO/model definitions exchanged between HTTP handlers, workers,
// EN: SSE stream, and internal orchestration logic.
// RU: Общие DTO/модели, которыми обмениваются HTTP-обработчики, воркеры,
// RU: SSE-поток и внутренняя оркестрация.
type Status struct {
Timestamp string `json:"timestamp"`
IPCount int `json:"ip_count"`
DomainCount int `json:"domain_count"`
Iface string `json:"iface"`
Table string `json:"table"`
Mark string `json:"mark"`
PolicyRouteOK *bool `json:"policy_route_ok,omitempty"`
RouteOK *bool `json:"route_ok,omitempty"`
}
type cmdResult struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
ExitCode int `json:"exitCode,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
}
type VPNLoginState struct {
State string `json:"state"`
Email string `json:"email,omitempty"`
Msg string `json:"msg,omitempty"`
// для GUI
Text string `json:"text,omitempty"`
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 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 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"`
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"`
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"`
AutoLocalBypass bool `json:"auto_local_bypass"`
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"`
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"`
}
type SystemdState struct {
State string `json:"state"`
}
// ---------------------------------------------------------------------
// события / SSE
// ---------------------------------------------------------------------
type Event struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Ts string `json:"ts"`
Data interface{} `json:"data,omitempty"`
}
// EN: Callback for streaming user-visible progress from long-running nft updates.
// RU: Колбэк для отправки прогресса длительных nft-операций в пользовательский интерфейс.
type ProgressCallback func(percent int, message string)
// ---------------------------------------------------------------------
// resolver модели
// ---------------------------------------------------------------------
// EN: Input contract for the Go-based domain resolver job.
// RU: Контракт входных параметров для Go-резолвера доменов.
type ResolverOpts struct {
DomainsPath string
MetaPath string
StaticPath string
CachePath string
PtrCachePath string
TraceLog string
TTL int
Workers int
DNSConfigPath string
ViaSmartDNS bool
Mode DNSResolverMode
SmartDNSAddr string
SmartDNSWildcards []string
}
// EN: Aggregated resolver outputs consumed by routes update pipeline.
// RU: Агрегированные результаты резолвера, используемые пайплайном обновления маршрутов.
type resolverResult struct {
IPs []string
IPMap [][2]string
DirectIPs []string
DirectIPMap [][2]string
WildcardIPs []string
WildcardIPMap [][2]string
DomainCache map[string]any
PtrCache map[string]any
}
// EN: Runtime DNS upstream pools for standard and meta-special lookups.
// RU: Наборы DNS-апстримов для обычных и meta-special резолвов.
type dnsConfig struct {
Default []string
Meta []string
SmartDNS string
Mode DNSResolverMode
}

View File

@@ -0,0 +1,371 @@
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,
})
}

View File

@@ -0,0 +1,539 @@
package app
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/creack/pty"
)
// ---------------------------------------------------------------------
// AdGuard VPN interactive login session (PTY)
// ---------------------------------------------------------------------
// EN: Interactive AdGuard VPN login session over PTY.
// EN: This file contains session state machine, PTY reader/parser, and HTTP API
// EN: endpoints to start/poll/control/cancel login flow.
// RU: Интерактивная PTY-сессия логина AdGuard VPN.
// RU: Файл содержит state machine, PTY reader/parser и HTTP API для
// RU: старта/опроса/управления/остановки login-процесса.
// ---------------------------------------------------------------------
// login session API models
// ---------------------------------------------------------------------
type LoginSessionStartResp struct {
OK bool `json:"ok"`
Phase string `json:"phase"`
Level string `json:"level"`
PID int `json:"pid,omitempty"`
Email string `json:"email,omitempty"`
Error string `json:"error,omitempty"`
}
type LoginSessionStateResp struct {
OK bool `json:"ok"`
Phase string `json:"phase"`
Level string `json:"level"`
Alive bool `json:"alive"`
URL string `json:"url,omitempty"`
Email string `json:"email,omitempty"`
Cursor int64 `json:"cursor"`
Lines []string `json:"lines"`
CanOpen bool `json:"can_open"`
CanCheck bool `json:"can_check"`
CanCancel bool `json:"can_cancel"`
Error string `json:"error,omitempty"`
}
type LoginSessionActionReq struct {
Action string `json:"action"`
}
type loginLine struct {
N int64
Line string
}
// ---------------------------------------------------------------------
// login session manager
// ---------------------------------------------------------------------
type loginSessionManager struct {
mu sync.Mutex
cmd *exec.Cmd
pty *os.File
phase string
level string
alive bool
url string
email string
lines []loginLine
max int
lastN int64
lastAutoCheck time.Time
reURL *regexp.Regexp
reEmail *regexp.Regexp
reNextCheck *regexp.Regexp
}
var loginMgr = newLoginSessionManager(defaultTraceTailMax)
// ---------------------------------------------------------------------
// EN: `newLoginSessionManager` creates a new instance for login session manager.
// RU: `newLoginSessionManager` - создает новый экземпляр для login session manager.
// ---------------------------------------------------------------------
func newLoginSessionManager(max int) *loginSessionManager {
return &loginSessionManager{
phase: "idle",
level: "yellow",
alive: false,
max: max,
reURL: regexp.MustCompile(`(https?://\S+)`),
reEmail: regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+`),
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})
}

View File

@@ -0,0 +1,229 @@
package app
import (
"context"
"crypto/sha256"
"encoding/json"
"os"
"strings"
"time"
)
// ---------------------------------------------------------------------
// фоновые вотчеры / события
// ---------------------------------------------------------------------
// EN: Background poll-based watchers that detect file/service state changes and
// 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
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 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)
}
// ---------------------------------------------------------------------
// 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),
})
}
}

8
selective-vpn-api/go.mod Normal file
View File

@@ -0,0 +1,8 @@
module selective-vpn-api
go 1.24.2
require (
github.com/cenkalti/backoff/v4 v4.3.0
github.com/creack/pty v1.1.24
)

4
selective-vpn-api/go.sum Normal file
View File

@@ -0,0 +1,4 @@
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=

View File

@@ -0,0 +1,2 @@
###
# Default bases list (seed). Add domains here; one per line.

View File

@@ -0,0 +1 @@
# meta domains (seed)

View File

@@ -0,0 +1 @@
# static IPs (seed)

View File

@@ -0,0 +1,3 @@
www
api
static

View File

@@ -0,0 +1,9 @@
package main
import app "selective-vpn-api/app"
// EN: Thin executable entrypoint that delegates runtime startup to the app package.
// RU: Тонкая точка входа бинаря, делегирующая запуск пакету app.
func main() {
app.Run()
}

View File

@@ -0,0 +1,460 @@
Окей, давай сначала аккуратно разрулим логику, а уже потом можно будет добивать конкретный код (Go + Qt).
Ты сейчас хочешь:
Нормальную модель «DNS/SmartDNS» (кто за что отвечает).
Новую вкладку DNS в дашборде, без текстовой панели, но с понятной индикацией:
запущен ли smartdns-daemon (systemd unit),
использует ли именно приложение SmartDNS или ходит напрямую на default/meta.
Логи пока оставим на второй шаг — главное, чтобы сама схема была ровная.
1. Предлагаемая модель DNS / SmartDNS
1.1. Состояния
У нас по сути три независимых штуки:
Upstreams для резолвера selective-vpn
default1, default2, meta1, meta2
лежат в dnsUpstreamsConf (как и сейчас).
SmartDNS systemd unit
юнит типа smartdns-local.service
может быть active/inactive/failed.
Режим работы резолвера selective-vpn
via_smartdns = false → резолвер ходит напрямую на default/meta.
via_smartdns = true → резолвер ходит на 127.0.0.1#6053 (SmartDNS), а тот уже сам цепочкой: локальные → bind9 → adguard-dns.
Важно: юнит SmartDNS и режим резолвера — разные вещи.
Может быть включён юнит, но приложение всё равно ходит напрямую на adguard-dns.
1.2. Где хранить режим
Вариант попроще (без изменения 100500 мест):
Заводим отдельный json в stateDir, например
dns-mode.json:
{
"via_smartdns": true
}
Go-структура:
// types.go
type DNSMode struct {
ViaSmartDNS bool `json:"via_smartdns"`
}
Помощники:
// config.go или resolver.go
const dnsModePath = stateDir + "/dns-mode.json"
func loadDNSMode() DNSMode {
data, err := os.ReadFile(dnsModePath)
if err != nil {
return DNSMode{ViaSmartDNS: false}
}
var m DNSMode
if err := json.Unmarshal(data, &m); err != nil {
return DNSMode{ViaSmartDNS: false}
}
return m
}
func saveDNSMode(m DNSMode) {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return
}
tmp := dnsModePath + ".tmp"
_ = os.WriteFile(tmp, b, 0o644)
_ = os.Rename(tmp, dnsModePath)
}
1.3. Как это зашить в резолвер
В ResolverOpts (в resolver.go) уже есть поле DNSConfigPath.
Добавляем туда флаг:
type ResolverOpts struct {
DomainsPath string
MetaPath string
StaticPath string
CachePath string
PtrCachePath string
TraceLog string
TTL int
Workers int
DNSConfigPath string
ViaSmartDNS bool
}
Там, где сейчас создаёшь ResolverOpts (в routes_update.go / autoloop.go), просто подставляешь:
mode := loadDNSMode()
opts := ResolverOpts{
// ...
DNSConfigPath: dnsUpstreamsConf,
ViaSmartDNS: mode.ViaSmartDNS,
}
И дальше в runResolverJob:
func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResult, error) {
// ...
cfg := loadDNSConfig(opts.DNSConfigPath, logf)
// если включён режим SmartDNS игнорируем default/meta из файла
if opts.ViaSmartDNS {
cfg.Default = []string{"127.0.0.1#6053"}
cfg.Meta = []string{"127.0.0.1#6053"}
if logf != nil {
logf("dns-mode: via smartdns 127.0.0.1#6053")
}
}
// дальше как было
// ...
}
Так мы гарантируем, что при включённом via_smartdns твой Go-резолвер больше не будет ходить на 94.140.* и т.п. — всё через локальный SmartDNS.
1.4. API для режима и юнита
Чтобы вкладка DNS могла всё это показать/пощёлкать, делаем два эндпойнта:
Статус
// types.go
type DNSStatusResponse struct {
ViaSmartDNS bool `json:"via_smartdns"`
UnitState string `json:"unit_state"` // "active", "inactive", "failed", "unknown"
}
// vpn_handlers.go или dns_handlers.go
func handleDNSStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
mode := loadDNSMode()
unitState := "unknown"
if out, err := ShellRunCapture("systemctl", "is-active", "smartdns-local.service"); err == nil {
unitState = strings.TrimSpace(out)
}
writeJSON(w, http.StatusOK, DNSStatusResponse{
ViaSmartDNS: mode.ViaSmartDNS,
UnitState: unitState,
})
}
Смена режима (использовать SmartDNS или нет)
type DNSModeRequest struct {
ViaSmartDNS bool `json:"via_smartdns"`
}
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()
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
}
mode := loadDNSMode()
mode.ViaSmartDNS = req.ViaSmartDNS
saveDNSMode(mode)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"via_smartdns": mode.ViaSmartDNS,
})
}
Управление юнитом SmartDNS (у тебя уже что-то такое есть — можно просто нормализовать):
type SmartdnsServiceRequest struct {
Action string `json:"action"` // "start" / "stop" / "restart"
}
type SmartdnsServiceResponse struct {
UnitState string `json:"unit_state"`
}
func handleSmartdnsService(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req SmartdnsServiceRequest
if r.Body != nil {
defer r.Body.Close()
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
}
act := strings.ToLower(strings.TrimSpace(req.Action))
if act == "" {
act = "restart"
}
if act == "start" || act == "restart" || act == "stop" {
_ = ShellRun("systemctl", act, "smartdns-local.service")
}
state := "unknown"
if out, err := ShellRunCapture("systemctl", "is-active", "smartdns-local.service"); err == nil {
state = strings.TrimSpace(out)
}
writeJSON(w, http.StatusOK, SmartdnsServiceResponse{
UnitState: state,
})
}
Роуты:
mux.HandleFunc("/api/v1/dns/status", handleDNSStatus)
mux.HandleFunc("/api/v1/dns/mode", handleDNSModeSet)
mux.HandleFunc("/api/v1/dns/smartdns-service", handleSmartdnsService)
2. Новая вкладка DNS в Qt
Теперь UI можно сделать максимально простым:
2.1. Как будет выглядеть
Группа 1 — Upstreams
4 поля: default1, default2, meta1, meta2
Кнопка «Save upstreams»
Группа 2 — SmartDNS
Чекбокс: Use SmartDNS (127.0.0.1:6053) → это via_smartdns
Лейбл: SmartDNS unit: active/inactive/failed
Лейбл: Resolver mode: via SmartDNS / direct upstreams
(опционально) две кнопки — Start / Stop юнит
Нижняя текстовая панель полностью убирается — всё, что касается логов, уже есть на вкладке Trace (режим mode=smartdns и mode=full).
2.2. Код виджета (переписанный _build_tab_dns)
В vpn_dashboard_qt.py вместо старой _build_tab_dns можно вот так (без учёта импортов — у тебя уже есть):
def _build_tab_dns(self):
tab = QWidget()
main_layout = QVBoxLayout(tab)
# ---------------- Upstreams ----------------
grp_up = QGroupBox("Upstreams")
up_layout = QFormLayout(grp_up)
self.dns_default1 = QLineEdit()
self.dns_default2 = QLineEdit()
self.dns_meta1 = QLineEdit()
self.dns_meta2 = QLineEdit()
up_layout.addRow("default1", self.dns_default1)
up_layout.addRow("default2", self.dns_default2)
up_layout.addRow("meta1", self.dns_meta1)
up_layout.addRow("meta2", self.dns_meta2)
btn_save = QPushButton("Save upstreams")
btn_save.clicked.connect(self.on_save_upstreams_clicked)
up_layout.addRow(btn_save)
# ---------------- SmartDNS ----------------
grp_smartdns = QGroupBox("SmartDNS")
sd_layout = QVBoxLayout(grp_smartdns)
self.chk_dns_via_smartdns = QCheckBox("Use SmartDNS (127.0.0.1:6053)")
self.chk_dns_via_smartdns.stateChanged.connect(self.on_dns_mode_changed)
self.lbl_smartdns_unit = QLabel("SmartDNS unit: unknown")
self.lbl_dns_mode = QLabel("Resolver mode: unknown")
btn_row = QHBoxLayout()
self.btn_smartdns_start = QPushButton("Start unit")
self.btn_smartdns_stop = QPushButton("Stop unit")
self.btn_smartdns_start.clicked.connect(
lambda: self.on_smartdns_unit_action("start")
)
self.btn_smartdns_stop.clicked.connect(
lambda: self.on_smartdns_unit_action("stop")
)
btn_row.addWidget(self.btn_smartdns_start)
btn_row.addWidget(self.btn_smartdns_stop)
btn_row.addStretch(1)
sd_layout.addWidget(self.chk_dns_via_smartdns)
sd_layout.addWidget(self.lbl_smartdns_unit)
sd_layout.addWidget(self.lbl_dns_mode)
sd_layout.addLayout(btn_row)
# ---------------- Compose ----------------
main_layout.addWidget(grp_up)
main_layout.addWidget(grp_smartdns)
main_layout.addStretch(1)
self.tab_dns = tab
self.tabs.addTab(tab, "DNS")
2.3. Обновление вкладки (refresh_dns_tab)
def refresh_dns_tab(self):
# 1) upstreams
ups = self.c.dns_upstreams_view() # как и было
self.dns_default1.setText(ups.default1 or "")
self.dns_default2.setText(ups.default2 or "")
self.dns_meta1.setText(ups.meta1 or "")
self.dns_meta2.setText(ups.meta2 or "")
# 2) статус DNS / SmartDNS
st = self.c.dns_status_view() # новый метод в контроллере
# режим
self.chk_dns_via_smartdns.blockSignals(True)
self.chk_dns_via_smartdns.setChecked(bool(st.via_smartdns))
self.chk_dns_via_smartdns.blockSignals(False)
mode_txt = "via SmartDNS" if st.via_smartdns else "direct upstreams"
self.lbl_dns_mode.setText(f"Resolver mode: {mode_txt}")
# юнит
self.lbl_smartdns_unit.setText(f"SmartDNS unit: {st.unit_state or 'unknown'}")
# немного UX: если юнит inactive, кнопка Start активна, Stop — серый
is_active = (st.unit_state == "active")
self.btn_smartdns_start.setEnabled(not is_active)
self.btn_smartdns_stop.setEnabled(is_active)
2.4. Обработчики
def on_save_upstreams_clicked(self):
ups = self.c.dns_upstreams_view()
ups.default1 = self.dns_default1.text().strip()
ups.default2 = self.dns_default2.text().strip()
ups.meta1 = self.dns_meta1.text().strip()
ups.meta2 = self.dns_meta2.text().strip()
ok, err = self.c.dns_upstreams_save(ups)
if not ok:
QMessageBox.critical(self, "Error", f"Failed to save upstreams:\n{err}")
else:
self.show_status("DNS upstreams saved")
def on_dns_mode_changed(self, state: int):
via = (state == Qt.Checked)
ok, st, err = self.c.dns_mode_set(via)
if not ok:
QMessageBox.critical(self, "Error", f"Failed to change DNS mode:\n{err}")
# откатываем чекбокс
self.refresh_dns_tab()
return
# обновляем лейблы
self.refresh_dns_tab()
def on_smartdns_unit_action(self, action: str):
ok, st, err = self.c.smartdns_service_action(action)
if not ok:
QMessageBox.critical(self, "Error", f"SmartDNS {action} failed:\n{err}")
return
self.refresh_dns_tab()
self.refresh_status_tab() # если хочешь синхронизировать индикатор на Status
2.5. Методы в dashboard_controller.py
Нужно добавить:
@dataclass
class DNSStatus:
via_smartdns: bool
unit_state: str
class DashboardController:
# ...
def dns_status_view(self) -> DNSStatus:
data = self.client.get_json("/api/v1/dns/status")
return DNSStatus(
via_smartdns=bool(data.get("via_smartdns")),
unit_state=data.get("unit_state") or "unknown",
)
def dns_mode_set(self, via: bool) -> tuple[bool, DNSStatus | None, str | None]:
try:
data = self.client.post_json(
"/api/v1/dns/mode",
{"via_smartdns": bool(via)},
)
st = self.dns_status_view()
return True, st, None
except Exception as e:
return False, None, str(e)
def smartdns_service_action(self, action: str) -> tuple[bool, str | None, str | None]:
try:
data = self.client.post_json(
"/api/v1/dns/smartdns-service",
{"action": action},
)
return True, data.get("unit_state"), None
except Exception as e:
return False, None, str(e)
Upstreams-методы у тебя уже есть, их трогать не нужно.
3. Что дальше с логами
Когда эта схема заведётся (юнит живёт своей жизнью, резолвер знает via_smartdns и ходит только на 127.0.0.1), дальше можно:
В digA логировать отдельно:
dns smartdns когда ViaSmartDNS == true,
dns direct для прямых upstream.
В handleTraceJSON(mode="gui") продолжать выкидывать «шум» вроде больших дампов резолвера.
Для mode=smartdns показывать только строки, где есть smartdns в префиксе — и там уже будет видно цепочку.
Если хочешь, в следующем шаге можем прям точечно добить:
исправление поля ViaSmartDNS bool в types.go (я видел, что у тебя там раздралось на ViaSmartDN S),
аккуратно переписать digA-лог, чтобы не было этих сообщений вида dns error ... via 94.140.14.14: lookup ... on 192.168.50.10:53, когда на самом деле всё ок.

View File

@@ -0,0 +1,565 @@
Ок, давай разберём по двум пунктам:
Финальные имена и что именно править в коде
Почему стало ~590 IP вместо привычных ~1700
1. Имена / где что менять
Чтобы не было зоопарка, фиксируем одну схему:
внутренний конфиг резолвера:
// types.go
type DNSMode string
const (
DNSModeDirect DNSMode = "direct" // ходим напрямую на default/meta
DNSModeSmartOnly DNSMode = "smartdns" // всё через SmartDNS
)
type dnsConfig struct {
Default []string `json:"default"`
Meta []string `json:"meta"`
SmartDNS string `json:"smartdns"`
Mode DNSMode `json:"mode"`
}
Важно: одно имя — dnsConfig, одно поле режима — Mode.
Никаких ViaSmartDNS, ViaSmartDnS и т.п. только Mode.
1.1. loadDNSConfig (используется резолвером)
Внутри dns_settings.go (или там, где у тебя сейчас чтение /etc/selective-vpn/dns-upstreams.conf) логика должна отдавать именно такой dnsConfig:
func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig {
cfg := dnsConfig{
Default: []string{defaultDNS1, defaultDNS2},
Meta: []string{defaultMeta1, defaultMeta2},
SmartDNS: "",
Mode: DNSModeDirect,
}
data, err := os.ReadFile(path)
if err != nil {
// файла нет просто дефолты
return cfg
}
var def, meta []string
mode := DNSModeDirect
smart := ""
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":
def = append(def, vals...)
case "meta":
meta = append(meta, vals...)
case "smartdns":
if len(vals) > 0 {
smart = vals[0]
}
case "mode":
switch DNSMode(vals[0]) {
case DNSModeSmartOnly:
mode = DNSModeSmartOnly
default:
mode = DNSModeDirect
}
}
}
if len(def) > 0 {
cfg.Default = def
}
if len(meta) > 0 {
cfg.Meta = meta
}
if smart != "" {
cfg.SmartDNS = smart
}
cfg.Mode = mode
if logf != nil {
if cfg.Mode == DNSModeSmartOnly && cfg.SmartDNS != "" {
logf("dns-config: mode=smartdns smartdns=%s", cfg.SmartDNS)
} else {
logf("dns-config: mode=direct default=%v meta=%v", cfg.Default, cfg.Meta)
}
}
return cfg
}
1.2. runResolverJob — как он выбирает режим
Твой кусок:
cfg := loadDNSConfig(opts.DNSConfigPath, logf)
if logf != nil {
if cfg.ViaSmartDNS {
logf("resolver dns mode: SmartDNS-only (%v)", cfg.Default)
} else {
logf("resolver dns mode: direct default=%v meta=%v", cfg.Default, cfg.Meta)
}
}
Нужно заменить на:
cfg := loadDNSConfig(opts.DNSConfigPath, logf)
if logf != nil {
if cfg.Mode == DNSModeSmartOnly && cfg.SmartDNS != "" {
logf("resolver dns mode: SmartDNS-only (%s)", cfg.SmartDNS)
} else {
logf("resolver dns mode: direct default=%v meta=%v", cfg.Default, cfg.Meta)
}
}
1.3. Как резолвер реально ходит в DNS
В resolveHostGo сейчас у тебя примерно так (я по смыслу):
func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, logf func(string, ...any)) ([]string, int) {
useMeta := false
for _, m := range metaSpecial {
if host == m {
useMeta = true
break
}
}
dnsList := cfg.Default
if useMeta {
dnsList = cfg.Meta
}
ips, errs := digA(host, dnsList, 3*time.Second, logf)
...
}
Делаем так, чтобы при включённом SmartDNS всегда шли только на него:
func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, logf func(string, ...any)) ([]string, int) {
useMeta := false
for _, m := range metaSpecial {
if host == m {
useMeta = true
break
}
}
var dnsList []string
if cfg.Mode == DNSModeSmartOnly && cfg.SmartDNS != "" {
// ВСЁ через SmartDNS
dnsList = []string{cfg.SmartDNS}
} else if useMeta {
dnsList = cfg.Meta
} else {
dnsList = cfg.Default
}
ips, errs := digA(host, dnsList, 3*time.Second, logf)
...
}
И для PTR-запросов (resolveStaticLabels → digPTR) — аналогично:
func resolveStaticLabels(entries [][3]string, cfg dnsConfig, ptrCache map[string]any, ttl int, logf func(string, ...any)) (map[string][]string, int, int) {
...
dnsForPtr := ""
if cfg.Mode == DNSModeSmartOnly && cfg.SmartDNS != "" {
dnsForPtr = cfg.SmartDNS
} else if len(cfg.Default) > 0 {
dnsForPtr = cfg.Default[0]
} else {
dnsForPtr = defaultDNS1
}
...
}
1.4. routes_update.go — что именно должно быть
Там, где запускается резолвер (что-то вроде runResolverJob), должно быть только это (без своих конфигов):
opts := ResolverOpts{
DomainsPath: domainDir + "/bases.txt",
MetaPath: domainDir + "/meta-special.txt",
StaticPath: staticIPsFile,
CachePath: stateDir + "/resolver-cache.json",
PtrCachePath: stateDir + "/resolver-ptr-cache.json",
TraceLog: traceLogPath,
TTL: 24 * 3600, // или твой config.ResolverTTLSeconds
Workers: 200, // или config.ResolverWorkers
DNSConfigPath: dnsUpstreamsConf, // ВАЖНО: один путь, один формат
}
res, err := runResolverJob(opts, logf)
Никаких других структур / полей для DNS в этом файле не нужно.
Вся магия DNS живёт в dns_settings.go + resolver.go.
2. Почему теперь 590 IP, а не 1700
Смотрим на лог:
summary: domains=14864, cache_hits=2 resolved_now=1281 unresolved=13581 static_entries=294 ...
unique_ips=590 ...
Это говорит о трёх вещах:
Резолвер реально прошёлся по всем ~15k доменам.
Ответы он получил только по ~1281 домену.
Из них после фильтра isPrivateIPv4 осталось 590 уникальных публичных IP.
Что поменялось по сравнению с прошлой версией:
2.1. Всё теперь идёт ТОЛЬКО через SmartDNS
Раньше схема была примерно:
резолвер → напрямую на 94.140.* + 46.243.*
SmartDNS жил своей жизнью как локальный помощник
Сейчас при Use SmartDNS for resolver:
резолвер → только на 127.0.0.1#6053
SmartDNS → дальше сам ходит на 192.168.50.10:53 (bind) → AdGuard DNS и т.д.
Поэтому:
все блокировки / NXDOMAIN / подмена на 0.0.0.0 / 127.0.0.1 происходят до нашего Go-кода;
Go-резолвер видит либо нормальный внешний IP, либо 0.0.0.0/локалку/ошибку.
2.2. Мы фильтруем все приватные и мусорные IP
В runResolverJob:
if len(rawIps) > 0 && ts > 0 && now-int(ts) <= ttl {
for _, v := range rawIps {
if s, ok := v.(string); ok && s != "" && !isPrivateIPv4(s) {
ips = append(ips, s)
}
}
}
...
if isPrivateIPv4(ip) {
continue
}
А isPrivateIPv4 считает приватными:
0.x.x.x
10.x.x.x
127.x.x.x
192.168.x.x
172.1631.x.x
и вообще всё, что невалидное
То есть:
если AdGuard/SmartDNS возвращает 0.0.0.0 или 127.0.0.1 для трекеров / рекламы,
мы их больше не учитываем вообще;
раньше, когда ходили мимо SmartDNS, таких «мусорных» ответов было гораздо меньше.
Отсюда очень понятная картина:
1700 IP ранее = реальные IP + всякий мусор (0.0.0.0, локалки, возможно ещё что-то);
590 IP сейчас = только реальные публичные адреса, которые реально нужно засунуть в ipset и гонять через VPN.
2.3. Что с ошибками в логах вида ... on 192.168.50.10:53
Это просто текст ошибки из SmartDNS/bind, который пробрасывается наружу:
ptr error 104.26.10.38 via 127.0.0.1:6053: lookup 38.10.26.104.in-addr.arpa. on 192.168.50.10:53: no such host
мы обращаемся к 127.0.0.1:6053;
SmartDNS дальше ходит на 192.168.50.10:53 и говорит «no such host»;
net.Resolver формирует такое красивое сообщение.
То есть Go-код SmartDNS не обходит, всё как раз правильно идёт через него.
Если хочешь «как раньше» по цифрам
Если прям хочется снова видеть ~1700 IP (включая мусор):
Можно временно ослабить фильтр:
func isPrivateIPv4(ip string) bool {
// В тесте можно не резать 0.0.0.0 / 127.x.x.x и т.п.,
// просто вернуть false и посмотреть цифры
return false
}
Или сделать две метрики:
unique_ips_total — с учётом всего, что вернул DNS;
unique_ips_public — после фильтра приватных;
и логировать обе.
Но с точки зрения селективных маршрутов тебе как раз нужны только публичные IP, так что текущие 590 очень вероятно ближе к «правильной» картине.
Если хочешь, в следующем шаге могу:
дать патч для логов, чтобы в summary было:
unique_ips_public, unique_ips_private, blocked_0_ips и т.д.,
плюс подсветить в Trace вкладке отдельной строкой, сколько доменов SmartDNS убил в ноль.
в том то и дело, что теперь половина доменов которые железно работали, не работают теперь, что то изменилось, не понимаю что, у нас кеш пишется? что же не так ... и кстати логи по прежнему не верно отображается
Перестала думать
в том то и дело, что теперь половина доменов которые железно работали, не работают теперь, что то изменилось, не понимаю что, у нас кеш пишется? что же не так ... и кстати логи по прежнему не верно отображается
Думал на протяжении 2m 20s
Окей, давай по порядку, что видно из скрина логов, и что мы реально поменяли в коде.
⚠️ Сначала техническая ремарка
Часть файлов, которые ты раньше заливал (resolver.go, config.go и т.д.), у меня уже недоступны система их подчищает со временем. Я помню общую структуру и последние куски, которые ты присылал в текст, но если захочешь, чтобы я дал прям точные патчи построчно по актуальному resolver.go / dns_settings.go, лучше закинуть их ещё раз.
1. Что сейчас не так по логам
На твоём скрине снизу была строчка типа:
dns_errors=13295 unresolved=13581 unique_ips=590 ...
И вверху куча:
dns warn www.twitterinc.com via 127.0.0.1:6053:
lookup www.twitterinc.com on 192.168.50.53:53: i/o timeout
...
dns warn www.twitterstat.us via 127.0.0.1:6053:
dial udp 127.0.0.1:6053: i/o timeout
Это говорит о простом:
Домены в большинстве случаев не резолвятся вообще.
Не из-за кэша, а из-за того, что SmartDNS/BIND/AdGuard под нагрузкой просто не отвечает (таймауты).
Из-за этого у нас:
resolved_now маленький
dns_errors огромный
unique_ips ≈ 500+, хотя раньше было ≈ 1700.
То есть лог честный: с точки зрения нашего Go-резолвера для этих доменов реально "no IPs".
2. Что именно изменилось в логике DNS
До рефакторинга:
наш resolver.go бился напрямую в default1/default2/meta1/meta2 (типа 94.140.14.15 и т.д.);
SmartDNS жил сам по себе и занимался вайлдкарт-ipsetами.
После рефакторинга (который мы как раз и делали по твоей просьбе):
если отмечено «Use SmartDNS for resolver» — мы:
подменяем cfg.Default = []string{ smartdnsAddr } (127.0.0.1#6053);
и всё резолвим через SmartDNS;
параллельно мы оставили очень агрессивный пул: workers = 200, domains ≈ 15k.
И получается ситуация:
15 000 доменов × 200 воркеров × таймаут 3 секунды → местный SmartDNS + BIND просто не успевает, начинает дропать/таймаутить, мы видим кучу i/o timeout, и в итоге получаем только ~590 IP.
То есть сломали не кэш, а заддосили свой же локальный resolver 😅
3. Кэш жив он или нет?
Да, по коду (который ты кидал):
domainCache := loadJSONMap(opts.CachePath)
ptrCache := loadJSONMap(opts.PtrCachePath)
...
domainCache[host] = map[string]any{
"ips": r.ips,
"last_resolved": now,
}
...
res.DomainCache = domainCache
res.PtrCache = ptrCache
...
saveJSON(res.DomainCache, opts.CachePath)
saveJSON(res.PtrCache, opts.PtrCachePath)
Кэш:
читается в начале (loadJSONMap);
обновляется только для успешных доменов;
сохраняется в конце.
Проблема в другом: когда у тебя за один прогон 13k DNS-ошибок, в кэш почти нечего писать — большая часть доменов вообще не получила IP, и в следующий прогон они снова идут "с нуля".
4. Что я бы сделал сейчас в коде (минимально и по делу)
4.1. Ограничить параллелизм при работе через SmartDNS
В runResolverJob (там, где ты сейчас считаешь workers), сделай так:
workers := opts.Workers
if workers <= 0 {
workers = 200
}
// если работаем через SmartDNS не лупим по нему 200 горутин
if cfg.ViaSmartDNS && workers > 50 {
workers = 50
}
// safety clamps
if workers < 1 {
workers = 1
}
if workers > 500 {
workers = 500
}
if logf != nil {
logf("resolver start: domains=%d ttl=%ds workers=%d viaSmartDNS=%v",
len(domains), ttl, workers, cfg.ViaSmartDNS)
}
Суть:
при прямых DNS — всё как раньше (200 воркеров нормально жрут 94.140.*);
при SmartDNS — ставим жёсткий потолок, например 50 (можно 30-80 поэкспериментировать).
Это радикально уменьшит количество одновременных запросов к SmartDNS/BIND, и таймаутов станет сильно меньше → dns_errors упадут, unique_ips вырастут.
4.2. (Опционально) Увеличить таймаут для SmartDNS
Сейчас digA вызывается так (по прошлому коду):
ips, errs := digA(host, dnsList, 3*time.Second, logf)
Если хочешь мягко подстраховаться:
timeout := 3 * time.Second
if cfg.ViaSmartDNS {
timeout = 5 * time.Second
}
ips, errs := digA(host, dnsList, timeout, logf)
Для этого нужно просто протащить cfg или флажок viaSmartDNS до resolveHostGo:
func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, viaSmartDNS bool, logf func(string, ...any)) ([]string, int) {
...
timeout := 3 * time.Second
if viaSmartDNS {
timeout = 5 * time.Second
}
ips, errs := digA(host, dnsList, timeout, logf)
...
}
И в runResolverJob вызывать:
ips, errs := resolveHostGo(j.host, cfg, metaSpecial, cfg.ViaSmartDNS, logf)
Это не обязательно, но в связке с уменьшением workers даст более стабильную картину.
5. Почему лог «неверно отображается»
Тут важно разделить:
То, что реально делает наш резолвер.
Если он пишет:
dns warn www.twitterinc.com via 127.0.0.1:6053: dial udp 127.0.0.1:6053: i/o timeout
это значит, что конкретно этот запрос реально упал по таймауту.
Даже если "в браузере всё открывается", это уже другие запросы, в другое время и с другим паттерном (не 15k доменов за раз).
Фильтрация на вкладке Events/SmartDNS/Full.
Мы уже делали фильтр для mode=gui, чтобы убрать:
[routes], resolver, dnstt, smartdns, nft add element, cache hit и т.п. шум.
Если сейчас в режиме Events ты всё равно видишь этот трэш с [routes] dns warn ... — значит, в handleTraceJSON в ветке case "gui": надо ещё сильнее ужать фильтр, например:
if strings.HasPrefix(ll, "[routes]") ||
strings.Contains(ll, " dns warn ") ||
strings.Contains(ll, " dns error ") {
continue
}
Тогда:
весь подробный DNS-дебаг остаётся в Full и SmartDNS;
Events показывает только человеческие штуки [gui], [login], [vpn], [error] и т.д.
Если хочешь, чтобы я точно подогнал фильтр под твой текущий trace_handlers.go, просто перекинь файл ещё раз — старая версия у меня уже протухла.
6. Что бы я сделал дальше по шагам
Внести правку с ограничением workers при ViaSmartDNS.
(Опционально) Протянуть флаг viaSmartDNS в resolveHostGo и увеличить таймаут до 5s.
Запустить update-selective-routes и посмотреть:
строку summary (особенно dns_errors и unique_ips);
стало ли больше IP (должно быть ближе к твоим старым ~1700, хотя не обязательно 1:1).
Если с IP всё норм, но Events всё ещё замусорен подправить фильтр в handleTraceJSON (mode = "gui").
Если хочешь, чтобы я прям по твоим актуальным файлам выписал патчи diffом (resolver.go, dns_settings.go, trace_handlers.go) скинь их ещё раз, я аккуратно вставлю всё в нужные места без переименования функций и с теми же структурами, что уже есть.

View File

@@ -0,0 +1,201 @@
Инструкция 3 (safe-версия)
Цель:
Сделать улучшения резолвера без risky-переписывания, сохранить обратную совместимость с текущим GUI/API,
и убрать главные источники шума: массовые NXDOMAIN, таймауты и нечитабельные метрики.
--------------------------------------------------------------------
0) Что подтверждено по текущему состоянию
--------------------------------------------------------------------
1. Основной рабочий код сейчас в:
- app/resolver.go
- app/routes_update.go
- app/dns_settings.go
- app/domains_handlers.go
2. Логи показывают:
- много NXDOMAIN (ожидаемо при широком base x subs)
- заметную долю timeout
- один агрегированный счетчик dns_errors, из-за чего трудно понять причину деградации.
3. Формат dns upstream у нас: host#port (например 94.140.14.15#53 или 127.0.0.1#6053).
Это важно: нельзя использовать валидацию, которая принимает только host:port.
--------------------------------------------------------------------
1) Архитектурное решение (рекомендуемое)
--------------------------------------------------------------------
Не оставлять только переключатель direct <-> smartdns.
Сделать 3 режима резолвера:
- direct:
обычные домены через default/meta upstream.
- smartdns:
все домены через SmartDNS address.
- hybrid_wildcard (recommended):
только wildcard-домены через SmartDNS, остальные напрямую через default/meta.
Почему так лучше:
- сохраняем скорость и отказоустойчивость direct для обычных доменов;
- wildcard-логику держим строго в SmartDNS, как ты и хотел;
- не ломаем текущий UX: можно оставить старый bool и маппить его на mode.
--------------------------------------------------------------------
2) Что НЕ внедряем из старой инструкция3
--------------------------------------------------------------------
1. Не используем netip.ParseAddrPort для upstream-валидации (ломает host#port).
2. Не используем netip.MustParseAddr в hot path (может паниковать).
3. Не добавляем лишний semaphore поверх worker pool (сложность без явной выгоды).
4. Не делаем агрессивный рефактор API-контрактов без backward-compat.
--------------------------------------------------------------------
3) Пакет безопасных правок (приоритет P1)
--------------------------------------------------------------------
P1.1 - Режимы DNS (backward compatible)
Файлы:
- app/types.go
- app/dns_settings.go
- app/routes_update.go
- app/resolver.go
Изменения:
1) Ввести enum режима:
type DNSResolverMode string
const (
DNSModeDirect DNSResolverMode = "direct"
DNSModeSmartDNS DNSResolverMode = "smartdns"
DNSModeHybrid DNSResolverMode = "hybrid_wildcard"
)
2) Расширить DNSMode/DNSStatusResponse/DNSModeRequest полем Mode,
но оставить ViaSmartDNS для старого GUI:
- если Mode пустой, использовать ViaSmartDNS:
- true -> smartdns
- false -> direct
3) В ResolverOpts передавать Mode и список wildcard-доменов (один раз на job).
4) В resolveHostGo выбирать dnsList так:
- mode == smartdns: []{smartdnsAddr}
- mode == hybrid_wildcard и host совпал с wildcard: []{smartdnsAddr}
- иначе: meta или default по текущей логике.
Примечание:
Wildcard-список уже хранится в smartdns-wildcards.json через /api/v1/smartdns/wildcards.
Нужно только использовать его в резолвере.
P1.2 - Upstream fallback с классификацией ошибок
Файл:
- app/resolver.go
Изменения:
1) В digA:
- идти по upstream последовательно;
- timeout/temporary -> fallback на следующий upstream;
- nxdomain -> остановить попытки для домена (дальше пробовать бессмысленно).
2) Классифицировать ошибки через net.DNSError + fallback по тексту:
- nxdomain
- timeout
- temporary
- other
3) Вместо одного dns_errors вести структуру счетчиков:
dns_attempts, dns_ok, dns_nxdomain, dns_timeout, dns_temporary, dns_other.
P1.3 - Разделенные метрики в summary
Файл:
- app/resolver.go
Изменения:
1) Обновить финальный лог "resolve summary" с раздельными счетчиками DNS-ошибок.
2) Добавить per-upstream агрегаты (минимум attempts/ok/timeout/nxdomain/other).
Формат может быть одной строкой JSON, чтобы GUI/анализатору было проще парсить.
P1.4 - Ограничение domain expansion
Файл:
- app/routes_update.go
Изменения:
1) Добавить конфиг-лимиты через env:
- RESOLVE_SUBS_PER_BASE_LIMIT (например default 25)
- RESOLVE_DOMAINS_HARD_CAP (например default 12000)
2) После построения domainSet:
- сортировать домены;
- при превышении hard cap обрезать хвост детерминированно;
- писать явный warning в trace.
3) Логировать breakdown:
bases_count, subs_count, expanded_count, total_domains.
--------------------------------------------------------------------
4) Пакет улучшений P2 (после P1)
--------------------------------------------------------------------
P2.1 - Negative cache
Файл:
- app/resolver.go
Идея:
- кэшировать nxdomain/servfail на короткий TTL (например 10-20 минут),
чтобы не долбить одинаковые несуществующие имена каждую прогонку.
P2.2 - PTR retry (ограниченный)
Файл:
- app/resolver.go
Идея:
- для digPTR сделать 1-2 retry только на timeout/temporary;
- не ретраить nxdomain.
P2.3 - GUI/API отображение режима
Файлы GUI:
- selective-vpn-gui/api_client.py
- selective-vpn-gui/dashboard_controller.py
- selective-vpn-gui/vpn_dashboard_qt.py
Идея:
- показать mode = direct/smartdns/hybrid_wildcard;
- оставить старый toggle рабочим (маппинг direct/smartdns),
а hybrid можно добавить как отдельный выбор (позже).
--------------------------------------------------------------------
5) Порядок внедрения (рекомендуемый)
--------------------------------------------------------------------
Шаг 1:
Реализовать режимы + fallback + split-метрики (P1.1, P1.2, P1.3).
Шаг 2:
Добавить лимиты expansion (P1.4), проверить изменение unresolved/timeouts.
Шаг 3:
Добавить negative cache и PTR retry (P2.1, P2.2).
--------------------------------------------------------------------
6) Критерии готовности
--------------------------------------------------------------------
1. В trace видно не один dns_errors, а раздельные причины.
2. В режиме direct при падении одного upstream запросы частично продолжают проходить через fallback.
3. В режиме hybrid_wildcard wildcard-домены уходят через SmartDNS, остальные через direct.
4. Количество timeout и общее время прогона снижаются относительно текущего baseline.
--------------------------------------------------------------------
7) Короткий диагноз по сути
--------------------------------------------------------------------
Проблема не в одном SmartDNS bottleneck.
Основной вклад сейчас дает комбинация:
- большой domain expansion,
- много несуществующих имен (NXDOMAIN),
- и отсутствие четкой стратегии fallback/метрик.
Safe-путь:
ввести гибридный routing DNS + раздельную диагностику + контролируемый expansion.

View File

@@ -0,0 +1,252 @@
Анализ твоего кода resolver.go
Я внимательно прошёлся по всему коду: структура хорошая (модульный, с логами, кэшем, concurrent резолвом), логика правильная для задачи (домены → IPs с meta/default upstream'ами, static с PTR-лейблами, приватные IP фильтруем). Это уже рабочий инструмент, который может обрабатывать тысячи доменов без сбоев. Но да, есть потенциальные проблемы и места для улучшений — некоторые очевидные (edge-кейсы), другие — для масштаба/надёжности.
Я разделил на потенциальные поломки (где код может крашнуться или дать неверный результат), слабые места (не поломки, но риски в проде) и рекомендации с кодовыми примерами. Всё основано на твоём коде — не выдумываю, а анализирую.
1. Потенциальные поломки (где код может упасть или дать мусор)
Конфликт портов в upstream (в resolveHostGo и digPTR):
splitDNS возвращает port (e.g., "127.0.0.1#6053" → "127.0.0.1", "6053").
Но в net.Resolver.Dial ты используешь net.JoinHostPort(server, port) — это ок, но Resolver по дефолту использует UDP. Если upstream требует TCP (e.g., большие ответы >512 байт) — запрос fail (dns.exception.SyntaxError или timeout).
Поломка: Для доменов с большим TXT/NS — no IPs, но errs++ (не обновит кэш).
Тест: Резолв домен с большим ответом (e.g., "example.com" TXT) на upstream с port. Если fail — вот оно.
Timeout слишком короткий (3s в ctx):
На медленных upstream'ах (e.g., DNS over Tor или VPN) — запрос не успеет, вернёт [] и errs++.
Поломка: Unresolved домены, даже если upstream живой. При повторном запуске — из кэша не возьмёт (потому что не обновил).
Тест: Установи медленный DNS (e.g., 8.8.8.8 с delay) — увидишь dns.timeout.
PTR fail без retry (в resolveStaticLabels):
digPTR без retry — если upstream временно down, PTR не получит, и лейбл = "*[STATIC-IP]".
Поломка: Если все upstream fail — все static без PTR, но код не крашится. В проде — потеря лейблов.
Тест: Установи invalid upstream — увидишь ptr_errors++, но код продолжит.
loadDNSConfig fallback на дефолт без валидации:
Если файл битый (e.g., "default abc#invalid") — fallback на дефолт, но дефолт может не работать.
Поломка: Silent fail — резолв будет работать на старых дефолтах, но юзер не поймёт почему.
Тест: Сделай файл с кривыми upstream'ами — увидишь в логах fallback, но без ошибки.
uniqueStrings и map в IPMap — но без сортировки в map:
IPMap использует map[string]struct{} для dedup, но при append в res.IPMap — sort.Slice по IP, но лейблы внутри не отсортированы.
Поломка: Не поломка, но нестабильный порядок лейблов в mapLines — если GUI полагается на порядок.
Тест: Несколько лейблов на IP — порядок random.
isPrivateIPv4 не обрабатывает /mask в IP:
Проверяет только base IP, но если ip = "10.0.0.0/8" — parts = ["10.0.0.0/8"] → len!=4 → return true (ок). Но если mask invalid — не catch.
Поломка: Если static с кривым CIDR (e.g., "1.2.3.4/33") — пропустит как non-private.
Тест: Добавь "1.2.3.4/33" в static — пройдёт, хотя invalid.
runResolverJob: если workers=0 или ttl=0 — не краш, но дефолты
Ты clamp'ишь (ttl 60..86400, workers 1..500) — хорошо, но если envInt fail — silent.
Поломка: Не поломка, но если ENV кривой — silent fallback.
2. Слабые места (не поломки, но риски в проде)
Concurrent резолв без rate-limit:
80 workers + 500+ доменов — может flood upstream'ы (DNS rate-limit ban).
Риск: Upstream забанит IP, все запросы fail.
Нет IPv6:
Всё только IPv4 (A-записи, isPrivateIPv4). Если домен только AAAA — no IPs.
Риск: Современные сайты (e.g., google) имеют IPv6 — потеряешь трафик.
Кэш без invalidation:
Кэш на TTL, но если upstream сменился — старый кэш может быть invalid.
Риск: После смены DNS — старые IPs в кэше до TTL.
PTR без кэша ошибок:
Если PTR fail — всё равно пишем в кэш empty names. Следующий раз — из кэша empty.
Риск: Временный fail upstream — навсегда потерянные лейблы до ручного clear cache.
Логи только в файл:
appendTraceLine — ok, но если файл locked — silent fail.
Риск: В многопоточном режиме — race на append.
envInt без дефолта fallback:
Если ENV empty — strconv.Atoi("") → error, но ты используешь default. Ok, но можно сделать safe.
3. Рекомендации и фиксы
Вот конкретные советы + кодовые патчи. Внедряй по приоритету (сначала поломки).
Фикс портов и TCP в resolveHostGo (для поломки 1):
Используй net.Dialer с UDP/TCP fallback.
Patch:Gofunc resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, logf func(string, ...any)) ([]string, int) {
// ... (твой код)
var ips []string
errs := 0
for _, upstream := range dnsList {
server, port := splitDNS(upstream)
if server == "" {
errs++
continue
}
if port == "" {
port = "53"
}
addr := net.JoinHostPort(server, port)
// Сначала UDP
ipsUdp, errUdp := lookupA(host, addr, "udp", 3*time.Second)
if errUdp == nil {
ips = append(ips, ipsUdp...)
continue
} else if logf != nil {
logf("UDP fail for %s@%s: %v", host, addr, errUdp)
}
// Fallback TCP
ipsTcp, errTcp := lookupA(host, addr, "tcp", 5*time.Second)
if errTcp == nil {
ips = append(ips, ipsTcp...)
} else {
errs++
if logf != nil {
logf("TCP fail for %s@%s: %v", host, addr, errTcp)
}
}
}
// dedup
seen := map[string]struct{}{}
unique := []string{}
for _, ip := range ips {
if _, ok := seen[ip]; !ok && !isPrivateIPv4(ip) {
seen[ip] = struct{}{}
unique = append(unique, ip)
}
}
return unique, errs
}
func lookupA(host, addr, network string, timeout time.Duration) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
r := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, network, addr)
},
}
ips, err := r.LookupHost(ctx, host)
if err != nil {
return nil, err
}
var out []string
for _, ip := range ips {
if netip.MustParseAddr(ip).Is4() {
out = append(out, ip)
}
}
return out, nil
}
Увеличь timeout + retry в resolveHostGo (для поломки 2):
Добавь retry для всей функции (3 попытки).
Patch: Оберни loop по upstream в backoff.Retry (как в nft).
Retry для PTR (для поломки 3):
Patch в resolveStaticLabels:Go// Внутри if !names:
for attempt := 0; attempt < 3; attempt++ {
names, err = digPTR(base_ip, dns_for_ptr, 5*time.Second, logf)
if err == nil {
break
}
time.Sleep(500 * time.Millisecond)
}
if err != nil {
ptrErrors++
}
Валидация upstream в loadDNSConfig (для поломки 4):
Patch:Gofunc loadDNSConfig(path string, logf func(string, ...any)) dnsConfig {
// ... (твой код)
valid := func(s string) bool {
if s == "" {
return false
}
_, err := netip.ParseAddrPort(s)
return err == nil
}
cfg.Default = filter(cfg.Default, valid)
cfg.Meta = filter(cfg.Meta, valid)
if len(cfg.Default) == 0 {
cfg.Default = defaultDNS
if logf != nil {
logf("dns-config: no valid default upstreams, fallback to built-in")
}
}
// аналогично для meta
}
func filter(ss []string, fn func(string) bool) []string {
var out []string
for _, s := range ss {
if fn(s) {
out = append(out, s)
}
}
return out
}
Сортировка лейблов в IPMap (для поломки 5):
Patch в runResolverJob после for host, ips...:Gofor ip, labelsMap := range ipMap {
var labels []string
for lbl := range labelsMap {
labels = append(labels, lbl)
}
sort.Strings(labels)
for _, lbl := range labels {
res.IPMap = append(res.IPMap, [2]string{ip, lbl})
}
}
Валидация CIDR в isPrivateIPv4 (для поломки 6):
Patch: Используй netip.ParsePrefix:Gofunc isPrivateIPv4(ip string) bool {
if strings.Contains(ip, "/") {
p, err := netip.ParsePrefix(ip)
if err != nil {
return true // invalid → private (пропустить)
}
return p.IsPrivate()
}
a, err := netip.ParseAddr(ip)
if err != nil {
return true
}
return a.IsPrivate()
}
Rate-limit в concurrent (для слабого 1):
Добавь semaphore в workers-loop:Gosem := make(chan struct{}, 20) // лимит 20 одновременных запросов
for i := 0; i < workers; i++ {
go func() {
for j := range jobs {
sem <- struct{}{}
// resolve
<-sem
}
}()
}
IPv6 поддержка (для слабого 2):
Добавь AAAA в resolveHostGo (параллельно с A).
Добавь isPrivateIPv6.
Кэш invalidation (для слабого 3):
Добавь флаг --force для игнора TTL.
Логи в файл с sync (для слабого 5):
В appendTraceLine добавь file.Sync() после Write.
Итог
Поломки: В основном edge-кейсы (медленный DNS, invalid upstream, большие ответы) — фиксы выше.
Слабые: Масштаб (rate-limit, IPv6) и robustness (retry PTR, validate).
Код в целом очень хороший — внедряй фиксы по приоритету (сначала порты/TCP, timeout/retry).