Compare commits
22 Commits
70c5eea935
...
0f88cfeeaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f88cfeeaa | ||
|
|
a7ec4fe801 | ||
|
|
0b28586f31 | ||
|
|
a5e93888a5 | ||
|
|
b617963034 | ||
|
|
dd367728f6 | ||
|
|
5eb292f17a | ||
|
|
69f3b2ef6a | ||
|
|
a7a6091b78 | ||
|
|
b39ff5f0d9 | ||
|
|
0c41a938af | ||
|
|
2b32427e5b | ||
|
|
8791f7f364 | ||
|
|
86a2f85155 | ||
|
|
ad994a04b1 | ||
|
|
defe88e324 | ||
|
|
a09464e98f | ||
|
|
6ab126251e | ||
|
|
3bb0f11ec5 | ||
|
|
f14dd7bc89 | ||
|
|
1a96e849bb | ||
|
|
b040b9e7d7 |
@@ -8,11 +8,13 @@ Key features:
|
||||
- Traffic modes: Selective (fwmark `0x66`), Full tunnel, Direct.
|
||||
- Auto-local bypass to keep LAN/docker reachable in Full tunnel.
|
||||
- Policy overrides: force VPN/Direct by source subnet, UID, or systemd cgroup.
|
||||
- Runtime per-app routing: launch an app in a `systemd --user` scope and apply a temporary cgroup-based mark.
|
||||
- Runtime per-app routing: launch an app in a `systemd --user` unit and apply a temporary cgroup-based mark (VPN/Direct).
|
||||
- Saved app profiles + desktop shortcuts: one-click launch for a profile (uses `selective-vpn-gui/svpn_run_profile.py`).
|
||||
|
||||
Repo layout:
|
||||
- `selective-vpn-api/` - Go backend API (localhost, default `127.0.0.1:8080`).
|
||||
- `selective-vpn-gui/` - PySide6 GUI (`vpn_dashboard_qt.py`).
|
||||
- `selective-vpn-gui/svpn_run_profile.py` - headless launcher used by profile shortcuts.
|
||||
|
||||
Requirements (high level):
|
||||
- Linux with `systemd`, `nftables`, `iproute2`, cgroup v2.
|
||||
|
||||
@@ -12,11 +12,12 @@ import "embed"
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
stateDir = "/var/lib/selective-vpn"
|
||||
statusFilePath = stateDir + "/status.json"
|
||||
dnsModePath = stateDir + "/dns-mode.json"
|
||||
trafficModePath = stateDir + "/traffic-mode.json"
|
||||
trafficAppMarksPath = stateDir + "/traffic-appmarks.json"
|
||||
stateDir = "/var/lib/selective-vpn"
|
||||
statusFilePath = stateDir + "/status.json"
|
||||
dnsModePath = stateDir + "/dns-mode.json"
|
||||
trafficModePath = stateDir + "/traffic-mode.json"
|
||||
trafficAppMarksPath = stateDir + "/traffic-appmarks.json"
|
||||
trafficAppProfilesPath = stateDir + "/traffic-app-profiles.json"
|
||||
|
||||
traceLogPath = stateDir + "/trace.log"
|
||||
smartdnsLogPath = stateDir + "/smartdns.log"
|
||||
@@ -30,11 +31,14 @@ const (
|
||||
routesCacheIPs = stateDir + "/routes-clear-cache-ips.txt"
|
||||
routesCacheDyn = stateDir + "/routes-clear-cache-ips-dyn.txt"
|
||||
routesCacheMap = stateDir + "/routes-clear-cache-ips-map.txt"
|
||||
routesCacheMapD = stateDir + "/routes-clear-cache-ips-map-direct.txt"
|
||||
routesCacheMapW = stateDir + "/routes-clear-cache-ips-map-wildcard.txt"
|
||||
routesCacheRT = stateDir + "/routes-clear-cache-routes.txt"
|
||||
|
||||
autoloopLogPath = stateDir + "/adguard-autoloop.log"
|
||||
loginStatePath = stateDir + "/adguard-login.json"
|
||||
dnsUpstreamsPath = stateDir + "/dns-upstreams.json"
|
||||
dnsUpstreamPool = stateDir + "/dns-upstream-pool.json"
|
||||
smartdnsWLPath = stateDir + "/smartdns-wildcards.json"
|
||||
smartdnsRTPath = stateDir + "/smartdns-runtime.json"
|
||||
desiredLocation = stateDir + "/adguard-location.txt"
|
||||
@@ -63,6 +67,7 @@ const (
|
||||
// RU: Дополнительные метки для per-app маршрутизации (systemd scope / cgroup).
|
||||
MARK_DIRECT = "0x67" // force direct (bypass VPN table even in full tunnel)
|
||||
MARK_APP = "0x68" // force VPN for app-scoped traffic (works even in traffic-mode=direct)
|
||||
MARK_INGRESS = "0x69" // keep ingress reply-path direct in full tunnel (server-safe)
|
||||
defaultDNS1 = "94.140.14.14"
|
||||
defaultDNS2 = "94.140.15.15"
|
||||
defaultMeta1 = "46.243.231.30"
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -55,6 +56,30 @@ func handleDNSUpstreams(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleDNSUpstreamPool(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
items := loadDNSUpstreamPoolState()
|
||||
writeJSON(w, http.StatusOK, DNSUpstreamPoolState{Items: items})
|
||||
case http.MethodPost:
|
||||
var body DNSUpstreamPoolState
|
||||
if r.Body != nil {
|
||||
defer r.Body.Close()
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := saveDNSUpstreamPoolState(body.Items); err != nil {
|
||||
http.Error(w, "write error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, DNSUpstreamPoolState{Items: loadDNSUpstreamPoolState()})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// EN: `handleDNSStatus` is an HTTP handler for dns status.
|
||||
// RU: `handleDNSStatus` - HTTP-обработчик для dns status.
|
||||
@@ -68,6 +93,341 @@ func handleDNSStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, makeDNSStatusResponse(mode))
|
||||
}
|
||||
|
||||
var dnsBenchmarkDefaultDomains = []string{
|
||||
"cloudflare.com",
|
||||
"google.com",
|
||||
"telegram.org",
|
||||
"github.com",
|
||||
"youtube.com",
|
||||
"twitter.com",
|
||||
}
|
||||
|
||||
func handleDNSBenchmark(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req DNSBenchmarkRequest
|
||||
if r.Body != nil {
|
||||
defer r.Body.Close()
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && err != io.EOF {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
upstreams := normalizeBenchmarkUpstreams(req.Upstreams)
|
||||
if len(upstreams) == 0 {
|
||||
pool := loadDNSUpstreamPoolState()
|
||||
if len(pool) > 0 {
|
||||
tmp := make([]DNSBenchmarkUpstream, 0, len(pool))
|
||||
for _, item := range pool {
|
||||
tmp = append(tmp, DNSBenchmarkUpstream{Addr: item.Addr, Enabled: item.Enabled})
|
||||
}
|
||||
upstreams = normalizeBenchmarkUpstreams(tmp)
|
||||
}
|
||||
if len(upstreams) == 0 {
|
||||
cfg := loadDNSUpstreamsConf()
|
||||
upstreams = normalizeBenchmarkUpstreamStrings([]string{
|
||||
cfg.Default1,
|
||||
cfg.Default2,
|
||||
cfg.Meta1,
|
||||
cfg.Meta2,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(upstreams) == 0 {
|
||||
http.Error(w, "no upstreams", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
domains := normalizeBenchmarkDomains(req.Domains)
|
||||
if len(domains) == 0 {
|
||||
domains = append(domains, dnsBenchmarkDefaultDomains...)
|
||||
}
|
||||
|
||||
timeoutMS := req.TimeoutMS
|
||||
if timeoutMS <= 0 {
|
||||
timeoutMS = 1800
|
||||
}
|
||||
if timeoutMS < 300 {
|
||||
timeoutMS = 300
|
||||
}
|
||||
if timeoutMS > 5000 {
|
||||
timeoutMS = 5000
|
||||
}
|
||||
|
||||
attempts := req.Attempts
|
||||
if attempts <= 0 {
|
||||
attempts = 1
|
||||
}
|
||||
if attempts > 3 {
|
||||
attempts = 3
|
||||
}
|
||||
|
||||
concurrency := req.Concurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = 6
|
||||
}
|
||||
if concurrency < 1 {
|
||||
concurrency = 1
|
||||
}
|
||||
if concurrency > 32 {
|
||||
concurrency = 32
|
||||
}
|
||||
if concurrency > len(upstreams) {
|
||||
concurrency = len(upstreams)
|
||||
}
|
||||
|
||||
results := make([]DNSBenchmarkResult, 0, len(upstreams))
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, concurrency)
|
||||
timeout := time.Duration(timeoutMS) * time.Millisecond
|
||||
|
||||
for _, upstream := range upstreams {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(upstream string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
result := benchmarkDNSUpstream(upstream, domains, timeout, attempts)
|
||||
mu.Lock()
|
||||
results = append(results, result)
|
||||
mu.Unlock()
|
||||
}(upstream)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
if results[i].Score == results[j].Score {
|
||||
if results[i].AvgMS == results[j].AvgMS {
|
||||
if results[i].OK == results[j].OK {
|
||||
return results[i].Upstream < results[j].Upstream
|
||||
}
|
||||
return results[i].OK > results[j].OK
|
||||
}
|
||||
return results[i].AvgMS < results[j].AvgMS
|
||||
}
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
resp := DNSBenchmarkResponse{
|
||||
Results: results,
|
||||
DomainsUsed: domains,
|
||||
TimeoutMS: timeoutMS,
|
||||
AttemptsPerDomain: attempts,
|
||||
}
|
||||
resp.RecommendedDefault = benchmarkTopN(results, 2, upstreams)
|
||||
resp.RecommendedMeta = benchmarkTopN(results, 2, upstreams)
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func normalizeBenchmarkUpstreams(in []DNSBenchmarkUpstream) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range in {
|
||||
n := normalizeDNSUpstream(item.Addr, "53")
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeBenchmarkUpstreamStrings(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range in {
|
||||
n := normalizeDNSUpstream(raw, "53")
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeBenchmarkDomains(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range in {
|
||||
d := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")
|
||||
if d == "" || strings.HasPrefix(d, "#") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[d]; ok {
|
||||
continue
|
||||
}
|
||||
seen[d] = struct{}{}
|
||||
out = append(out, d)
|
||||
}
|
||||
if len(out) > 100 {
|
||||
out = out[:100]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func benchmarkDNSUpstream(upstream string, domains []string, timeout time.Duration, attempts int) DNSBenchmarkResult {
|
||||
res := DNSBenchmarkResult{Upstream: upstream}
|
||||
durations := make([]int, 0, len(domains)*attempts)
|
||||
|
||||
for _, host := range domains {
|
||||
for i := 0; i < attempts; i++ {
|
||||
start := time.Now()
|
||||
_, err := dnsLookupAOnce(host, upstream, timeout)
|
||||
elapsed := int(time.Since(start).Milliseconds())
|
||||
if elapsed < 1 {
|
||||
elapsed = 1
|
||||
}
|
||||
res.Attempts++
|
||||
if err != nil {
|
||||
res.Fail++
|
||||
switch classifyDNSError(err) {
|
||||
case dnsErrorNXDomain:
|
||||
res.NXDomain++
|
||||
case dnsErrorTimeout:
|
||||
res.Timeout++
|
||||
case dnsErrorTemporary:
|
||||
res.Temporary++
|
||||
default:
|
||||
res.Other++
|
||||
}
|
||||
continue
|
||||
}
|
||||
res.OK++
|
||||
durations = append(durations, elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
if len(durations) > 0 {
|
||||
sort.Ints(durations)
|
||||
sum := 0
|
||||
for _, d := range durations {
|
||||
sum += d
|
||||
}
|
||||
res.AvgMS = sum / len(durations)
|
||||
idx := int(float64(len(durations)-1) * 0.95)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
res.P95MS = durations[idx]
|
||||
}
|
||||
|
||||
total := res.Attempts
|
||||
if total > 0 {
|
||||
okRate := float64(res.OK) / float64(total)
|
||||
timeoutRate := float64(res.Timeout) / float64(total)
|
||||
nxRate := float64(res.NXDomain) / float64(total)
|
||||
avg := float64(res.AvgMS)
|
||||
if avg <= 0 {
|
||||
avg = float64(timeout.Milliseconds())
|
||||
}
|
||||
res.Score = okRate*100.0 - timeoutRate*45.0 - nxRate*12.0 - (avg / 30.0)
|
||||
}
|
||||
|
||||
timeoutRate := 0.0
|
||||
if res.Attempts > 0 {
|
||||
timeoutRate = float64(res.Timeout) / float64(res.Attempts)
|
||||
}
|
||||
switch {
|
||||
case res.OK == 0 || timeoutRate >= 0.15 || res.AvgMS > 400:
|
||||
res.Color = "red"
|
||||
case res.AvgMS < 200 && timeoutRate == 0:
|
||||
res.Color = "green"
|
||||
default:
|
||||
res.Color = "yellow"
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func dnsLookupAOnce(host string, upstream string, timeout time.Duration) ([]string, error) {
|
||||
server, port := splitDNS(upstream)
|
||||
if server == "" {
|
||||
return nil, fmt.Errorf("upstream empty")
|
||||
}
|
||||
if port == "" {
|
||||
port = "53"
|
||||
}
|
||||
addr := net.JoinHostPort(server, port)
|
||||
resolver := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
d := net.Dialer{}
|
||||
return d.DialContext(ctx, "udp", addr)
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
records, err := resolver.LookupHost(ctx, host)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(records))
|
||||
for _, ip := range records {
|
||||
if isPrivateIPv4(ip) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ip]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ip] = struct{}{}
|
||||
out = append(out, ip)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no public ips")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func benchmarkTopN(results []DNSBenchmarkResult, n int, fallback []string) []string {
|
||||
out := make([]string, 0, n)
|
||||
for _, item := range results {
|
||||
if item.OK <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, item.Upstream)
|
||||
if len(out) >= n {
|
||||
return out
|
||||
}
|
||||
}
|
||||
for _, item := range fallback {
|
||||
if len(out) >= n {
|
||||
break
|
||||
}
|
||||
dup := false
|
||||
for _, e := range out {
|
||||
if e == item {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// EN: `handleDNSModeSet` is an HTTP handler for dns mode set.
|
||||
// RU: `handleDNSModeSet` - HTTP-обработчик для dns mode set.
|
||||
@@ -513,11 +873,7 @@ func prewarmAggressiveFromEnv() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// EN: `loadDNSUpstreamsConf` loads dns upstreams conf from storage or config.
|
||||
// RU: `loadDNSUpstreamsConf` - загружает dns upstreams conf из хранилища или конфига.
|
||||
// ---------------------------------------------------------------------
|
||||
func loadDNSUpstreamsConf() DNSUpstreams {
|
||||
func loadDNSUpstreamsConfFile() DNSUpstreams {
|
||||
cfg := DNSUpstreams{
|
||||
Default1: defaultDNS1,
|
||||
Default2: defaultDNS2,
|
||||
@@ -574,11 +930,139 @@ func loadDNSUpstreamsConf() DNSUpstreams {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// EN: `saveDNSUpstreamsConf` saves dns upstreams conf to persistent storage.
|
||||
// RU: `saveDNSUpstreamsConf` - сохраняет dns upstreams conf в постоянное хранилище.
|
||||
// ---------------------------------------------------------------------
|
||||
func saveDNSUpstreamsConf(cfg DNSUpstreams) error {
|
||||
func normalizeDNSUpstreamPoolItems(items []DNSUpstreamPoolItem) []DNSUpstreamPoolItem {
|
||||
out := make([]DNSUpstreamPoolItem, 0, len(items))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range items {
|
||||
addr := normalizeDNSUpstream(item.Addr, "53")
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[addr]; ok {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
out = append(out, DNSUpstreamPoolItem{
|
||||
Addr: addr,
|
||||
Enabled: item.Enabled,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dnsUpstreamPoolFromLegacy(cfg DNSUpstreams) []DNSUpstreamPoolItem {
|
||||
raw := []string{cfg.Default1, cfg.Default2, cfg.Meta1, cfg.Meta2}
|
||||
out := make([]DNSUpstreamPoolItem, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
n := normalizeDNSUpstream(item, "53")
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, DNSUpstreamPoolItem{Addr: n, Enabled: true})
|
||||
}
|
||||
return normalizeDNSUpstreamPoolItems(out)
|
||||
}
|
||||
|
||||
func dnsUpstreamPoolToLegacy(items []DNSUpstreamPoolItem) DNSUpstreams {
|
||||
enabled := make([]string, 0, len(items))
|
||||
all := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
n := normalizeDNSUpstream(item.Addr, "53")
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
all = append(all, n)
|
||||
if item.Enabled {
|
||||
enabled = append(enabled, n)
|
||||
}
|
||||
}
|
||||
list := enabled
|
||||
if len(list) == 0 {
|
||||
list = all
|
||||
}
|
||||
if len(list) == 0 {
|
||||
list = []string{defaultDNS1, defaultDNS2, defaultMeta1, defaultMeta2}
|
||||
}
|
||||
pick := func(idx int, fallback string) string {
|
||||
if len(list) == 0 {
|
||||
return fallback
|
||||
}
|
||||
if idx < len(list) {
|
||||
return list[idx]
|
||||
}
|
||||
return list[idx%len(list)]
|
||||
}
|
||||
return DNSUpstreams{
|
||||
Default1: pick(0, defaultDNS1),
|
||||
Default2: pick(1, defaultDNS2),
|
||||
Meta1: pick(2, defaultMeta1),
|
||||
Meta2: pick(3, defaultMeta2),
|
||||
}
|
||||
}
|
||||
|
||||
func saveDNSUpstreamPoolFile(items []DNSUpstreamPoolItem) error {
|
||||
state := DNSUpstreamPoolState{Items: normalizeDNSUpstreamPoolItems(items)}
|
||||
if err := os.MkdirAll(filepath.Dir(dnsUpstreamPool), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dnsUpstreamPool + ".tmp"
|
||||
b, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dnsUpstreamPool)
|
||||
}
|
||||
|
||||
func loadDNSUpstreamPoolState() []DNSUpstreamPoolItem {
|
||||
data, err := os.ReadFile(dnsUpstreamPool)
|
||||
if err == nil {
|
||||
var st DNSUpstreamPoolState
|
||||
if json.Unmarshal(data, &st) == nil {
|
||||
items := normalizeDNSUpstreamPoolItems(st.Items)
|
||||
if len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
legacy := loadDNSUpstreamsConfFile()
|
||||
items := dnsUpstreamPoolFromLegacy(legacy)
|
||||
if len(items) > 0 {
|
||||
_ = saveDNSUpstreamPoolFile(items)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func saveDNSUpstreamPoolState(items []DNSUpstreamPoolItem) error {
|
||||
items = normalizeDNSUpstreamPoolItems(items)
|
||||
if len(items) == 0 {
|
||||
items = dnsUpstreamPoolFromLegacy(loadDNSUpstreamsConfFile())
|
||||
}
|
||||
if err := saveDNSUpstreamPoolFile(items); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveDNSUpstreamsConfFile(dnsUpstreamPoolToLegacy(items))
|
||||
}
|
||||
|
||||
func loadEnabledDNSUpstreamPool() []string {
|
||||
items := loadDNSUpstreamPoolState()
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !item.Enabled {
|
||||
continue
|
||||
}
|
||||
n := normalizeDNSUpstream(item.Addr, "53")
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return uniqueStrings(out)
|
||||
}
|
||||
|
||||
func saveDNSUpstreamsConfFile(cfg DNSUpstreams) error {
|
||||
cfg.Default1 = normalizeDNSUpstream(cfg.Default1, "53")
|
||||
cfg.Default2 = normalizeDNSUpstream(cfg.Default2, "53")
|
||||
cfg.Meta1 = normalizeDNSUpstream(cfg.Meta1, "53")
|
||||
@@ -618,10 +1102,32 @@ func saveDNSUpstreamsConf(cfg DNSUpstreams) error {
|
||||
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
|
||||
_ = os.WriteFile(dnsUpstreamsPath, b, 0o644)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// EN: `loadDNSUpstreamsConf` loads dns upstreams conf from storage or config.
|
||||
// RU: `loadDNSUpstreamsConf` - загружает dns upstreams conf из хранилища или конфига.
|
||||
// ---------------------------------------------------------------------
|
||||
func loadDNSUpstreamsConf() DNSUpstreams {
|
||||
pool := loadDNSUpstreamPoolState()
|
||||
if len(pool) > 0 {
|
||||
return dnsUpstreamPoolToLegacy(pool)
|
||||
}
|
||||
return loadDNSUpstreamsConfFile()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// EN: `saveDNSUpstreamsConf` saves dns upstreams conf to persistent storage.
|
||||
// RU: `saveDNSUpstreamsConf` - сохраняет dns upstreams conf в постоянное хранилище.
|
||||
// ---------------------------------------------------------------------
|
||||
func saveDNSUpstreamsConf(cfg DNSUpstreams) error {
|
||||
if err := saveDNSUpstreamsConfFile(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveDNSUpstreamPoolFile(dnsUpstreamPoolFromLegacy(cfg))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// EN: `loadDNSMode` loads dns mode from storage or config.
|
||||
// RU: `loadDNSMode` - загружает dns mode из хранилища или конфига.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -140,6 +141,10 @@ type wildcardMatcher struct {
|
||||
suffix []string
|
||||
}
|
||||
|
||||
// Empty by default: primary resolver pool comes from DNS upstream pool state.
|
||||
// Optional fallback list can still be provided via RESOLVE_DNS_FALLBACKS env.
|
||||
var resolverFallbackDNS []string
|
||||
|
||||
func normalizeWildcardDomain(raw string) string {
|
||||
d := strings.TrimSpace(strings.SplitN(raw, "#", 2)[0])
|
||||
d = strings.ToLower(d)
|
||||
@@ -248,6 +253,14 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
|
||||
if workers > 500 {
|
||||
workers = 500
|
||||
}
|
||||
dnsTimeoutMs := envInt("RESOLVE_DNS_TIMEOUT_MS", 1800)
|
||||
if dnsTimeoutMs < 300 {
|
||||
dnsTimeoutMs = 300
|
||||
}
|
||||
if dnsTimeoutMs > 5000 {
|
||||
dnsTimeoutMs = 5000
|
||||
}
|
||||
dnsTimeout := time.Duration(dnsTimeoutMs) * time.Millisecond
|
||||
|
||||
domainCache := loadDomainCacheState(opts.CachePath, logf)
|
||||
ptrCache := loadJSONMap(opts.PtrCachePath)
|
||||
@@ -266,7 +279,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
|
||||
}
|
||||
|
||||
if logf != nil {
|
||||
logf("resolver start: domains=%d ttl=%ds workers=%d", len(domains), ttl, workers)
|
||||
logf("resolver start: domains=%d ttl=%ds workers=%d dns_timeout_ms=%d", len(domains), ttl, workers, dnsTimeoutMs)
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
@@ -309,7 +322,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
for j := range jobs {
|
||||
ips, stats := resolveHostGo(j.host, cfg, metaSpecial, wildcards, logf)
|
||||
ips, stats := resolveHostGo(j.host, cfg, metaSpecial, wildcards, dnsTimeout, logf)
|
||||
results <- struct {
|
||||
host string
|
||||
ips []string
|
||||
@@ -462,7 +475,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
|
||||
// DNS resolve helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards wildcardMatcher, logf func(string, ...any)) ([]string, dnsMetrics) {
|
||||
func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards wildcardMatcher, timeout time.Duration, logf func(string, ...any)) ([]string, dnsMetrics) {
|
||||
useMeta := false
|
||||
for _, m := range metaSpecial {
|
||||
if host == m {
|
||||
@@ -484,7 +497,7 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w
|
||||
dnsList = []string{cfg.SmartDNS}
|
||||
}
|
||||
}
|
||||
ips, stats := digA(host, dnsList, 3*time.Second, logf)
|
||||
ips, stats := digA(host, dnsList, timeout, logf)
|
||||
out := []string{}
|
||||
seen := map[string]struct{}{}
|
||||
for _, ip := range ips {
|
||||
@@ -504,9 +517,22 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w
|
||||
// RU: `digA` - содержит основную логику для dig a.
|
||||
// ---------------------------------------------------------------------
|
||||
func digA(host string, dnsList []string, timeout time.Duration, logf func(string, ...any)) ([]string, dnsMetrics) {
|
||||
var ips []string
|
||||
stats := dnsMetrics{}
|
||||
for _, entry := range dnsList {
|
||||
if len(dnsList) == 0 {
|
||||
return nil, stats
|
||||
}
|
||||
|
||||
tryLimit := envInt("RESOLVE_DNS_TRY_LIMIT", 2)
|
||||
if tryLimit < 1 {
|
||||
tryLimit = 1
|
||||
}
|
||||
if tryLimit > len(dnsList) {
|
||||
tryLimit = len(dnsList)
|
||||
}
|
||||
|
||||
start := pickDNSStartIndex(host, len(dnsList))
|
||||
for attempt := 0; attempt < tryLimit; attempt++ {
|
||||
entry := dnsList[(start+attempt)%len(dnsList)]
|
||||
server, port := splitDNS(entry)
|
||||
if server == "" {
|
||||
continue
|
||||
@@ -533,15 +559,24 @@ func digA(host string, dnsList []string, timeout time.Duration, logf func(string
|
||||
}
|
||||
continue
|
||||
}
|
||||
stats.addSuccess(addr)
|
||||
var ips []string
|
||||
for _, ip := range records {
|
||||
if isPrivateIPv4(ip) {
|
||||
continue
|
||||
}
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
stats.addError(addr, dnsErrorOther)
|
||||
if logf != nil {
|
||||
logf("dns warn %s via %s: kind=other err=no_public_ips", host, addr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
stats.addSuccess(addr)
|
||||
return uniqueStrings(ips), stats
|
||||
}
|
||||
return uniqueStrings(ips), stats
|
||||
return nil, stats
|
||||
}
|
||||
|
||||
func classifyDNSError(err error) dnsErrorKind {
|
||||
@@ -998,6 +1033,11 @@ func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig {
|
||||
SmartDNS: smartDNSAddr(),
|
||||
Mode: DNSModeDirect,
|
||||
}
|
||||
activePool := loadEnabledDNSUpstreamPool()
|
||||
if len(activePool) > 0 {
|
||||
cfg.Default = activePool
|
||||
cfg.Meta = activePool
|
||||
}
|
||||
|
||||
// 1) Если форсируем SmartDNS — вообще игнорим файл и ходим только через локальный резолвер.
|
||||
if smartDNSForced() {
|
||||
@@ -1013,12 +1053,14 @@ func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// 2) Иначе пытаемся прочитать dns-upstreams.conf, как и раньше.
|
||||
// 2) Читаем dns-upstreams.conf для legacy-совместимости и smartdns/mode значений.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if logf != nil {
|
||||
logf("dns-config: use built-in defaults, can't read %s: %v", path, err)
|
||||
logf("dns-config: can't read %s: %v", path, err)
|
||||
}
|
||||
cfg.Default = mergeDNSUpstreamPools(cfg.Default, resolverFallbackPool())
|
||||
cfg.Meta = mergeDNSUpstreamPools(cfg.Meta, resolverFallbackPool())
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -1060,12 +1102,16 @@ func loadDNSConfig(path string, logf func(string, ...any)) dnsConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(def) > 0 {
|
||||
cfg.Default = def
|
||||
}
|
||||
if len(meta) > 0 {
|
||||
cfg.Meta = meta
|
||||
if len(activePool) == 0 {
|
||||
if len(def) > 0 {
|
||||
cfg.Default = def
|
||||
}
|
||||
if len(meta) > 0 {
|
||||
cfg.Meta = meta
|
||||
}
|
||||
}
|
||||
cfg.Default = mergeDNSUpstreamPools(cfg.Default, resolverFallbackPool())
|
||||
cfg.Meta = mergeDNSUpstreamPools(cfg.Meta, resolverFallbackPool())
|
||||
if logf != nil {
|
||||
logf("dns-config: accept %s: mode=%s smartdns=%s default=%v; meta=%v", path, cfg.Mode, cfg.SmartDNS, cfg.Default, cfg.Meta)
|
||||
}
|
||||
@@ -1130,6 +1176,65 @@ func uniqueStrings(in []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func pickDNSStartIndex(host string, size int) int {
|
||||
if size <= 1 {
|
||||
return 0
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(strings.ToLower(strings.TrimSpace(host))))
|
||||
return int(h.Sum32() % uint32(size))
|
||||
}
|
||||
|
||||
func resolverFallbackPool() []string {
|
||||
raw := strings.TrimSpace(os.Getenv("RESOLVE_DNS_FALLBACKS"))
|
||||
switch strings.ToLower(raw) {
|
||||
case "off", "none", "0":
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates := resolverFallbackDNS
|
||||
if raw != "" {
|
||||
candidates = nil
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t'
|
||||
})
|
||||
for _, f := range fields {
|
||||
if n := normalizeDNSUpstream(f, "53"); n != "" {
|
||||
candidates = append(candidates, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqueStrings(candidates)
|
||||
}
|
||||
|
||||
func mergeDNSUpstreamPools(primary, fallback []string) []string {
|
||||
maxUpstreams := envInt("RESOLVE_DNS_MAX_UPSTREAMS", 12)
|
||||
if maxUpstreams < 1 {
|
||||
maxUpstreams = 1
|
||||
}
|
||||
out := make([]string, 0, len(primary)+len(fallback))
|
||||
seen := map[string]struct{}{}
|
||||
add := func(items []string) {
|
||||
for _, item := range items {
|
||||
if len(out) >= maxUpstreams {
|
||||
return
|
||||
}
|
||||
n := normalizeDNSUpstream(item, "53")
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
add(primary)
|
||||
add(fallback)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// text cleanup + IP classifiers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -93,6 +93,12 @@ func Run() {
|
||||
defer cancel()
|
||||
|
||||
ensureSeeds()
|
||||
if err := ensureAppMarksNft(); err != nil {
|
||||
log.Printf("traffic appmarks nft init warning: %v", err)
|
||||
}
|
||||
if err := restoreAppMarksFromState(); err != nil {
|
||||
log.Printf("traffic appmarks restore warning: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -144,10 +150,17 @@ func Run() {
|
||||
mux.HandleFunc("/api/v1/routes/fix-policy", handleFixPolicyRoute)
|
||||
mux.HandleFunc("/api/v1/traffic/mode", handleTrafficMode)
|
||||
mux.HandleFunc("/api/v1/traffic/mode/test", handleTrafficModeTest)
|
||||
mux.HandleFunc("/api/v1/traffic/advanced/reset", handleTrafficAdvancedReset)
|
||||
mux.HandleFunc("/api/v1/traffic/interfaces", handleTrafficInterfaces)
|
||||
mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates)
|
||||
// per-app runtime marks (systemd scope / cgroup -> fwmark)
|
||||
mux.HandleFunc("/api/v1/traffic/appmarks", handleTrafficAppMarks)
|
||||
// list runtime marks items (for UI)
|
||||
mux.HandleFunc("/api/v1/traffic/appmarks/items", handleTrafficAppMarksItems)
|
||||
// persistent app profiles (saved launch configs)
|
||||
mux.HandleFunc("/api/v1/traffic/app-profiles", handleTrafficAppProfiles)
|
||||
// traffic audit (sanity checks / duplicates / nft consistency)
|
||||
mux.HandleFunc("/api/v1/traffic/audit", handleTrafficAudit)
|
||||
|
||||
// trace: хвост + JSON + append для GUI
|
||||
mux.HandleFunc("/api/v1/trace", handleTraceTailPlain)
|
||||
@@ -156,8 +169,10 @@ func Run() {
|
||||
|
||||
// DNS upstreams
|
||||
mux.HandleFunc("/api/v1/dns-upstreams", handleDNSUpstreams)
|
||||
mux.HandleFunc("/api/v1/dns/upstream-pool", handleDNSUpstreamPool)
|
||||
mux.HandleFunc("/api/v1/dns/status", handleDNSStatus)
|
||||
mux.HandleFunc("/api/v1/dns/mode", handleDNSModeSet)
|
||||
mux.HandleFunc("/api/v1/dns/benchmark", handleDNSBenchmark)
|
||||
mux.HandleFunc("/api/v1/dns/smartdns-service", handleDNSSmartdnsService)
|
||||
|
||||
// SmartDNS service
|
||||
|
||||
321
selective-vpn-api/app/traffic_app_profiles.go
Normal file
321
selective-vpn-api/app/traffic_app_profiles.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// traffic app profiles (persistent app configs)
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// EN: App profiles are persistent configs that describe *what* to launch and
|
||||
// EN: how to route it. They are separate from runtime marks, because runtime
|
||||
// EN: marks are tied to a конкретный systemd unit/cgroup.
|
||||
// RU: App profiles - это постоянные конфиги, которые описывают *что* запускать
|
||||
// RU: и как маршрутизировать. Они отдельно от runtime marks, потому что marks
|
||||
// RU: привязаны к конкретному systemd unit/cgroup.
|
||||
|
||||
const (
|
||||
trafficAppProfilesDefaultTTLSec = 24 * 60 * 60
|
||||
)
|
||||
|
||||
var trafficAppProfilesMu sync.Mutex
|
||||
|
||||
type trafficAppProfilesState struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Profiles []TrafficAppProfile `json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
func handleTrafficAppProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
profiles := listTrafficAppProfiles()
|
||||
if profiles == nil {
|
||||
profiles = []TrafficAppProfile{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: profiles, Message: "ok"})
|
||||
case http.MethodPost:
|
||||
var body TrafficAppProfileUpsertRequest
|
||||
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
|
||||
}
|
||||
}
|
||||
prof, err := upsertTrafficAppProfile(body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: nil, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
events.push("traffic_profiles_changed", map[string]any{"id": prof.ID, "target": prof.Target})
|
||||
writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: []TrafficAppProfile{prof}, Message: "saved"})
|
||||
case http.MethodDelete:
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
http.Error(w, "missing id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ok, msg := deleteTrafficAppProfile(id)
|
||||
events.push("traffic_profiles_changed", map[string]any{"id": id, "deleted": ok})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "message": msg})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func listTrafficAppProfiles() []TrafficAppProfile {
|
||||
trafficAppProfilesMu.Lock()
|
||||
defer trafficAppProfilesMu.Unlock()
|
||||
|
||||
st := loadTrafficAppProfilesState()
|
||||
out := append([]TrafficAppProfile(nil), st.Profiles...)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
// Newest first.
|
||||
return out[i].UpdatedAt > out[j].UpdatedAt
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func upsertTrafficAppProfile(req TrafficAppProfileUpsertRequest) (TrafficAppProfile, error) {
|
||||
trafficAppProfilesMu.Lock()
|
||||
defer trafficAppProfilesMu.Unlock()
|
||||
|
||||
st := loadTrafficAppProfilesState()
|
||||
|
||||
target := strings.ToLower(strings.TrimSpace(req.Target))
|
||||
if target == "" {
|
||||
target = "vpn"
|
||||
}
|
||||
if target != "vpn" && target != "direct" {
|
||||
return TrafficAppProfile{}, fmt.Errorf("target must be vpn|direct")
|
||||
}
|
||||
|
||||
cmd := strings.TrimSpace(req.Command)
|
||||
if cmd == "" {
|
||||
return TrafficAppProfile{}, fmt.Errorf("missing command")
|
||||
}
|
||||
|
||||
appKey := strings.TrimSpace(req.AppKey)
|
||||
if appKey == "" {
|
||||
fields := strings.Fields(cmd)
|
||||
if len(fields) > 0 {
|
||||
appKey = strings.TrimSpace(fields[0])
|
||||
}
|
||||
}
|
||||
appKey = canonicalizeAppKey(appKey, cmd)
|
||||
if appKey == "" {
|
||||
return TrafficAppProfile{}, fmt.Errorf("cannot infer app_key")
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(req.ID)
|
||||
if id == "" {
|
||||
// If profile for same app_key+target exists, update it.
|
||||
for _, p := range st.Profiles {
|
||||
if strings.TrimSpace(p.AppKey) == appKey && strings.ToLower(strings.TrimSpace(p.Target)) == target {
|
||||
id = strings.TrimSpace(p.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
id = deriveTrafficAppProfileID(appKey, target, st.Profiles)
|
||||
}
|
||||
if id == "" {
|
||||
return TrafficAppProfile{}, fmt.Errorf("cannot derive profile id")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = filepath.Base(appKey)
|
||||
if name == "" || name == "/" || name == "." {
|
||||
name = id
|
||||
}
|
||||
}
|
||||
|
||||
ttl := req.TTLSec
|
||||
if ttl <= 0 {
|
||||
ttl = trafficAppProfilesDefaultTTLSec
|
||||
}
|
||||
|
||||
vpnProfile := strings.TrimSpace(req.VPNProfile)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
prof := TrafficAppProfile{
|
||||
ID: id,
|
||||
Name: name,
|
||||
AppKey: appKey,
|
||||
Command: cmd,
|
||||
Target: target,
|
||||
TTLSec: ttl,
|
||||
VPNProfile: vpnProfile,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Upsert.
|
||||
updated := false
|
||||
for i := range st.Profiles {
|
||||
if strings.TrimSpace(st.Profiles[i].ID) != id {
|
||||
continue
|
||||
}
|
||||
// Keep created_at stable.
|
||||
prof.CreatedAt = strings.TrimSpace(st.Profiles[i].CreatedAt)
|
||||
if prof.CreatedAt == "" {
|
||||
prof.CreatedAt = now
|
||||
}
|
||||
st.Profiles[i] = prof
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
if !updated {
|
||||
prof.CreatedAt = now
|
||||
st.Profiles = append(st.Profiles, prof)
|
||||
}
|
||||
|
||||
if err := saveTrafficAppProfilesState(st); err != nil {
|
||||
return TrafficAppProfile{}, err
|
||||
}
|
||||
return prof, nil
|
||||
}
|
||||
|
||||
func deleteTrafficAppProfile(id string) (bool, string) {
|
||||
trafficAppProfilesMu.Lock()
|
||||
defer trafficAppProfilesMu.Unlock()
|
||||
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return false, "empty id"
|
||||
}
|
||||
|
||||
st := loadTrafficAppProfilesState()
|
||||
kept := st.Profiles[:0]
|
||||
found := false
|
||||
for _, p := range st.Profiles {
|
||||
if strings.TrimSpace(p.ID) == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
st.Profiles = kept
|
||||
|
||||
if !found {
|
||||
return true, "not found"
|
||||
}
|
||||
if err := saveTrafficAppProfilesState(st); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return true, "deleted"
|
||||
}
|
||||
|
||||
func deriveTrafficAppProfileID(appKey string, target string, existing []TrafficAppProfile) string {
|
||||
base := filepath.Base(strings.TrimSpace(appKey))
|
||||
if base == "" || base == "/" || base == "." {
|
||||
base = "app"
|
||||
}
|
||||
base = sanitizeID(base)
|
||||
if base == "" {
|
||||
base = "app"
|
||||
}
|
||||
|
||||
idBase := base + "-" + strings.ToLower(strings.TrimSpace(target))
|
||||
id := idBase
|
||||
|
||||
used := map[string]struct{}{}
|
||||
for _, p := range existing {
|
||||
used[strings.TrimSpace(p.ID)] = struct{}{}
|
||||
}
|
||||
if _, ok := used[id]; !ok {
|
||||
return id
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
cand := fmt.Sprintf("%s-%d", idBase, i)
|
||||
if _, ok := used[cand]; !ok {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeID(s string) string {
|
||||
in := strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
b.Grow(len(in))
|
||||
lastDash := false
|
||||
for i := 0; i < len(in); i++ {
|
||||
ch := in[i]
|
||||
isAZ := ch >= 'a' && ch <= 'z'
|
||||
is09 := ch >= '0' && ch <= '9'
|
||||
if isAZ || is09 {
|
||||
b.WriteByte(ch)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
return out
|
||||
}
|
||||
|
||||
func loadTrafficAppProfilesState() trafficAppProfilesState {
|
||||
st := trafficAppProfilesState{Version: 1}
|
||||
data, err := os.ReadFile(trafficAppProfilesPath)
|
||||
if err != nil {
|
||||
return st
|
||||
}
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
return trafficAppProfilesState{Version: 1}
|
||||
}
|
||||
if st.Version == 0 {
|
||||
st.Version = 1
|
||||
}
|
||||
if st.Profiles == nil {
|
||||
st.Profiles = nil
|
||||
}
|
||||
|
||||
// EN: Best-effort migration: normalize app keys to canonical form.
|
||||
// RU: Best-effort миграция: нормализуем app_key в канонический вид.
|
||||
changed := false
|
||||
for i := range st.Profiles {
|
||||
canon := canonicalizeAppKey(st.Profiles[i].AppKey, st.Profiles[i].Command)
|
||||
if canon != "" && strings.TrimSpace(st.Profiles[i].AppKey) != canon {
|
||||
st.Profiles[i].AppKey = canon
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
_ = saveTrafficAppProfilesState(st)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func saveTrafficAppProfilesState(st trafficAppProfilesState) error {
|
||||
st.Version = 1
|
||||
st.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(trafficAppProfilesPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := trafficAppProfilesPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, trafficAppProfilesPath)
|
||||
}
|
||||
153
selective-vpn-api/app/traffic_appkey.go
Normal file
153
selective-vpn-api/app/traffic_appkey.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// traffic app key normalization
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// EN: app_key is used as a stable per-app identity for:
|
||||
// EN: - deduplicating runtime marks (avoid unbounded growth)
|
||||
// EN: - matching profiles <-> runtime marks in UI
|
||||
// EN:
|
||||
// EN: Raw command token[0] is not stable across launch methods:
|
||||
// EN: - "/usr/bin/google-chrome-stable" vs "google-chrome-stable"
|
||||
// EN: - "flatpak run org.mozilla.firefox" (token[0]="flatpak")
|
||||
// EN:
|
||||
// EN: We normalize app_key into a canonical form.
|
||||
// RU: app_key используется как стабильный идентификатор приложения для:
|
||||
// RU: - дедупликации runtime marks (не плодить бесконечно)
|
||||
// RU: - сопоставления profiles <-> runtime marks в UI
|
||||
// RU:
|
||||
// RU: token[0] команды нестабилен для разных способов запуска:
|
||||
// RU: - "/usr/bin/google-chrome-stable" vs "google-chrome-stable"
|
||||
// RU: - "flatpak run org.mozilla.firefox" (token[0]="flatpak")
|
||||
// RU:
|
||||
// RU: Нормализуем app_key в канонический вид.
|
||||
|
||||
func canonicalizeAppKey(appKey string, command string) string {
|
||||
key := strings.TrimSpace(appKey)
|
||||
cmd := strings.TrimSpace(command)
|
||||
|
||||
fields := strings.Fields(cmd)
|
||||
if len(fields) == 0 && key != "" {
|
||||
fields = []string{key}
|
||||
}
|
||||
|
||||
primary := key
|
||||
if len(fields) > 0 {
|
||||
primary = fields[0]
|
||||
}
|
||||
primary = stripOuterQuotes(strings.TrimSpace(primary))
|
||||
if primary == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Normalize common wrappers into stable identifiers.
|
||||
base := strings.ToLower(filepath.Base(primary))
|
||||
// Build a cleaned field list for wrapper parsing.
|
||||
clean := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
f = stripOuterQuotes(strings.TrimSpace(f))
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, f)
|
||||
}
|
||||
|
||||
switch base {
|
||||
case "flatpak":
|
||||
if id := extractRunTarget(clean); id != "" {
|
||||
return "flatpak:" + id
|
||||
}
|
||||
return "flatpak"
|
||||
case "snap":
|
||||
if name := extractRunTarget(clean); name != "" {
|
||||
return "snap:" + name
|
||||
}
|
||||
return "snap"
|
||||
case "gtk-launch":
|
||||
// gtk-launch <desktop-id>
|
||||
if len(clean) >= 2 {
|
||||
id := strings.TrimSpace(clean[1])
|
||||
if id != "" && !strings.HasPrefix(id, "-") {
|
||||
return "desktop:" + id
|
||||
}
|
||||
}
|
||||
case "env":
|
||||
// env VAR=1 /usr/bin/app ...
|
||||
// EN: Skip env flags and VAR=VAL assignments and re-canonicalize for the real command.
|
||||
// RU: Пропускаем флаги env и VAR=VAL и канонизируем по реальной команде.
|
||||
for i := 1; i < len(clean); i++ {
|
||||
tok := strings.TrimSpace(clean[i])
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(tok, "-") {
|
||||
continue
|
||||
}
|
||||
// VAR=VAL assignment
|
||||
if strings.Contains(tok, "=") {
|
||||
continue
|
||||
}
|
||||
return canonicalizeAppKey(tok, strings.Join(clean[i:], " "))
|
||||
}
|
||||
return "env"
|
||||
}
|
||||
|
||||
// If it looks like a path, canonicalize to basename.
|
||||
if strings.Contains(primary, "/") {
|
||||
b := filepath.Base(primary)
|
||||
if b != "" && b != "." && b != "/" {
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
return primary
|
||||
}
|
||||
|
||||
func stripOuterQuotes(s string) string {
|
||||
in := strings.TrimSpace(s)
|
||||
if len(in) >= 2 {
|
||||
if (in[0] == '"' && in[len(in)-1] == '"') || (in[0] == '\'' && in[len(in)-1] == '\'') {
|
||||
return strings.TrimSpace(in[1 : len(in)-1])
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// extractRunTarget finds the first non-flag token after "run".
|
||||
// Example: flatpak run --branch=stable org.mozilla.firefox => org.mozilla.firefox
|
||||
// Example: snap run chromium => chromium
|
||||
func extractRunTarget(fields []string) string {
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
idx := -1
|
||||
for i := 0; i < len(fields); i++ {
|
||||
if strings.TrimSpace(fields[i]) == "run" {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
for j := idx + 1; j < len(fields); j++ {
|
||||
tok := strings.TrimSpace(fields[j])
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
if tok == "--" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(tok, "-") {
|
||||
continue
|
||||
}
|
||||
return tok
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -238,6 +239,57 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleTrafficAppMarksItems(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
_ = pruneExpiredAppMarks()
|
||||
|
||||
appMarksMu.Lock()
|
||||
st := loadAppMarksState()
|
||||
appMarksMu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
items := make([]TrafficAppMarkItemView, 0, len(st.Items))
|
||||
for _, it := range st.Items {
|
||||
rem := 0
|
||||
exp, err := time.Parse(time.RFC3339, strings.TrimSpace(it.ExpiresAt))
|
||||
if err == nil {
|
||||
rem = int(exp.Sub(now).Seconds())
|
||||
if rem < 0 {
|
||||
rem = 0
|
||||
}
|
||||
}
|
||||
items = append(items, TrafficAppMarkItemView{
|
||||
ID: it.ID,
|
||||
Target: it.Target,
|
||||
Cgroup: it.Cgroup,
|
||||
CgroupRel: it.CgroupRel,
|
||||
Level: it.Level,
|
||||
Unit: it.Unit,
|
||||
Command: it.Command,
|
||||
AppKey: it.AppKey,
|
||||
AddedAt: it.AddedAt,
|
||||
ExpiresAt: it.ExpiresAt,
|
||||
RemainingSec: rem,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort: target -> app_key -> remaining desc.
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Target != items[j].Target {
|
||||
return items[i].Target < items[j].Target
|
||||
}
|
||||
if items[i].AppKey != items[j].AppKey {
|
||||
return items[i].AppKey < items[j].AppKey
|
||||
}
|
||||
return items[i].RemainingSec > items[j].RemainingSec
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, TrafficAppMarksItemsResponse{Items: items, Message: "ok"})
|
||||
}
|
||||
|
||||
func appMarksGetStatus() (vpnCount int, directCount int) {
|
||||
_ = pruneExpiredAppMarks()
|
||||
|
||||
@@ -279,7 +331,7 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
|
||||
|
||||
unit = strings.TrimSpace(unit)
|
||||
command = strings.TrimSpace(command)
|
||||
appKey = normalizeAppKey(appKey, command)
|
||||
appKey = canonicalizeAppKey(appKey, command)
|
||||
|
||||
// EN: Avoid unbounded growth of marks for the same app.
|
||||
// RU: Не даём бесконечно плодить метки на одно и то же приложение.
|
||||
@@ -622,6 +674,20 @@ func loadAppMarksState() appMarksState {
|
||||
if st.Version == 0 {
|
||||
st.Version = 1
|
||||
}
|
||||
|
||||
// EN: Best-effort migration: normalize app keys to canonical form.
|
||||
// RU: Best-effort миграция: нормализуем app_key в канонический вид.
|
||||
changed := false
|
||||
for i := range st.Items {
|
||||
canon := canonicalizeAppKey(st.Items[i].AppKey, st.Items[i].Command)
|
||||
if canon != "" && strings.TrimSpace(st.Items[i].AppKey) != canon {
|
||||
st.Items[i].AppKey = canon
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
_ = saveAppMarksState(st)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
@@ -643,22 +709,6 @@ func saveAppMarksState(st appMarksState) error {
|
||||
return os.Rename(tmp, trafficAppMarksPath)
|
||||
}
|
||||
|
||||
func normalizeAppKey(appKey string, command string) string {
|
||||
key := strings.TrimSpace(appKey)
|
||||
if key != "" {
|
||||
return key
|
||||
}
|
||||
cmd := strings.TrimSpace(command)
|
||||
if cmd == "" {
|
||||
return ""
|
||||
}
|
||||
fields := strings.Fields(cmd)
|
||||
if len(fields) > 0 {
|
||||
return strings.TrimSpace(fields[0])
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
|
||||
288
selective-vpn-api/app/traffic_audit.go
Normal file
288
selective-vpn-api/app/traffic_audit.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// traffic audit (sanity checks / duplicates / nft consistency)
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// EN: Provides a pragmatic sanity-check endpoint for troubleshooting.
|
||||
// EN: We check:
|
||||
// EN: - traffic mode health (evaluateTrafficMode)
|
||||
// EN: - runtime marks duplicates (target+app_key)
|
||||
// EN: - nft chain consistency (state <-> output_apps rules)
|
||||
// RU: Практичный sanity-check эндпоинт для диагностики.
|
||||
// RU: Проверяем:
|
||||
// RU: - health traffic mode (evaluateTrafficMode)
|
||||
// RU: - дубли runtime marks (target+app_key)
|
||||
// RU: - консистентность nft chain (state <-> output_apps rules)
|
||||
|
||||
func handleTrafficAudit(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// 1) Traffic mode status (includes route probe).
|
||||
traffic := evaluateTrafficMode(loadTrafficModeState())
|
||||
|
||||
// 2) Profiles (persistent) duplicates by (target, app_key).
|
||||
profiles := listTrafficAppProfiles()
|
||||
profDups := findProfileDuplicates(profiles)
|
||||
|
||||
// 3) Runtime marks state + duplicates.
|
||||
_ = pruneExpiredAppMarks()
|
||||
appMarksMu.Lock()
|
||||
marksSt := loadAppMarksState()
|
||||
appMarksMu.Unlock()
|
||||
|
||||
markDups := findMarkDuplicates(marksSt.Items)
|
||||
|
||||
// 4) nft output_apps rules check (state <-> nft).
|
||||
nftIssues, nftSummary := auditNftAppMarks(marksSt.Items)
|
||||
|
||||
issues := []string{}
|
||||
if !traffic.Healthy {
|
||||
issues = append(issues, "traffic_mode_unhealthy: "+strings.TrimSpace(traffic.Message))
|
||||
}
|
||||
for _, d := range profDups {
|
||||
issues = append(issues, "profile_duplicate: "+d)
|
||||
}
|
||||
for _, d := range markDups {
|
||||
issues = append(issues, "mark_duplicate: "+d)
|
||||
}
|
||||
issues = append(issues, nftIssues...)
|
||||
|
||||
ok := true
|
||||
for _, it := range issues {
|
||||
if strings.HasPrefix(it, "traffic_mode_unhealthy:") ||
|
||||
strings.HasPrefix(it, "nft_error:") ||
|
||||
strings.HasPrefix(it, "nft_missing_rule:") {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pretty := buildTrafficAuditPretty(now, traffic, profiles, marksSt.Items, issues, nftSummary)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": ok,
|
||||
"now": now,
|
||||
"message": "ok",
|
||||
"pretty": pretty,
|
||||
"traffic": traffic,
|
||||
"counts": map[string]any{
|
||||
"profiles": len(profiles),
|
||||
"marks": len(marksSt.Items),
|
||||
},
|
||||
"issues": issues,
|
||||
"nft": nftSummary,
|
||||
})
|
||||
}
|
||||
|
||||
func findProfileDuplicates(profiles []TrafficAppProfile) []string {
|
||||
seen := map[string]int{}
|
||||
for _, p := range profiles {
|
||||
tgt := strings.ToLower(strings.TrimSpace(p.Target))
|
||||
key := strings.TrimSpace(p.AppKey)
|
||||
if tgt == "" || key == "" {
|
||||
continue
|
||||
}
|
||||
seen[tgt+"|"+key]++
|
||||
}
|
||||
var out []string
|
||||
for k, n := range seen {
|
||||
if n > 1 {
|
||||
out = append(out, fmt.Sprintf("%s x%d", k, n))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func findMarkDuplicates(items []appMarkItem) []string {
|
||||
seen := map[string]int{}
|
||||
for _, it := range items {
|
||||
tgt := strings.ToLower(strings.TrimSpace(it.Target))
|
||||
key := strings.TrimSpace(it.AppKey)
|
||||
if tgt == "" || key == "" {
|
||||
continue
|
||||
}
|
||||
seen[tgt+"|"+key]++
|
||||
}
|
||||
var out []string
|
||||
for k, n := range seen {
|
||||
if n > 1 {
|
||||
out = append(out, fmt.Sprintf("%s x%d", k, n))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func auditNftAppMarks(state []appMarkItem) (issues []string, summary map[string]any) {
|
||||
summary = map[string]any{
|
||||
"output_jump_ok": false,
|
||||
"output_apps_ok": false,
|
||||
"state_items": len(state),
|
||||
"nft_rules": 0,
|
||||
"missing_rules": 0,
|
||||
"orphan_rules": 0,
|
||||
"missing_rule_ids": []string{},
|
||||
"orphan_rule_ids": []string{},
|
||||
}
|
||||
|
||||
// Check output -> jump output_apps.
|
||||
outOutput, _, codeOut, errOut := runCommandTimeout(3*time.Second, "nft", "list", "chain", "inet", appMarksTable, "output")
|
||||
if errOut != nil || codeOut != 0 {
|
||||
issues = append(issues, "nft_error: failed to read chain output")
|
||||
} else {
|
||||
ok := strings.Contains(outOutput, "jump "+appMarksChain)
|
||||
summary["output_jump_ok"] = ok
|
||||
if !ok {
|
||||
issues = append(issues, "nft_missing_jump: output -> output_apps")
|
||||
}
|
||||
}
|
||||
|
||||
outApps, _, codeApps, errApps := runCommandTimeout(3*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain)
|
||||
if errApps != nil || codeApps != 0 {
|
||||
issues = append(issues, "nft_error: failed to read chain output_apps")
|
||||
return issues, summary
|
||||
}
|
||||
summary["output_apps_ok"] = true
|
||||
|
||||
rules := parseAppMarkRules(outApps)
|
||||
summary["nft_rules"] = len(rules)
|
||||
|
||||
stateIDs := map[string]struct{}{}
|
||||
for _, it := range state {
|
||||
tgt := strings.ToLower(strings.TrimSpace(it.Target))
|
||||
if tgt != "vpn" && tgt != "direct" {
|
||||
continue
|
||||
}
|
||||
if it.ID == 0 {
|
||||
continue
|
||||
}
|
||||
stateIDs[fmt.Sprintf("%s:%d", tgt, it.ID)] = struct{}{}
|
||||
}
|
||||
|
||||
ruleIDs := map[string]struct{}{}
|
||||
for _, k := range rules {
|
||||
ruleIDs[k] = struct{}{}
|
||||
}
|
||||
|
||||
missing := []string{}
|
||||
for k := range stateIDs {
|
||||
if _, ok := ruleIDs[k]; !ok {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
}
|
||||
orphan := []string{}
|
||||
for k := range ruleIDs {
|
||||
if _, ok := stateIDs[k]; !ok {
|
||||
orphan = append(orphan, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(orphan)
|
||||
|
||||
summary["missing_rules"] = len(missing)
|
||||
summary["orphan_rules"] = len(orphan)
|
||||
summary["missing_rule_ids"] = missing
|
||||
summary["orphan_rule_ids"] = orphan
|
||||
|
||||
for _, k := range missing {
|
||||
issues = append(issues, "nft_missing_rule: "+k)
|
||||
}
|
||||
for _, k := range orphan {
|
||||
issues = append(issues, "nft_orphan_rule: "+k)
|
||||
}
|
||||
|
||||
return issues, summary
|
||||
}
|
||||
|
||||
// parseAppMarkRules extracts "target:id" keys from output_apps chain dump.
|
||||
func parseAppMarkRules(out string) []string {
|
||||
var keys []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
// comment "svpn_appmark:vpn:123"
|
||||
i := strings.Index(line, appMarkCommentPrefix+":")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
rest := line[i:]
|
||||
end := len(rest)
|
||||
for j := 0; j < len(rest); j++ {
|
||||
ch := rest[j]
|
||||
if ch == '"' || ch == ' ' || ch == '\t' {
|
||||
end = j
|
||||
break
|
||||
}
|
||||
}
|
||||
tag := rest[:end]
|
||||
parts := strings.Split(tag, ":")
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
tgt := strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
idRaw := strings.TrimSpace(parts[2])
|
||||
if tgt != "vpn" && tgt != "direct" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseUint(idRaw, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, fmt.Sprintf("%s:%d", tgt, id))
|
||||
}
|
||||
sort.Strings(keys)
|
||||
// Dedup.
|
||||
outKeys := keys[:0]
|
||||
var last string
|
||||
for _, k := range keys {
|
||||
if k == last {
|
||||
continue
|
||||
}
|
||||
outKeys = append(outKeys, k)
|
||||
last = k
|
||||
}
|
||||
return outKeys
|
||||
}
|
||||
|
||||
func buildTrafficAuditPretty(now string, traffic TrafficModeStatusResponse, profiles []TrafficAppProfile, marks []appMarkItem, issues []string, nft map[string]any) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Traffic audit\n")
|
||||
b.WriteString("now=" + now + "\n\n")
|
||||
|
||||
b.WriteString("traffic: desired=" + string(traffic.DesiredMode) + " applied=" + string(traffic.AppliedMode) + " iface=" + strings.TrimSpace(traffic.ActiveIface) + " healthy=" + strconv.FormatBool(traffic.Healthy) + "\n")
|
||||
if strings.TrimSpace(traffic.Message) != "" {
|
||||
b.WriteString("traffic_message: " + strings.TrimSpace(traffic.Message) + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString(fmt.Sprintf("profiles=%d marks=%d\n", len(profiles), len(marks)))
|
||||
if nft != nil {
|
||||
b.WriteString(fmt.Sprintf("nft: rules=%v missing=%v orphan=%v jump_ok=%v\n",
|
||||
nft["nft_rules"], nft["missing_rules"], nft["orphan_rules"], nft["output_jump_ok"]))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(issues) == 0 {
|
||||
b.WriteString("issues: none\n")
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString("issues:\n")
|
||||
for _, it := range issues {
|
||||
b.WriteString("- " + it + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -46,6 +46,15 @@ type DNSUpstreams struct {
|
||||
Meta2 string `json:"meta2"`
|
||||
}
|
||||
|
||||
type DNSUpstreamPoolItem struct {
|
||||
Addr string `json:"addr"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type DNSUpstreamPoolState struct {
|
||||
Items []DNSUpstreamPoolItem `json:"items"`
|
||||
}
|
||||
|
||||
type DNSResolverMode string
|
||||
|
||||
const (
|
||||
@@ -77,6 +86,43 @@ type DNSModeRequest struct {
|
||||
Mode DNSResolverMode `json:"mode"`
|
||||
}
|
||||
|
||||
type DNSBenchmarkUpstream struct {
|
||||
Addr string `json:"addr"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type DNSBenchmarkRequest struct {
|
||||
Upstreams []DNSBenchmarkUpstream `json:"upstreams"`
|
||||
Domains []string `json:"domains"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
Attempts int `json:"attempts"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
}
|
||||
|
||||
type DNSBenchmarkResult struct {
|
||||
Upstream string `json:"upstream"`
|
||||
Attempts int `json:"attempts"`
|
||||
OK int `json:"ok"`
|
||||
Fail int `json:"fail"`
|
||||
NXDomain int `json:"nxdomain"`
|
||||
Timeout int `json:"timeout"`
|
||||
Temporary int `json:"temporary"`
|
||||
Other int `json:"other"`
|
||||
AvgMS int `json:"avg_ms"`
|
||||
P95MS int `json:"p95_ms"`
|
||||
Score float64 `json:"score"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type DNSBenchmarkResponse struct {
|
||||
Results []DNSBenchmarkResult `json:"results"`
|
||||
DomainsUsed []string `json:"domains_used"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
AttemptsPerDomain int `json:"attempts_per_domain"`
|
||||
RecommendedDefault []string `json:"recommended_default"`
|
||||
RecommendedMeta []string `json:"recommended_meta"`
|
||||
}
|
||||
|
||||
type SmartDNSRuntimeStatusResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AppliedEnable bool `json:"applied_enabled"`
|
||||
@@ -105,6 +151,7 @@ type TrafficModeState struct {
|
||||
Mode TrafficMode `json:"mode"`
|
||||
PreferredIface string `json:"preferred_iface,omitempty"`
|
||||
AutoLocalBypass bool `json:"auto_local_bypass"`
|
||||
IngressReplyBypass bool `json:"ingress_reply_bypass"`
|
||||
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
||||
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
||||
ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"`
|
||||
@@ -118,6 +165,7 @@ type TrafficModeRequest struct {
|
||||
Mode TrafficMode `json:"mode"`
|
||||
PreferredIface *string `json:"preferred_iface,omitempty"`
|
||||
AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"`
|
||||
IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"`
|
||||
ForceVPNSubnets *[]string `json:"force_vpn_subnets,omitempty"`
|
||||
ForceVPNUIDs *[]string `json:"force_vpn_uids,omitempty"`
|
||||
ForceVPNCGroups *[]string `json:"force_vpn_cgroups,omitempty"`
|
||||
@@ -131,7 +179,11 @@ type TrafficModeStatusResponse struct {
|
||||
DesiredMode TrafficMode `json:"desired_mode"`
|
||||
AppliedMode TrafficMode `json:"applied_mode"`
|
||||
PreferredIface string `json:"preferred_iface,omitempty"`
|
||||
AdvancedActive bool `json:"advanced_active"`
|
||||
AutoLocalBypass bool `json:"auto_local_bypass"`
|
||||
AutoLocalActive bool `json:"auto_local_active"`
|
||||
IngressReplyBypass bool `json:"ingress_reply_bypass"`
|
||||
IngressReplyActive bool `json:"ingress_reply_active"`
|
||||
BypassCandidates int `json:"bypass_candidates"`
|
||||
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
||||
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
||||
@@ -146,6 +198,8 @@ type TrafficModeStatusResponse struct {
|
||||
IfaceReason string `json:"iface_reason,omitempty"`
|
||||
RuleMark bool `json:"rule_mark"`
|
||||
RuleFull bool `json:"rule_full"`
|
||||
IngressRulePresent bool `json:"ingress_rule_present"`
|
||||
IngressNftActive bool `json:"ingress_nft_active"`
|
||||
TableDefault bool `json:"table_default"`
|
||||
ProbeOK bool `json:"probe_ok"`
|
||||
ProbeMessage string `json:"probe_message,omitempty"`
|
||||
@@ -208,7 +262,7 @@ type TrafficAppMarksRequest struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add
|
||||
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add; 0 = persistent
|
||||
}
|
||||
|
||||
type TrafficAppMarksResponse struct {
|
||||
@@ -227,6 +281,60 @@ type TrafficAppMarksStatusResponse struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// EN: Detailed list item for runtime per-app marks (for UI).
|
||||
// RU: Детальный элемент списка runtime per-app меток (для UI).
|
||||
type TrafficAppMarkItemView struct {
|
||||
ID uint64 `json:"id"`
|
||||
Target string `json:"target"` // vpn|direct
|
||||
Cgroup string `json:"cgroup,omitempty"`
|
||||
CgroupRel string `json:"cgroup_rel,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
AddedAt string `json:"added_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
RemainingSec int `json:"remaining_sec,omitempty"` // -1 = persistent
|
||||
}
|
||||
|
||||
type TrafficAppMarksItemsResponse struct {
|
||||
Items []TrafficAppMarkItemView `json:"items"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// traffic app profiles (persistent app launcher configs)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// EN: Persistent per-app launcher profile (separate from runtime marks).
|
||||
// RU: Постоянный профиль запуска приложения (отдельно от runtime marks).
|
||||
type TrafficAppProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Target string `json:"target,omitempty"` // vpn|direct
|
||||
TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent
|
||||
VPNProfile string `json:"vpn_profile,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type TrafficAppProfilesResponse struct {
|
||||
Profiles []TrafficAppProfile `json:"profiles"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type TrafficAppProfileUpsertRequest struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Target string `json:"target,omitempty"` // vpn|direct
|
||||
TTLSec int `json:"ttl_sec,omitempty"` // 0 = persistent
|
||||
VPNProfile string `json:"vpn_profile,omitempty"`
|
||||
}
|
||||
|
||||
type SystemdState struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
@@ -90,7 +90,11 @@ class TrafficModeStatus:
|
||||
desired_mode: str
|
||||
applied_mode: str
|
||||
preferred_iface: str
|
||||
advanced_active: bool
|
||||
auto_local_bypass: bool
|
||||
auto_local_active: bool
|
||||
ingress_reply_bypass: bool
|
||||
ingress_reply_active: bool
|
||||
bypass_candidates: int
|
||||
force_vpn_subnets: List[str]
|
||||
force_vpn_uids: List[str]
|
||||
@@ -105,6 +109,8 @@ class TrafficModeStatus:
|
||||
iface_reason: str
|
||||
rule_mark: bool
|
||||
rule_full: bool
|
||||
ingress_rule_present: bool
|
||||
ingress_nft_active: bool
|
||||
table_default: bool
|
||||
probe_ok: bool
|
||||
probe_message: str
|
||||
@@ -138,6 +144,50 @@ class TrafficAppMarksResult:
|
||||
timeout_sec: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficAppMarkItem:
|
||||
id: int
|
||||
target: str # vpn|direct
|
||||
cgroup: str
|
||||
cgroup_rel: str
|
||||
level: int
|
||||
unit: str
|
||||
command: str
|
||||
app_key: str
|
||||
added_at: str
|
||||
expires_at: str
|
||||
remaining_sec: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficAppProfile:
|
||||
id: str
|
||||
name: str
|
||||
app_key: str
|
||||
command: str
|
||||
target: str # vpn|direct
|
||||
ttl_sec: int
|
||||
vpn_profile: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficAppProfileSaveResult:
|
||||
ok: bool
|
||||
message: str
|
||||
profile: Optional[TrafficAppProfile] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficAudit:
|
||||
ok: bool
|
||||
message: str
|
||||
now: str
|
||||
pretty: str
|
||||
issues: List[str]
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficCandidateSubnet:
|
||||
@@ -177,6 +227,43 @@ class DnsUpstreams:
|
||||
meta2: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DNSBenchmarkUpstream:
|
||||
addr: str
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DNSBenchmarkResult:
|
||||
upstream: str
|
||||
attempts: int
|
||||
ok: int
|
||||
fail: int
|
||||
nxdomain: int
|
||||
timeout: int
|
||||
temporary: int
|
||||
other: int
|
||||
avg_ms: int
|
||||
p95_ms: int
|
||||
score: float
|
||||
color: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DNSBenchmarkResponse:
|
||||
results: List[DNSBenchmarkResult]
|
||||
domains_used: List[str]
|
||||
timeout_ms: int
|
||||
attempts_per_domain: int
|
||||
recommended_default: List[str]
|
||||
recommended_meta: List[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DNSUpstreamPoolState:
|
||||
items: List[DNSBenchmarkUpstream]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmartdnsServiceState:
|
||||
state: str
|
||||
@@ -610,7 +697,11 @@ class ApiClient:
|
||||
desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"),
|
||||
applied_mode=str(data.get("applied_mode") or "direct"),
|
||||
preferred_iface=str(data.get("preferred_iface") or ""),
|
||||
advanced_active=bool(data.get("advanced_active", False)),
|
||||
auto_local_bypass=bool(data.get("auto_local_bypass", True)),
|
||||
auto_local_active=bool(data.get("auto_local_active", False)),
|
||||
ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)),
|
||||
ingress_reply_active=bool(data.get("ingress_reply_active", False)),
|
||||
bypass_candidates=int(data.get("bypass_candidates", 0) or 0),
|
||||
force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()],
|
||||
force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()],
|
||||
@@ -625,6 +716,8 @@ class ApiClient:
|
||||
iface_reason=str(data.get("iface_reason") or ""),
|
||||
rule_mark=bool(data.get("rule_mark", False)),
|
||||
rule_full=bool(data.get("rule_full", False)),
|
||||
ingress_rule_present=bool(data.get("ingress_rule_present", False)),
|
||||
ingress_nft_active=bool(data.get("ingress_nft_active", False)),
|
||||
table_default=bool(data.get("table_default", False)),
|
||||
probe_ok=bool(data.get("probe_ok", False)),
|
||||
probe_message=str(data.get("probe_message") or ""),
|
||||
@@ -637,6 +730,7 @@ class ApiClient:
|
||||
mode: str,
|
||||
preferred_iface: Optional[str] = None,
|
||||
auto_local_bypass: Optional[bool] = None,
|
||||
ingress_reply_bypass: Optional[bool] = None,
|
||||
force_vpn_subnets: Optional[List[str]] = None,
|
||||
force_vpn_uids: Optional[List[str]] = None,
|
||||
force_vpn_cgroups: Optional[List[str]] = None,
|
||||
@@ -652,6 +746,8 @@ class ApiClient:
|
||||
payload["preferred_iface"] = str(preferred_iface).strip()
|
||||
if auto_local_bypass is not None:
|
||||
payload["auto_local_bypass"] = bool(auto_local_bypass)
|
||||
if ingress_reply_bypass is not None:
|
||||
payload["ingress_reply_bypass"] = bool(ingress_reply_bypass)
|
||||
if force_vpn_subnets is not None:
|
||||
payload["force_vpn_subnets"] = [str(x) for x in force_vpn_subnets]
|
||||
if force_vpn_uids is not None:
|
||||
@@ -680,7 +776,11 @@ class ApiClient:
|
||||
desired_mode=str(data.get("desired_mode") or data.get("mode") or m),
|
||||
applied_mode=str(data.get("applied_mode") or "direct"),
|
||||
preferred_iface=str(data.get("preferred_iface") or ""),
|
||||
advanced_active=bool(data.get("advanced_active", False)),
|
||||
auto_local_bypass=bool(data.get("auto_local_bypass", True)),
|
||||
auto_local_active=bool(data.get("auto_local_active", False)),
|
||||
ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)),
|
||||
ingress_reply_active=bool(data.get("ingress_reply_active", False)),
|
||||
bypass_candidates=int(data.get("bypass_candidates", 0) or 0),
|
||||
force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()],
|
||||
force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()],
|
||||
@@ -695,6 +795,8 @@ class ApiClient:
|
||||
iface_reason=str(data.get("iface_reason") or ""),
|
||||
rule_mark=bool(data.get("rule_mark", False)),
|
||||
rule_full=bool(data.get("rule_full", False)),
|
||||
ingress_rule_present=bool(data.get("ingress_rule_present", False)),
|
||||
ingress_nft_active=bool(data.get("ingress_nft_active", False)),
|
||||
table_default=bool(data.get("table_default", False)),
|
||||
probe_ok=bool(data.get("probe_ok", False)),
|
||||
probe_message=str(data.get("probe_message") or ""),
|
||||
@@ -712,7 +814,11 @@ class ApiClient:
|
||||
desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"),
|
||||
applied_mode=str(data.get("applied_mode") or "direct"),
|
||||
preferred_iface=str(data.get("preferred_iface") or ""),
|
||||
advanced_active=bool(data.get("advanced_active", False)),
|
||||
auto_local_bypass=bool(data.get("auto_local_bypass", True)),
|
||||
auto_local_active=bool(data.get("auto_local_active", False)),
|
||||
ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)),
|
||||
ingress_reply_active=bool(data.get("ingress_reply_active", False)),
|
||||
bypass_candidates=int(data.get("bypass_candidates", 0) or 0),
|
||||
force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()],
|
||||
force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()],
|
||||
@@ -727,6 +833,46 @@ class ApiClient:
|
||||
iface_reason=str(data.get("iface_reason") or ""),
|
||||
rule_mark=bool(data.get("rule_mark", False)),
|
||||
rule_full=bool(data.get("rule_full", False)),
|
||||
ingress_rule_present=bool(data.get("ingress_rule_present", False)),
|
||||
ingress_nft_active=bool(data.get("ingress_nft_active", False)),
|
||||
table_default=bool(data.get("table_default", False)),
|
||||
probe_ok=bool(data.get("probe_ok", False)),
|
||||
probe_message=str(data.get("probe_message") or ""),
|
||||
healthy=bool(data.get("healthy", False)),
|
||||
message=str(data.get("message") or ""),
|
||||
)
|
||||
|
||||
def traffic_advanced_reset(self) -> TrafficModeStatus:
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(self._request("POST", "/api/v1/traffic/advanced/reset")) or {},
|
||||
)
|
||||
return TrafficModeStatus(
|
||||
mode=str(data.get("mode") or "selective"),
|
||||
desired_mode=str(data.get("desired_mode") or data.get("mode") or "selective"),
|
||||
applied_mode=str(data.get("applied_mode") or "direct"),
|
||||
preferred_iface=str(data.get("preferred_iface") or ""),
|
||||
advanced_active=bool(data.get("advanced_active", False)),
|
||||
auto_local_bypass=bool(data.get("auto_local_bypass", True)),
|
||||
auto_local_active=bool(data.get("auto_local_active", False)),
|
||||
ingress_reply_bypass=bool(data.get("ingress_reply_bypass", False)),
|
||||
ingress_reply_active=bool(data.get("ingress_reply_active", False)),
|
||||
bypass_candidates=int(data.get("bypass_candidates", 0) or 0),
|
||||
force_vpn_subnets=[str(x) for x in (data.get("force_vpn_subnets") or []) if str(x).strip()],
|
||||
force_vpn_uids=[str(x) for x in (data.get("force_vpn_uids") or []) if str(x).strip()],
|
||||
force_vpn_cgroups=[str(x) for x in (data.get("force_vpn_cgroups") or []) if str(x).strip()],
|
||||
force_direct_subnets=[str(x) for x in (data.get("force_direct_subnets") or []) if str(x).strip()],
|
||||
force_direct_uids=[str(x) for x in (data.get("force_direct_uids") or []) if str(x).strip()],
|
||||
force_direct_cgroups=[str(x) for x in (data.get("force_direct_cgroups") or []) if str(x).strip()],
|
||||
overrides_applied=int(data.get("overrides_applied", 0) or 0),
|
||||
cgroup_resolved_uids=int(data.get("cgroup_resolved_uids", 0) or 0),
|
||||
cgroup_warning=str(data.get("cgroup_warning") or ""),
|
||||
active_iface=str(data.get("active_iface") or ""),
|
||||
iface_reason=str(data.get("iface_reason") or ""),
|
||||
rule_mark=bool(data.get("rule_mark", False)),
|
||||
rule_full=bool(data.get("rule_full", False)),
|
||||
ingress_rule_present=bool(data.get("ingress_rule_present", False)),
|
||||
ingress_nft_active=bool(data.get("ingress_nft_active", False)),
|
||||
table_default=bool(data.get("table_default", False)),
|
||||
probe_ok=bool(data.get("probe_ok", False)),
|
||||
probe_message=str(data.get("probe_message") or ""),
|
||||
@@ -819,6 +965,43 @@ class ApiClient:
|
||||
message=str(data.get("message") or ""),
|
||||
)
|
||||
|
||||
def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]:
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(self._request("GET", "/api/v1/traffic/appmarks/items")) or {},
|
||||
)
|
||||
raw = data.get("items") or []
|
||||
if not isinstance(raw, list):
|
||||
raw = []
|
||||
|
||||
out: List[TrafficAppMarkItem] = []
|
||||
for it in raw:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
try:
|
||||
mid = int(it.get("id", 0) or 0)
|
||||
except Exception:
|
||||
mid = 0
|
||||
tgt = str(it.get("target") or "").strip().lower()
|
||||
if mid <= 0 or tgt not in ("vpn", "direct"):
|
||||
continue
|
||||
out.append(
|
||||
TrafficAppMarkItem(
|
||||
id=mid,
|
||||
target=tgt,
|
||||
cgroup=str(it.get("cgroup") or "").strip(),
|
||||
cgroup_rel=str(it.get("cgroup_rel") or "").strip(),
|
||||
level=int(it.get("level", 0) or 0),
|
||||
unit=str(it.get("unit") or "").strip(),
|
||||
command=str(it.get("command") or "").strip(),
|
||||
app_key=str(it.get("app_key") or "").strip(),
|
||||
added_at=str(it.get("added_at") or "").strip(),
|
||||
expires_at=str(it.get("expires_at") or "").strip(),
|
||||
remaining_sec=int(it.get("remaining_sec", 0) or 0),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
def traffic_appmarks_apply(
|
||||
self,
|
||||
*,
|
||||
@@ -860,6 +1043,130 @@ class ApiClient:
|
||||
timeout_sec=int(data.get("timeout_sec", 0) or 0),
|
||||
)
|
||||
|
||||
def traffic_app_profiles_list(self) -> List[TrafficAppProfile]:
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(self._request("GET", "/api/v1/traffic/app-profiles")) or {},
|
||||
)
|
||||
raw = data.get("profiles") or []
|
||||
if not isinstance(raw, list):
|
||||
raw = []
|
||||
|
||||
out: List[TrafficAppProfile] = []
|
||||
for it in raw:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
pid = str(it.get("id") or "").strip()
|
||||
if not pid:
|
||||
continue
|
||||
out.append(
|
||||
TrafficAppProfile(
|
||||
id=pid,
|
||||
name=str(it.get("name") or "").strip(),
|
||||
app_key=str(it.get("app_key") or "").strip(),
|
||||
command=str(it.get("command") or "").strip(),
|
||||
target=str(it.get("target") or "").strip().lower(),
|
||||
ttl_sec=int(it.get("ttl_sec", 0) or 0),
|
||||
vpn_profile=str(it.get("vpn_profile") or "").strip(),
|
||||
created_at=str(it.get("created_at") or "").strip(),
|
||||
updated_at=str(it.get("updated_at") or "").strip(),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
def traffic_app_profile_upsert(
|
||||
self,
|
||||
*,
|
||||
id: str = "",
|
||||
name: str = "",
|
||||
app_key: str = "",
|
||||
command: str,
|
||||
target: str,
|
||||
ttl_sec: int = 0,
|
||||
vpn_profile: str = "",
|
||||
) -> TrafficAppProfileSaveResult:
|
||||
payload: Dict[str, Any] = {
|
||||
"command": str(command or "").strip(),
|
||||
"target": str(target or "").strip().lower(),
|
||||
}
|
||||
if id:
|
||||
payload["id"] = str(id).strip()
|
||||
if name:
|
||||
payload["name"] = str(name).strip()
|
||||
if app_key:
|
||||
payload["app_key"] = str(app_key).strip()
|
||||
if int(ttl_sec or 0) > 0:
|
||||
payload["ttl_sec"] = int(ttl_sec)
|
||||
if vpn_profile:
|
||||
payload["vpn_profile"] = str(vpn_profile).strip()
|
||||
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(
|
||||
self._request("POST", "/api/v1/traffic/app-profiles", json_body=payload)
|
||||
)
|
||||
or {},
|
||||
)
|
||||
msg = str(data.get("message") or "")
|
||||
raw = data.get("profiles") or []
|
||||
if not isinstance(raw, list):
|
||||
raw = []
|
||||
prof: Optional[TrafficAppProfile] = None
|
||||
if raw and isinstance(raw[0], dict):
|
||||
it = cast(Dict[str, Any], raw[0])
|
||||
pid = str(it.get("id") or "").strip()
|
||||
if pid:
|
||||
prof = TrafficAppProfile(
|
||||
id=pid,
|
||||
name=str(it.get("name") or "").strip(),
|
||||
app_key=str(it.get("app_key") or "").strip(),
|
||||
command=str(it.get("command") or "").strip(),
|
||||
target=str(it.get("target") or "").strip().lower(),
|
||||
ttl_sec=int(it.get("ttl_sec", 0) or 0),
|
||||
vpn_profile=str(it.get("vpn_profile") or "").strip(),
|
||||
created_at=str(it.get("created_at") or "").strip(),
|
||||
updated_at=str(it.get("updated_at") or "").strip(),
|
||||
)
|
||||
|
||||
ok = bool(prof) and (msg.strip().lower() in ("saved", "ok"))
|
||||
if not msg and ok:
|
||||
msg = "saved"
|
||||
return TrafficAppProfileSaveResult(ok=ok, message=msg, profile=prof)
|
||||
|
||||
def traffic_app_profile_delete(self, id: str) -> CmdResult:
|
||||
pid = str(id or "").strip()
|
||||
if not pid:
|
||||
raise ValueError("missing id")
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(
|
||||
self._request("DELETE", "/api/v1/traffic/app-profiles", params={"id": pid})
|
||||
)
|
||||
or {},
|
||||
)
|
||||
return CmdResult(
|
||||
ok=bool(data.get("ok", False)),
|
||||
message=str(data.get("message") or ""),
|
||||
exit_code=None,
|
||||
stdout="",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
def traffic_audit_get(self) -> TrafficAudit:
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(self._request("GET", "/api/v1/traffic/audit")) or {},
|
||||
)
|
||||
raw_issues = data.get("issues") or []
|
||||
if not isinstance(raw_issues, list):
|
||||
raw_issues = []
|
||||
return TrafficAudit(
|
||||
ok=bool(data.get("ok", False)),
|
||||
message=strip_ansi(str(data.get("message") or "").strip()),
|
||||
now=str(data.get("now") or "").strip(),
|
||||
pretty=strip_ansi(str(data.get("pretty") or "").strip()),
|
||||
issues=[strip_ansi(str(x)).strip() for x in raw_issues if str(x).strip()],
|
||||
)
|
||||
|
||||
# DNS / SmartDNS
|
||||
def dns_upstreams_get(self) -> DnsUpstreams:
|
||||
@@ -883,6 +1190,105 @@ class ApiClient:
|
||||
},
|
||||
)
|
||||
|
||||
def dns_upstream_pool_get(self) -> DNSUpstreamPoolState:
|
||||
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/upstream-pool")) or {})
|
||||
raw = data.get("items") or []
|
||||
if not isinstance(raw, list):
|
||||
raw = []
|
||||
items: List[DNSBenchmarkUpstream] = []
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
addr = str(row.get("addr") or "").strip()
|
||||
if not addr:
|
||||
continue
|
||||
items.append(DNSBenchmarkUpstream(addr=addr, enabled=bool(row.get("enabled", True))))
|
||||
return DNSUpstreamPoolState(items=items)
|
||||
|
||||
def dns_upstream_pool_set(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState:
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(
|
||||
self._request(
|
||||
"POST",
|
||||
"/api/v1/dns/upstream-pool",
|
||||
json_body={
|
||||
"items": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (items or [])],
|
||||
},
|
||||
)
|
||||
)
|
||||
or {},
|
||||
)
|
||||
raw = data.get("items") or []
|
||||
if not isinstance(raw, list):
|
||||
raw = []
|
||||
out: List[DNSBenchmarkUpstream] = []
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
addr = str(row.get("addr") or "").strip()
|
||||
if not addr:
|
||||
continue
|
||||
out.append(DNSBenchmarkUpstream(addr=addr, enabled=bool(row.get("enabled", True))))
|
||||
return DNSUpstreamPoolState(items=out)
|
||||
|
||||
def dns_benchmark(
|
||||
self,
|
||||
upstreams: List[DNSBenchmarkUpstream],
|
||||
domains: List[str],
|
||||
timeout_ms: int = 1800,
|
||||
attempts: int = 1,
|
||||
concurrency: int = 6,
|
||||
) -> DNSBenchmarkResponse:
|
||||
data = cast(
|
||||
Dict[str, Any],
|
||||
self._json(
|
||||
self._request(
|
||||
"POST",
|
||||
"/api/v1/dns/benchmark",
|
||||
json_body={
|
||||
"upstreams": [{"addr": u.addr, "enabled": bool(u.enabled)} for u in (upstreams or [])],
|
||||
"domains": [str(d or "").strip() for d in (domains or []) if str(d or "").strip()],
|
||||
"timeout_ms": int(timeout_ms),
|
||||
"attempts": int(attempts),
|
||||
"concurrency": int(concurrency),
|
||||
},
|
||||
)
|
||||
)
|
||||
or {},
|
||||
)
|
||||
raw_results = data.get("results") or []
|
||||
if not isinstance(raw_results, list):
|
||||
raw_results = []
|
||||
results: List[DNSBenchmarkResult] = []
|
||||
for row in raw_results:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
results.append(
|
||||
DNSBenchmarkResult(
|
||||
upstream=str(row.get("upstream") or "").strip(),
|
||||
attempts=int(row.get("attempts", 0) or 0),
|
||||
ok=int(row.get("ok", 0) or 0),
|
||||
fail=int(row.get("fail", 0) or 0),
|
||||
nxdomain=int(row.get("nxdomain", 0) or 0),
|
||||
timeout=int(row.get("timeout", 0) or 0),
|
||||
temporary=int(row.get("temporary", 0) or 0),
|
||||
other=int(row.get("other", 0) or 0),
|
||||
avg_ms=int(row.get("avg_ms", 0) or 0),
|
||||
p95_ms=int(row.get("p95_ms", 0) or 0),
|
||||
score=float(row.get("score", 0.0) or 0.0),
|
||||
color=str(row.get("color") or "").strip().lower(),
|
||||
)
|
||||
)
|
||||
return DNSBenchmarkResponse(
|
||||
results=results,
|
||||
domains_used=[str(d or "").strip() for d in (data.get("domains_used") or []) if str(d or "").strip()],
|
||||
timeout_ms=int(data.get("timeout_ms", 0) or 0),
|
||||
attempts_per_domain=int(data.get("attempts_per_domain", 0) or 0),
|
||||
recommended_default=[str(d or "").strip() for d in (data.get("recommended_default") or []) if str(d or "").strip()],
|
||||
recommended_meta=[str(d or "").strip() for d in (data.get("recommended_meta") or []) if str(d or "").strip()],
|
||||
)
|
||||
|
||||
def dns_status_get(self) -> DNSStatus:
|
||||
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/dns/status")) or {})
|
||||
return self._parse_dns_status(data)
|
||||
|
||||
@@ -24,6 +24,9 @@ _NEXT_CHECK_RE = re.compile(
|
||||
from api_client import (
|
||||
ApiClient,
|
||||
CmdResult,
|
||||
DNSBenchmarkResponse,
|
||||
DNSBenchmarkUpstream,
|
||||
DNSUpstreamPoolState,
|
||||
DNSStatus,
|
||||
DnsUpstreams,
|
||||
DomainsFile,
|
||||
@@ -34,6 +37,10 @@ from api_client import (
|
||||
TrafficCandidates,
|
||||
TrafficAppMarksResult,
|
||||
TrafficAppMarksStatus,
|
||||
TrafficAppMarkItem,
|
||||
TrafficAppProfile,
|
||||
TrafficAppProfileSaveResult,
|
||||
TrafficAudit,
|
||||
TrafficInterfaces,
|
||||
TrafficModeStatus,
|
||||
TraceDump,
|
||||
@@ -124,7 +131,11 @@ class TrafficModeView:
|
||||
desired_mode: str
|
||||
applied_mode: str
|
||||
preferred_iface: str
|
||||
advanced_active: bool
|
||||
auto_local_bypass: bool
|
||||
auto_local_active: bool
|
||||
ingress_reply_bypass: bool
|
||||
ingress_reply_active: bool
|
||||
bypass_candidates: int
|
||||
force_vpn_subnets: List[str]
|
||||
force_vpn_uids: List[str]
|
||||
@@ -137,6 +148,8 @@ class TrafficModeView:
|
||||
cgroup_warning: str
|
||||
active_iface: str
|
||||
iface_reason: str
|
||||
ingress_rule_present: bool
|
||||
ingress_nft_active: bool
|
||||
probe_ok: bool
|
||||
probe_message: str
|
||||
healthy: bool
|
||||
@@ -200,6 +213,9 @@ class DashboardController:
|
||||
return ["routes"]
|
||||
if k == "traffic_mode_changed":
|
||||
return ["routes", "status"]
|
||||
if k == "traffic_profiles_changed":
|
||||
# Used by Traffic mode dialog (Apps/runtime) for persistent app profiles.
|
||||
return ["routes"]
|
||||
return []
|
||||
|
||||
# -------- helpers --------
|
||||
@@ -607,7 +623,11 @@ class DashboardController:
|
||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
@@ -620,6 +640,8 @@ class DashboardController:
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
@@ -631,6 +653,7 @@ class DashboardController:
|
||||
mode: str,
|
||||
preferred_iface: Optional[str] = None,
|
||||
auto_local_bypass: Optional[bool] = None,
|
||||
ingress_reply_bypass: Optional[bool] = None,
|
||||
force_vpn_subnets: Optional[List[str]] = None,
|
||||
force_vpn_uids: Optional[List[str]] = None,
|
||||
force_vpn_cgroups: Optional[List[str]] = None,
|
||||
@@ -642,6 +665,7 @@ class DashboardController:
|
||||
mode,
|
||||
preferred_iface,
|
||||
auto_local_bypass,
|
||||
ingress_reply_bypass,
|
||||
force_vpn_subnets,
|
||||
force_vpn_uids,
|
||||
force_vpn_cgroups,
|
||||
@@ -653,7 +677,11 @@ class DashboardController:
|
||||
desired_mode=(st.desired_mode or st.mode or mode),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
@@ -666,6 +694,8 @@ class DashboardController:
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
@@ -678,7 +708,11 @@ class DashboardController:
|
||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
@@ -691,6 +725,39 @@ class DashboardController:
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
message=st.message or "",
|
||||
)
|
||||
|
||||
def traffic_advanced_reset(self) -> TrafficModeView:
|
||||
st: TrafficModeStatus = self.client.traffic_advanced_reset()
|
||||
return TrafficModeView(
|
||||
desired_mode=(st.desired_mode or st.mode or "selective"),
|
||||
applied_mode=(st.applied_mode or "direct"),
|
||||
preferred_iface=st.preferred_iface or "",
|
||||
advanced_active=bool(st.advanced_active),
|
||||
auto_local_bypass=bool(st.auto_local_bypass),
|
||||
auto_local_active=bool(st.auto_local_active),
|
||||
ingress_reply_bypass=bool(st.ingress_reply_bypass),
|
||||
ingress_reply_active=bool(st.ingress_reply_active),
|
||||
bypass_candidates=int(st.bypass_candidates),
|
||||
force_vpn_subnets=list(st.force_vpn_subnets or []),
|
||||
force_vpn_uids=list(st.force_vpn_uids or []),
|
||||
force_vpn_cgroups=list(st.force_vpn_cgroups or []),
|
||||
force_direct_subnets=list(st.force_direct_subnets or []),
|
||||
force_direct_uids=list(st.force_direct_uids or []),
|
||||
force_direct_cgroups=list(st.force_direct_cgroups or []),
|
||||
overrides_applied=int(st.overrides_applied),
|
||||
cgroup_resolved_uids=int(st.cgroup_resolved_uids),
|
||||
cgroup_warning=st.cgroup_warning or "",
|
||||
active_iface=st.active_iface or "",
|
||||
iface_reason=st.iface_reason or "",
|
||||
ingress_rule_present=bool(st.ingress_rule_present),
|
||||
ingress_nft_active=bool(st.ingress_nft_active),
|
||||
probe_ok=bool(st.probe_ok),
|
||||
probe_message=st.probe_message or "",
|
||||
healthy=bool(st.healthy),
|
||||
@@ -710,6 +777,9 @@ class DashboardController:
|
||||
def traffic_appmarks_status(self) -> TrafficAppMarksStatus:
|
||||
return self.client.traffic_appmarks_status()
|
||||
|
||||
def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]:
|
||||
return self.client.traffic_appmarks_items()
|
||||
|
||||
def traffic_appmarks_apply(
|
||||
self,
|
||||
*,
|
||||
@@ -731,6 +801,36 @@ class DashboardController:
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
def traffic_app_profiles_list(self) -> List[TrafficAppProfile]:
|
||||
return self.client.traffic_app_profiles_list()
|
||||
|
||||
def traffic_app_profile_upsert(
|
||||
self,
|
||||
*,
|
||||
id: str = "",
|
||||
name: str = "",
|
||||
app_key: str = "",
|
||||
command: str,
|
||||
target: str,
|
||||
ttl_sec: int = 0,
|
||||
vpn_profile: str = "",
|
||||
) -> TrafficAppProfileSaveResult:
|
||||
return self.client.traffic_app_profile_upsert(
|
||||
id=id,
|
||||
name=name,
|
||||
app_key=app_key,
|
||||
command=command,
|
||||
target=target,
|
||||
ttl_sec=ttl_sec,
|
||||
vpn_profile=vpn_profile,
|
||||
)
|
||||
|
||||
def traffic_app_profile_delete(self, id: str) -> CmdResult:
|
||||
return self.client.traffic_app_profile_delete(id)
|
||||
|
||||
def traffic_audit(self) -> TrafficAudit:
|
||||
return self.client.traffic_audit_get()
|
||||
|
||||
def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView:
|
||||
"""
|
||||
Превращает Event(kind='routes_nft_progress') в удобную модель
|
||||
@@ -771,6 +871,28 @@ class DashboardController:
|
||||
def dns_upstreams_save(self, cfg: DnsUpstreams) -> None:
|
||||
self.client.dns_upstreams_set(cfg)
|
||||
|
||||
def dns_upstream_pool_view(self) -> DNSUpstreamPoolState:
|
||||
return self.client.dns_upstream_pool_get()
|
||||
|
||||
def dns_upstream_pool_save(self, items: List[DNSBenchmarkUpstream]) -> DNSUpstreamPoolState:
|
||||
return self.client.dns_upstream_pool_set(items)
|
||||
|
||||
def dns_benchmark(
|
||||
self,
|
||||
upstreams: List[DNSBenchmarkUpstream],
|
||||
domains: List[str],
|
||||
timeout_ms: int = 1800,
|
||||
attempts: int = 1,
|
||||
concurrency: int = 6,
|
||||
) -> DNSBenchmarkResponse:
|
||||
return self.client.dns_benchmark(
|
||||
upstreams=upstreams,
|
||||
domains=domains,
|
||||
timeout_ms=timeout_ms,
|
||||
attempts=attempts,
|
||||
concurrency=concurrency,
|
||||
)
|
||||
|
||||
def dns_status_view(self) -> DNSStatus:
|
||||
return self.client.dns_status_get()
|
||||
|
||||
|
||||
367
selective-vpn-gui/dns_benchmark_dialog.py
Normal file
367
selective-vpn-gui/dns_benchmark_dialog.py
Normal file
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Callable, List
|
||||
|
||||
from PySide6.QtCore import Qt, QSettings
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QPlainTextEdit,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from api_client import DNSBenchmarkUpstream
|
||||
from dashboard_controller import DashboardController
|
||||
|
||||
|
||||
DEFAULT_UPSTREAMS = [
|
||||
"94.140.14.14",
|
||||
"94.140.14.15",
|
||||
"94.140.15.15",
|
||||
"94.140.15.16",
|
||||
"1.1.1.1",
|
||||
"1.0.0.1",
|
||||
"8.8.8.8",
|
||||
"8.8.4.4",
|
||||
"208.67.222.222",
|
||||
"208.67.220.220",
|
||||
"76.76.2.0",
|
||||
"76.76.10.0",
|
||||
]
|
||||
|
||||
DEFAULT_DOMAINS = [
|
||||
"cloudflare.com",
|
||||
"google.com",
|
||||
"github.com",
|
||||
"telegram.org",
|
||||
"youtube.com",
|
||||
"twitter.com",
|
||||
]
|
||||
|
||||
|
||||
class DNSBenchmarkDialog(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
ctrl: DashboardController,
|
||||
settings: QSettings,
|
||||
refresh_cb: Callable[[], None] | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.ctrl = ctrl
|
||||
self.settings = settings
|
||||
self.refresh_cb = refresh_cb
|
||||
|
||||
self.setWindowTitle("DNS benchmark")
|
||||
self.resize(980, 660)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
hint = QLabel(
|
||||
"One DNS per row. Checkbox means ACTIVE for resolver wave mode. "
|
||||
"Benchmark checks all rows and shows health."
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
hint.setStyleSheet("color: gray;")
|
||||
root.addWidget(hint)
|
||||
|
||||
self.tbl_sources = QTableWidget(0, 2)
|
||||
self.tbl_sources.setHorizontalHeaderLabels(["Active", "DNS upstream"])
|
||||
self.tbl_sources.horizontalHeader().setStretchLastSection(True)
|
||||
self.tbl_sources.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.tbl_sources.setSelectionMode(QTableWidget.SingleSelection)
|
||||
self.tbl_sources.itemChanged.connect(self._on_sources_changed)
|
||||
root.addWidget(self.tbl_sources, stretch=2)
|
||||
|
||||
row_btns = QHBoxLayout()
|
||||
self.btn_add = QPushButton("Add DNS")
|
||||
self.btn_add.clicked.connect(self.on_add_dns)
|
||||
row_btns.addWidget(self.btn_add)
|
||||
self.btn_remove = QPushButton("Remove selected")
|
||||
self.btn_remove.clicked.connect(self.on_remove_selected)
|
||||
row_btns.addWidget(self.btn_remove)
|
||||
self.btn_reset = QPushButton("Reset defaults")
|
||||
self.btn_reset.clicked.connect(self.on_reset_defaults)
|
||||
row_btns.addWidget(self.btn_reset)
|
||||
self.btn_reload = QPushButton("Reload active set")
|
||||
self.btn_reload.clicked.connect(self.on_reload_pool)
|
||||
row_btns.addWidget(self.btn_reload)
|
||||
self.btn_save_pool = QPushButton("Save active set")
|
||||
self.btn_save_pool.clicked.connect(self.on_save_pool)
|
||||
row_btns.addWidget(self.btn_save_pool)
|
||||
row_btns.addStretch(1)
|
||||
root.addLayout(row_btns)
|
||||
|
||||
self.txt_domains = QPlainTextEdit()
|
||||
self.txt_domains.setPlaceholderText(
|
||||
"Test domains (one per line)\n"
|
||||
"Example:\n"
|
||||
"cloudflare.com\n"
|
||||
"google.com\n"
|
||||
"telegram.org"
|
||||
)
|
||||
self.txt_domains.textChanged.connect(self._on_domains_changed)
|
||||
self.txt_domains.setFixedHeight(120)
|
||||
root.addWidget(self.txt_domains)
|
||||
|
||||
opts = QHBoxLayout()
|
||||
self.spin_timeout = QSpinBox()
|
||||
self.spin_timeout.setRange(300, 5000)
|
||||
self.spin_timeout.setValue(1800)
|
||||
self.spin_timeout.setSuffix(" ms")
|
||||
opts.addWidget(QLabel("Timeout:"))
|
||||
opts.addWidget(self.spin_timeout)
|
||||
|
||||
self.spin_attempts = QSpinBox()
|
||||
self.spin_attempts.setRange(1, 3)
|
||||
self.spin_attempts.setValue(1)
|
||||
opts.addWidget(QLabel("Attempts/domain:"))
|
||||
opts.addWidget(self.spin_attempts)
|
||||
|
||||
self.spin_concurrency = QSpinBox()
|
||||
self.spin_concurrency.setRange(1, 32)
|
||||
self.spin_concurrency.setValue(6)
|
||||
opts.addWidget(QLabel("Parallel DNS checks:"))
|
||||
opts.addWidget(self.spin_concurrency)
|
||||
|
||||
self.btn_run = QPushButton("Run benchmark")
|
||||
self.btn_run.clicked.connect(self.on_run_benchmark)
|
||||
opts.addWidget(self.btn_run)
|
||||
opts.addStretch(1)
|
||||
root.addLayout(opts)
|
||||
|
||||
self.lbl_summary = QLabel("No benchmark yet")
|
||||
self.lbl_summary.setStyleSheet("color: gray;")
|
||||
root.addWidget(self.lbl_summary)
|
||||
|
||||
self.tbl_results = QTableWidget(0, 7)
|
||||
self.tbl_results.setHorizontalHeaderLabels(
|
||||
["DNS", "OK/Fail", "Avg/P95", "Timeout", "NX", "Score", "Status"]
|
||||
)
|
||||
self.tbl_results.horizontalHeader().setStretchLastSection(True)
|
||||
self.tbl_results.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.tbl_results.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.tbl_results.setSelectionMode(QTableWidget.SingleSelection)
|
||||
root.addWidget(self.tbl_results, stretch=3)
|
||||
|
||||
close_row = QHBoxLayout()
|
||||
close_row.addStretch(1)
|
||||
self.btn_close = QPushButton("Close")
|
||||
self.btn_close.clicked.connect(self.accept)
|
||||
close_row.addWidget(self.btn_close)
|
||||
root.addLayout(close_row)
|
||||
|
||||
self._load_sources()
|
||||
self._load_domains()
|
||||
|
||||
def _safe(self, fn, title: str) -> None:
|
||||
try:
|
||||
fn()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, title, str(e))
|
||||
|
||||
def _load_sources(self) -> None:
|
||||
rows: List[tuple[bool, str]] = []
|
||||
|
||||
try:
|
||||
st = self.ctrl.dns_upstream_pool_view()
|
||||
for item in st.items:
|
||||
addr = str(item.addr or "").strip()
|
||||
if not addr:
|
||||
continue
|
||||
rows.append((bool(item.enabled), addr))
|
||||
except Exception:
|
||||
rows = []
|
||||
|
||||
if not rows:
|
||||
raw = str(self.settings.value("dns_benchmark/upstreams", "") or "").strip()
|
||||
if raw:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
addr = str(item.get("addr") or "").strip()
|
||||
if not addr:
|
||||
continue
|
||||
rows.append((bool(item.get("enabled", True)), addr))
|
||||
except Exception:
|
||||
rows = []
|
||||
|
||||
if not rows:
|
||||
rows = [(True, item) for item in DEFAULT_UPSTREAMS]
|
||||
|
||||
self.tbl_sources.blockSignals(True)
|
||||
self.tbl_sources.setRowCount(0)
|
||||
for enabled, addr in rows:
|
||||
self._append_source_row(enabled, addr)
|
||||
self.tbl_sources.blockSignals(False)
|
||||
self._save_settings()
|
||||
|
||||
def _load_domains(self) -> None:
|
||||
raw = str(self.settings.value("dns_benchmark/domains", "") or "").strip()
|
||||
if not raw:
|
||||
raw = "\n".join(DEFAULT_DOMAINS)
|
||||
self.txt_domains.blockSignals(True)
|
||||
self.txt_domains.setPlainText(raw)
|
||||
self.txt_domains.blockSignals(False)
|
||||
|
||||
def _save_settings(self) -> None:
|
||||
items = []
|
||||
for i in range(self.tbl_sources.rowCount()):
|
||||
ck = self.tbl_sources.item(i, 0)
|
||||
addr = self.tbl_sources.item(i, 1)
|
||||
if not ck or not addr:
|
||||
continue
|
||||
val = str(addr.text() or "").strip()
|
||||
if not val:
|
||||
continue
|
||||
items.append({"enabled": ck.checkState() == Qt.Checked, "addr": val})
|
||||
self.settings.setValue("dns_benchmark/upstreams", json.dumps(items, ensure_ascii=True))
|
||||
self.settings.setValue("dns_benchmark/domains", self.txt_domains.toPlainText().strip())
|
||||
|
||||
def _on_sources_changed(self, _item: QTableWidgetItem) -> None:
|
||||
self._save_settings()
|
||||
|
||||
def _on_domains_changed(self) -> None:
|
||||
self._save_settings()
|
||||
|
||||
def _append_source_row(self, enabled: bool, addr: str) -> None:
|
||||
row = self.tbl_sources.rowCount()
|
||||
self.tbl_sources.insertRow(row)
|
||||
ck = QTableWidgetItem("")
|
||||
ck.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
ck.setCheckState(Qt.Checked if enabled else Qt.Unchecked)
|
||||
self.tbl_sources.setItem(row, 0, ck)
|
||||
it = QTableWidgetItem(addr)
|
||||
it.setFlags(it.flags() | Qt.ItemIsEditable)
|
||||
self.tbl_sources.setItem(row, 1, it)
|
||||
|
||||
def _source_payload(self) -> List[DNSBenchmarkUpstream]:
|
||||
out: List[DNSBenchmarkUpstream] = []
|
||||
for i in range(self.tbl_sources.rowCount()):
|
||||
ck = self.tbl_sources.item(i, 0)
|
||||
addr = self.tbl_sources.item(i, 1)
|
||||
if not ck or not addr:
|
||||
continue
|
||||
val = str(addr.text() or "").strip()
|
||||
if not val:
|
||||
continue
|
||||
out.append(DNSBenchmarkUpstream(addr=val, enabled=(ck.checkState() == Qt.Checked)))
|
||||
return out
|
||||
|
||||
def _domains_payload(self) -> List[str]:
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for ln in self.txt_domains.toPlainText().splitlines():
|
||||
d = str(ln or "").strip().lower().rstrip(".")
|
||||
if not d or d.startswith("#") or d in seen:
|
||||
continue
|
||||
seen.add(d)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
def on_add_dns(self) -> None:
|
||||
self._append_source_row(True, "")
|
||||
self._save_settings()
|
||||
|
||||
def on_remove_selected(self) -> None:
|
||||
row = self.tbl_sources.currentRow()
|
||||
if row >= 0:
|
||||
self.tbl_sources.removeRow(row)
|
||||
self._save_settings()
|
||||
|
||||
def on_reset_defaults(self) -> None:
|
||||
self.tbl_sources.blockSignals(True)
|
||||
self.tbl_sources.setRowCount(0)
|
||||
for item in DEFAULT_UPSTREAMS:
|
||||
self._append_source_row(True, item)
|
||||
self.tbl_sources.blockSignals(False)
|
||||
self._save_settings()
|
||||
|
||||
def on_reload_pool(self) -> None:
|
||||
self._safe(self._load_sources, "Reload DNS active set error")
|
||||
|
||||
def on_save_pool(self) -> None:
|
||||
def work() -> None:
|
||||
payload = self._source_payload()
|
||||
st = self.ctrl.dns_upstream_pool_save(payload)
|
||||
active = sum(1 for x in st.items if x.enabled)
|
||||
total = len(st.items)
|
||||
self._save_settings()
|
||||
self.lbl_summary.setText(f"Saved active DNS set: active={active}/{total}")
|
||||
self.lbl_summary.setStyleSheet("color: green;")
|
||||
if self.refresh_cb:
|
||||
self.refresh_cb()
|
||||
|
||||
self._safe(work, "Save DNS active set error")
|
||||
|
||||
def on_run_benchmark(self) -> None:
|
||||
def work() -> None:
|
||||
self._save_settings()
|
||||
payload = self._source_payload()
|
||||
domains = self._domains_payload()
|
||||
resp = self.ctrl.dns_benchmark(
|
||||
upstreams=payload,
|
||||
domains=domains,
|
||||
timeout_ms=int(self.spin_timeout.value()),
|
||||
attempts=int(self.spin_attempts.value()),
|
||||
concurrency=int(self.spin_concurrency.value()),
|
||||
)
|
||||
self._render_results(resp)
|
||||
if self.refresh_cb:
|
||||
self.refresh_cb()
|
||||
|
||||
self._safe(work, "DNS benchmark error")
|
||||
|
||||
def _render_results(self, resp) -> None:
|
||||
self.tbl_results.setRowCount(0)
|
||||
ok_total = 0
|
||||
fail_total = 0
|
||||
timeout_total = 0
|
||||
for row_data in (resp.results or []):
|
||||
ok_total += int(row_data.ok or 0)
|
||||
fail_total += int(row_data.fail or 0)
|
||||
timeout_total += int(row_data.timeout or 0)
|
||||
row = self.tbl_results.rowCount()
|
||||
self.tbl_results.insertRow(row)
|
||||
self.tbl_results.setItem(row, 0, QTableWidgetItem(row_data.upstream))
|
||||
self.tbl_results.setItem(row, 1, QTableWidgetItem(f"{row_data.ok}/{row_data.fail}"))
|
||||
self.tbl_results.setItem(row, 2, QTableWidgetItem(f"{row_data.avg_ms} / {row_data.p95_ms} ms"))
|
||||
self.tbl_results.setItem(row, 3, QTableWidgetItem(str(row_data.timeout)))
|
||||
self.tbl_results.setItem(row, 4, QTableWidgetItem(str(row_data.nxdomain)))
|
||||
self.tbl_results.setItem(row, 5, QTableWidgetItem(f"{row_data.score:.1f}"))
|
||||
status = row_data.color or "unknown"
|
||||
st_item = QTableWidgetItem(status)
|
||||
low = status.lower()
|
||||
if low == "green":
|
||||
st_item.setForeground(QColor("green"))
|
||||
elif low in ("yellow", "orange"):
|
||||
st_item.setForeground(QColor("#b58900"))
|
||||
else:
|
||||
st_item.setForeground(QColor("red"))
|
||||
self.tbl_results.setItem(row, 6, st_item)
|
||||
|
||||
self.lbl_summary.setText(
|
||||
f"Checked: {len(resp.results)} DNS | domains={len(resp.domains_used)} "
|
||||
f"| timeout={resp.timeout_ms}ms"
|
||||
)
|
||||
self.lbl_summary.setStyleSheet("color: gray;")
|
||||
|
||||
avg_values = [int(r.avg_ms or 0) for r in (resp.results or []) if int(r.ok or 0) > 0 and int(r.avg_ms or 0) > 0]
|
||||
avg_all = int(sum(avg_values) / len(avg_values)) if avg_values else 0
|
||||
self.settings.setValue("dns_benchmark/last_avg_ms", avg_all)
|
||||
self.settings.setValue("dns_benchmark/last_ok", ok_total)
|
||||
self.settings.setValue("dns_benchmark/last_fail", fail_total)
|
||||
self.settings.setValue("dns_benchmark/last_timeout", timeout_total)
|
||||
358
selective-vpn-gui/svpn_run_profile.py
Normal file
358
selective-vpn-gui/svpn_run_profile.py
Normal file
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
svpn_run_profile.py
|
||||
|
||||
EN:
|
||||
Headless launcher for a saved "traffic app profile".
|
||||
- Runs app via `systemd-run --user` (transient unit)
|
||||
- Resolves effective cgroupv2 path for that unit
|
||||
- Calls Selective-VPN API to apply a per-app routing mark (VPN/Direct)
|
||||
|
||||
RU:
|
||||
Headless-лаунчер для сохраненного "app profile".
|
||||
- Запускает приложение через `systemd-run --user` (transient unit)
|
||||
- Получает реальный cgroupv2 путь для юнита
|
||||
- Дергает Selective-VPN API чтобы применить per-app метку маршрутизации (VPN/Direct)
|
||||
|
||||
This script is used for "desktop shortcuts" and can be run manually.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
DEFAULT_API = "http://127.0.0.1:8080"
|
||||
|
||||
|
||||
def _log_path() -> str:
|
||||
base = os.path.join(os.path.expanduser("~"), ".local", "share", "selective-vpn")
|
||||
os.makedirs(base, exist_ok=True)
|
||||
return os.path.join(base, "svpn_run_profile.log")
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
line = (msg or "").rstrip()
|
||||
if not line:
|
||||
return
|
||||
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
out = f"[{ts}] {line}"
|
||||
try:
|
||||
with open(_log_path(), "a", encoding="utf-8", errors="replace") as f:
|
||||
f.write(out + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
print(out, flush=True)
|
||||
|
||||
|
||||
def api_base() -> str:
|
||||
return str(os.environ.get("SELECTIVE_VPN_API") or DEFAULT_API).rstrip("/")
|
||||
|
||||
|
||||
def api_request(method: str, path: str, *, json_body: Optional[Dict[str, Any]] = None, params=None, timeout: float = 5.0) -> Dict[str, Any]:
|
||||
url = api_base() + path
|
||||
try:
|
||||
r = requests.request(method.upper(), url, json=json_body, params=params, timeout=timeout, headers={"Accept": "application/json"})
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"api request failed: {method} {url}: {e}") from e
|
||||
|
||||
if not (200 <= r.status_code < 300):
|
||||
raise RuntimeError(f"api error: {method} {url} ({r.status_code}): {r.text.strip()}")
|
||||
|
||||
if not r.content:
|
||||
return {}
|
||||
try:
|
||||
v = r.json()
|
||||
if isinstance(v, dict):
|
||||
return v
|
||||
return {"raw": v}
|
||||
except ValueError:
|
||||
return {"raw": r.text}
|
||||
|
||||
|
||||
def list_profiles() -> list[dict]:
|
||||
data = api_request("GET", "/api/v1/traffic/app-profiles", timeout=4.0)
|
||||
raw = data.get("profiles") or []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out = []
|
||||
for it in raw:
|
||||
if isinstance(it, dict):
|
||||
out.append(it)
|
||||
return out
|
||||
|
||||
|
||||
def get_profile(profile_id: str) -> dict:
|
||||
pid = str(profile_id or "").strip()
|
||||
if not pid:
|
||||
raise ValueError("missing profile id")
|
||||
for p in list_profiles():
|
||||
if str(p.get("id") or "").strip() == pid:
|
||||
return p
|
||||
raise RuntimeError(f"profile not found: {pid}")
|
||||
|
||||
|
||||
def infer_app_key(cmdline: str) -> str:
|
||||
return canonicalize_app_key("", cmdline)
|
||||
|
||||
|
||||
def canonicalize_app_key(app_key: str, cmdline: str) -> str:
|
||||
key = (app_key or "").strip()
|
||||
cmd = (cmdline or "").strip()
|
||||
try:
|
||||
tokens = shlex.split(cmd) if cmd else []
|
||||
except Exception:
|
||||
tokens = cmd.split() if cmd else []
|
||||
if not tokens and key:
|
||||
tokens = [key]
|
||||
tokens = [str(x or "").strip() for x in tokens if str(x or "").strip()]
|
||||
if not tokens:
|
||||
return ""
|
||||
|
||||
def base(t: str) -> str:
|
||||
return os.path.basename(str(t or "").strip())
|
||||
|
||||
def extract_run_target(toks: list[str]) -> str:
|
||||
idx = -1
|
||||
for i, t in enumerate(toks):
|
||||
if t == "run":
|
||||
idx = i
|
||||
break
|
||||
if idx < 0:
|
||||
return ""
|
||||
for j in range(idx + 1, len(toks)):
|
||||
t = toks[j].strip()
|
||||
if not t or t == "--":
|
||||
continue
|
||||
if t.startswith("-"):
|
||||
continue
|
||||
return t
|
||||
return ""
|
||||
|
||||
primary = tokens[0]
|
||||
b = base(primary).lower()
|
||||
|
||||
if b == "env":
|
||||
for j in range(1, len(tokens)):
|
||||
t = tokens[j].strip()
|
||||
if not t or t == "--":
|
||||
continue
|
||||
if t.startswith("-"):
|
||||
continue
|
||||
if "=" in t:
|
||||
continue
|
||||
return canonicalize_app_key("", " ".join(tokens[j:]))
|
||||
return "env"
|
||||
|
||||
if b == "flatpak":
|
||||
appid = extract_run_target(tokens)
|
||||
return f"flatpak:{appid}" if appid else "flatpak"
|
||||
|
||||
if b == "snap":
|
||||
name = extract_run_target(tokens)
|
||||
return f"snap:{name}" if name else "snap"
|
||||
|
||||
if b == "gtk-launch" and len(tokens) >= 2:
|
||||
did = tokens[1].strip()
|
||||
if did and not did.startswith("-"):
|
||||
return f"desktop:{did}"
|
||||
|
||||
if "/" in primary:
|
||||
return base(primary) or primary
|
||||
|
||||
return primary
|
||||
|
||||
|
||||
def systemctl_user(args: list[str], *, timeout: float = 4.0) -> tuple[int, str]:
|
||||
cmd = ["systemctl", "--user"] + list(args or [])
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=timeout)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return 124, f"timeout: {' '.join(cmd)}"
|
||||
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
||||
return int(p.returncode or 0), out
|
||||
|
||||
|
||||
def cgroup_path_from_pid(pid: int) -> str:
|
||||
p = int(pid or 0)
|
||||
if p <= 0:
|
||||
return ""
|
||||
try:
|
||||
with open(f"/proc/{p}/cgroup", "r", encoding="utf-8", errors="replace") as f:
|
||||
for raw in f:
|
||||
line = (raw or "").strip()
|
||||
if line.startswith("0::"):
|
||||
cg = line[len("0::"):].strip()
|
||||
return cg
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def effective_cgroup_for_unit(unit: str, *, timeout_sec: float = 2.5) -> str:
|
||||
u = (unit or "").strip()
|
||||
if not u:
|
||||
return ""
|
||||
deadline = time.time() + max(0.2, float(timeout_sec))
|
||||
last = ""
|
||||
while time.time() < deadline:
|
||||
code, out = systemctl_user(["show", "-p", "MainPID", "--value", u], timeout=2.0)
|
||||
last = out or ""
|
||||
if code == 0:
|
||||
try:
|
||||
pid = int((out or "").strip() or "0")
|
||||
except Exception:
|
||||
pid = 0
|
||||
if pid > 0:
|
||||
cg = cgroup_path_from_pid(pid)
|
||||
if cg:
|
||||
return cg
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"failed to resolve effective cgroup for unit={u}: {last.strip() or '(no output)'}")
|
||||
|
||||
|
||||
def run_systemd_unit(cmdline: str, *, unit: str) -> str:
|
||||
cmd = (cmdline or "").strip()
|
||||
if not cmd:
|
||||
raise ValueError("empty command")
|
||||
args = shlex.split(cmd)
|
||||
if not args:
|
||||
raise ValueError("empty args")
|
||||
|
||||
run_cmd = [
|
||||
"systemd-run",
|
||||
"--user",
|
||||
"--unit",
|
||||
unit,
|
||||
"--no-block",
|
||||
"--collect",
|
||||
"--same-dir",
|
||||
] + args
|
||||
|
||||
try:
|
||||
p = subprocess.run(run_cmd, capture_output=True, text=True, check=False, timeout=6)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError("systemd-run timed out") from e
|
||||
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
||||
if p.returncode != 0:
|
||||
raise RuntimeError(f"systemd-run failed: rc={p.returncode}\n{out}".strip())
|
||||
|
||||
cg = effective_cgroup_for_unit(unit, timeout_sec=3.0)
|
||||
return cg
|
||||
|
||||
|
||||
def refresh_if_running(*, target: str, app_key: str, command: str, ttl_sec: int) -> bool:
|
||||
tgt = (target or "").strip().lower()
|
||||
key = (app_key or "").strip()
|
||||
if tgt not in ("vpn", "direct") or not key:
|
||||
return False
|
||||
|
||||
data = api_request("GET", "/api/v1/traffic/appmarks/items", timeout=4.0)
|
||||
items = data.get("items") or []
|
||||
if not isinstance(items, list):
|
||||
return False
|
||||
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
if str(it.get("target") or "").strip().lower() != tgt:
|
||||
continue
|
||||
if str(it.get("app_key") or "").strip() != key:
|
||||
continue
|
||||
unit = str(it.get("unit") or "").strip()
|
||||
if not unit:
|
||||
continue
|
||||
code, out = systemctl_user(["is-active", unit], timeout=2.0)
|
||||
if code != 0 or (out or "").strip().lower() != "active":
|
||||
continue
|
||||
|
||||
cg = effective_cgroup_for_unit(unit, timeout_sec=2.5)
|
||||
payload = {
|
||||
"op": "add",
|
||||
"target": tgt,
|
||||
"cgroup": cg,
|
||||
"unit": unit,
|
||||
"command": command,
|
||||
"app_key": key,
|
||||
"timeout_sec": int(ttl_sec or 0),
|
||||
}
|
||||
res = api_request("POST", "/api/v1/traffic/appmarks", json_body=payload, timeout=4.0)
|
||||
if not bool(res.get("ok", False)):
|
||||
raise RuntimeError(f"appmark refresh failed: {res.get('message')}")
|
||||
log(f"refreshed mark: target={tgt} app={key} unit={unit} cgroup_id={res.get('cgroup_id')}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def apply_mark(*, target: str, cgroup: str, unit: str, command: str, app_key: str, ttl_sec: int) -> None:
|
||||
payload = {
|
||||
"op": "add",
|
||||
"target": target,
|
||||
"cgroup": cgroup,
|
||||
"unit": unit,
|
||||
"command": command,
|
||||
"app_key": app_key,
|
||||
"timeout_sec": int(ttl_sec or 0),
|
||||
}
|
||||
res = api_request("POST", "/api/v1/traffic/appmarks", json_body=payload, timeout=4.0)
|
||||
if not bool(res.get("ok", False)):
|
||||
raise RuntimeError(f"appmark failed: {res.get('message')}")
|
||||
log(f"mark added: target={target} app={app_key} unit={unit} cgroup_id={res.get('cgroup_id')} ttl={res.get('timeout_sec')}")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--id", required=True, help="profile id")
|
||||
ap.add_argument("--json", action="store_true", help="print machine-readable json result")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
pid = str(args.id or "").strip()
|
||||
prof = get_profile(pid)
|
||||
|
||||
cmd = str(prof.get("command") or "").strip()
|
||||
if not cmd:
|
||||
raise RuntimeError("profile command is empty")
|
||||
target = str(prof.get("target") or "vpn").strip().lower()
|
||||
if target not in ("vpn", "direct"):
|
||||
target = "vpn"
|
||||
|
||||
app_key_raw = str(prof.get("app_key") or "").strip()
|
||||
app_key = canonicalize_app_key(app_key_raw, cmd) or canonicalize_app_key("", cmd)
|
||||
ttl = int(prof.get("ttl_sec", 0) or 0)
|
||||
if ttl <= 0:
|
||||
ttl = 24 * 60 * 60
|
||||
|
||||
# Try refresh first if already running.
|
||||
if refresh_if_running(target=target, app_key=app_key, command=cmd, ttl_sec=ttl):
|
||||
if args.json:
|
||||
print(json.dumps({"ok": True, "op": "refresh", "id": pid, "target": target, "app_key": app_key}))
|
||||
return 0
|
||||
|
||||
unit = f"svpn-{target}-{int(time.time())}.service"
|
||||
log(f"launching profile id={pid} target={target} app={app_key} unit={unit}")
|
||||
cg = run_systemd_unit(cmd, unit=unit)
|
||||
log(f"ControlGroup: {cg}")
|
||||
apply_mark(target=target, cgroup=cg, unit=unit, command=cmd, app_key=app_key, ttl_sec=ttl)
|
||||
if args.json:
|
||||
print(json.dumps({"ok": True, "op": "run", "id": pid, "target": target, "app_key": app_key, "unit": unit}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as e:
|
||||
log(f"ERROR: {e}")
|
||||
raise SystemExit(1)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,9 @@ from PySide6.QtWidgets import (
|
||||
QProgressBar,
|
||||
)
|
||||
|
||||
from api_client import ApiClient, DnsUpstreams
|
||||
from api_client import ApiClient
|
||||
from dashboard_controller import DashboardController, TraceMode
|
||||
from dns_benchmark_dialog import DNSBenchmarkDialog
|
||||
from traffic_mode_dialog import TrafficModeDialog
|
||||
|
||||
_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s")
|
||||
@@ -420,31 +421,27 @@ RU: Агрессивный режим дополнительно дергает
|
||||
tip.setStyleSheet("color: gray;")
|
||||
main_layout.addWidget(tip)
|
||||
|
||||
ups_group = QGroupBox("Upstreams (auto-save)")
|
||||
ups_group.setToolTip("""EN: DNS upstreams for direct resolver mode (and non-wildcard lists in hybrid mode).
|
||||
RU: DNS апстримы для direct-резолвера (и для не-wildcard списков в hybrid режиме).""")
|
||||
ups_form = QFormLayout(ups_group)
|
||||
self.ent_def1 = QLineEdit()
|
||||
self.ent_def1.setToolTip("""EN: Upstream default1. You can set an IP (port 53 is assumed).
|
||||
RU: Апстрим default1. Можно указать IP (порт 53 по умолчанию).""")
|
||||
self.ent_def2 = QLineEdit()
|
||||
self.ent_def2.setToolTip("""EN: Upstream default2. You can set an IP (port 53 is assumed).
|
||||
RU: Апстрим default2. Можно указать IP (порт 53 по умолчанию).""")
|
||||
self.ent_meta1 = QLineEdit()
|
||||
self.ent_meta1.setToolTip("""EN: Upstream meta1. You can set an IP (port 53 is assumed).
|
||||
RU: Апстрим meta1. Можно указать IP (порт 53 по умолчанию).""")
|
||||
self.ent_meta2 = QLineEdit()
|
||||
self.ent_meta2.setToolTip("""EN: Upstream meta2. You can set an IP (port 53 is assumed).
|
||||
RU: Апстрим meta2. Можно указать IP (порт 53 по умолчанию).""")
|
||||
self.ent_def1.textEdited.connect(self._schedule_dns_autosave)
|
||||
self.ent_def2.textEdited.connect(self._schedule_dns_autosave)
|
||||
self.ent_meta1.textEdited.connect(self._schedule_dns_autosave)
|
||||
self.ent_meta2.textEdited.connect(self._schedule_dns_autosave)
|
||||
ups_form.addRow("default1", self.ent_def1)
|
||||
ups_form.addRow("default2", self.ent_def2)
|
||||
ups_form.addRow("meta1", self.ent_meta1)
|
||||
ups_form.addRow("meta2", self.ent_meta2)
|
||||
main_layout.addWidget(ups_group)
|
||||
resolver_group = QGroupBox("Resolver DNS")
|
||||
resolver_group.setToolTip("""EN: Compact resolver DNS status. Open benchmark to test/apply upstreams.
|
||||
RU: Компактный статус DNS резолвера. Открой benchmark для проверки/применения апстримов.""")
|
||||
resolver_layout = QVBoxLayout(resolver_group)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.btn_dns_benchmark = QPushButton("Open DNS benchmark")
|
||||
self.btn_dns_benchmark.clicked.connect(self.on_open_dns_benchmark)
|
||||
row.addWidget(self.btn_dns_benchmark)
|
||||
row.addStretch(1)
|
||||
resolver_layout.addLayout(row)
|
||||
|
||||
self.lbl_dns_resolver_upstreams = QLabel("Resolver upstreams: default[—, —] meta[—, —]")
|
||||
self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;")
|
||||
resolver_layout.addWidget(self.lbl_dns_resolver_upstreams)
|
||||
|
||||
self.lbl_dns_resolver_health = QLabel("Resolver health: —")
|
||||
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
|
||||
resolver_layout.addWidget(self.lbl_dns_resolver_health)
|
||||
|
||||
main_layout.addWidget(resolver_group)
|
||||
|
||||
smart_group = QGroupBox("SmartDNS")
|
||||
smart_group.setToolTip("""EN: SmartDNS is used for wildcard domains in hybrid mode.
|
||||
@@ -732,17 +729,68 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
self.lbl_dns_mode_state.setText(txt)
|
||||
self.lbl_dns_mode_state.setStyleSheet(f"color: {color};")
|
||||
|
||||
def _set_dns_resolver_summary(self, pool_items) -> None:
|
||||
active = []
|
||||
total = 0
|
||||
for item in pool_items or []:
|
||||
addr = str(getattr(item, "addr", "") or "").strip()
|
||||
if not addr:
|
||||
continue
|
||||
total += 1
|
||||
if bool(getattr(item, "enabled", False)):
|
||||
active.append(addr)
|
||||
if not active:
|
||||
text = f"Resolver upstreams: active=0/{total} (empty set)"
|
||||
else:
|
||||
preview = ", ".join(active[:4])
|
||||
if len(active) > 4:
|
||||
preview += f", +{len(active)-4} more"
|
||||
text = f"Resolver upstreams: active={len(active)}/{total} [{preview}]"
|
||||
self.lbl_dns_resolver_upstreams.setText(text)
|
||||
self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;")
|
||||
|
||||
avg_ms = self._ui_settings.value("dns_benchmark/last_avg_ms", None)
|
||||
ok = self._ui_settings.value("dns_benchmark/last_ok", None)
|
||||
fail = self._ui_settings.value("dns_benchmark/last_fail", None)
|
||||
timeout = self._ui_settings.value("dns_benchmark/last_timeout", None)
|
||||
if avg_ms is None or ok is None or fail is None:
|
||||
self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet")
|
||||
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
|
||||
return
|
||||
try:
|
||||
avg = int(avg_ms)
|
||||
ok_i = int(ok)
|
||||
fail_i = int(fail)
|
||||
timeout_i = int(timeout or 0)
|
||||
except Exception:
|
||||
self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet")
|
||||
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
|
||||
return
|
||||
color = "green" if avg < 200 else ("#b58900" if avg <= 400 else "red")
|
||||
if timeout_i > 0 and color != "red":
|
||||
color = "#b58900"
|
||||
self.lbl_dns_resolver_health.setText(
|
||||
f"Resolver health: avg={avg}ms ok={ok_i} fail={fail_i} timeout={timeout_i}"
|
||||
)
|
||||
self.lbl_dns_resolver_health.setStyleSheet(f"color: {color};")
|
||||
|
||||
def _set_traffic_mode_state(
|
||||
self,
|
||||
desired_mode: str,
|
||||
applied_mode: str,
|
||||
preferred_iface: str,
|
||||
advanced_active: bool,
|
||||
auto_local_bypass: bool,
|
||||
auto_local_active: bool,
|
||||
ingress_reply_bypass: bool,
|
||||
ingress_reply_active: bool,
|
||||
bypass_candidates: int,
|
||||
overrides_applied: int,
|
||||
cgroup_resolved_uids: int,
|
||||
cgroup_warning: str,
|
||||
healthy: bool,
|
||||
ingress_rule_present: bool,
|
||||
ingress_nft_active: bool,
|
||||
probe_ok: bool,
|
||||
probe_message: str,
|
||||
active_iface: str,
|
||||
@@ -763,9 +811,17 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
diag_parts = []
|
||||
diag_parts.append(f"preferred={preferred_iface or 'auto'}")
|
||||
diag_parts.append(
|
||||
f"auto_local_bypass={'on' if auto_local_bypass else 'off'}"
|
||||
f"advanced={'on' if advanced_active else 'off'}"
|
||||
)
|
||||
if bypass_candidates > 0:
|
||||
diag_parts.append(
|
||||
f"auto_local={'on' if auto_local_bypass else 'off'}"
|
||||
f"({'active' if auto_local_active else 'saved'})"
|
||||
)
|
||||
diag_parts.append(
|
||||
f"ingress_reply={'on' if ingress_reply_bypass else 'off'}"
|
||||
f"({'active' if ingress_reply_active else 'saved'})"
|
||||
)
|
||||
if auto_local_active and bypass_candidates > 0:
|
||||
diag_parts.append(f"bypass_routes={bypass_candidates}")
|
||||
diag_parts.append(f"overrides={overrides_applied}")
|
||||
if cgroup_resolved_uids > 0:
|
||||
@@ -776,6 +832,10 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
diag_parts.append(f"iface={active_iface}")
|
||||
if iface_reason:
|
||||
diag_parts.append(f"source={iface_reason}")
|
||||
diag_parts.append(
|
||||
f"ingress_diag=rule:{'ok' if ingress_rule_present else 'off'}"
|
||||
f"/nft:{'ok' if ingress_nft_active else 'off'}"
|
||||
)
|
||||
diag_parts.append(f"probe={'ok' if probe_ok else 'fail'}")
|
||||
if probe_message:
|
||||
diag_parts.append(probe_message)
|
||||
@@ -998,12 +1058,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
t.desired_mode,
|
||||
t.applied_mode,
|
||||
t.preferred_iface,
|
||||
bool(t.advanced_active),
|
||||
bool(t.auto_local_bypass),
|
||||
bool(t.auto_local_active),
|
||||
bool(t.ingress_reply_bypass),
|
||||
bool(t.ingress_reply_active),
|
||||
int(t.bypass_candidates),
|
||||
int(t.overrides_applied),
|
||||
int(t.cgroup_resolved_uids),
|
||||
t.cgroup_warning,
|
||||
bool(t.healthy),
|
||||
bool(t.ingress_rule_present),
|
||||
bool(t.ingress_nft_active),
|
||||
bool(t.probe_ok),
|
||||
t.probe_message,
|
||||
t.active_iface,
|
||||
@@ -1016,11 +1082,8 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
def work():
|
||||
self._dns_ui_refresh = True
|
||||
try:
|
||||
ups = self.ctrl.dns_upstreams_view()
|
||||
self.ent_def1.setText(ups.default1 or "")
|
||||
self.ent_def2.setText(ups.default2 or "")
|
||||
self.ent_meta1.setText(ups.meta1 or "")
|
||||
self.ent_meta2.setText(ups.meta2 or "")
|
||||
pool = self.ctrl.dns_upstream_pool_view()
|
||||
self._set_dns_resolver_summary(getattr(pool, "items", []))
|
||||
|
||||
st = self.ctrl.dns_status_view()
|
||||
self.ent_smartdns_addr.setText(st.smartdns_addr or "")
|
||||
@@ -1037,12 +1100,6 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
self.chk_dns_via_smartdns.setChecked(hybrid_enabled)
|
||||
self.chk_dns_via_smartdns.blockSignals(False)
|
||||
|
||||
# In direct + hybrid modes upstreams stay editable.
|
||||
self.ent_def1.setEnabled(True)
|
||||
self.ent_def2.setEnabled(True)
|
||||
self.ent_meta1.setEnabled(True)
|
||||
self.ent_meta2.setEnabled(True)
|
||||
|
||||
unit_state = (st.unit_state or "unknown").strip().lower()
|
||||
unit_active = unit_state == "active"
|
||||
self.chk_dns_unit_relay.blockSignals(True)
|
||||
@@ -1386,13 +1443,6 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
def work():
|
||||
if self._dns_ui_refresh:
|
||||
return
|
||||
ups = DnsUpstreams(
|
||||
default1=self.ent_def1.text().strip(),
|
||||
default2=self.ent_def2.text().strip(),
|
||||
meta1=self.ent_meta1.text().strip(),
|
||||
meta2=self.ent_meta2.text().strip(),
|
||||
)
|
||||
self.ctrl.dns_upstreams_save(ups)
|
||||
self.ctrl.dns_mode_set(
|
||||
self.chk_dns_via_smartdns.isChecked(),
|
||||
self.ent_smartdns_addr.text().strip(),
|
||||
@@ -1400,6 +1450,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
||||
self.ctrl.log_gui("DNS settings autosaved")
|
||||
self._safe(work, title="DNS save error")
|
||||
|
||||
def on_open_dns_benchmark(self) -> None:
|
||||
def work():
|
||||
dlg = DNSBenchmarkDialog(
|
||||
self.ctrl,
|
||||
settings=self._ui_settings,
|
||||
refresh_cb=self.refresh_dns_tab,
|
||||
parent=self,
|
||||
)
|
||||
dlg.exec()
|
||||
self.refresh_dns_tab()
|
||||
self._safe(work, title="DNS benchmark error")
|
||||
|
||||
def on_dns_mode_toggle(self) -> None:
|
||||
def work():
|
||||
via = self.chk_dns_via_smartdns.isChecked()
|
||||
|
||||
Reference in New Issue
Block a user