baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
This commit is contained in:
139
selective-vpn-api/OPS_CHECKLIST.md
Normal file
139
selective-vpn-api/OPS_CHECKLIST.md
Normal 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 отвергнет их (лимит на каждый тип).
|
||||
173
selective-vpn-api/TRAFFIC_OVERRIDES_USAGE.md
Normal file
173
selective-vpn-api/TRAFFIC_OVERRIDES_USAGE.md
Normal 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
|
||||
2
selective-vpn-api/app/assets/domains/bases.txt
Normal file
2
selective-vpn-api/app/assets/domains/bases.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
###
|
||||
# Default bases list (seed). Add domains here; one per line.
|
||||
1
selective-vpn-api/app/assets/domains/meta-special.txt
Normal file
1
selective-vpn-api/app/assets/domains/meta-special.txt
Normal file
@@ -0,0 +1 @@
|
||||
# meta domains (seed)
|
||||
1
selective-vpn-api/app/assets/domains/static-ips.txt
Normal file
1
selective-vpn-api/app/assets/domains/static-ips.txt
Normal file
@@ -0,0 +1 @@
|
||||
# static IPs (seed)
|
||||
3
selective-vpn-api/app/assets/domains/subs.txt
Normal file
3
selective-vpn-api/app/assets/domains/subs.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
www
|
||||
api
|
||||
static
|
||||
204
selective-vpn-api/app/autoloop.go
Normal file
204
selective-vpn-api/app/autoloop.go
Normal 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
|
||||
}
|
||||
109
selective-vpn-api/app/config.go
Normal file
109
selective-vpn-api/app/config.go
Normal 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
|
||||
886
selective-vpn-api/app/dns_settings.go
Normal file
886
selective-vpn-api/app/dns_settings.go
Normal 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"
|
||||
}
|
||||
184
selective-vpn-api/app/domains_handlers.go
Normal file
184
selective-vpn-api/app/domains_handlers.go
Normal 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
|
||||
}
|
||||
109
selective-vpn-api/app/events_bus.go
Normal file
109
selective-vpn-api/app/events_bus.go
Normal 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))
|
||||
111
selective-vpn-api/app/events_handlers.go
Normal file
111
selective-vpn-api/app/events_handlers.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
selective-vpn-api/app/http_helpers.go
Normal file
59
selective-vpn-api/app/http_helpers.go
Normal 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),
|
||||
})
|
||||
}
|
||||
400
selective-vpn-api/app/nft_update.go
Normal file
400
selective-vpn-api/app/nft_update.go
Normal 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
|
||||
}
|
||||
1170
selective-vpn-api/app/resolver.go
Normal file
1170
selective-vpn-api/app/resolver.go
Normal file
File diff suppressed because it is too large
Load Diff
70
selective-vpn-api/app/resolver_cache_test.go
Normal file
70
selective-vpn-api/app/resolver_cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
399
selective-vpn-api/app/routes_cache.go
Normal file
399
selective-vpn-api/app/routes_cache.go
Normal 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
|
||||
}
|
||||
405
selective-vpn-api/app/routes_handlers.go
Normal file
405
selective-vpn-api/app/routes_handlers.go
Normal 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",
|
||||
})
|
||||
}
|
||||
52
selective-vpn-api/app/routes_units.go
Normal file
52
selective-vpn-api/app/routes_units.go
Normal 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)
|
||||
}
|
||||
703
selective-vpn-api/app/routes_update.go
Normal file
703
selective-vpn-api/app/routes_update.go
Normal 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)
|
||||
}
|
||||
47
selective-vpn-api/app/seeds.go
Normal file
47
selective-vpn-api/app/seeds.go
Normal 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)
|
||||
}
|
||||
}
|
||||
204
selective-vpn-api/app/server.go
Normal file
204
selective-vpn-api/app/server.go
Normal 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)
|
||||
}
|
||||
}
|
||||
72
selective-vpn-api/app/shell.go
Normal file
72
selective-vpn-api/app/shell.go
Normal 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
|
||||
}
|
||||
224
selective-vpn-api/app/smartdns_runtime.go
Normal file
224
selective-vpn-api/app/smartdns_runtime.go
Normal 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,
|
||||
}
|
||||
}
|
||||
132
selective-vpn-api/app/smartdns_wildcards_store.go
Normal file
132
selective-vpn-api/app/smartdns_wildcards_store.go
Normal 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)
|
||||
}
|
||||
261
selective-vpn-api/app/trace_handlers.go
Normal file
261
selective-vpn-api/app/trace_handlers.go
Normal 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
|
||||
}
|
||||
225
selective-vpn-api/app/traffic_candidates.go
Normal file
225
selective-vpn-api/app/traffic_candidates.go
Normal 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
|
||||
}
|
||||
1154
selective-vpn-api/app/traffic_mode.go
Normal file
1154
selective-vpn-api/app/traffic_mode.go
Normal file
File diff suppressed because it is too large
Load Diff
250
selective-vpn-api/app/types.go
Normal file
250
selective-vpn-api/app/types.go
Normal 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
|
||||
}
|
||||
371
selective-vpn-api/app/vpn_handlers.go
Normal file
371
selective-vpn-api/app/vpn_handlers.go
Normal 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,
|
||||
})
|
||||
}
|
||||
539
selective-vpn-api/app/vpn_login_session.go
Normal file
539
selective-vpn-api/app/vpn_login_session.go
Normal 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})
|
||||
}
|
||||
229
selective-vpn-api/app/watchers.go
Normal file
229
selective-vpn-api/app/watchers.go
Normal 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
8
selective-vpn-api/go.mod
Normal 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
4
selective-vpn-api/go.sum
Normal 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=
|
||||
2
selective-vpn-api/internal/assets/domains/bases.txt
Normal file
2
selective-vpn-api/internal/assets/domains/bases.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
###
|
||||
# Default bases list (seed). Add domains here; one per line.
|
||||
@@ -0,0 +1 @@
|
||||
# meta domains (seed)
|
||||
1
selective-vpn-api/internal/assets/domains/static-ips.txt
Normal file
1
selective-vpn-api/internal/assets/domains/static-ips.txt
Normal file
@@ -0,0 +1 @@
|
||||
# static IPs (seed)
|
||||
3
selective-vpn-api/internal/assets/domains/subs.txt
Normal file
3
selective-vpn-api/internal/assets/domains/subs.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
www
|
||||
api
|
||||
static
|
||||
9
selective-vpn-api/main.go
Normal file
9
selective-vpn-api/main.go
Normal 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()
|
||||
}
|
||||
460
selective-vpn-api/инструкция.txt
Normal file
460
selective-vpn-api/инструкция.txt
Normal 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, когда на самом деле всё ок.
|
||||
565
selective-vpn-api/инструкция2.txt
Normal file
565
selective-vpn-api/инструкция2.txt
Normal 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.16–31.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) – скинь их ещё раз, я аккуратно вставлю всё в нужные места без переименования функций и с теми же структурами, что уже есть.
|
||||
201
selective-vpn-api/инструкция3-safe.txt
Normal file
201
selective-vpn-api/инструкция3-safe.txt
Normal 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.
|
||||
252
selective-vpn-api/инструкция3.txt
Normal file
252
selective-vpn-api/инструкция3.txt
Normal 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).
|
||||
Reference in New Issue
Block a user