Harden resolver and expand traffic runtime controls
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
135
selective-vpn-api/app/traffic_appkey_test.go
Normal file
135
selective-vpn-api/app/traffic_appkey_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 --------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user