Harden resolver and expand traffic runtime controls

This commit is contained in:
beckline
2026-02-24 00:17:46 +03:00
parent 89eaaf3f23
commit 50518a641d
18 changed files with 2048 additions and 181 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ selective-vpn-gui/main.go
*.bak.*
*.tmp
selective-vpn-api/works
selective-vpn-api/_backups/
# Local archive / old copies (kept out of repo root)
_legacy/

View File

@@ -20,3 +20,11 @@ Requirements (high level):
- Linux with `systemd`, `nftables`, `iproute2`, cgroup v2.
- Python 3 + PySide6 + `requests` (GUI).
- Root privileges for routing/nftables changes (run API as a privileged service).
Quick traffic checklist (production-safe):
- Start from `Selective` mode for mixed host/server workloads.
- For `Full tunnel`, open **Advanced bypass** in Traffic basics and usually enable:
- `Auto-local bypass` (LAN/container reachability),
- `Ingress-reply bypass` (keep inbound/public services reachable).
- Verify mode health is `OK` and ingress diagnostics are active when ingress bypass is enabled.
- If something breaks, use **Reset bypass** (advanced bypass dialog) or temporarily switch back to `Selective`.

View File

@@ -836,7 +836,15 @@ func runSmartdnsPrewarm(limit, workers, timeoutMS int, aggressiveSubs bool) cmdR
)
}
if len(domains) > loggedHosts {
appendTraceLineTo(smartdnsLogPath, "smartdns", fmt.Sprintf("prewarm add: +%d domains omitted", len(domains)-loggedHosts))
appendTraceLineTo(
smartdnsLogPath,
"smartdns",
fmt.Sprintf(
"prewarm add: trace truncated, omitted=%d hosts (full wildcard map: %s)",
len(domains)-loggedHosts,
lastIPsMapDyn,
),
)
}
msg := fmt.Sprintf(

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
)
@@ -42,9 +43,24 @@ func handleDomainsTable(w http.ResponseWriter, r *http.Request) {
return
}
stdout, _, _, err := runCommand("ipset", "list", "agvpn4")
lines := []string{}
if err == nil {
for _, setName := range []string{"agvpn4", "agvpn_dyn4"} {
stdout, _, code, _ := runCommand("nft", "list", "set", "inet", "agvpn", setName)
if code == 0 {
for _, l := range strings.Split(stdout, "\n") {
l = strings.TrimRight(l, "\r")
if l != "" {
lines = append(lines, l)
}
}
continue
}
// Backward-compatible fallback for legacy hosts that still have ipset.
stdout, _, code, _ = runCommand("ipset", "list", setName)
if code != 0 {
continue
}
for _, l := range strings.Split(stdout, "\n") {
l = strings.TrimRight(l, "\r")
if l != "" {
@@ -59,7 +75,7 @@ func handleDomainsTable(w http.ResponseWriter, r *http.Request) {
// domains file
// ---------------------------------------------------------------------
// GET /api/v1/domains/file?name=bases|meta|subs|static|smartdns|last-ips-map|last-ips-map-direct|last-ips-map-wildcard
// GET /api/v1/domains/file?name=bases|meta|subs|static|smartdns|last-ips-map|last-ips-map-direct|last-ips-map-wildcard|wildcard-observed-hosts
// POST /api/v1/domains/file { "name": "...", "content": "..." }
func handleDomainsFile(w http.ResponseWriter, r *http.Request) {
switch r.Method {
@@ -73,6 +89,13 @@ func handleDomainsFile(w http.ResponseWriter, r *http.Request) {
})
return
}
if name == "wildcard-observed-hosts" {
writeJSON(w, http.StatusOK, map[string]string{
"content": readWildcardObservedHostsContent(),
"source": "derived",
})
return
}
path, ok := domainFiles[name]
if !ok {
http.Error(w, "unknown file name", http.StatusBadRequest)
@@ -126,7 +149,7 @@ func handleDomainsFile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return
}
if body.Name == "last-ips-map-direct" || body.Name == "last-ips-map-wildcard" {
if body.Name == "last-ips-map-direct" || body.Name == "last-ips-map-wildcard" || body.Name == "wildcard-observed-hosts" {
http.Error(w, "read-only file name", http.StatusBadRequest)
return
}
@@ -146,6 +169,39 @@ func handleDomainsFile(w http.ResponseWriter, r *http.Request) {
}
}
func readWildcardObservedHostsContent() string {
data, err := os.ReadFile(lastIPsMapDyn)
if err != nil {
return ""
}
seen := make(map[string]struct{})
out := make([]string, 0, 256)
for _, ln := range strings.Split(string(data), "\n") {
ln = strings.TrimSpace(ln)
if ln == "" || strings.HasPrefix(ln, "#") {
continue
}
fields := strings.Fields(ln)
if len(fields) < 2 {
continue
}
host := strings.TrimSpace(fields[1])
if host == "" || strings.HasPrefix(host, "[") {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
out = append(out, host)
}
sort.Strings(out)
if len(out) == 0 {
return ""
}
return strings.Join(out, "\n") + "\n"
}
// ---------------------------------------------------------------------
// smartdns wildcards
// ---------------------------------------------------------------------

View File

@@ -265,6 +265,23 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
domainCache := loadDomainCacheState(opts.CachePath, logf)
ptrCache := loadJSONMap(opts.PtrCachePath)
now := int(time.Now().Unix())
negTTLNX := envInt("RESOLVE_NEGATIVE_TTL_NX", 6*3600)
negTTLTimeout := envInt("RESOLVE_NEGATIVE_TTL_TIMEOUT", 15*60)
negTTLTemporary := envInt("RESOLVE_NEGATIVE_TTL_TEMPORARY", 10*60)
negTTLOther := envInt("RESOLVE_NEGATIVE_TTL_OTHER", 10*60)
clampTTL := func(v int) int {
if v < 0 {
return 0
}
if v > 24*3600 {
return 24 * 3600
}
return v
}
negTTLNX = clampTTL(negTTLNX)
negTTLTimeout = clampTTL(negTTLTimeout)
negTTLTemporary = clampTTL(negTTLTemporary)
negTTLOther = clampTTL(negTTLOther)
cacheSourceForHost := func(host string) domainCacheSource {
switch cfg.Mode {
@@ -284,6 +301,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
start := time.Now()
fresh := map[string][]string{}
cacheNegativeHits := 0
var toResolve []string
for _, d := range domains {
source := cacheSourceForHost(d)
@@ -294,6 +312,13 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
}
continue
}
if kind, age, ok := domainCache.getNegative(d, source, now, negTTLNX, negTTLTimeout, negTTLTemporary, negTTLOther); ok {
cacheNegativeHits++
if logf != nil {
logf("cache neg hit[%s/%s age=%ds]: %s", source, kind, age, d)
}
continue
}
toResolve = append(toResolve, d)
}
@@ -303,7 +328,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
}
if logf != nil {
logf("resolve: domains=%d cache_hits=%d to_resolve=%d", len(domains), len(fresh), len(toResolve))
logf("resolve: domains=%d cache_hits=%d cache_neg_hits=%d to_resolve=%d", len(domains), len(fresh), cacheNegativeHits, len(toResolve))
}
dnsStats := dnsMetrics{}
@@ -349,8 +374,16 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
if logf != nil {
logf("%s -> %v", r.host, r.ips)
}
} else if logf != nil {
logf("%s: no IPs", r.host)
} else {
if hostErrors > 0 {
source := cacheSourceForHost(r.host)
if kind, ok := classifyHostErrorKind(r.stats); ok {
domainCache.setError(r.host, source, kind, now)
}
}
if logf != nil {
logf("%s: no IPs", r.host)
}
}
}
}
@@ -443,9 +476,10 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
if logf != nil {
dnsErrors := dnsStats.totalErrors()
logf(
"resolve summary: domains=%d cache_hits=%d resolved_now=%d unresolved=%d static_entries=%d static_skipped=%d unique_ips=%d direct_ips=%d wildcard_ips=%d ptr_lookups=%d ptr_errors=%d dns_attempts=%d dns_ok=%d dns_nxdomain=%d dns_timeout=%d dns_temporary=%d dns_other=%d dns_errors=%d duration_ms=%d",
"resolve summary: domains=%d cache_hits=%d cache_neg_hits=%d resolved_now=%d unresolved=%d static_entries=%d static_skipped=%d unique_ips=%d direct_ips=%d wildcard_ips=%d ptr_lookups=%d ptr_errors=%d dns_attempts=%d dns_ok=%d dns_nxdomain=%d dns_timeout=%d dns_temporary=%d dns_other=%d dns_errors=%d duration_ms=%d",
len(domains),
len(fresh),
cacheNegativeHits,
len(resolved)-len(fresh),
len(domains)-len(resolved),
len(staticEntries),
@@ -487,17 +521,45 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w
if useMeta {
dnsList = cfg.Meta
}
primaryViaSmartDNS := false
switch cfg.Mode {
case DNSModeSmartDNS:
if cfg.SmartDNS != "" {
dnsList = []string{cfg.SmartDNS}
primaryViaSmartDNS = true
}
case DNSModeHybridWildcard:
if cfg.SmartDNS != "" && wildcards.match(host) {
dnsList = []string{cfg.SmartDNS}
primaryViaSmartDNS = true
}
}
ips, stats := digA(host, dnsList, timeout, logf)
if len(ips) == 0 &&
!primaryViaSmartDNS &&
cfg.SmartDNS != "" &&
smartDNSFallbackForTimeoutEnabled() &&
shouldFallbackToSmartDNS(stats) {
if logf != nil {
logf(
"dns fallback %s: trying smartdns=%s after errors nxdomain=%d timeout=%d temporary=%d other=%d",
host,
cfg.SmartDNS,
stats.NXDomain,
stats.Timeout,
stats.Temporary,
stats.Other,
)
}
fallbackIPs, fallbackStats := digA(host, []string{cfg.SmartDNS}, timeout, logf)
stats.merge(fallbackStats)
if len(fallbackIPs) > 0 {
ips = fallbackIPs
if logf != nil {
logf("dns fallback %s: resolved via smartdns (%d ips)", host, len(fallbackIPs))
}
}
}
out := []string{}
seen := map[string]struct{}{}
for _, ip := range ips {
@@ -512,6 +574,52 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w
return out, stats
}
// smartDNSFallbackForTimeoutEnabled controls direct->SmartDNS fallback behavior.
// Default is disabled to avoid overloading SmartDNS on large unresolved batches.
// Set RESOLVE_SMARTDNS_TIMEOUT_FALLBACK=1 to enable.
func smartDNSFallbackForTimeoutEnabled() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("RESOLVE_SMARTDNS_TIMEOUT_FALLBACK")))
switch v {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return false
}
}
// Fallback is useful only for transport-like errors. If we already got NXDOMAIN,
// SmartDNS fallback is unlikely to change result and only adds latency/noise.
func shouldFallbackToSmartDNS(stats dnsMetrics) bool {
if stats.OK > 0 {
return false
}
if stats.NXDomain > 0 {
return false
}
if stats.Timeout > 0 || stats.Temporary > 0 {
return true
}
return stats.Other > 0
}
func classifyHostErrorKind(stats dnsMetrics) (dnsErrorKind, bool) {
if stats.Timeout > 0 {
return dnsErrorTimeout, true
}
if stats.Temporary > 0 {
return dnsErrorTemporary, true
}
if stats.Other > 0 {
return dnsErrorOther, true
}
if stats.NXDomain > 0 {
return dnsErrorNXDomain, true
}
return "", false
}
// ---------------------------------------------------------------------
// EN: `digA` contains core logic for dig a.
// RU: `digA` - содержит основную логику для dig a.
@@ -742,8 +850,10 @@ const (
)
type domainCacheEntry struct {
IPs []string `json:"ips"`
LastResolved int `json:"last_resolved"`
IPs []string `json:"ips,omitempty"`
LastResolved int `json:"last_resolved,omitempty"`
LastErrorKind string `json:"last_error_kind,omitempty"`
LastErrorAt int `json:"last_error_at,omitempty"`
}
type domainCacheRecord struct {
@@ -758,7 +868,7 @@ type domainCacheState struct {
func newDomainCacheState() domainCacheState {
return domainCacheState{
Version: 2,
Version: 3,
Domains: map[string]domainCacheRecord{},
}
}
@@ -781,6 +891,41 @@ func normalizeCacheIPs(raw []string) []string {
return out
}
func normalizeCacheErrorKind(raw string) (dnsErrorKind, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case string(dnsErrorNXDomain):
return dnsErrorNXDomain, true
case string(dnsErrorTimeout):
return dnsErrorTimeout, true
case string(dnsErrorTemporary):
return dnsErrorTemporary, true
case string(dnsErrorOther):
return dnsErrorOther, true
default:
return "", false
}
}
func normalizeDomainCacheEntry(in *domainCacheEntry) *domainCacheEntry {
if in == nil {
return nil
}
out := &domainCacheEntry{}
ips := normalizeCacheIPs(in.IPs)
if len(ips) > 0 && in.LastResolved > 0 {
out.IPs = ips
out.LastResolved = in.LastResolved
}
if kind, ok := normalizeCacheErrorKind(in.LastErrorKind); ok && in.LastErrorAt > 0 {
out.LastErrorKind = string(kind)
out.LastErrorAt = in.LastErrorAt
}
if out.LastResolved <= 0 && out.LastErrorAt <= 0 {
return nil
}
return out
}
func parseAnyStringSlice(raw any) []string {
switch v := raw.(type) {
case []string:
@@ -842,7 +987,7 @@ func loadDomainCacheState(path string, logf func(string, ...any)) domainCacheSta
var st domainCacheState
if err := json.Unmarshal(data, &st); err == nil && st.Domains != nil {
if st.Version <= 0 {
st.Version = 2
st.Version = 3
}
normalized := newDomainCacheState()
for host, rec := range st.Domains {
@@ -851,18 +996,8 @@ func loadDomainCacheState(path string, logf func(string, ...any)) domainCacheSta
continue
}
nrec := domainCacheRecord{}
if rec.Direct != nil {
ips := normalizeCacheIPs(rec.Direct.IPs)
if len(ips) > 0 && rec.Direct.LastResolved > 0 {
nrec.Direct = &domainCacheEntry{IPs: ips, LastResolved: rec.Direct.LastResolved}
}
}
if rec.Wildcard != nil {
ips := normalizeCacheIPs(rec.Wildcard.IPs)
if len(ips) > 0 && rec.Wildcard.LastResolved > 0 {
nrec.Wildcard = &domainCacheEntry{IPs: ips, LastResolved: rec.Wildcard.LastResolved}
}
}
nrec.Direct = normalizeDomainCacheEntry(rec.Direct)
nrec.Wildcard = normalizeDomainCacheEntry(rec.Wildcard)
if nrec.Direct != nil || nrec.Wildcard != nil {
normalized.Domains[host] = nrec
}
@@ -926,6 +1061,46 @@ func (s domainCacheState) get(domain string, source domainCacheSource, now, ttl
return ips, true
}
func (s domainCacheState) getNegative(domain string, source domainCacheSource, now, nxTTL, timeoutTTL, temporaryTTL, otherTTL int) (dnsErrorKind, int, bool) {
rec, ok := s.Domains[strings.TrimSpace(strings.ToLower(domain))]
if !ok {
return "", 0, false
}
var entry *domainCacheEntry
switch source {
case domainCacheSourceWildcard:
entry = rec.Wildcard
default:
entry = rec.Direct
}
if entry == nil || entry.LastErrorAt <= 0 {
return "", 0, false
}
kind, ok := normalizeCacheErrorKind(entry.LastErrorKind)
if !ok {
return "", 0, false
}
age := now - entry.LastErrorAt
if age < 0 {
return "", 0, false
}
cacheTTL := 0
switch kind {
case dnsErrorNXDomain:
cacheTTL = nxTTL
case dnsErrorTimeout:
cacheTTL = timeoutTTL
case dnsErrorTemporary:
cacheTTL = temporaryTTL
case dnsErrorOther:
cacheTTL = otherTTL
}
if cacheTTL <= 0 || age > cacheTTL {
return "", 0, false
}
return kind, age, true
}
func (s *domainCacheState) set(domain string, source domainCacheSource, ips []string, now int) {
host := strings.TrimSpace(strings.ToLower(domain))
if host == "" || now <= 0 {
@@ -939,7 +1114,10 @@ func (s *domainCacheState) set(domain string, source domainCacheSource, ips []st
s.Domains = map[string]domainCacheRecord{}
}
rec := s.Domains[host]
entry := &domainCacheEntry{IPs: norm, LastResolved: now}
entry := &domainCacheEntry{
IPs: norm,
LastResolved: now,
}
switch source {
case domainCacheSourceWildcard:
rec.Wildcard = entry
@@ -949,9 +1127,39 @@ func (s *domainCacheState) set(domain string, source domainCacheSource, ips []st
s.Domains[host] = rec
}
func (s *domainCacheState) setError(domain string, source domainCacheSource, kind dnsErrorKind, now int) {
host := strings.TrimSpace(strings.ToLower(domain))
if host == "" || now <= 0 {
return
}
normKind, ok := normalizeCacheErrorKind(string(kind))
if !ok {
return
}
if s.Domains == nil {
s.Domains = map[string]domainCacheRecord{}
}
rec := s.Domains[host]
switch source {
case domainCacheSourceWildcard:
if rec.Wildcard == nil {
rec.Wildcard = &domainCacheEntry{}
}
rec.Wildcard.LastErrorKind = string(normKind)
rec.Wildcard.LastErrorAt = now
default:
if rec.Direct == nil {
rec.Direct = &domainCacheEntry{}
}
rec.Direct.LastErrorKind = string(normKind)
rec.Direct.LastErrorAt = now
}
s.Domains[host] = rec
}
func (s domainCacheState) toMap() map[string]any {
out := map[string]any{
"version": 2,
"version": 3,
"domains": map[string]any{},
}
domainsAny := out["domains"].(map[string]any)
@@ -963,16 +1171,32 @@ func (s domainCacheState) toMap() map[string]any {
for _, host := range hosts {
rec := s.Domains[host]
recOut := map[string]any{}
if rec.Direct != nil && len(rec.Direct.IPs) > 0 && rec.Direct.LastResolved > 0 {
recOut["direct"] = map[string]any{
"ips": rec.Direct.IPs,
"last_resolved": rec.Direct.LastResolved,
if rec.Direct != nil {
directOut := map[string]any{}
if len(rec.Direct.IPs) > 0 && rec.Direct.LastResolved > 0 {
directOut["ips"] = rec.Direct.IPs
directOut["last_resolved"] = rec.Direct.LastResolved
}
if kind, ok := normalizeCacheErrorKind(rec.Direct.LastErrorKind); ok && rec.Direct.LastErrorAt > 0 {
directOut["last_error_kind"] = string(kind)
directOut["last_error_at"] = rec.Direct.LastErrorAt
}
if len(directOut) > 0 {
recOut["direct"] = directOut
}
}
if rec.Wildcard != nil && len(rec.Wildcard.IPs) > 0 && rec.Wildcard.LastResolved > 0 {
recOut["wildcard"] = map[string]any{
"ips": rec.Wildcard.IPs,
"last_resolved": rec.Wildcard.LastResolved,
if rec.Wildcard != nil {
wildOut := map[string]any{}
if len(rec.Wildcard.IPs) > 0 && rec.Wildcard.LastResolved > 0 {
wildOut["ips"] = rec.Wildcard.IPs
wildOut["last_resolved"] = rec.Wildcard.LastResolved
}
if kind, ok := normalizeCacheErrorKind(rec.Wildcard.LastErrorKind); ok && rec.Wildcard.LastErrorAt > 0 {
wildOut["last_error_kind"] = string(kind)
wildOut["last_error_at"] = rec.Wildcard.LastErrorAt
}
if len(wildOut) > 0 {
recOut["wildcard"] = wildOut
}
}
if len(recOut) > 0 {

View File

@@ -59,6 +59,12 @@ func saveRoutesClearCache() (routesClearCacheMeta, error) {
if err := cacheCopyOrEmpty(stateDir+"/last-ips-map.txt", routesCacheMap); err != nil {
warns = append(warns, fmt.Sprintf("last-ips-map cache copy failed: %v", err))
}
if err := cacheCopyOrEmpty(lastIPsMapDirect, routesCacheMapD); err != nil {
warns = append(warns, fmt.Sprintf("last-ips-map-direct cache copy failed: %v", err))
}
if err := cacheCopyOrEmpty(lastIPsMapDyn, routesCacheMapW); err != nil {
warns = append(warns, fmt.Sprintf("last-ips-map-wildcard cache copy failed: %v", err))
}
meta := routesClearCacheMeta{
CreatedAt: time.Now().UTC().Format(time.RFC3339),
@@ -83,6 +89,10 @@ func saveRoutesClearCache() (routesClearCacheMeta, error) {
}
func restoreRoutesFromCache() cmdResult {
return withRoutesOpLock("routes restore", restoreRoutesFromCacheUnlocked)
}
func restoreRoutesFromCacheUnlocked() cmdResult {
meta, err := loadRoutesClearCacheMeta()
if err != nil {
return cmdResult{
@@ -174,6 +184,13 @@ func restoreRoutesFromCache() cmdResult {
if fileExists(routesCacheMap) {
_ = cacheCopyOrEmpty(routesCacheMap, stateDir+"/last-ips-map.txt")
}
if fileExists(routesCacheMapD) {
_ = cacheCopyOrEmpty(routesCacheMapD, lastIPsMapDirect)
}
if fileExists(routesCacheMapW) {
_ = cacheCopyOrEmpty(routesCacheMapW, lastIPsMapDyn)
}
_ = writeStatusSnapshot(len(ips)+len(dynIPs), iface)
return cmdResult{
OK: true,

View File

@@ -9,6 +9,7 @@ import (
"os"
"strings"
"syscall"
"time"
)
// ---------------------------------------------------------------------
@@ -263,6 +264,10 @@ func handleRoutesCacheRestore(w http.ResponseWriter, r *http.Request) {
// RU: `routesClear` - содержит основную логику для routes clear.
// ---------------------------------------------------------------------
func routesClear() cmdResult {
return withRoutesOpLock("routes clear", routesClearUnlocked)
}
func routesClearUnlocked() cmdResult {
cacheMeta, cacheErr := saveRoutesClearCache()
stdout, stderr, _, err := runCommand("ip", "rule", "show")
@@ -273,6 +278,11 @@ func routesClear() cmdResult {
_, _, _, _ = runCommand("ip", "route", "flush", "table", routesTableName())
_, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn4")
_, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn_dyn4")
iface := strings.TrimSpace(cacheMeta.Iface)
if iface == "" {
iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface)
}
_ = writeStatusSnapshot(0, iface)
res := cmdResult{
OK: true,
@@ -297,6 +307,50 @@ func routesClear() cmdResult {
return res
}
func withRoutesOpLock(opName string, fn func() cmdResult) cmdResult {
lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return cmdResult{
OK: false,
Message: fmt.Sprintf("%s lock open error: %v", opName, err),
}
}
defer lock.Close()
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
return cmdResult{
OK: false,
Message: fmt.Sprintf("%s skipped: routes operation already running", opName),
}
}
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
return fn()
}
func writeStatusSnapshot(ipCount int, iface string) error {
if ipCount < 0 {
ipCount = 0
}
iface = strings.TrimSpace(iface)
if iface == "" {
iface = "-"
}
st := Status{
Timestamp: time.Now().UTC().Format(time.RFC3339),
IPCount: ipCount,
DomainCount: countDomainsFromMap(lastIPsMapPath),
Iface: iface,
Table: routesTableName(),
Mark: MARK,
}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
return os.WriteFile(statusFilePath, data, 0o644)
}
// ---------------------------------------------------------------------
// policy route
// ---------------------------------------------------------------------

View File

@@ -189,6 +189,13 @@ func routesUpdate(iface string) cmdResult {
bases := loadList(domainDir + "/bases.txt")
subs := loadList(domainDir + "/subs.txt")
wildcards := loadSmartDNSWildcardDomains(logp)
wildcardBaseSet := make(map[string]struct{}, len(wildcards))
for _, d := range wildcards {
d = strings.TrimSpace(d)
if d != "" {
wildcardBaseSet[d] = struct{}{}
}
}
wildcardBasesAdded := 0
for _, d := range wildcards {
d = strings.TrimSpace(d)
@@ -212,7 +219,10 @@ func routesUpdate(iface string) cmdResult {
twitterAdded := 0
for _, d := range bases {
domainSet[d] = struct{}{}
if !isGoogleLike(d) {
_, wildcardBase := wildcardBaseSet[d]
// Wildcard bases are now resolved "as-is" (no subs fan-out) to keep
// SmartDNS wildcard behavior transparent and avoid synthetic host noise.
if !wildcardBase && !isGoogleLike(d) {
limit := len(subs)
if subsPerBaseLimit > 0 && subsPerBaseLimit < limit {
limit = subsPerBaseLimit
@@ -258,6 +268,14 @@ func routesUpdate(iface string) cmdResult {
)
if wildcardBasesAdded > 0 {
logp("domains wildcard seed added: %d base domains from smartdns.conf state", wildcardBasesAdded)
appendTraceLineTo(
smartdnsLogPath,
"smartdns",
fmt.Sprintf(
"wildcard plan: base_domains=%d sub_expanded=0 (routes update uses pure wildcard bases; subs fan-out only in aggressive prewarm)",
wildcardBasesAdded,
),
)
}
domTmp, _ := os.CreateTemp(stateDir, "domains-*.txt")
@@ -612,19 +630,27 @@ func logWildcardSmartDNSTrace(mode DNSMode, source string, pairs [][2]string, wi
}
sort.Strings(hosts)
const maxHostsLog = 200
omitted := 0
if len(hosts) > maxHostsLog {
omitted = len(hosts) - maxHostsLog
}
appendTraceLineTo(
smartdnsLogPath,
"smartdns",
fmt.Sprintf("wildcard sync: mode=%s source=%s domains=%d ips=%d", mode.Mode, source, len(hosts), wildcardIPCount),
fmt.Sprintf(
"wildcard sync: mode=%s source=%s domains=%d ips=%d logged=%d omitted=%d map=%s",
mode.Mode, source, len(hosts), wildcardIPCount, len(hosts)-omitted, omitted, lastIPsMapDyn,
),
)
const maxHostsLog = 200
for i, host := range hosts {
if i >= maxHostsLog {
appendTraceLineTo(
smartdnsLogPath,
"smartdns",
fmt.Sprintf("wildcard sync: +%d domains omitted", len(hosts)-maxHostsLog),
fmt.Sprintf("wildcard sync: trace truncated, %d domains not shown (see %s)", omitted, lastIPsMapDyn),
)
return
}

View File

@@ -25,7 +25,7 @@ import (
// RU: привязаны к конкретному systemd unit/cgroup.
const (
trafficAppProfilesDefaultTTLSec = 24 * 60 * 60
trafficAppProfilesDefaultTTLSec = 0 // 0 = persistent runtime mark policy
)
var trafficAppProfilesMu sync.Mutex
@@ -295,6 +295,11 @@ func loadTrafficAppProfilesState() trafficAppProfilesState {
st.Profiles[i].AppKey = canon
changed = true
}
st.Profiles[i].Target = strings.ToLower(strings.TrimSpace(st.Profiles[i].Target))
}
if deduped, dedupChanged := dedupeTrafficAppProfiles(st.Profiles); dedupChanged {
st.Profiles = deduped
changed = true
}
if changed {
_ = saveTrafficAppProfilesState(st)
@@ -302,6 +307,89 @@ func loadTrafficAppProfilesState() trafficAppProfilesState {
return st
}
func dedupeTrafficAppProfiles(in []TrafficAppProfile) ([]TrafficAppProfile, bool) {
if len(in) <= 1 {
return in, false
}
out := make([]TrafficAppProfile, 0, len(in))
byID := map[string]int{}
byAppTarget := map[string]int{}
changed := false
for _, raw := range in {
p := raw
p.ID = strings.TrimSpace(p.ID)
p.Target = strings.ToLower(strings.TrimSpace(p.Target))
p.AppKey = canonicalizeAppKey(p.AppKey, p.Command)
if p.ID == "" {
changed = true
continue
}
if p.Target != "vpn" && p.Target != "direct" {
p.Target = "vpn"
changed = true
}
if idx, ok := byID[p.ID]; ok {
if preferTrafficProfile(p, out[idx]) {
out[idx] = p
}
changed = true
continue
}
if p.AppKey != "" {
key := p.Target + "|" + p.AppKey
if idx, ok := byAppTarget[key]; ok {
if preferTrafficProfile(p, out[idx]) {
byID[p.ID] = idx
out[idx] = p
}
changed = true
continue
}
byAppTarget[key] = len(out)
}
byID[p.ID] = len(out)
out = append(out, p)
}
return out, changed
}
func preferTrafficProfile(cand, cur TrafficAppProfile) bool {
cu := strings.TrimSpace(cand.UpdatedAt)
ou := strings.TrimSpace(cur.UpdatedAt)
if cu != ou {
if cu == "" {
return false
}
if ou == "" {
return true
}
return cu > ou
}
cc := strings.TrimSpace(cand.CreatedAt)
oc := strings.TrimSpace(cur.CreatedAt)
if cc != oc {
if cc == "" {
return false
}
if oc == "" {
return true
}
return cc > oc
}
if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" {
return true
}
return false
}
func saveTrafficAppProfilesState(st trafficAppProfilesState) error {
st.Version = 1
st.UpdatedAt = time.Now().UTC().Format(time.RFC3339)

View File

@@ -32,7 +32,7 @@ func canonicalizeAppKey(appKey string, command string) string {
key := strings.TrimSpace(appKey)
cmd := strings.TrimSpace(command)
fields := strings.Fields(cmd)
fields := splitCommandTokens(cmd)
if len(fields) == 0 && key != "" {
fields = []string{key}
}
@@ -61,12 +61,12 @@ func canonicalizeAppKey(appKey string, command string) string {
switch base {
case "flatpak":
if id := extractRunTarget(clean); id != "" {
return "flatpak:" + id
return "flatpak:" + strings.ToLower(strings.TrimSpace(id))
}
return "flatpak"
case "snap":
if name := extractRunTarget(clean); name != "" {
return "snap:" + name
return "snap:" + strings.ToLower(strings.TrimSpace(name))
}
return "snap"
case "gtk-launch":
@@ -74,7 +74,7 @@ func canonicalizeAppKey(appKey string, command string) string {
if len(clean) >= 2 {
id := strings.TrimSpace(clean[1])
if id != "" && !strings.HasPrefix(id, "-") {
return "desktop:" + id
return "desktop:" + strings.ToLower(id)
}
}
case "env":
@@ -102,11 +102,11 @@ func canonicalizeAppKey(appKey string, command string) string {
if strings.Contains(primary, "/") {
b := filepath.Base(primary)
if b != "" && b != "." && b != "/" {
return b
return strings.ToLower(strings.TrimSpace(b))
}
}
return primary
return strings.ToLower(strings.TrimSpace(primary))
}
func stripOuterQuotes(s string) string {
@@ -151,3 +151,65 @@ func extractRunTarget(fields []string) string {
}
return ""
}
// splitCommandTokens performs lightweight shell-style tokenization.
// It supports single/double quotes and backslash escaping which is enough
// for canonical app key extraction.
func splitCommandTokens(raw string) []string {
s := strings.TrimSpace(raw)
if s == "" {
return nil
}
out := make([]string, 0, 8)
var cur strings.Builder
inSingle := false
inDouble := false
escaped := false
flush := func() {
if cur.Len() == 0 {
return
}
out = append(out, cur.String())
cur.Reset()
}
for _, r := range s {
if escaped {
cur.WriteRune(r)
escaped = false
continue
}
switch r {
case '\\':
if inSingle {
cur.WriteRune(r)
} else {
escaped = true
}
case '\'':
if inDouble {
cur.WriteRune(r)
} else {
inSingle = !inSingle
}
case '"':
if inSingle {
cur.WriteRune(r)
} else {
inDouble = !inDouble
}
case ' ', '\t', '\n', '\r':
if inSingle || inDouble {
cur.WriteRune(r)
} else {
flush()
}
default:
cur.WriteRune(r)
}
}
flush()
return out
}

View File

@@ -0,0 +1,135 @@
package app
import "testing"
func TestCanonicalizeAppKey(t *testing.T) {
tests := []struct {
name string
appKey string
command string
want string
}{
{
name: "path vs bare command normalized to lowercase basename",
command: "/usr/bin/Google-Chrome-Stable --new-window",
want: "google-chrome-stable",
},
{
name: "quoted path with spaces",
command: "'/opt/My Apps/Opera' --private",
want: "opera",
},
{
name: "env wrapper skips assignments",
command: "env GTK_THEME=Adwaita /usr/bin/Brave-Browser --incognito",
want: "brave-browser",
},
{
name: "flatpak run app id",
command: "flatpak run org.mozilla.Firefox",
want: "flatpak:org.mozilla.firefox",
},
{
name: "snap run app id",
command: "snap run --experimental foo.Bar",
want: "snap:foo.bar",
},
{
name: "gtk-launch desktop id",
command: "gtk-launch Org.Gnome.Nautilus.desktop",
want: "desktop:org.gnome.nautilus.desktop",
},
{
name: "explicit app key fallback",
appKey: "Opera",
want: "opera",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := canonicalizeAppKey(tc.appKey, tc.command)
if got != tc.want {
t.Fatalf("canonicalizeAppKey(%q,%q) = %q, want %q", tc.appKey, tc.command, got, tc.want)
}
})
}
}
func TestSplitCommandTokens(t *testing.T) {
in := `env A=1 "/opt/My App/bin/App" --flag="x y"`
got := splitCommandTokens(in)
want := []string{"env", "A=1", "/opt/My App/bin/App", "--flag=x y"}
if len(got) != len(want) {
t.Fatalf("tokens len=%d want=%d tokens=%v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("token[%d]=%q want=%q all=%v", i, got[i], want[i], got)
}
}
}
func TestDedupeTrafficAppProfilesByCanonicalAppKey(t *testing.T) {
in := []TrafficAppProfile{
{
ID: "chrome-old",
Target: "VPN",
AppKey: "Google-Chrome-Stable",
Command: "/usr/bin/Google-Chrome-Stable --new-window",
UpdatedAt: "2026-02-20T10:00:00Z",
},
{
ID: "chrome-new",
Target: "vpn",
AppKey: "google-chrome-stable",
Command: "google-chrome-stable --incognito",
UpdatedAt: "2026-02-20T11:00:00Z",
},
}
out, changed := dedupeTrafficAppProfiles(in)
if !changed {
t.Fatalf("expected changed=true")
}
if len(out) != 1 {
t.Fatalf("expected 1 profile, got %d", len(out))
}
if out[0].ID != "chrome-new" {
t.Fatalf("expected newest profile to win, got id=%q", out[0].ID)
}
if out[0].AppKey != "google-chrome-stable" {
t.Fatalf("expected canonical app key, got %q", out[0].AppKey)
}
}
func TestDedupeAppMarkItemsByCanonicalAppKey(t *testing.T) {
in := []appMarkItem{
{
ID: 101,
Target: "VPN",
AppKey: "Opera",
Command: "/usr/bin/Opera --private",
AddedAt: "2026-02-20T10:00:00Z",
},
{
ID: 202,
Target: "vpn",
AppKey: "opera",
Command: "opera --new-window",
AddedAt: "2026-02-20T11:00:00Z",
},
}
out, changed := dedupeAppMarkItems(in)
if !changed {
t.Fatalf("expected changed=true")
}
if len(out) != 1 {
t.Fatalf("expected 1 app mark item, got %d", len(out))
}
if out[0].ID != 202 {
t.Fatalf("expected newest item to win, got id=%d", out[0].ID)
}
if out[0].AppKey != "opera" {
t.Fatalf("expected canonical app key, got %q", out[0].AppKey)
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/netip"
"os"
"path/filepath"
"sort"
@@ -31,8 +32,11 @@ import (
const (
appMarksTable = "agvpn"
appMarksChain = "output_apps"
appMarksGuardChain = "output_guard"
appMarksLocalBypassSet = "svpn_local4"
appMarkCommentPrefix = "svpn_appmark"
defaultAppMarkTTLSeconds = 24 * 60 * 60
appGuardCommentPrefix = "svpn_appguard"
defaultAppMarkTTLSeconds = 0 // 0 = persistent until explicit unmark/clear
)
var appMarksMu sync.Mutex
@@ -129,9 +133,6 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
}
ttl := timeoutSec
if ttl == 0 {
ttl = defaultAppMarkTTLSeconds
}
rel, level, inodeID, cgAbs, err := resolveCgroupV2PathForNft(cgroup)
if err != nil {
@@ -145,6 +146,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
return
}
vpnIface := ""
if target == "vpn" {
traffic := loadTrafficModeState()
iface, _ := resolveTrafficIface(traffic.PreferredIface)
@@ -159,6 +161,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
})
return
}
vpnIface = strings.TrimSpace(iface)
if err := ensureTrafficRouteBase(iface, traffic.AutoLocalBypass); err != nil {
writeJSON(w, http.StatusOK, TrafficAppMarksResponse{
OK: false,
@@ -172,7 +175,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
}
}
if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl); err != nil {
if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl, vpnIface); err != nil {
writeJSON(w, http.StatusOK, TrafficAppMarksResponse{
OK: false,
Op: string(op),
@@ -253,11 +256,16 @@ func handleTrafficAppMarksItems(w http.ResponseWriter, r *http.Request) {
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 := -1 // persistent by default
expRaw := strings.TrimSpace(it.ExpiresAt)
if expRaw != "" {
exp, err := time.Parse(time.RFC3339, expRaw)
if err == nil {
rem = int(exp.Sub(now).Seconds())
if rem < 0 {
rem = 0
}
} else {
rem = 0
}
}
@@ -308,7 +316,7 @@ func appMarksGetStatus() (vpnCount int, directCount int) {
return vpnCount, directCount
}
func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int) error {
func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int, vpnIface string) error {
target = strings.ToLower(strings.TrimSpace(target))
if target != "vpn" && target != "direct" {
return fmt.Errorf("invalid target")
@@ -333,30 +341,51 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
command = strings.TrimSpace(command)
appKey = canonicalizeAppKey(appKey, command)
// EN: Avoid unbounded growth of marks for the same app.
// RU: Не даём бесконечно плодить метки на одно и то же приложение.
if appKey != "" {
kept := st.Items[:0]
for _, it := range st.Items {
if strings.ToLower(strings.TrimSpace(it.Target)) == target &&
strings.TrimSpace(it.AppKey) == appKey &&
it.ID != id {
_ = nftDeleteAppMarkRule(target, it.ID)
changed = true
continue
}
kept = append(kept, it)
// EN: Keep only one effective mark per app and avoid cross-target conflicts.
// EN: If the same app_key is re-marked with another target, old mark is removed first.
// RU: Держим только одну эффективную метку на приложение и убираем конфликты между target.
// RU: Если тот же app_key перемечается на другой target — старая метка удаляется.
kept := st.Items[:0]
for _, it := range st.Items {
itTarget := strings.ToLower(strings.TrimSpace(it.Target))
itKey := strings.TrimSpace(it.AppKey)
remove := false
// Same cgroup id but different target => conflicting rules (mark+guard).
if it.ID == id && it.ID != 0 && itTarget != target {
remove = true
}
st.Items = kept
// Same app_key (if known) should not keep multiple active runtime routes.
if !remove && appKey != "" && itKey != "" && itKey == appKey {
if it.ID != id || itTarget != target {
remove = true
}
}
if remove {
_ = nftDeleteAppMarkRule(itTarget, it.ID)
changed = true
continue
}
kept = append(kept, it)
}
st.Items = kept
// Replace any existing rule/state for this (target,id).
_ = nftDeleteAppMarkRule(target, id)
if err := nftInsertAppMarkRule(target, rel, level, id); err != nil {
if err := nftInsertAppMarkRule(target, rel, level, id, vpnIface); err != nil {
return err
}
if !nftHasAppMarkRule(target, id) {
_ = nftDeleteAppMarkRule(target, id)
return fmt.Errorf("appmark rule not active after insert (target=%s id=%d)", target, id)
}
now := time.Now().UTC()
expiresAt := ""
if ttlSec > 0 {
expiresAt = now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339)
}
item := appMarkItem{
ID: id,
Target: target,
@@ -367,13 +396,15 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
Command: command,
AppKey: appKey,
AddedAt: now.Format(time.RFC3339),
ExpiresAt: now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339),
ExpiresAt: expiresAt,
}
st.Items = upsertAppMarkItem(st.Items, item)
changed = true
if changed {
if err := saveAppMarksState(st); err != nil {
// Keep runtime state and nft in sync on disk write errors.
_ = nftDeleteAppMarkRule(target, id)
return err
}
}
@@ -479,7 +510,9 @@ func ensureAppMarksNft() error {
// Best-effort "ensure": ignore "exists" errors and proceed.
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", appMarksTable)
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}")
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, appMarksGuardChain, "{", "type", "filter", "hook", "output", "priority", "filter;", "policy", "accept;", "}")
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, appMarksChain)
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", appMarksTable, appMarksLocalBypassSet, "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}")
out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "list", "chain", "inet", appMarksTable, "output")
if !strings.Contains(out, "jump "+appMarksChain) {
@@ -514,7 +547,102 @@ func appMarkComment(target string, id uint64) string {
return fmt.Sprintf("%s:%s:%d", appMarkCommentPrefix, target, id)
}
func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error {
func appGuardComment(target string, id uint64) string {
return fmt.Sprintf("%s:%s:%d", appGuardCommentPrefix, target, id)
}
func appGuardEnabled() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("SVPN_APP_GUARD")))
return v == "1" || v == "true" || v == "yes" || v == "on"
}
func updateAppMarkLocalBypassSet(vpnIface string) error {
// EN: Keep a small allowlist for local/LAN/container destinations so VPN app kill-switch
// EN: does not break host-local access.
// RU: Держим небольшой allowlist локальных/LAN/container направлений, чтобы VPN kill-switch
// RU: не ломал локальный доступ хоста.
vpnIface = strings.TrimSpace(vpnIface)
_ = ensureAppMarksNft()
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", appMarksTable, appMarksLocalBypassSet)
elems := []string{"127.0.0.0/8"}
for _, rt := range detectAutoLocalBypassRoutes(vpnIface) {
dst := strings.TrimSpace(rt.Dst)
if dst == "" || dst == "default" {
continue
}
elems = append(elems, dst)
}
elems = compactIPv4IntervalElements(elems)
for _, e := range elems {
_, out, code, err := runCommandTimeout(
5*time.Second,
"nft", "add", "element", "inet", appMarksTable, appMarksLocalBypassSet,
"{", e, "}",
)
if err != nil || code != 0 {
if err == nil {
err = fmt.Errorf("nft add element exited with %d", code)
}
return fmt.Errorf("failed to update %s: %w (%s)", appMarksLocalBypassSet, err, strings.TrimSpace(out))
}
}
return nil
}
func compactIPv4IntervalElements(raw []string) []string {
pfxs := make([]netip.Prefix, 0, len(raw))
for _, v := range raw {
s := strings.TrimSpace(v)
if s == "" {
continue
}
if strings.Contains(s, "/") {
p, err := netip.ParsePrefix(s)
if err != nil || !p.Addr().Is4() {
continue
}
pfxs = append(pfxs, p.Masked())
continue
}
a, err := netip.ParseAddr(s)
if err != nil || !a.Is4() {
continue
}
pfxs = append(pfxs, netip.PrefixFrom(a, 32))
}
sort.Slice(pfxs, func(i, j int) bool {
ib, jb := pfxs[i].Bits(), pfxs[j].Bits()
if ib != jb {
return ib < jb // broader first
}
return pfxs[i].Addr().Less(pfxs[j].Addr())
})
out := make([]netip.Prefix, 0, len(pfxs))
for _, p := range pfxs {
covered := false
for _, ex := range out {
if ex.Contains(p.Addr()) {
covered = true
break
}
}
if covered {
continue
}
out = append(out, p)
}
res := make([]string, 0, len(out))
for _, p := range out {
res = append(res, p.String())
}
return res
}
func nftInsertAppMarkRule(target string, rel string, level int, id uint64, vpnIface string) error {
mark := MARK_DIRECT
if target == "vpn" {
mark = MARK_APP
@@ -527,6 +655,58 @@ func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error
pathLit := fmt.Sprintf("\"%s\"", rel)
commentLit := fmt.Sprintf("\"%s\"", comment)
if target == "vpn" {
if !appGuardEnabled() {
goto insertMark
}
iface := strings.TrimSpace(vpnIface)
if iface == "" {
return fmt.Errorf("vpn interface required for app guard")
}
if err := updateAppMarkLocalBypassSet(iface); err != nil {
return err
}
guardComment := appGuardComment(target, id)
guardCommentLit := fmt.Sprintf("\"%s\"", guardComment)
// IPv4: drop non-tun egress except local bypass ranges.
_, out, code, err := runCommandTimeout(
5*time.Second,
"nft", "insert", "rule", "inet", appMarksTable, appMarksGuardChain,
"socket", "cgroupv2", "level", strconv.Itoa(level), pathLit,
"meta", "mark", MARK_APP,
"oifname", "!=", iface,
"ip", "daddr", "!=", "@"+appMarksLocalBypassSet,
"drop",
"comment", guardCommentLit,
)
if err != nil || code != 0 {
if err == nil {
err = fmt.Errorf("nft insert guard(v4) exited with %d", code)
}
return fmt.Errorf("nft insert app guard(v4) failed: %w (%s)", err, strings.TrimSpace(out))
}
// IPv6: default deny outside VPN iface to prevent WebRTC/STUN leaks on dual-stack hosts.
_, out, code, err = runCommandTimeout(
5*time.Second,
"nft", "insert", "rule", "inet", appMarksTable, appMarksGuardChain,
"socket", "cgroupv2", "level", strconv.Itoa(level), pathLit,
"meta", "mark", MARK_APP,
"oifname", "!=", iface,
"meta", "nfproto", "ipv6",
"drop",
"comment", guardCommentLit,
)
if err != nil || code != 0 {
if err == nil {
err = fmt.Errorf("nft insert guard(v6) exited with %d", code)
}
return fmt.Errorf("nft insert app guard(v6) failed: %w (%s)", err, strings.TrimSpace(out))
}
}
insertMark:
_, out, code, err := runCommandTimeout(
5*time.Second,
"nft", "insert", "rule", "inet", appMarksTable, appMarksChain,
@@ -539,27 +719,71 @@ func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error
if err == nil {
err = fmt.Errorf("nft insert rule exited with %d", code)
}
_ = nftDeleteAppMarkRule(target, id)
return fmt.Errorf("nft insert appmark rule failed: %w (%s)", err, strings.TrimSpace(out))
}
return nil
}
func nftDeleteAppMarkRule(target string, id uint64) error {
comment := appMarkComment(target, id)
out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain)
for _, line := range strings.Split(out, "\n") {
if !strings.Contains(line, comment) {
continue
comments := []string{
appMarkComment(target, id),
appGuardComment(target, id),
}
chains := []string{appMarksChain, appMarksGuardChain}
for _, chain := range chains {
out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, chain)
for _, line := range strings.Split(out, "\n") {
match := false
for _, comment := range comments {
if strings.Contains(line, comment) {
match = true
break
}
}
if !match {
continue
}
h := parseNftHandle(line)
if h <= 0 {
continue
}
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, chain, "handle", strconv.Itoa(h))
}
h := parseNftHandle(line)
if h <= 0 {
continue
}
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, appMarksChain, "handle", strconv.Itoa(h))
}
return nil
}
func nftHasAppMarkRule(target string, id uint64) bool {
markComment := appMarkComment(target, id)
guardComment := appGuardComment(target, id)
hasMark := false
out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain)
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, markComment) {
hasMark = true
break
}
}
if !hasMark {
return false
}
if strings.EqualFold(strings.TrimSpace(target), "vpn") {
if !appGuardEnabled() {
return true
}
out, _, _, _ = runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksGuardChain)
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, guardComment) {
return true
}
}
return false
}
return true
}
func parseNftHandle(line string) int {
fields := strings.Fields(line)
for i := 0; i < len(fields)-1; i++ {
@@ -638,8 +862,20 @@ func pruneExpiredAppMarksLocked(st *appMarksState, now time.Time) (changed bool)
}
kept := st.Items[:0]
for _, it := range st.Items {
exp, err := time.Parse(time.RFC3339, strings.TrimSpace(it.ExpiresAt))
if err != nil || !exp.After(now) {
expRaw := strings.TrimSpace(it.ExpiresAt)
if expRaw == "" {
kept = append(kept, it)
continue
}
exp, err := time.Parse(time.RFC3339, expRaw)
if err != nil {
// Corrupted timestamp: keep mark as persistent to avoid accidental route leak.
it.ExpiresAt = ""
kept = append(kept, it)
changed = true
continue
}
if !exp.After(now) {
_ = nftDeleteAppMarkRule(strings.ToLower(strings.TrimSpace(it.Target)), it.ID)
changed = true
continue
@@ -662,6 +898,116 @@ func upsertAppMarkItem(items []appMarkItem, next appMarkItem) []appMarkItem {
return out
}
func clearManagedAppMarkRules(chain string) {
out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, chain)
for _, line := range strings.Split(out, "\n") {
l := strings.ToLower(line)
if !strings.Contains(l, strings.ToLower(appMarkCommentPrefix)) &&
!strings.Contains(l, strings.ToLower(appGuardCommentPrefix)) {
continue
}
h := parseNftHandle(line)
if h <= 0 {
continue
}
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, chain, "handle", strconv.Itoa(h))
}
}
func restoreAppMarksFromState() error {
appMarksMu.Lock()
defer appMarksMu.Unlock()
if err := ensureAppMarksNft(); err != nil {
return err
}
st := loadAppMarksState()
now := time.Now().UTC()
changed := pruneExpiredAppMarksLocked(&st, now)
clearManagedAppMarkRules(appMarksChain)
clearManagedAppMarkRules(appMarksGuardChain)
traffic := loadTrafficModeState()
vpnIface, _ := resolveTrafficIface(traffic.PreferredIface)
vpnIface = strings.TrimSpace(vpnIface)
kept := make([]appMarkItem, 0, len(st.Items))
for _, it := range st.Items {
target := strings.ToLower(strings.TrimSpace(it.Target))
if target != "vpn" && target != "direct" {
changed = true
continue
}
rel := normalizeCgroupRelOnly(it.CgroupRel)
if rel == "" {
rel = normalizeCgroupRelOnly(it.Cgroup)
}
if rel == "" {
changed = true
continue
}
id := it.ID
if id == 0 {
inode, err := cgroupDirInode(rel)
if err != nil {
changed = true
continue
}
id = inode
it.ID = inode
changed = true
}
level := it.Level
if level <= 0 {
level = strings.Count(strings.Trim(rel, "/"), "/") + 1
it.Level = level
changed = true
}
abs := "/" + strings.TrimPrefix(rel, "/")
it.CgroupRel = rel
it.Cgroup = abs
if _, err := cgroupDirInode(rel); err != nil {
changed = true
continue
}
iface := ""
if target == "vpn" {
if vpnIface == "" {
// Keep state for later retry when VPN interface appears.
kept = append(kept, it)
continue
}
iface = vpnIface
}
if err := nftInsertAppMarkRule(target, rel, level, id, iface); err != nil {
appendTraceLine("traffic", fmt.Sprintf("appmarks restore failed target=%s id=%d err=%v", target, id, err))
kept = append(kept, it)
continue
}
if !nftHasAppMarkRule(target, id) {
appendTraceLine("traffic", fmt.Sprintf("appmarks restore post-check failed target=%s id=%d", target, id))
kept = append(kept, it)
continue
}
kept = append(kept, it)
}
st.Items = kept
if changed {
return saveAppMarksState(st)
}
return nil
}
func loadAppMarksState() appMarksState {
st := appMarksState{Version: 1}
data, err := os.ReadFile(trafficAppMarksPath)
@@ -679,18 +1025,88 @@ func loadAppMarksState() appMarksState {
// RU: Best-effort миграция: нормализуем app_key в канонический вид.
changed := false
for i := range st.Items {
st.Items[i].Target = strings.ToLower(strings.TrimSpace(st.Items[i].Target))
canon := canonicalizeAppKey(st.Items[i].AppKey, st.Items[i].Command)
if canon != "" && strings.TrimSpace(st.Items[i].AppKey) != canon {
st.Items[i].AppKey = canon
changed = true
}
}
if deduped, dedupChanged := dedupeAppMarkItems(st.Items); dedupChanged {
st.Items = deduped
changed = true
}
if changed {
_ = saveAppMarksState(st)
}
return st
}
func dedupeAppMarkItems(in []appMarkItem) ([]appMarkItem, bool) {
if len(in) <= 1 {
return in, false
}
out := make([]appMarkItem, 0, len(in))
byTargetID := map[string]int{}
byTargetApp := map[string]int{}
changed := false
for _, raw := range in {
it := raw
it.Target = strings.ToLower(strings.TrimSpace(it.Target))
if it.Target != "vpn" && it.Target != "direct" {
changed = true
continue
}
it.AppKey = canonicalizeAppKey(it.AppKey, it.Command)
if it.ID > 0 {
idKey := fmt.Sprintf("%s:%d", it.Target, it.ID)
if idx, ok := byTargetID[idKey]; ok {
if preferAppMarkItem(it, out[idx]) {
out[idx] = it
}
changed = true
continue
}
byTargetID[idKey] = len(out)
}
if it.AppKey != "" {
appKey := it.Target + "|" + it.AppKey
if idx, ok := byTargetApp[appKey]; ok {
if preferAppMarkItem(it, out[idx]) {
out[idx] = it
}
changed = true
continue
}
byTargetApp[appKey] = len(out)
}
out = append(out, it)
}
return out, changed
}
func preferAppMarkItem(cand, cur appMarkItem) bool {
ca := strings.TrimSpace(cand.AddedAt)
oa := strings.TrimSpace(cur.AddedAt)
if ca != oa {
if ca == "" {
return false
}
if oa == "" {
return true
}
return ca > oa
}
if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" {
return true
}
return false
}
func saveAppMarksState(st appMarksState) error {
st.Version = 1
st.UpdatedAt = time.Now().UTC().Format(time.RFC3339)

View File

@@ -11,11 +11,13 @@ import (
"sort"
"strconv"
"strings"
"syscall"
"time"
)
const (
trafficRulePrefMarkDirect = 11500
trafficRulePrefMarkIngressReply = 11505
trafficRulePrefMarkAppVPN = 11510
trafficRulePrefDirectSubnetStart = 11600
trafficRulePrefDirectUIDStart = 11680
@@ -27,6 +29,13 @@ const (
trafficRulePrefManagedMax = 12099
trafficRulePerKindLimit = 70
trafficAutoLocalDefault = true
trafficIngressReplyDefault = false
trafficIngressPreroutingChain = "prerouting_ingress_reply"
trafficIngressOutputChain = "output_ingress_reply"
trafficIngressCaptureComment = "svpn_ingress_reply_capture"
trafficIngressRestoreComment = "svpn_ingress_reply_restore"
)
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
@@ -199,6 +208,7 @@ func loadTrafficModeState() TrafficModeState {
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"`
@@ -214,6 +224,7 @@ func loadTrafficModeState() TrafficModeState {
Mode: raw.Mode,
PreferredIface: raw.PreferredIface,
AutoLocalBypass: trafficAutoLocalDefault,
IngressReplyBypass: trafficIngressReplyDefault,
ForceVPNSubnets: append([]string(nil), raw.ForceVPNSubnets...),
ForceVPNUIDs: append([]string(nil), raw.ForceVPNUIDs...),
ForceVPNCGroups: append([]string(nil), raw.ForceVPNCGroups...),
@@ -224,6 +235,9 @@ func loadTrafficModeState() TrafficModeState {
if raw.AutoLocalBypass != nil {
st.AutoLocalBypass = *raw.AutoLocalBypass
}
if raw.IngressReplyBypass != nil {
st.IngressReplyBypass = *raw.IngressReplyBypass
}
return normalizeTrafficModeState(st)
}
@@ -253,6 +267,7 @@ func inferTrafficModeState() TrafficModeState {
Mode: mode,
PreferredIface: iface,
AutoLocalBypass: trafficAutoLocalDefault,
IngressReplyBypass: trafficIngressReplyDefault,
ForceVPNSubnets: nil,
ForceVPNUIDs: nil,
ForceVPNCGroups: nil,
@@ -529,6 +544,116 @@ func applyAutoLocalBypass(vpnIface string) {
}
}
func nftObjectMissing(stdout, stderr string) bool {
text := strings.ToLower(strings.TrimSpace(stdout + " " + stderr))
return strings.Contains(text, "no such file") || strings.Contains(text, "not found")
}
func ensureIngressReplyBypassChains() {
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", routesTableName())
_, _, _, _ = runCommandTimeout(
5*time.Second,
"nft", "add", "chain", "inet", routesTableName(), trafficIngressPreroutingChain,
"{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}",
)
_, _, _, _ = runCommandTimeout(
5*time.Second,
"nft", "add", "chain", "inet", routesTableName(), trafficIngressOutputChain,
"{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}",
)
}
func flushIngressReplyBypassChains() error {
for _, chain := range []string{trafficIngressPreroutingChain, trafficIngressOutputChain} {
out, errOut, code, err := runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", routesTableName(), chain)
if err == nil && code == 0 {
continue
}
if nftObjectMissing(out, errOut) {
continue
}
if err == nil {
err = fmt.Errorf("nft flush chain exited with %d", code)
}
return fmt.Errorf("flush %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut))
}
return nil
}
func enableIngressReplyBypass(vpnIface string) error {
vpnIface = strings.TrimSpace(vpnIface)
if vpnIface == "" {
return fmt.Errorf("empty vpn iface for ingress bypass")
}
ensureIngressReplyBypassChains()
if err := flushIngressReplyBypassChains(); err != nil {
return err
}
addRule := func(chain string, args ...string) error {
out, errOut, code, err := runCommandTimeout(5*time.Second, "nft", append([]string{"add", "rule", "inet", routesTableName(), chain}, args...)...)
if err != nil || code != 0 {
if err == nil {
err = fmt.Errorf("nft add rule exited with %d", code)
}
return fmt.Errorf("nft add rule %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut))
}
return nil
}
// EN: Mark inbound NEW connections (except loopback/VPN iface) so reply path can stay direct in full tunnel.
// RU: Помечаем входящие NEW-соединения (кроме loopback/VPN iface), чтобы ответ шел напрямую в full tunnel.
if err := addRule(
trafficIngressPreroutingChain,
"iifname", "!=", "lo",
"iifname", "!=", vpnIface,
"fib", "daddr", "type", "local",
"ct", "state", "new",
"ct", "mark", "set", MARK_INGRESS,
"comment", trafficIngressCaptureComment,
); err != nil {
return err
}
// EN: Restore fwmark from ct mark in prerouting for forwarded reply traffic.
// RU: Восстанавливаем fwmark из ct mark в prerouting для forwarded-ответов.
if err := addRule(
trafficIngressPreroutingChain,
"ct", "mark", MARK_INGRESS,
"meta", "mark", "set", MARK_INGRESS,
"comment", trafficIngressRestoreComment,
); err != nil {
return err
}
// EN: Restore fwmark from ct mark in output for local-process replies.
// RU: Восстанавливаем fwmark из ct mark в output для ответов локальных процессов.
if err := addRule(
trafficIngressOutputChain,
"ct", "mark", MARK_INGRESS,
"meta", "mark", "set", MARK_INGRESS,
"comment", trafficIngressRestoreComment,
); err != nil {
return err
}
return nil
}
func disableIngressReplyBypass() error {
ensureIngressReplyBypassChains()
return flushIngressReplyBypassChains()
}
func ingressReplyNftActive() bool {
outPre, _, codePre, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", routesTableName(), trafficIngressPreroutingChain)
outOut, _, codeOut, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", routesTableName(), trafficIngressOutputChain)
if codePre != 0 || codeOut != 0 {
return false
}
return strings.Contains(outPre, trafficIngressCaptureComment) &&
strings.Contains(outPre, trafficIngressRestoreComment) &&
strings.Contains(outOut, trafficIngressRestoreComment)
}
func prefStr(v int) string {
return strconv.Itoa(v)
}
@@ -827,16 +952,22 @@ func ensureTrafficRouteBase(iface string, autoLocalBypass bool) error {
func applyTrafficMode(st TrafficModeState, iface string) error {
st = normalizeTrafficModeState(st)
eff := buildEffectiveOverrides(st)
advancedActive := st.Mode == TrafficModeFullTunnel
autoLocalActive := advancedActive && st.AutoLocalBypass
ingressReplyActive := advancedActive && st.IngressReplyBypass
removeTrafficRulesForTable()
// EN: Ensure the policy table name exists even in direct mode so mark-based rules can be installed.
// RU: Гарантируем наличие имени policy-table даже в direct режиме, чтобы можно было ставить mark-правила.
ensureRoutesTableEntry()
if err := disableIngressReplyBypass(); err != nil {
return err
}
needVPNTable := st.Mode != TrafficModeDirect || len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0
if needVPNTable {
if err := ensureTrafficRouteBase(iface, st.AutoLocalBypass); err != nil {
if err := ensureTrafficRouteBase(iface, autoLocalActive); err != nil {
return err
}
}
@@ -852,6 +983,11 @@ func applyTrafficMode(st TrafficModeState, iface string) error {
if err := applyRule(trafficRulePrefMarkDirect, "fwmark", MARK_DIRECT, "lookup", "main"); err != nil {
return err
}
if ingressReplyActive {
if err := applyRule(trafficRulePrefMarkIngressReply, "fwmark", MARK_INGRESS, "lookup", "main"); err != nil {
return err
}
}
if err := applyRule(trafficRulePrefMarkAppVPN, "fwmark", MARK_APP, "lookup", routesTableName()); err != nil {
return err
}
@@ -870,13 +1006,23 @@ func applyTrafficMode(st TrafficModeState, iface string) error {
default:
return fmt.Errorf("unknown traffic mode: %s", st.Mode)
}
if ingressReplyActive {
if err := enableIngressReplyBypass(iface); err != nil {
return err
}
}
if err := restoreAppMarksFromState(); err != nil {
appendTraceLine("traffic", fmt.Sprintf("appmarks restore warning: %v", err))
}
return nil
}
type trafficRulesState struct {
Mark bool
Full bool
Mark bool
Full bool
IngressReply bool
}
func readTrafficRules() trafficRulesState {
@@ -884,7 +1030,7 @@ func readTrafficRules() trafficRulesState {
var st trafficRulesState
for _, line := range strings.Split(out, "\n") {
l := strings.ToLower(strings.TrimSpace(line))
if l == "" || !strings.Contains(l, "lookup "+routesTableName()) {
if l == "" {
continue
}
fields := strings.Fields(l)
@@ -895,9 +1041,17 @@ func readTrafficRules() trafficRulesState {
pref, _ := strconv.Atoi(prefRaw)
switch pref {
case trafficRulePrefSelective:
st.Mark = true
if strings.Contains(l, "lookup "+routesTableName()) {
st.Mark = true
}
case trafficRulePrefFull:
st.Full = true
if strings.Contains(l, "lookup "+routesTableName()) {
st.Full = true
}
case trafficRulePrefMarkIngressReply:
if strings.Contains(l, "fwmark "+strings.ToLower(MARK_INGRESS)) && strings.Contains(l, "lookup main") {
st.IngressReply = true
}
}
}
return st
@@ -954,12 +1108,20 @@ func probeTrafficMode(mode TrafficMode, iface string) (bool, string) {
func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
st = normalizeTrafficModeState(st)
eff := buildEffectiveOverrides(st)
advancedActive := st.Mode == TrafficModeFullTunnel
autoLocalActive := advancedActive && st.AutoLocalBypass
ingressDesired := st.IngressReplyBypass
ingressExpected := advancedActive && ingressDesired
hasVPN := len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0
iface, reason := resolveTrafficIface(st.PreferredIface)
rules := readTrafficRules()
applied := detectAppliedTrafficMode(rules)
ingressNft := false
if rules.IngressReply || st.Mode == TrafficModeFullTunnel || st.IngressReplyBypass {
ingressNft = ingressReplyNftActive()
}
bypassCandidates := 0
if st.AutoLocalBypass && (st.Mode != TrafficModeDirect || hasVPN) {
if autoLocalActive && (st.Mode != TrafficModeDirect || hasVPN) {
bypassCandidates = len(detectAutoLocalBypassRoutes(iface))
}
@@ -976,7 +1138,11 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
DesiredMode: st.Mode,
AppliedMode: applied,
PreferredIface: st.PreferredIface,
AdvancedActive: advancedActive,
AutoLocalBypass: st.AutoLocalBypass,
AutoLocalActive: autoLocalActive,
IngressReplyBypass: ingressDesired,
IngressReplyActive: rules.IngressReply && ingressNft,
BypassCandidates: bypassCandidates,
ForceVPNSubnets: append([]string(nil), st.ForceVPNSubnets...),
ForceVPNUIDs: append([]string(nil), st.ForceVPNUIDs...),
@@ -991,6 +1157,8 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
IfaceReason: reason,
RuleMark: rules.Mark,
RuleFull: rules.Full,
IngressRulePresent: rules.IngressReply,
IngressNftActive: ingressNft,
TableDefault: tableDefault,
}
@@ -1001,14 +1169,18 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
// direct mode can still be healthy when vpn overrides exist
// (base full/selective rules must be absent).
if hasVPN {
res.Healthy = !rules.Mark && !rules.Full && tableDefault && iface != "" && res.ProbeOK
res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK
} else {
res.Healthy = !rules.Mark && !rules.Full && res.ProbeOK
res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && res.ProbeOK
}
case TrafficModeFullTunnel:
res.Healthy = rules.Full && !rules.Mark && tableDefault && iface != "" && res.ProbeOK
if ingressExpected {
res.Healthy = rules.Full && !rules.Mark && rules.IngressReply && ingressNft && tableDefault && iface != "" && res.ProbeOK
} else {
res.Healthy = rules.Full && !rules.Mark && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK
}
case TrafficModeSelective:
res.Healthy = rules.Mark && !rules.Full && tableDefault && iface != "" && res.ProbeOK
res.Healthy = rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK
default:
res.Healthy = false
}
@@ -1037,6 +1209,14 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
res.Message = "conflicting traffic rules detected"
return res
}
if ingressExpected && (!rules.IngressReply || !ingressNft) {
res.Message = "ingress-reply bypass rule is not active"
return res
}
if !ingressExpected && (rules.IngressReply || ingressNft) {
res.Message = "stale ingress-reply bypass rule is active"
return res
}
res.Message = "traffic mode check failed"
return res
}
@@ -1067,12 +1247,102 @@ func handleTrafficModeTest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, evaluateTrafficMode(st))
}
func acquireTrafficApplyLock() (*os.File, *TrafficModeStatusResponse) {
lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
msg := evaluateTrafficMode(loadTrafficModeState())
msg.Message = "traffic lock open failed: " + err.Error()
return nil, &msg
}
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
_ = lock.Close()
msg := evaluateTrafficMode(loadTrafficModeState())
msg.Message = "traffic apply skipped: routes operation already running"
return nil, &msg
}
return lock, nil
}
func handleTrafficAdvancedReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
lock, lockMsg := acquireTrafficApplyLock()
if lockMsg != nil {
writeJSON(w, http.StatusOK, *lockMsg)
return
}
defer func() {
_ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
_ = lock.Close()
}()
prev := normalizeTrafficModeState(loadTrafficModeState())
next := prev
next.AutoLocalBypass = false
next.IngressReplyBypass = false
nextIface, _ := resolveTrafficIface(next.PreferredIface)
if err := applyTrafficMode(next, nextIface); err != nil {
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
_ = applyTrafficMode(prev, prevIface)
msg := evaluateTrafficMode(prev)
msg.Message = "advanced reset failed, rolled back: " + err.Error()
writeJSON(w, http.StatusOK, msg)
return
}
if err := saveTrafficModeState(next); err != nil {
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
_ = applyTrafficMode(prev, prevIface)
_ = saveTrafficModeState(prev)
msg := evaluateTrafficMode(prev)
msg.Message = "advanced reset save failed, rolled back: " + err.Error()
writeJSON(w, http.StatusOK, msg)
return
}
res := evaluateTrafficMode(next)
if !res.Healthy {
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
_ = applyTrafficMode(prev, prevIface)
_ = saveTrafficModeState(prev)
rolled := evaluateTrafficMode(prev)
rolled.Message = "advanced reset verification failed, rolled back: " + res.Message
writeJSON(w, http.StatusOK, rolled)
return
}
events.push("traffic_advanced_reset", map[string]any{
"mode": res.Mode,
"applied": res.AppliedMode,
"active_iface": res.ActiveIface,
"healthy": res.Healthy,
"auto_local": res.AutoLocalBypass,
"ingress_reply": res.IngressReplyBypass,
"advanced_active": res.AdvancedActive,
})
res.Message = "advanced bypass reset"
writeJSON(w, http.StatusOK, res)
}
func handleTrafficMode(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
st := loadTrafficModeState()
writeJSON(w, http.StatusOK, evaluateTrafficMode(st))
case http.MethodPost:
lock, lockMsg := acquireTrafficApplyLock()
if lockMsg != nil {
writeJSON(w, http.StatusOK, *lockMsg)
return
}
defer func() {
_ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
_ = lock.Close()
}()
prev := loadTrafficModeState()
next := prev
@@ -1094,6 +1364,9 @@ func handleTrafficMode(w http.ResponseWriter, r *http.Request) {
if body.AutoLocalBypass != nil {
next.AutoLocalBypass = *body.AutoLocalBypass
}
if body.IngressReplyBypass != nil {
next.IngressReplyBypass = *body.IngressReplyBypass
}
if body.ForceVPNSubnets != nil {
next.ForceVPNSubnets = append([]string(nil), (*body.ForceVPNSubnets)...)
}
@@ -1127,21 +1400,12 @@ func handleTrafficMode(w http.ResponseWriter, r *http.Request) {
}
if err := saveTrafficModeState(next); err != nil {
writeJSON(w, http.StatusOK, TrafficModeStatusResponse{
Mode: next.Mode,
DesiredMode: next.Mode,
PreferredIface: next.PreferredIface,
AutoLocalBypass: next.AutoLocalBypass,
ForceVPNSubnets: append([]string(nil), next.ForceVPNSubnets...),
ForceVPNUIDs: append([]string(nil), next.ForceVPNUIDs...),
ForceVPNCGroups: append([]string(nil), next.ForceVPNCGroups...),
ForceDirectSubnets: append([]string(nil), next.ForceDirectSubnets...),
ForceDirectUIDs: append([]string(nil), next.ForceDirectUIDs...),
ForceDirectCGroups: append([]string(nil), next.ForceDirectCGroups...),
OverridesApplied: len(next.ForceVPNSubnets) + len(next.ForceVPNUIDs) + len(next.ForceDirectSubnets) + len(next.ForceDirectUIDs),
Healthy: false,
Message: "state save failed: " + err.Error(),
})
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
_ = applyTrafficMode(prev, prevIface)
_ = saveTrafficModeState(prev)
rolled := evaluateTrafficMode(prev)
rolled.Message = "state save failed, rolled back: " + err.Error()
writeJSON(w, http.StatusOK, rolled)
return
}
@@ -1161,7 +1425,11 @@ func handleTrafficMode(w http.ResponseWriter, r *http.Request) {
"applied": res.AppliedMode,
"active_iface": res.ActiveIface,
"healthy": res.Healthy,
"advanced_active": res.AdvancedActive,
"auto_local_bypass": res.AutoLocalBypass,
"auto_local_active": res.AutoLocalActive,
"ingress_reply": res.IngressReplyBypass,
"ingress_active": res.IngressReplyActive,
"overrides_applied": res.OverridesApplied,
})
writeJSON(w, http.StatusOK, res)

View File

@@ -1240,6 +1240,20 @@ class ApiClient:
attempts: int = 1,
concurrency: int = 6,
) -> DNSBenchmarkResponse:
# Benchmark can legitimately run much longer than the default 5s API timeout.
# Estimate a safe read timeout from payload size and cap it to keep UI responsive.
upstream_count = len(upstreams or [])
domain_count = len(domains or [])
if domain_count <= 0:
domain_count = 6 # backend default domains
clamped_attempts = max(1, min(int(attempts), 3))
clamped_concurrency = max(1, min(int(concurrency), 32))
if upstream_count <= 0:
upstream_count = 1
waves = (upstream_count + clamped_concurrency - 1) // clamped_concurrency
per_wave_sec = domain_count * clamped_attempts * (max(300, int(timeout_ms)) / 1000.0)
bench_timeout = min(180.0, max(15.0, waves*per_wave_sec*1.2+5.0))
data = cast(
Dict[str, Any],
self._json(
@@ -1253,6 +1267,7 @@ class ApiClient:
"attempts": int(attempts),
"concurrency": int(concurrency),
},
timeout=bench_timeout,
)
)
or {},
@@ -1412,13 +1427,40 @@ class ApiClient:
lines = []
return DomainsTable(lines=[str(x) for x in lines])
def domains_file_get(self, name: Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"]) -> DomainsFile:
def domains_file_get(
self,
name: Literal[
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
],
) -> DomainsFile:
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/domains/file", params={"name": name})) or {})
content = str(data.get("content") or "")
source = str(data.get("source") or "")
return DomainsFile(name=name, content=content, source=source)
def domains_file_set(self, name: Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"], content: str) -> None:
def domains_file_set(
self,
name: Literal[
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
],
content: str,
) -> None:
self._request("POST", "/api/v1/domains/file", json_body={"name": name, "content": content})
# VPN

View File

@@ -922,18 +922,65 @@ class DashboardController:
def domains_file_load(self, name: str) -> DomainsFile:
nm = name.strip().lower()
if nm not in ("bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"):
if nm not in (
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
):
raise ValueError(f"Invalid domains file name: {name}")
return self.client.domains_file_get(
cast(Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"], nm)
cast(
Literal[
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
],
nm,
)
)
def domains_file_save(self, name: str, content: str) -> None:
nm = name.strip().lower()
if nm not in ("bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"):
if nm not in (
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
):
raise ValueError(f"Invalid domains file name: {name}")
self.client.domains_file_set(
cast(Literal["bases", "meta", "subs", "static", "smartdns", "last-ips-map", "last-ips-map-direct", "last-ips-map-wildcard"], nm), content
cast(
Literal[
"bases",
"meta",
"subs",
"static",
"smartdns",
"last-ips-map",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
],
nm,
),
content,
)
# -------- Trace --------

View File

@@ -105,6 +105,74 @@ def infer_app_key(cmdline: str) -> str:
return canonicalize_app_key("", cmdline)
def browser_harden_enabled() -> bool:
raw = str(os.environ.get("SVPN_BROWSER_HARDEN", "1") or "1").strip().lower()
return raw not in ("0", "false", "no", "off")
def is_chromium_like_cmd(tokens: list[str]) -> bool:
toks = [str(x or "").strip() for x in (tokens or []) if str(x or "").strip()]
if not toks:
return False
exe = os.path.basename(toks[0]).lower()
known = {
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"microsoft-edge",
"microsoft-edge-stable",
"brave",
"brave-browser",
"opera",
"opera-beta",
"opera-developer",
"vivaldi",
"vivaldi-stable",
}
if exe in known:
return True
if any(x in exe for x in ("chrome", "chromium", "edge", "brave", "opera", "vivaldi")):
return True
if exe == "flatpak":
for i, t in enumerate(toks):
if t == "run":
for cand in toks[i + 1:]:
c = cand.strip().lower()
if not c or c.startswith("-") or c == "--":
continue
return any(x in c for x in ("chrome", "chromium", "edge", "brave", "opera", "vivaldi"))
break
return False
def maybe_harden_browser_cmdline(cmdline: str) -> str:
raw = (cmdline or "").strip()
if not raw or not browser_harden_enabled():
return raw
try:
toks = shlex.split(raw)
except Exception:
return raw
if not is_chromium_like_cmd(toks):
return raw
flags = [
"--disable-quic",
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
]
low = [t.lower() for t in toks]
changed = False
for fl in flags:
fl_low = fl.lower()
if any(t == fl_low or t.startswith(fl_low + "=") for t in low):
continue
toks.append(fl)
changed = True
if not changed:
return raw
return " ".join(shlex.quote(t) for t in toks)
def canonicalize_app_key(app_key: str, cmdline: str) -> str:
key = (app_key or "").strip()
cmd = (cmdline or "").strip()
@@ -181,6 +249,19 @@ def systemctl_user(args: list[str], *, timeout: float = 4.0) -> tuple[int, str]:
out = ((p.stdout or "") + (p.stderr or "")).strip()
return int(p.returncode or 0), out
def stop_user_unit_best_effort(unit: str) -> tuple[bool, str]:
u = (unit or "").strip()
if not u:
return False, "empty unit"
code, out = systemctl_user(["stop", u], timeout=4.0)
if code == 0:
return True, out
code2, out2 = systemctl_user(["kill", u], timeout=4.0)
if code2 == 0:
return True, out2
msg = (out2 or out or f"stop/kill failed for {u}").strip()
return False, msg
def cgroup_path_from_pid(pid: int) -> str:
p = int(pid or 0)
@@ -246,7 +327,13 @@ def run_systemd_unit(cmdline: str, *, unit: str) -> str:
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)
try:
cg = effective_cgroup_for_unit(unit, timeout_sec=3.0)
except Exception as e:
stopped, stop_msg = stop_user_unit_best_effort(unit)
if stopped:
raise RuntimeError(f"{e}\n\nUnit was stopped (fail-closed): {unit}") from e
raise RuntimeError(f"{e}\n\nWARNING: failed to stop unit {unit}: {stop_msg}") from e
return cg
@@ -307,7 +394,8 @@ def apply_mark(*, target: str, cgroup: str, unit: str, command: str, app_key: st
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')}")
ttl_txt = "persistent" if int(res.get("timeout_sec", 0) or 0) <= 0 else f"{int(res.get('timeout_sec', 0) or 0)}s"
log(f"mark added: target={target} app={app_key} unit={unit} cgroup_id={res.get('cgroup_id')} ttl={ttl_txt}")
def main(argv: list[str]) -> int:
@@ -322,27 +410,36 @@ def main(argv: list[str]) -> int:
cmd = str(prof.get("command") or "").strip()
if not cmd:
raise RuntimeError("profile command is empty")
run_cmd = maybe_harden_browser_cmdline(cmd)
if run_cmd != cmd:
log("browser hardening: added anti-leak flags")
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)
app_key = canonicalize_app_key(app_key_raw, run_cmd) or canonicalize_app_key("", run_cmd)
ttl = int(prof.get("ttl_sec", 0) or 0)
if ttl <= 0:
ttl = 24 * 60 * 60
if ttl < 0:
ttl = 0
# Try refresh first if already running.
if refresh_if_running(target=target, app_key=app_key, command=cmd, ttl_sec=ttl):
if refresh_if_running(target=target, app_key=app_key, command=run_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)
cg = run_systemd_unit(run_cmd, unit=unit)
log(f"ControlGroup: {cg}")
apply_mark(target=target, cgroup=cg, unit=unit, command=cmd, app_key=app_key, ttl_sec=ttl)
try:
apply_mark(target=target, cgroup=cg, unit=unit, command=run_cmd, app_key=app_key, ttl_sec=ttl)
except Exception as e:
stopped, stop_msg = stop_user_unit_best_effort(unit)
if stopped:
raise RuntimeError(f"{e}\n\nUnit was stopped (fail-closed): {unit}") from e
raise RuntimeError(f"{e}\n\nWARNING: failed to stop unit {unit}: {stop_msg}") from e
if args.json:
print(json.dumps({"ok": True, "op": "run", "id": pid, "target": target, "app_key": app_key, "unit": unit}))
return 0

View File

@@ -100,6 +100,8 @@ class TrafficModeDialog(QDialog):
self._last_app_cgroup_id: int = int(self._settings.value("traffic_app_last_cgroup_id", 0) or 0)
except Exception:
self._last_app_cgroup_id = 0
self._adv_auto_local_bypass: bool = True
self._adv_ingress_reply_bypass: bool = False
hint_group = QGroupBox("Mode behavior")
hint_layout = QVBoxLayout(hint_group)
@@ -172,13 +174,32 @@ RU: Обновить список доступных интерфейсов (UP)
row_iface.addStretch(1)
mode_layout.addLayout(row_iface)
self.chk_auto_local = QCheckBox("Auto-local bypass (LAN/container subnets)")
self.chk_auto_local.setToolTip("""EN: Mirrors local/LAN/docker routes from main into agvpn table to prevent breakage in full tunnel.
EN: This does NOT force containers to use direct internet; use Force Direct subnets for that.
RU: Копирует локальные/LAN/docker маршруты из main в agvpn, чтобы не ломалась локалка в full tunnel.
RU: Это НЕ делает контейнеры direct в интернет; для этого используй Force Direct subnets.""")
self.chk_auto_local.stateChanged.connect(lambda _state: self.on_auto_local_toggle())
mode_layout.addWidget(self.chk_auto_local)
row_adv_button = QHBoxLayout()
self.btn_adv_bypass = QPushButton("Advanced bypass...")
self.btn_adv_bypass.setToolTip(
"EN: Open compact Full tunnel advanced bypass settings (auto-local + ingress-reply).\n"
"RU: Открыть компактные расширенные bypass-настройки Full tunnel (auto-local + ingress-reply)."
)
self.btn_adv_bypass.clicked.connect(self.on_open_advanced_bypass_dialog)
row_adv_button.addWidget(self.btn_adv_bypass)
self.btn_mode_checklist = QPushButton("Checklist...")
self.btn_mode_checklist.setToolTip(
"EN: Quick production checklist for traffic mode/full tunnel safety.\n"
"RU: Короткий боевой чеклист по режимам трафика и безопасному full tunnel."
)
self.btn_mode_checklist.clicked.connect(self.on_show_mode_checklist)
row_adv_button.addWidget(self.btn_mode_checklist)
self.lbl_adv_quick = QLabel("Advanced bypass: —")
self.lbl_adv_quick.setToolTip(
"EN: Saved and active state for Full tunnel advanced bypass.\n"
"RU: Сохраненное и активное состояние advanced bypass для Full tunnel."
)
self.lbl_adv_quick.setStyleSheet("color: gray;")
row_adv_button.addWidget(self.lbl_adv_quick, stretch=1)
row_adv_button.addStretch(1)
mode_layout.addLayout(row_adv_button)
self.lbl_state = QLabel("Traffic mode: —")
self.lbl_state.setStyleSheet("color: gray;")
@@ -371,6 +392,17 @@ RU: Восстанавливает маршруты/nft из последнег
row_cmd.addWidget(self.btn_app_pick)
run_layout.addLayout(row_cmd)
row_harden = QHBoxLayout()
self.chk_app_browser_harden = QCheckBox("Browser anti-leak flags (WebRTC/QUIC)")
self.chk_app_browser_harden.setChecked(True)
self.chk_app_browser_harden.setToolTip(
"EN: For Chromium-family browsers, auto-add flags to reduce WebRTC/STUN and QUIC leaks.\n"
"RU: Для Chromium-подобных браузеров автоматически добавляет флаги против утечек WebRTC/STUN и QUIC."
)
row_harden.addWidget(self.chk_app_browser_harden)
row_harden.addStretch(1)
run_layout.addLayout(row_harden)
row_target = QHBoxLayout()
row_target.addWidget(QLabel("Route via"))
self.rad_app_vpn = QRadioButton("VPN")
@@ -393,10 +425,20 @@ RU: Восстанавливает маршруты/nft из последнег
run_layout.addLayout(row_target)
row_ttl = QHBoxLayout()
self.chk_app_temporary = QCheckBox("Temporary mark (TTL)")
self.chk_app_temporary.setToolTip(
"EN: Off (default): mark is persistent until manual unmark/clear.\n"
"EN: On: mark expires after TTL hours.\n"
"RU: Выкл (по умолчанию): метка постоянная до ручного удаления.\n"
"RU: Вкл: метка истекает через TTL часов."
)
self.chk_app_temporary.setChecked(False)
row_ttl.addWidget(self.chk_app_temporary)
row_ttl.addWidget(QLabel("TTL (hours)"))
self.spn_app_ttl = QSpinBox()
self.spn_app_ttl.setRange(1, 24 * 30) # up to ~30 days
self.spn_app_ttl.setValue(24)
self.spn_app_ttl.setEnabled(False)
self.spn_app_ttl.setToolTip(
"EN: How long the runtime mark stays active (backend nftset element timeout).\n"
"RU: Сколько живет runtime-метка (timeout элемента в nftset)."
@@ -404,6 +446,7 @@ RU: Восстанавливает маршруты/nft из последнег
row_ttl.addWidget(self.spn_app_ttl)
row_ttl.addStretch(1)
run_layout.addLayout(row_ttl)
self.chk_app_temporary.toggled.connect(self.spn_app_ttl.setEnabled)
pid_group = QGroupBox("Mark existing PID (no launch)")
pid_layout = QHBoxLayout(pid_group)
@@ -483,7 +526,7 @@ RU: Восстанавливает маршруты/nft из последнег
tab_run_layout.addStretch(1)
self.apps_tabs.addTab(tab_run, "Run")
marks_group = QGroupBox("Active runtime marks (TTL)")
marks_group = QGroupBox("Active runtime marks")
marks_layout = QVBoxLayout(marks_group)
marks_row = QHBoxLayout()
@@ -509,8 +552,8 @@ RU: Восстанавливает маршруты/nft из последнег
self.lst_marks = QListWidget()
self.lst_marks.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.lst_marks.setToolTip(
"EN: Active runtime marks. Stored by backend with TTL.\n"
"RU: Активные runtime-метки. Хранятся backend с TTL."
"EN: Active runtime marks. Can be persistent or temporary (TTL).\n"
"RU: Активные runtime-метки. Могут быть постоянными или временными (TTL)."
)
self.lst_marks.setFixedHeight(140)
marks_layout.addWidget(self.lst_marks)
@@ -598,6 +641,14 @@ RU: Восстанавливает маршруты/nft из последнег
tab_adv = QWidget()
tab_adv_layout = QVBoxLayout(tab_adv)
adv_hint = QLabel(
"Policy overrides are source-based rules (subnet/UID/cgroup).\n"
"Full tunnel advanced bypass (auto-local + ingress-reply) is configured from Traffic basics."
)
adv_hint.setWordWrap(True)
adv_hint.setStyleSheet("color: gray;")
tab_adv_layout.addWidget(adv_hint)
self.ed_vpn_subnets = QPlainTextEdit()
self.ed_vpn_subnets.setToolTip("""EN: Force VPN by source subnet. Useful for docker subnets when you want containers via VPN.
RU: Принудительно через VPN по source subnet. Полезно для docker-подсетей, если хочешь контейнеры через VPN.""")
@@ -878,12 +929,18 @@ RU: Применяет policy-rules и проверяет health. При оши
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,
@@ -903,10 +960,16 @@ RU: Применяет policy-rules и проверяет health. При оши
text = f"Traffic mode: {desired} (applied: {applied}) [{health_txt}]"
diag_parts = []
diag_parts.append(f"preferred={preferred_iface or 'auto'}")
diag_parts.append(f"advanced={'on' if advanced_active else 'off'}")
diag_parts.append(
f"auto_local_bypass={'on' if auto_local_bypass else 'off'}"
f"auto_local={'on' if auto_local_bypass else 'off'}"
f"({'active' if auto_local_active else 'saved'})"
)
if bypass_candidates > 0:
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:
@@ -917,6 +980,10 @@ RU: Применяет policy-rules и проверяет health. При оши
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)
@@ -929,6 +996,20 @@ RU: Применяет policy-rules и проверяет health. При оши
self.lbl_diag.setText(diag)
self.lbl_diag.setStyleSheet("color: gray;")
quick = (
f"Advanced bypass: auto-local={'on' if auto_local_bypass else 'off'} "
f"({('active' if auto_local_active else 'saved')}), "
f"ingress-reply={'on' if ingress_reply_bypass else 'off'} "
f"({('active' if ingress_reply_active else 'saved')})"
)
if advanced_active:
adv_color = "green" if (ingress_reply_active or auto_local_active) else "gray"
self.lbl_adv_quick.setText(quick)
self.lbl_adv_quick.setStyleSheet(f"color: {adv_color};")
else:
self.lbl_adv_quick.setText(f"{quick} | applies only in Full tunnel")
self.lbl_adv_quick.setStyleSheet("color: gray;")
def refresh_state(self) -> None:
def work() -> None:
view = self.ctrl.traffic_mode_view()
@@ -946,9 +1027,9 @@ RU: Применяет policy-rules и проверяет health. При оши
opts = self.ctrl.traffic_interfaces()
self._set_preferred_iface_options(opts, view.preferred_iface)
self.chk_auto_local.blockSignals(True)
self.chk_auto_local.setChecked(bool(view.auto_local_bypass))
self.chk_auto_local.blockSignals(False)
self._set_full_tunnel_advanced_enabled(mode)
self._adv_auto_local_bypass = bool(view.auto_local_bypass)
self._adv_ingress_reply_bypass = bool(view.ingress_reply_bypass)
self._set_lines(self.ed_vpn_subnets, list(view.force_vpn_subnets or []))
self._set_lines(self.ed_vpn_uids, list(view.force_vpn_uids or []))
self._set_lines(self.ed_vpn_cgroups, list(view.force_vpn_cgroups or []))
@@ -960,12 +1041,18 @@ RU: Применяет policy-rules и проверяет health. При оши
view.desired_mode,
view.applied_mode,
view.preferred_iface,
bool(view.advanced_active),
bool(view.auto_local_bypass),
bool(view.auto_local_active),
bool(view.ingress_reply_bypass),
bool(view.ingress_reply_active),
int(view.bypass_candidates),
int(view.overrides_applied),
int(view.cgroup_resolved_uids),
view.cgroup_warning,
bool(view.healthy),
bool(view.ingress_rule_present),
bool(view.ingress_nft_active),
bool(view.probe_ok),
view.probe_message,
view.active_iface,
@@ -981,13 +1068,15 @@ RU: Применяет policy-rules и проверяет health. При оши
def work() -> None:
preferred = self._preferred_iface_value()
auto_local = self.chk_auto_local.isChecked()
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local)
auto_local = bool(self._adv_auto_local_bypass)
ingress_reply = bool(self._adv_ingress_reply_bypass)
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local, ingress_reply)
msg = (
f"Traffic mode set: desired={view.desired_mode}, "
f"applied={view.applied_mode}, iface={view.active_iface or '-'}, "
f"preferred={preferred or 'auto'}, probe_ok={view.probe_ok}, "
f"healthy={view.healthy}, auto_local_bypass={view.auto_local_bypass}, "
f"ingress_reply_bypass={view.ingress_reply_bypass}, ingress_reply_active={view.ingress_reply_active}, "
f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, "
f"cgroup_uids={view.cgroup_resolved_uids}, message={view.message}"
)
@@ -1027,35 +1116,158 @@ RU: Применяет policy-rules и проверяет health. При оши
return "direct"
return "selective"
def on_auto_local_toggle(self) -> None:
def _set_full_tunnel_advanced_enabled(self, mode: str) -> None:
is_full = (mode or "").strip().lower() == "full_tunnel"
self.btn_adv_bypass.setEnabled(True)
if is_full:
self.btn_adv_bypass.setText("Advanced bypass...")
self.btn_adv_bypass.setStyleSheet("")
else:
self.btn_adv_bypass.setText("Advanced bypass... (saved only)")
self.btn_adv_bypass.setStyleSheet("color: gray;")
def on_show_mode_checklist(self) -> None:
text = (
"Quick checklist\n\n"
"1) Select mode:\n"
"- Selective: safest default for mixed host/server workloads.\n"
"- Full tunnel: all traffic via VPN (then review advanced bypass).\n"
"- Direct: VPN policy rules disabled.\n\n"
"2) For Full tunnel:\n"
"- Open Advanced bypass.\n"
"- Enable Auto-local bypass for LAN/container reachability.\n"
"- Enable Ingress-reply bypass to keep public services reachable.\n\n"
"3) Verify status line:\n"
"- health must be [OK].\n"
"- ingress_diag should be rule:ok/nft:ok when ingress-reply is ON.\n\n"
"4) If something breaks:\n"
"- Use Advanced bypass -> Reset bypass.\n"
"- Or switch back to Selective and re-test."
)
QMessageBox.information(self, "Traffic mode checklist", text)
def on_open_advanced_bypass_dialog(self) -> None:
mode = self._selected_mode()
dlg = QDialog(self)
dlg.setWindowTitle("Advanced bypass (Full tunnel)")
dlg.setModal(True)
layout = QVBoxLayout(dlg)
hint = QLabel(
"Applies only in Full tunnel.\n"
"- Auto-local bypass: keep LAN/docker reachable.\n"
"- Ingress-reply bypass: keep inbound/public services reachable."
)
hint.setWordWrap(True)
hint.setStyleSheet("color: gray;")
layout.addWidget(hint)
chk_auto = QCheckBox("Auto-local bypass (LAN/container subnets)")
chk_auto.setChecked(bool(self._adv_auto_local_bypass))
chk_auto.setToolTip(
"EN: Keeps LAN/container routes direct in Full tunnel.\n"
"RU: Сохраняет LAN/контейнерные маршруты direct в Full tunnel."
)
layout.addWidget(chk_auto)
chk_ingress = QCheckBox("Ingress-reply bypass (keep public services reachable)")
chk_ingress.setChecked(bool(self._adv_ingress_reply_bypass))
chk_ingress.setToolTip(
"EN: Keeps replies for inbound WAN connections on main/direct route.\n"
"RU: Оставляет ответы на входящие WAN-соединения по main/direct."
)
layout.addWidget(chk_ingress)
state = QLabel(
"Current mode is Full tunnel: changes apply now."
if mode == "full_tunnel"
else "Current mode is not Full tunnel: changes are saved and applied later."
)
state.setWordWrap(True)
state.setStyleSheet("color: green;" if mode == "full_tunnel" else "color: #b07f00;")
layout.addWidget(state)
reset_note = QLabel(
"Reset bypass = disable both toggles and apply to current mode."
)
reset_note.setWordWrap(True)
reset_note.setStyleSheet("color: gray;")
layout.addWidget(reset_note)
row = QHBoxLayout()
row.addStretch(1)
btn_cancel = QPushButton("Cancel")
btn_reset = QPushButton("Reset bypass")
btn_apply = QPushButton("Apply")
row.addWidget(btn_cancel)
row.addWidget(btn_reset)
row.addWidget(btn_apply)
layout.addLayout(row)
btn_cancel.clicked.connect(dlg.reject)
btn_apply.clicked.connect(dlg.accept)
action = {"mode": "apply"}
def on_reset_click() -> None:
action["mode"] = "reset"
dlg.accept()
btn_reset.clicked.connect(on_reset_click)
if dlg.exec() != QDialog.Accepted:
return
def work() -> None:
mode = self._selected_mode()
if action["mode"] == "reset":
view = self.ctrl.traffic_advanced_reset()
self._adv_auto_local_bypass = bool(view.auto_local_bypass)
self._adv_ingress_reply_bypass = bool(view.ingress_reply_bypass)
self._emit_log(
"Traffic advanced bypass reset: "
f"mode={view.desired_mode}, auto_local={view.auto_local_bypass}, "
f"ingress_reply={view.ingress_reply_bypass}, message={view.message}"
)
op_ok = bool(view.healthy) and not self._is_operation_error(view.message)
self._set_action_status(
f"Advanced bypass reset ({view.message})",
ok=op_ok,
)
self.refresh_state()
if self.refresh_cb:
self.refresh_cb()
return
auto_local = bool(chk_auto.isChecked())
ingress_reply = bool(chk_ingress.isChecked())
preferred = self._preferred_iface_value()
auto_local = self.chk_auto_local.isChecked()
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local)
msg = (
f"Traffic auto-local set: mode={view.desired_mode}, "
f"auto_local_bypass={view.auto_local_bypass}, "
f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, "
f"cgroup_uids={view.cgroup_resolved_uids}, message={view.message}"
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local, ingress_reply)
self._adv_auto_local_bypass = bool(view.auto_local_bypass)
self._adv_ingress_reply_bypass = bool(view.ingress_reply_bypass)
self._emit_log(
"Traffic advanced bypass set: "
f"mode={view.desired_mode}, auto_local={view.auto_local_bypass}, "
f"ingress_reply={view.ingress_reply_bypass}, ingress_active={view.ingress_reply_active}, "
f"message={view.message}"
)
self._emit_log(msg)
op_ok = bool(view.healthy) and not self._is_operation_error(view.message)
self._set_action_status(
f"Auto-local bypass set: {'on' if view.auto_local_bypass else 'off'} ({view.message})",
"Advanced bypass saved: "
f"auto_local={'on' if view.auto_local_bypass else 'off'}, "
f"ingress_reply={'on' if view.ingress_reply_bypass else 'off'} ({view.message})",
ok=op_ok,
)
self.refresh_state()
if self.refresh_cb:
self.refresh_cb()
self._safe(work, title="Auto-local bypass error")
self._safe(work, title="Advanced bypass error")
def on_apply_overrides(self) -> None:
def work() -> None:
mode = self._selected_mode()
preferred = self._preferred_iface_value()
auto_local = self.chk_auto_local.isChecked()
auto_local = bool(self._adv_auto_local_bypass)
ingress_reply = bool(self._adv_ingress_reply_bypass)
vpn_subnets = self._lines_from_text(self.ed_vpn_subnets.toPlainText())
vpn_uids = self._lines_from_text(self.ed_vpn_uids.toPlainText())
vpn_cgroups = self._lines_from_text(self.ed_vpn_cgroups.toPlainText())
@@ -1067,6 +1279,7 @@ RU: Применяет policy-rules и проверяет health. При оши
mode,
preferred,
auto_local,
ingress_reply,
vpn_subnets,
vpn_uids,
vpn_cgroups,
@@ -1076,6 +1289,7 @@ RU: Применяет policy-rules и проверяет health. При оши
)
msg = (
f"Traffic overrides applied: mode={view.desired_mode}, "
f"auto_local={view.auto_local_bypass}, ingress_reply={view.ingress_reply_bypass}, ingress_active={view.ingress_reply_active}, "
f"vpn_subnets={len(view.force_vpn_subnets)}, vpn_uids={len(view.force_vpn_uids)}, vpn_cgroups={len(view.force_vpn_cgroups)}, "
f"direct_subnets={len(view.force_direct_subnets)}, direct_uids={len(view.force_direct_uids)}, direct_cgroups={len(view.force_direct_cgroups)}, "
f"overrides={view.overrides_applied}, cgroup_uids={view.cgroup_resolved_uids}, "
@@ -1177,6 +1391,77 @@ RU: Применяет policy-rules и проверяет health. При оши
return primary
def _ui_runtime_mark_ttl_sec(self) -> int:
if bool(getattr(self, "chk_app_temporary", None)) and self.chk_app_temporary.isChecked():
return int(self.spn_app_ttl.value()) * 3600
return 0
def _is_chromium_like_cmd(self, tokens: list[str]) -> bool:
toks = [str(x or "").strip() for x in (tokens or []) if str(x or "").strip()]
if not toks:
return False
exe = os.path.basename(toks[0]).lower()
known = {
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"microsoft-edge",
"microsoft-edge-stable",
"brave",
"brave-browser",
"opera",
"opera-beta",
"opera-developer",
"vivaldi",
"vivaldi-stable",
}
if exe in known:
return True
if any(x in exe for x in ("chrome", "chromium", "edge", "brave", "opera", "vivaldi")):
return True
# flatpak run <appid>
if exe == "flatpak":
for i, t in enumerate(toks):
if t == "run":
for cand in toks[i + 1:]:
c = cand.strip().lower()
if not c or c.startswith("-") or c == "--":
continue
return any(x in c for x in ("chrome", "chromium", "edge", "brave", "opera", "vivaldi"))
break
return False
def _maybe_harden_browser_cmdline(self, cmdline: str) -> str:
raw = (cmdline or "").strip()
if not raw:
return raw
if not bool(getattr(self, "chk_app_browser_harden", None)) or not self.chk_app_browser_harden.isChecked():
return raw
try:
toks = shlex.split(raw)
except Exception:
return raw
if not self._is_chromium_like_cmd(toks):
return raw
flags = [
"--disable-quic",
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
]
low = [t.lower() for t in toks]
changed = False
for fl in flags:
fl_low = fl.lower()
if any(t == fl_low or t.startswith(fl_low + "=") for t in low):
continue
toks.append(fl)
changed = True
if not changed:
return raw
return " ".join(shlex.quote(t) for t in toks)
def _launch_and_mark(
self,
*,
@@ -1193,8 +1478,12 @@ RU: Применяет policy-rules и проверяет health. При оши
raise ValueError("invalid target")
ttl = int(ttl_sec or 0)
if ttl <= 0:
ttl = int(self.spn_app_ttl.value()) * 3600
ttl = self._ui_runtime_mark_ttl_sec()
ttl_log = "persistent" if ttl <= 0 else f"{ttl}s"
key = (app_key or "").strip() or self._infer_app_key_from_cmdline(cmdline)
run_cmdline = self._maybe_harden_browser_cmdline(cmdline)
if run_cmdline != cmdline:
self._append_app_log("[app] browser hardening: added anti-leak flags")
# EN: If we already have a running unit for the same app_key+target, refresh mark instead of spawning.
# RU: Если уже есть запущенный unit для того же app_key+target — обновляем метку, не плодим инстансы.
@@ -1221,7 +1510,7 @@ RU: Применяет policy-rules и проверяет health. При оши
target=tgt,
cgroup=cg,
unit=unit,
command=cmdline,
command=run_cmdline,
app_key=key,
timeout_sec=ttl,
)
@@ -1235,7 +1524,7 @@ RU: Применяет policy-rules и проверяет health. При оши
unit=unit,
target=tgt,
app_key=key,
cmdline=cmdline,
cmdline=run_cmdline,
cgroup_id=int(res.cgroup_id or 0),
)
self.refresh_appmarks_items(quiet=True)
@@ -1245,42 +1534,59 @@ RU: Применяет policy-rules и проверяет health. При оши
return
unit = f"svpn-{tgt}-{int(time.time())}.service"
self._append_app_log(f"[app] launching: app={key or '-'} target={tgt} ttl={ttl}s unit={unit}")
cg, out = self._run_systemd_unit(cmdline, unit=unit)
self._append_app_log(f"[app] launching: app={key or '-'} target={tgt} ttl={ttl_log} unit={unit}")
try:
cg, out = self._run_systemd_unit(run_cmdline, unit=unit)
except Exception as e:
try:
self._stop_scope_unit(unit)
self._append_app_log(f"[app] fail-closed: stopped unit after launch error: {unit}")
except Exception as stop_err:
self._append_app_log(f"[app] fail-closed WARN: stop failed for {unit}: {stop_err}")
raise RuntimeError(f"{e}\n\nUnit: {unit}") from e
if out:
self._append_app_log(f"[app] systemd-run:\n{out}")
self._append_app_log(f"[app] ControlGroup: {cg}")
self._set_last_scope(unit=unit, target=tgt, app_key=key, cmdline=cmdline, cgroup_id=0)
res = self.ctrl.traffic_appmarks_apply(
op="add",
target=tgt,
cgroup=cg,
unit=unit,
command=cmdline,
command=run_cmdline,
app_key=key,
timeout_sec=ttl,
)
if not res.ok:
stop_note = ""
try:
self._stop_scope_unit(unit)
self._append_app_log(f"[app] fail-closed: stopped unit after mark failure: {unit}")
stop_note = "\n\nUnit was stopped (fail-closed)."
except Exception as stop_err:
self._append_app_log(f"[app] fail-closed WARN: stop failed for {unit}: {stop_err}")
stop_note = f"\n\nWARNING: failed to stop unit after mark error: {stop_err}"
low = (res.message or "").lower()
if "cgroupv2 path fails" in low or "no such file or directory" in low:
raise RuntimeError(
(res.message or "appmark apply failed")
+ stop_note
+ "\n\n"
+ "EN: This usually means the app didn't stay inside the new systemd unit "
+ "(often because it was already running). Close the app completely and run again.\n"
+ "RU: Обычно это значит, что приложение не осталось в новом systemd unit "
+ "(часто потому что оно уже было запущено). Полностью закрой приложение и запусти снова."
)
raise RuntimeError(res.message or "appmark apply failed")
raise RuntimeError((res.message or "appmark apply failed") + stop_note)
self._append_app_log(f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s")
timeout_txt = "persistent" if int(res.timeout_sec or 0) <= 0 else f"{res.timeout_sec}s"
self._append_app_log(f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={timeout_txt}")
self._set_action_status(f"App mark added: target={tgt} cgroup_id={res.cgroup_id}", ok=True)
self._set_last_scope(
unit=unit,
target=tgt,
app_key=key,
cmdline=cmdline,
cmdline=run_cmdline,
cgroup_id=int(res.cgroup_id or 0),
)
self.refresh_appmarks_items(quiet=True)
@@ -1322,7 +1628,7 @@ RU: Применяет policy-rules и проверяет health. При оши
script = os.path.abspath(os.path.join(os.path.dirname(__file__), "svpn_run_profile.py"))
# Use env python3 so the shortcut works even if python3 is not /usr/bin/python3.
exec_line = f"/usr/bin/env python3 {script} --id {pid}"
exec_line = f"/usr/bin/env SVPN_BROWSER_HARDEN=1 python3 {script} --id {pid}"
# Keep .desktop content ASCII-ish. Values are UTF-8-safe by spec, but avoid surprises.
name_safe = (name or "SVPN profile").replace("\n", " ").replace("\r", " ").strip()
@@ -1392,6 +1698,7 @@ RU: Применяет policy-rules и проверяет health. При оши
app_key = (getattr(p, "app_key", "") or "").strip()
cmd = (getattr(p, "command", "") or "").strip()
ttl_sec = int(getattr(p, "ttl_sec", 0) or 0)
ttl_txt = "persistent" if ttl_sec <= 0 else f"{ttl_sec}s"
label = name or pid or "(unnamed)"
if target in ("vpn", "direct"):
@@ -1434,7 +1741,7 @@ RU: Применяет policy-rules и проверяет health. При оши
f"id: {pid}\n"
f"app_key: {app_key}\n"
f"target: {target}\n"
f"ttl: {ttl_sec}s\n\n"
f"ttl: {ttl_txt}\n\n"
f"shortcut: {sc_state}\n"
f"shortcut_path: {sc_path}\n\n"
f"runtime_marks: {len(items)}\n"
@@ -1468,7 +1775,7 @@ RU: Применяет policy-rules и проверяет health. При оши
return
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_app_ttl.value()) * 3600
ttl_sec = self._ui_runtime_mark_ttl_sec()
name = (self.ed_app_profile_name.text() or "").strip()
app_key = self._infer_app_key_from_cmdline(cmdline)
@@ -1585,6 +1892,9 @@ RU: Применяет policy-rules и проверяет health. При оши
# UI uses hours; round up.
hours = max(1, (ttl_sec + 3599) // 3600)
self.spn_app_ttl.setValue(int(hours))
self.chk_app_temporary.setChecked(True)
else:
self.chk_app_temporary.setChecked(False)
self.ed_app_profile_name.setText(name)
self._set_action_status("Profile loaded into form", ok=True)
@@ -1675,13 +1985,15 @@ RU: Применяет policy-rules и проверяет health. При оши
unit = (getattr(it, "unit", "") or "").strip()
cmd = (getattr(it, "command", "") or "").strip()
rem = int(getattr(it, "remaining_sec", 0) or 0)
if rem < 0:
rem_txt = "persistent"
else:
rem_h = rem // 3600
rem_m = (rem % 3600) // 60
rem_s = rem % 60
rem_txt = f"ttl {rem_h:02d}:{rem_m:02d}:{rem_s:02d}"
rem_h = rem // 3600
rem_m = (rem % 3600) // 60
rem_s = rem % 60
rem_txt = f"{rem_h:02d}:{rem_m:02d}:{rem_s:02d}"
label = f"{tgt} {app_key or unit or mid} (ttl {rem_txt})"
label = f"{tgt} {app_key or unit or mid} ({rem_txt})"
q = QListWidgetItem(label)
q.setToolTip(
(
@@ -1689,7 +2001,7 @@ RU: Применяет policy-rules и проверяет health. При оши
f"target: {tgt}\n"
f"app_key: {app_key}\n"
f"unit: {unit}\n"
f"remaining: {rem}s\n\n"
f"remaining: {('persistent' if rem < 0 else str(rem) + 's')}\n\n"
f"{cmd}"
).strip()
)
@@ -2033,9 +2345,10 @@ RU: Применяет policy-rules и проверяет health. При оши
app_key = f"pid:{pid}"
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_app_ttl.value()) * 3600
ttl_sec = self._ui_runtime_mark_ttl_sec()
self._append_app_log(f"[pid] mark: pid={pid} target={target} ttl={ttl_sec}s")
ttl_txt = "persistent" if ttl_sec <= 0 else f"{ttl_sec}s"
self._append_app_log(f"[pid] mark: pid={pid} target={target} ttl={ttl_txt}")
self._append_app_log(f"[pid] cgroup: {cg}")
if cmdline:
self._append_app_log(f"[pid] cmdline: {cmdline}")
@@ -2055,7 +2368,8 @@ RU: Применяет policy-rules и проверяет health. При оши
QMessageBox.critical(self, "Mark PID error", res.message or "mark failed")
return
self._append_app_log(f"[pid] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s")
res_timeout_txt = "persistent" if int(res.timeout_sec or 0) <= 0 else f"{res.timeout_sec}s"
self._append_app_log(f"[pid] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res_timeout_txt}")
self._set_action_status(f"PID marked: target={target} cgroup_id={res.cgroup_id}", ok=True)
self._set_last_scope(unit="", target=target, app_key=app_key, cmdline=cmdline, cgroup_id=int(res.cgroup_id or 0))
@@ -2073,7 +2387,7 @@ RU: Применяет policy-rules и проверяет health. При оши
return
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_app_ttl.value()) * 3600
ttl_sec = self._ui_runtime_mark_ttl_sec()
app_key = self._infer_app_key_from_cmdline(cmdline)
self._launch_and_mark(cmdline=cmdline, target=target, ttl_sec=ttl_sec, app_key=app_key)

View File

@@ -511,6 +511,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
"static-ips",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
"smartdns.conf",
):
QListWidgetItem(name, self.lst_files)
@@ -631,6 +632,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
"static-ips": "static",
"last-ips-map-direct": "last-ips-map-direct",
"last-ips-map-wildcard": "last-ips-map-wildcard",
"wildcard-observed-hosts": "wildcard-observed-hosts",
"smartdns.conf": "smartdns",
}
if name in api_map:
@@ -643,6 +645,8 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
path = "/var/lib/selective-vpn/last-ips-map-direct.txt (artifact: agvpn4)"
elif name == "last-ips-map-wildcard":
path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (artifact: agvpn_dyn4)"
elif name == "wildcard-observed-hosts":
path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (derived unique hosts)"
else:
path = f"/etc/selective-vpn/domains/{name}.txt"
return content, source, path
@@ -1530,7 +1534,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
def work():
name = self._get_selected_domains_file()
content, source, path = self._load_file_content(name)
is_readonly = name in ("last-ips-map-direct", "last-ips-map-wildcard")
is_readonly = name in ("last-ips-map-direct", "last-ips-map-wildcard", "wildcard-observed-hosts")
self.txt_domains.setReadOnly(is_readonly)
self.btn_domains_save.setEnabled(not is_readonly)
self._set_text(self.txt_domains, content)