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.*
|
*.bak.*
|
||||||
*.tmp
|
*.tmp
|
||||||
selective-vpn-api/works
|
selective-vpn-api/works
|
||||||
|
selective-vpn-api/_backups/
|
||||||
|
|
||||||
# Local archive / old copies (kept out of repo root)
|
# Local archive / old copies (kept out of repo root)
|
||||||
_legacy/
|
_legacy/
|
||||||
|
|||||||
@@ -20,3 +20,11 @@ Requirements (high level):
|
|||||||
- Linux with `systemd`, `nftables`, `iproute2`, cgroup v2.
|
- Linux with `systemd`, `nftables`, `iproute2`, cgroup v2.
|
||||||
- Python 3 + PySide6 + `requests` (GUI).
|
- Python 3 + PySide6 + `requests` (GUI).
|
||||||
- Root privileges for routing/nftables changes (run API as a privileged service).
|
- 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 {
|
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(
|
msg := fmt.Sprintf(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,9 +43,24 @@ func handleDomainsTable(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout, _, _, err := runCommand("ipset", "list", "agvpn4")
|
|
||||||
lines := []string{}
|
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") {
|
for _, l := range strings.Split(stdout, "\n") {
|
||||||
l = strings.TrimRight(l, "\r")
|
l = strings.TrimRight(l, "\r")
|
||||||
if l != "" {
|
if l != "" {
|
||||||
@@ -59,7 +75,7 @@ func handleDomainsTable(w http.ResponseWriter, r *http.Request) {
|
|||||||
// domains file
|
// 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": "..." }
|
// POST /api/v1/domains/file { "name": "...", "content": "..." }
|
||||||
func handleDomainsFile(w http.ResponseWriter, r *http.Request) {
|
func handleDomainsFile(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
@@ -73,6 +89,13 @@ func handleDomainsFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if name == "wildcard-observed-hosts" {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"content": readWildcardObservedHostsContent(),
|
||||||
|
"source": "derived",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
path, ok := domainFiles[name]
|
path, ok := domainFiles[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "unknown file name", http.StatusBadRequest)
|
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"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
return
|
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)
|
http.Error(w, "read-only file name", http.StatusBadRequest)
|
||||||
return
|
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
|
// smartdns wildcards
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|||||||
@@ -265,6 +265,23 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
|
|||||||
domainCache := loadDomainCacheState(opts.CachePath, logf)
|
domainCache := loadDomainCacheState(opts.CachePath, logf)
|
||||||
ptrCache := loadJSONMap(opts.PtrCachePath)
|
ptrCache := loadJSONMap(opts.PtrCachePath)
|
||||||
now := int(time.Now().Unix())
|
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 {
|
cacheSourceForHost := func(host string) domainCacheSource {
|
||||||
switch cfg.Mode {
|
switch cfg.Mode {
|
||||||
@@ -284,6 +301,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
fresh := map[string][]string{}
|
fresh := map[string][]string{}
|
||||||
|
cacheNegativeHits := 0
|
||||||
var toResolve []string
|
var toResolve []string
|
||||||
for _, d := range domains {
|
for _, d := range domains {
|
||||||
source := cacheSourceForHost(d)
|
source := cacheSourceForHost(d)
|
||||||
@@ -294,6 +312,13 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
|
|||||||
}
|
}
|
||||||
continue
|
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)
|
toResolve = append(toResolve, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +328,7 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
|
|||||||
}
|
}
|
||||||
|
|
||||||
if logf != nil {
|
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{}
|
dnsStats := dnsMetrics{}
|
||||||
@@ -349,8 +374,16 @@ func runResolverJob(opts ResolverOpts, logf func(string, ...any)) (resolverResul
|
|||||||
if logf != nil {
|
if logf != nil {
|
||||||
logf("%s -> %v", r.host, r.ips)
|
logf("%s -> %v", r.host, r.ips)
|
||||||
}
|
}
|
||||||
} else if logf != nil {
|
} else {
|
||||||
logf("%s: no IPs", r.host)
|
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 {
|
if logf != nil {
|
||||||
dnsErrors := dnsStats.totalErrors()
|
dnsErrors := dnsStats.totalErrors()
|
||||||
logf(
|
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(domains),
|
||||||
len(fresh),
|
len(fresh),
|
||||||
|
cacheNegativeHits,
|
||||||
len(resolved)-len(fresh),
|
len(resolved)-len(fresh),
|
||||||
len(domains)-len(resolved),
|
len(domains)-len(resolved),
|
||||||
len(staticEntries),
|
len(staticEntries),
|
||||||
@@ -487,17 +521,45 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w
|
|||||||
if useMeta {
|
if useMeta {
|
||||||
dnsList = cfg.Meta
|
dnsList = cfg.Meta
|
||||||
}
|
}
|
||||||
|
primaryViaSmartDNS := false
|
||||||
switch cfg.Mode {
|
switch cfg.Mode {
|
||||||
case DNSModeSmartDNS:
|
case DNSModeSmartDNS:
|
||||||
if cfg.SmartDNS != "" {
|
if cfg.SmartDNS != "" {
|
||||||
dnsList = []string{cfg.SmartDNS}
|
dnsList = []string{cfg.SmartDNS}
|
||||||
|
primaryViaSmartDNS = true
|
||||||
}
|
}
|
||||||
case DNSModeHybridWildcard:
|
case DNSModeHybridWildcard:
|
||||||
if cfg.SmartDNS != "" && wildcards.match(host) {
|
if cfg.SmartDNS != "" && wildcards.match(host) {
|
||||||
dnsList = []string{cfg.SmartDNS}
|
dnsList = []string{cfg.SmartDNS}
|
||||||
|
primaryViaSmartDNS = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ips, stats := digA(host, dnsList, timeout, logf)
|
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{}
|
out := []string{}
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
@@ -512,6 +574,52 @@ func resolveHostGo(host string, cfg dnsConfig, metaSpecial []string, wildcards w
|
|||||||
return out, stats
|
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.
|
// EN: `digA` contains core logic for dig a.
|
||||||
// RU: `digA` - содержит основную логику для dig a.
|
// RU: `digA` - содержит основную логику для dig a.
|
||||||
@@ -742,8 +850,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type domainCacheEntry struct {
|
type domainCacheEntry struct {
|
||||||
IPs []string `json:"ips"`
|
IPs []string `json:"ips,omitempty"`
|
||||||
LastResolved int `json:"last_resolved"`
|
LastResolved int `json:"last_resolved,omitempty"`
|
||||||
|
LastErrorKind string `json:"last_error_kind,omitempty"`
|
||||||
|
LastErrorAt int `json:"last_error_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type domainCacheRecord struct {
|
type domainCacheRecord struct {
|
||||||
@@ -758,7 +868,7 @@ type domainCacheState struct {
|
|||||||
|
|
||||||
func newDomainCacheState() domainCacheState {
|
func newDomainCacheState() domainCacheState {
|
||||||
return domainCacheState{
|
return domainCacheState{
|
||||||
Version: 2,
|
Version: 3,
|
||||||
Domains: map[string]domainCacheRecord{},
|
Domains: map[string]domainCacheRecord{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -781,6 +891,41 @@ func normalizeCacheIPs(raw []string) []string {
|
|||||||
return out
|
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 {
|
func parseAnyStringSlice(raw any) []string {
|
||||||
switch v := raw.(type) {
|
switch v := raw.(type) {
|
||||||
case []string:
|
case []string:
|
||||||
@@ -842,7 +987,7 @@ func loadDomainCacheState(path string, logf func(string, ...any)) domainCacheSta
|
|||||||
var st domainCacheState
|
var st domainCacheState
|
||||||
if err := json.Unmarshal(data, &st); err == nil && st.Domains != nil {
|
if err := json.Unmarshal(data, &st); err == nil && st.Domains != nil {
|
||||||
if st.Version <= 0 {
|
if st.Version <= 0 {
|
||||||
st.Version = 2
|
st.Version = 3
|
||||||
}
|
}
|
||||||
normalized := newDomainCacheState()
|
normalized := newDomainCacheState()
|
||||||
for host, rec := range st.Domains {
|
for host, rec := range st.Domains {
|
||||||
@@ -851,18 +996,8 @@ func loadDomainCacheState(path string, logf func(string, ...any)) domainCacheSta
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
nrec := domainCacheRecord{}
|
nrec := domainCacheRecord{}
|
||||||
if rec.Direct != nil {
|
nrec.Direct = normalizeDomainCacheEntry(rec.Direct)
|
||||||
ips := normalizeCacheIPs(rec.Direct.IPs)
|
nrec.Wildcard = normalizeDomainCacheEntry(rec.Wildcard)
|
||||||
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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if nrec.Direct != nil || nrec.Wildcard != nil {
|
if nrec.Direct != nil || nrec.Wildcard != nil {
|
||||||
normalized.Domains[host] = nrec
|
normalized.Domains[host] = nrec
|
||||||
}
|
}
|
||||||
@@ -926,6 +1061,46 @@ func (s domainCacheState) get(domain string, source domainCacheSource, now, ttl
|
|||||||
return ips, true
|
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) {
|
func (s *domainCacheState) set(domain string, source domainCacheSource, ips []string, now int) {
|
||||||
host := strings.TrimSpace(strings.ToLower(domain))
|
host := strings.TrimSpace(strings.ToLower(domain))
|
||||||
if host == "" || now <= 0 {
|
if host == "" || now <= 0 {
|
||||||
@@ -939,7 +1114,10 @@ func (s *domainCacheState) set(domain string, source domainCacheSource, ips []st
|
|||||||
s.Domains = map[string]domainCacheRecord{}
|
s.Domains = map[string]domainCacheRecord{}
|
||||||
}
|
}
|
||||||
rec := s.Domains[host]
|
rec := s.Domains[host]
|
||||||
entry := &domainCacheEntry{IPs: norm, LastResolved: now}
|
entry := &domainCacheEntry{
|
||||||
|
IPs: norm,
|
||||||
|
LastResolved: now,
|
||||||
|
}
|
||||||
switch source {
|
switch source {
|
||||||
case domainCacheSourceWildcard:
|
case domainCacheSourceWildcard:
|
||||||
rec.Wildcard = entry
|
rec.Wildcard = entry
|
||||||
@@ -949,9 +1127,39 @@ func (s *domainCacheState) set(domain string, source domainCacheSource, ips []st
|
|||||||
s.Domains[host] = rec
|
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 {
|
func (s domainCacheState) toMap() map[string]any {
|
||||||
out := map[string]any{
|
out := map[string]any{
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"domains": map[string]any{},
|
"domains": map[string]any{},
|
||||||
}
|
}
|
||||||
domainsAny := out["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 {
|
for _, host := range hosts {
|
||||||
rec := s.Domains[host]
|
rec := s.Domains[host]
|
||||||
recOut := map[string]any{}
|
recOut := map[string]any{}
|
||||||
if rec.Direct != nil && len(rec.Direct.IPs) > 0 && rec.Direct.LastResolved > 0 {
|
if rec.Direct != nil {
|
||||||
recOut["direct"] = map[string]any{
|
directOut := map[string]any{}
|
||||||
"ips": rec.Direct.IPs,
|
if len(rec.Direct.IPs) > 0 && rec.Direct.LastResolved > 0 {
|
||||||
"last_resolved": rec.Direct.LastResolved,
|
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 {
|
if rec.Wildcard != nil {
|
||||||
recOut["wildcard"] = map[string]any{
|
wildOut := map[string]any{}
|
||||||
"ips": rec.Wildcard.IPs,
|
if len(rec.Wildcard.IPs) > 0 && rec.Wildcard.LastResolved > 0 {
|
||||||
"last_resolved": rec.Wildcard.LastResolved,
|
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 {
|
if len(recOut) > 0 {
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ func saveRoutesClearCache() (routesClearCacheMeta, error) {
|
|||||||
if err := cacheCopyOrEmpty(stateDir+"/last-ips-map.txt", routesCacheMap); err != nil {
|
if err := cacheCopyOrEmpty(stateDir+"/last-ips-map.txt", routesCacheMap); err != nil {
|
||||||
warns = append(warns, fmt.Sprintf("last-ips-map cache copy failed: %v", err))
|
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{
|
meta := routesClearCacheMeta{
|
||||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
@@ -83,6 +89,10 @@ func saveRoutesClearCache() (routesClearCacheMeta, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func restoreRoutesFromCache() cmdResult {
|
func restoreRoutesFromCache() cmdResult {
|
||||||
|
return withRoutesOpLock("routes restore", restoreRoutesFromCacheUnlocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreRoutesFromCacheUnlocked() cmdResult {
|
||||||
meta, err := loadRoutesClearCacheMeta()
|
meta, err := loadRoutesClearCacheMeta()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cmdResult{
|
return cmdResult{
|
||||||
@@ -174,6 +184,13 @@ func restoreRoutesFromCache() cmdResult {
|
|||||||
if fileExists(routesCacheMap) {
|
if fileExists(routesCacheMap) {
|
||||||
_ = cacheCopyOrEmpty(routesCacheMap, stateDir+"/last-ips-map.txt")
|
_ = 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{
|
return cmdResult{
|
||||||
OK: true,
|
OK: true,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -263,6 +264,10 @@ func handleRoutesCacheRestore(w http.ResponseWriter, r *http.Request) {
|
|||||||
// RU: `routesClear` - содержит основную логику для routes clear.
|
// RU: `routesClear` - содержит основную логику для routes clear.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
func routesClear() cmdResult {
|
func routesClear() cmdResult {
|
||||||
|
return withRoutesOpLock("routes clear", routesClearUnlocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
func routesClearUnlocked() cmdResult {
|
||||||
cacheMeta, cacheErr := saveRoutesClearCache()
|
cacheMeta, cacheErr := saveRoutesClearCache()
|
||||||
|
|
||||||
stdout, stderr, _, err := runCommand("ip", "rule", "show")
|
stdout, stderr, _, err := runCommand("ip", "rule", "show")
|
||||||
@@ -273,6 +278,11 @@ func routesClear() cmdResult {
|
|||||||
_, _, _, _ = runCommand("ip", "route", "flush", "table", routesTableName())
|
_, _, _, _ = runCommand("ip", "route", "flush", "table", routesTableName())
|
||||||
_, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn4")
|
_, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn4")
|
||||||
_, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn_dyn4")
|
_, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn_dyn4")
|
||||||
|
iface := strings.TrimSpace(cacheMeta.Iface)
|
||||||
|
if iface == "" {
|
||||||
|
iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface)
|
||||||
|
}
|
||||||
|
_ = writeStatusSnapshot(0, iface)
|
||||||
|
|
||||||
res := cmdResult{
|
res := cmdResult{
|
||||||
OK: true,
|
OK: true,
|
||||||
@@ -297,6 +307,50 @@ func routesClear() cmdResult {
|
|||||||
return res
|
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
|
// policy route
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|||||||
@@ -189,6 +189,13 @@ func routesUpdate(iface string) cmdResult {
|
|||||||
bases := loadList(domainDir + "/bases.txt")
|
bases := loadList(domainDir + "/bases.txt")
|
||||||
subs := loadList(domainDir + "/subs.txt")
|
subs := loadList(domainDir + "/subs.txt")
|
||||||
wildcards := loadSmartDNSWildcardDomains(logp)
|
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
|
wildcardBasesAdded := 0
|
||||||
for _, d := range wildcards {
|
for _, d := range wildcards {
|
||||||
d = strings.TrimSpace(d)
|
d = strings.TrimSpace(d)
|
||||||
@@ -212,7 +219,10 @@ func routesUpdate(iface string) cmdResult {
|
|||||||
twitterAdded := 0
|
twitterAdded := 0
|
||||||
for _, d := range bases {
|
for _, d := range bases {
|
||||||
domainSet[d] = struct{}{}
|
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)
|
limit := len(subs)
|
||||||
if subsPerBaseLimit > 0 && subsPerBaseLimit < limit {
|
if subsPerBaseLimit > 0 && subsPerBaseLimit < limit {
|
||||||
limit = subsPerBaseLimit
|
limit = subsPerBaseLimit
|
||||||
@@ -258,6 +268,14 @@ func routesUpdate(iface string) cmdResult {
|
|||||||
)
|
)
|
||||||
if wildcardBasesAdded > 0 {
|
if wildcardBasesAdded > 0 {
|
||||||
logp("domains wildcard seed added: %d base domains from smartdns.conf state", wildcardBasesAdded)
|
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")
|
domTmp, _ := os.CreateTemp(stateDir, "domains-*.txt")
|
||||||
@@ -612,19 +630,27 @@ func logWildcardSmartDNSTrace(mode DNSMode, source string, pairs [][2]string, wi
|
|||||||
}
|
}
|
||||||
sort.Strings(hosts)
|
sort.Strings(hosts)
|
||||||
|
|
||||||
|
const maxHostsLog = 200
|
||||||
|
omitted := 0
|
||||||
|
if len(hosts) > maxHostsLog {
|
||||||
|
omitted = len(hosts) - maxHostsLog
|
||||||
|
}
|
||||||
|
|
||||||
appendTraceLineTo(
|
appendTraceLineTo(
|
||||||
smartdnsLogPath,
|
smartdnsLogPath,
|
||||||
"smartdns",
|
"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 {
|
for i, host := range hosts {
|
||||||
if i >= maxHostsLog {
|
if i >= maxHostsLog {
|
||||||
appendTraceLineTo(
|
appendTraceLineTo(
|
||||||
smartdnsLogPath,
|
smartdnsLogPath,
|
||||||
"smartdns",
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
// RU: привязаны к конкретному systemd unit/cgroup.
|
// RU: привязаны к конкретному systemd unit/cgroup.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
trafficAppProfilesDefaultTTLSec = 24 * 60 * 60
|
trafficAppProfilesDefaultTTLSec = 0 // 0 = persistent runtime mark policy
|
||||||
)
|
)
|
||||||
|
|
||||||
var trafficAppProfilesMu sync.Mutex
|
var trafficAppProfilesMu sync.Mutex
|
||||||
@@ -295,6 +295,11 @@ func loadTrafficAppProfilesState() trafficAppProfilesState {
|
|||||||
st.Profiles[i].AppKey = canon
|
st.Profiles[i].AppKey = canon
|
||||||
changed = true
|
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 {
|
if changed {
|
||||||
_ = saveTrafficAppProfilesState(st)
|
_ = saveTrafficAppProfilesState(st)
|
||||||
@@ -302,6 +307,89 @@ func loadTrafficAppProfilesState() trafficAppProfilesState {
|
|||||||
return st
|
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 {
|
func saveTrafficAppProfilesState(st trafficAppProfilesState) error {
|
||||||
st.Version = 1
|
st.Version = 1
|
||||||
st.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
st.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func canonicalizeAppKey(appKey string, command string) string {
|
|||||||
key := strings.TrimSpace(appKey)
|
key := strings.TrimSpace(appKey)
|
||||||
cmd := strings.TrimSpace(command)
|
cmd := strings.TrimSpace(command)
|
||||||
|
|
||||||
fields := strings.Fields(cmd)
|
fields := splitCommandTokens(cmd)
|
||||||
if len(fields) == 0 && key != "" {
|
if len(fields) == 0 && key != "" {
|
||||||
fields = []string{key}
|
fields = []string{key}
|
||||||
}
|
}
|
||||||
@@ -61,12 +61,12 @@ func canonicalizeAppKey(appKey string, command string) string {
|
|||||||
switch base {
|
switch base {
|
||||||
case "flatpak":
|
case "flatpak":
|
||||||
if id := extractRunTarget(clean); id != "" {
|
if id := extractRunTarget(clean); id != "" {
|
||||||
return "flatpak:" + id
|
return "flatpak:" + strings.ToLower(strings.TrimSpace(id))
|
||||||
}
|
}
|
||||||
return "flatpak"
|
return "flatpak"
|
||||||
case "snap":
|
case "snap":
|
||||||
if name := extractRunTarget(clean); name != "" {
|
if name := extractRunTarget(clean); name != "" {
|
||||||
return "snap:" + name
|
return "snap:" + strings.ToLower(strings.TrimSpace(name))
|
||||||
}
|
}
|
||||||
return "snap"
|
return "snap"
|
||||||
case "gtk-launch":
|
case "gtk-launch":
|
||||||
@@ -74,7 +74,7 @@ func canonicalizeAppKey(appKey string, command string) string {
|
|||||||
if len(clean) >= 2 {
|
if len(clean) >= 2 {
|
||||||
id := strings.TrimSpace(clean[1])
|
id := strings.TrimSpace(clean[1])
|
||||||
if id != "" && !strings.HasPrefix(id, "-") {
|
if id != "" && !strings.HasPrefix(id, "-") {
|
||||||
return "desktop:" + id
|
return "desktop:" + strings.ToLower(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "env":
|
case "env":
|
||||||
@@ -102,11 +102,11 @@ func canonicalizeAppKey(appKey string, command string) string {
|
|||||||
if strings.Contains(primary, "/") {
|
if strings.Contains(primary, "/") {
|
||||||
b := filepath.Base(primary)
|
b := filepath.Base(primary)
|
||||||
if b != "" && b != "." && b != "/" {
|
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 {
|
func stripOuterQuotes(s string) string {
|
||||||
@@ -151,3 +151,65 @@ func extractRunTarget(fields []string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -31,8 +32,11 @@ import (
|
|||||||
const (
|
const (
|
||||||
appMarksTable = "agvpn"
|
appMarksTable = "agvpn"
|
||||||
appMarksChain = "output_apps"
|
appMarksChain = "output_apps"
|
||||||
|
appMarksGuardChain = "output_guard"
|
||||||
|
appMarksLocalBypassSet = "svpn_local4"
|
||||||
appMarkCommentPrefix = "svpn_appmark"
|
appMarkCommentPrefix = "svpn_appmark"
|
||||||
defaultAppMarkTTLSeconds = 24 * 60 * 60
|
appGuardCommentPrefix = "svpn_appguard"
|
||||||
|
defaultAppMarkTTLSeconds = 0 // 0 = persistent until explicit unmark/clear
|
||||||
)
|
)
|
||||||
|
|
||||||
var appMarksMu sync.Mutex
|
var appMarksMu sync.Mutex
|
||||||
@@ -129,9 +133,6 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ttl := timeoutSec
|
ttl := timeoutSec
|
||||||
if ttl == 0 {
|
|
||||||
ttl = defaultAppMarkTTLSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
rel, level, inodeID, cgAbs, err := resolveCgroupV2PathForNft(cgroup)
|
rel, level, inodeID, cgAbs, err := resolveCgroupV2PathForNft(cgroup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -145,6 +146,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vpnIface := ""
|
||||||
if target == "vpn" {
|
if target == "vpn" {
|
||||||
traffic := loadTrafficModeState()
|
traffic := loadTrafficModeState()
|
||||||
iface, _ := resolveTrafficIface(traffic.PreferredIface)
|
iface, _ := resolveTrafficIface(traffic.PreferredIface)
|
||||||
@@ -159,6 +161,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
vpnIface = strings.TrimSpace(iface)
|
||||||
if err := ensureTrafficRouteBase(iface, traffic.AutoLocalBypass); err != nil {
|
if err := ensureTrafficRouteBase(iface, traffic.AutoLocalBypass); err != nil {
|
||||||
writeJSON(w, http.StatusOK, TrafficAppMarksResponse{
|
writeJSON(w, http.StatusOK, TrafficAppMarksResponse{
|
||||||
OK: false,
|
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{
|
writeJSON(w, http.StatusOK, TrafficAppMarksResponse{
|
||||||
OK: false,
|
OK: false,
|
||||||
Op: string(op),
|
Op: string(op),
|
||||||
@@ -253,11 +256,16 @@ func handleTrafficAppMarksItems(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
items := make([]TrafficAppMarkItemView, 0, len(st.Items))
|
items := make([]TrafficAppMarkItemView, 0, len(st.Items))
|
||||||
for _, it := range st.Items {
|
for _, it := range st.Items {
|
||||||
rem := 0
|
rem := -1 // persistent by default
|
||||||
exp, err := time.Parse(time.RFC3339, strings.TrimSpace(it.ExpiresAt))
|
expRaw := strings.TrimSpace(it.ExpiresAt)
|
||||||
if err == nil {
|
if expRaw != "" {
|
||||||
rem = int(exp.Sub(now).Seconds())
|
exp, err := time.Parse(time.RFC3339, expRaw)
|
||||||
if rem < 0 {
|
if err == nil {
|
||||||
|
rem = int(exp.Sub(now).Seconds())
|
||||||
|
if rem < 0 {
|
||||||
|
rem = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
rem = 0
|
rem = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +316,7 @@ func appMarksGetStatus() (vpnCount int, directCount int) {
|
|||||||
return vpnCount, directCount
|
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))
|
target = strings.ToLower(strings.TrimSpace(target))
|
||||||
if target != "vpn" && target != "direct" {
|
if target != "vpn" && target != "direct" {
|
||||||
return fmt.Errorf("invalid target")
|
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)
|
command = strings.TrimSpace(command)
|
||||||
appKey = canonicalizeAppKey(appKey, command)
|
appKey = canonicalizeAppKey(appKey, command)
|
||||||
|
|
||||||
// EN: Avoid unbounded growth of marks for the same app.
|
// EN: Keep only one effective mark per app and avoid cross-target conflicts.
|
||||||
// RU: Не даём бесконечно плодить метки на одно и то же приложение.
|
// EN: If the same app_key is re-marked with another target, old mark is removed first.
|
||||||
if appKey != "" {
|
// RU: Держим только одну эффективную метку на приложение и убираем конфликты между target.
|
||||||
kept := st.Items[:0]
|
// RU: Если тот же app_key перемечается на другой target — старая метка удаляется.
|
||||||
for _, it := range st.Items {
|
kept := st.Items[:0]
|
||||||
if strings.ToLower(strings.TrimSpace(it.Target)) == target &&
|
for _, it := range st.Items {
|
||||||
strings.TrimSpace(it.AppKey) == appKey &&
|
itTarget := strings.ToLower(strings.TrimSpace(it.Target))
|
||||||
it.ID != id {
|
itKey := strings.TrimSpace(it.AppKey)
|
||||||
_ = nftDeleteAppMarkRule(target, it.ID)
|
remove := false
|
||||||
changed = true
|
|
||||||
continue
|
// Same cgroup id but different target => conflicting rules (mark+guard).
|
||||||
}
|
if it.ID == id && it.ID != 0 && itTarget != target {
|
||||||
kept = append(kept, it)
|
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).
|
// Replace any existing rule/state for this (target,id).
|
||||||
_ = nftDeleteAppMarkRule(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
|
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()
|
now := time.Now().UTC()
|
||||||
|
expiresAt := ""
|
||||||
|
if ttlSec > 0 {
|
||||||
|
expiresAt = now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339)
|
||||||
|
}
|
||||||
item := appMarkItem{
|
item := appMarkItem{
|
||||||
ID: id,
|
ID: id,
|
||||||
Target: target,
|
Target: target,
|
||||||
@@ -367,13 +396,15 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
|
|||||||
Command: command,
|
Command: command,
|
||||||
AppKey: appKey,
|
AppKey: appKey,
|
||||||
AddedAt: now.Format(time.RFC3339),
|
AddedAt: now.Format(time.RFC3339),
|
||||||
ExpiresAt: now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339),
|
ExpiresAt: expiresAt,
|
||||||
}
|
}
|
||||||
st.Items = upsertAppMarkItem(st.Items, item)
|
st.Items = upsertAppMarkItem(st.Items, item)
|
||||||
changed = true
|
changed = true
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
if err := saveAppMarksState(st); err != nil {
|
if err := saveAppMarksState(st); err != nil {
|
||||||
|
// Keep runtime state and nft in sync on disk write errors.
|
||||||
|
_ = nftDeleteAppMarkRule(target, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,7 +510,9 @@ func ensureAppMarksNft() error {
|
|||||||
// Best-effort "ensure": ignore "exists" errors and proceed.
|
// Best-effort "ensure": ignore "exists" errors and proceed.
|
||||||
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", appMarksTable)
|
_, _, _, _ = 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, "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", "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")
|
out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "list", "chain", "inet", appMarksTable, "output")
|
||||||
if !strings.Contains(out, "jump "+appMarksChain) {
|
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)
|
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
|
mark := MARK_DIRECT
|
||||||
if target == "vpn" {
|
if target == "vpn" {
|
||||||
mark = MARK_APP
|
mark = MARK_APP
|
||||||
@@ -527,6 +655,58 @@ func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error
|
|||||||
pathLit := fmt.Sprintf("\"%s\"", rel)
|
pathLit := fmt.Sprintf("\"%s\"", rel)
|
||||||
commentLit := fmt.Sprintf("\"%s\"", comment)
|
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(
|
_, out, code, err := runCommandTimeout(
|
||||||
5*time.Second,
|
5*time.Second,
|
||||||
"nft", "insert", "rule", "inet", appMarksTable, appMarksChain,
|
"nft", "insert", "rule", "inet", appMarksTable, appMarksChain,
|
||||||
@@ -539,27 +719,71 @@ func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
err = fmt.Errorf("nft insert rule exited with %d", code)
|
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 fmt.Errorf("nft insert appmark rule failed: %w (%s)", err, strings.TrimSpace(out))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func nftDeleteAppMarkRule(target string, id uint64) error {
|
func nftDeleteAppMarkRule(target string, id uint64) error {
|
||||||
comment := appMarkComment(target, id)
|
comments := []string{
|
||||||
out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain)
|
appMarkComment(target, id),
|
||||||
for _, line := range strings.Split(out, "\n") {
|
appGuardComment(target, id),
|
||||||
if !strings.Contains(line, comment) {
|
}
|
||||||
continue
|
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
|
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 {
|
func parseNftHandle(line string) int {
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
for i := 0; i < len(fields)-1; i++ {
|
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]
|
kept := st.Items[:0]
|
||||||
for _, it := range st.Items {
|
for _, it := range st.Items {
|
||||||
exp, err := time.Parse(time.RFC3339, strings.TrimSpace(it.ExpiresAt))
|
expRaw := strings.TrimSpace(it.ExpiresAt)
|
||||||
if err != nil || !exp.After(now) {
|
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)
|
_ = nftDeleteAppMarkRule(strings.ToLower(strings.TrimSpace(it.Target)), it.ID)
|
||||||
changed = true
|
changed = true
|
||||||
continue
|
continue
|
||||||
@@ -662,6 +898,116 @@ func upsertAppMarkItem(items []appMarkItem, next appMarkItem) []appMarkItem {
|
|||||||
return out
|
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 {
|
func loadAppMarksState() appMarksState {
|
||||||
st := appMarksState{Version: 1}
|
st := appMarksState{Version: 1}
|
||||||
data, err := os.ReadFile(trafficAppMarksPath)
|
data, err := os.ReadFile(trafficAppMarksPath)
|
||||||
@@ -679,18 +1025,88 @@ func loadAppMarksState() appMarksState {
|
|||||||
// RU: Best-effort миграция: нормализуем app_key в канонический вид.
|
// RU: Best-effort миграция: нормализуем app_key в канонический вид.
|
||||||
changed := false
|
changed := false
|
||||||
for i := range st.Items {
|
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)
|
canon := canonicalizeAppKey(st.Items[i].AppKey, st.Items[i].Command)
|
||||||
if canon != "" && strings.TrimSpace(st.Items[i].AppKey) != canon {
|
if canon != "" && strings.TrimSpace(st.Items[i].AppKey) != canon {
|
||||||
st.Items[i].AppKey = canon
|
st.Items[i].AppKey = canon
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if deduped, dedupChanged := dedupeAppMarkItems(st.Items); dedupChanged {
|
||||||
|
st.Items = deduped
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if changed {
|
if changed {
|
||||||
_ = saveAppMarksState(st)
|
_ = saveAppMarksState(st)
|
||||||
}
|
}
|
||||||
return 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 {
|
func saveAppMarksState(st appMarksState) error {
|
||||||
st.Version = 1
|
st.Version = 1
|
||||||
st.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
st.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
trafficRulePrefMarkDirect = 11500
|
trafficRulePrefMarkDirect = 11500
|
||||||
|
trafficRulePrefMarkIngressReply = 11505
|
||||||
trafficRulePrefMarkAppVPN = 11510
|
trafficRulePrefMarkAppVPN = 11510
|
||||||
trafficRulePrefDirectSubnetStart = 11600
|
trafficRulePrefDirectSubnetStart = 11600
|
||||||
trafficRulePrefDirectUIDStart = 11680
|
trafficRulePrefDirectUIDStart = 11680
|
||||||
@@ -27,6 +29,13 @@ const (
|
|||||||
trafficRulePrefManagedMax = 12099
|
trafficRulePrefManagedMax = 12099
|
||||||
trafficRulePerKindLimit = 70
|
trafficRulePerKindLimit = 70
|
||||||
trafficAutoLocalDefault = true
|
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")
|
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
|
||||||
@@ -199,6 +208,7 @@ func loadTrafficModeState() TrafficModeState {
|
|||||||
Mode TrafficMode `json:"mode"`
|
Mode TrafficMode `json:"mode"`
|
||||||
PreferredIface string `json:"preferred_iface,omitempty"`
|
PreferredIface string `json:"preferred_iface,omitempty"`
|
||||||
AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"`
|
AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"`
|
||||||
|
IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"`
|
||||||
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
||||||
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
||||||
ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"`
|
ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"`
|
||||||
@@ -214,6 +224,7 @@ func loadTrafficModeState() TrafficModeState {
|
|||||||
Mode: raw.Mode,
|
Mode: raw.Mode,
|
||||||
PreferredIface: raw.PreferredIface,
|
PreferredIface: raw.PreferredIface,
|
||||||
AutoLocalBypass: trafficAutoLocalDefault,
|
AutoLocalBypass: trafficAutoLocalDefault,
|
||||||
|
IngressReplyBypass: trafficIngressReplyDefault,
|
||||||
ForceVPNSubnets: append([]string(nil), raw.ForceVPNSubnets...),
|
ForceVPNSubnets: append([]string(nil), raw.ForceVPNSubnets...),
|
||||||
ForceVPNUIDs: append([]string(nil), raw.ForceVPNUIDs...),
|
ForceVPNUIDs: append([]string(nil), raw.ForceVPNUIDs...),
|
||||||
ForceVPNCGroups: append([]string(nil), raw.ForceVPNCGroups...),
|
ForceVPNCGroups: append([]string(nil), raw.ForceVPNCGroups...),
|
||||||
@@ -224,6 +235,9 @@ func loadTrafficModeState() TrafficModeState {
|
|||||||
if raw.AutoLocalBypass != nil {
|
if raw.AutoLocalBypass != nil {
|
||||||
st.AutoLocalBypass = *raw.AutoLocalBypass
|
st.AutoLocalBypass = *raw.AutoLocalBypass
|
||||||
}
|
}
|
||||||
|
if raw.IngressReplyBypass != nil {
|
||||||
|
st.IngressReplyBypass = *raw.IngressReplyBypass
|
||||||
|
}
|
||||||
return normalizeTrafficModeState(st)
|
return normalizeTrafficModeState(st)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +267,7 @@ func inferTrafficModeState() TrafficModeState {
|
|||||||
Mode: mode,
|
Mode: mode,
|
||||||
PreferredIface: iface,
|
PreferredIface: iface,
|
||||||
AutoLocalBypass: trafficAutoLocalDefault,
|
AutoLocalBypass: trafficAutoLocalDefault,
|
||||||
|
IngressReplyBypass: trafficIngressReplyDefault,
|
||||||
ForceVPNSubnets: nil,
|
ForceVPNSubnets: nil,
|
||||||
ForceVPNUIDs: nil,
|
ForceVPNUIDs: nil,
|
||||||
ForceVPNCGroups: 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 {
|
func prefStr(v int) string {
|
||||||
return strconv.Itoa(v)
|
return strconv.Itoa(v)
|
||||||
}
|
}
|
||||||
@@ -827,16 +952,22 @@ func ensureTrafficRouteBase(iface string, autoLocalBypass bool) error {
|
|||||||
func applyTrafficMode(st TrafficModeState, iface string) error {
|
func applyTrafficMode(st TrafficModeState, iface string) error {
|
||||||
st = normalizeTrafficModeState(st)
|
st = normalizeTrafficModeState(st)
|
||||||
eff := buildEffectiveOverrides(st)
|
eff := buildEffectiveOverrides(st)
|
||||||
|
advancedActive := st.Mode == TrafficModeFullTunnel
|
||||||
|
autoLocalActive := advancedActive && st.AutoLocalBypass
|
||||||
|
ingressReplyActive := advancedActive && st.IngressReplyBypass
|
||||||
|
|
||||||
removeTrafficRulesForTable()
|
removeTrafficRulesForTable()
|
||||||
|
|
||||||
// EN: Ensure the policy table name exists even in direct mode so mark-based rules can be installed.
|
// EN: Ensure the policy table name exists even in direct mode so mark-based rules can be installed.
|
||||||
// RU: Гарантируем наличие имени policy-table даже в direct режиме, чтобы можно было ставить mark-правила.
|
// RU: Гарантируем наличие имени policy-table даже в direct режиме, чтобы можно было ставить mark-правила.
|
||||||
ensureRoutesTableEntry()
|
ensureRoutesTableEntry()
|
||||||
|
if err := disableIngressReplyBypass(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
needVPNTable := st.Mode != TrafficModeDirect || len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0
|
needVPNTable := st.Mode != TrafficModeDirect || len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0
|
||||||
if needVPNTable {
|
if needVPNTable {
|
||||||
if err := ensureTrafficRouteBase(iface, st.AutoLocalBypass); err != nil {
|
if err := ensureTrafficRouteBase(iface, autoLocalActive); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,6 +983,11 @@ func applyTrafficMode(st TrafficModeState, iface string) error {
|
|||||||
if err := applyRule(trafficRulePrefMarkDirect, "fwmark", MARK_DIRECT, "lookup", "main"); err != nil {
|
if err := applyRule(trafficRulePrefMarkDirect, "fwmark", MARK_DIRECT, "lookup", "main"); err != nil {
|
||||||
return err
|
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 {
|
if err := applyRule(trafficRulePrefMarkAppVPN, "fwmark", MARK_APP, "lookup", routesTableName()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -870,13 +1006,23 @@ func applyTrafficMode(st TrafficModeState, iface string) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown traffic mode: %s", st.Mode)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type trafficRulesState struct {
|
type trafficRulesState struct {
|
||||||
Mark bool
|
Mark bool
|
||||||
Full bool
|
Full bool
|
||||||
|
IngressReply bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func readTrafficRules() trafficRulesState {
|
func readTrafficRules() trafficRulesState {
|
||||||
@@ -884,7 +1030,7 @@ func readTrafficRules() trafficRulesState {
|
|||||||
var st trafficRulesState
|
var st trafficRulesState
|
||||||
for _, line := range strings.Split(out, "\n") {
|
for _, line := range strings.Split(out, "\n") {
|
||||||
l := strings.ToLower(strings.TrimSpace(line))
|
l := strings.ToLower(strings.TrimSpace(line))
|
||||||
if l == "" || !strings.Contains(l, "lookup "+routesTableName()) {
|
if l == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fields := strings.Fields(l)
|
fields := strings.Fields(l)
|
||||||
@@ -895,9 +1041,17 @@ func readTrafficRules() trafficRulesState {
|
|||||||
pref, _ := strconv.Atoi(prefRaw)
|
pref, _ := strconv.Atoi(prefRaw)
|
||||||
switch pref {
|
switch pref {
|
||||||
case trafficRulePrefSelective:
|
case trafficRulePrefSelective:
|
||||||
st.Mark = true
|
if strings.Contains(l, "lookup "+routesTableName()) {
|
||||||
|
st.Mark = true
|
||||||
|
}
|
||||||
case trafficRulePrefFull:
|
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
|
return st
|
||||||
@@ -954,12 +1108,20 @@ func probeTrafficMode(mode TrafficMode, iface string) (bool, string) {
|
|||||||
func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
|
func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
|
||||||
st = normalizeTrafficModeState(st)
|
st = normalizeTrafficModeState(st)
|
||||||
eff := buildEffectiveOverrides(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
|
hasVPN := len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0
|
||||||
iface, reason := resolveTrafficIface(st.PreferredIface)
|
iface, reason := resolveTrafficIface(st.PreferredIface)
|
||||||
rules := readTrafficRules()
|
rules := readTrafficRules()
|
||||||
applied := detectAppliedTrafficMode(rules)
|
applied := detectAppliedTrafficMode(rules)
|
||||||
|
ingressNft := false
|
||||||
|
if rules.IngressReply || st.Mode == TrafficModeFullTunnel || st.IngressReplyBypass {
|
||||||
|
ingressNft = ingressReplyNftActive()
|
||||||
|
}
|
||||||
bypassCandidates := 0
|
bypassCandidates := 0
|
||||||
if st.AutoLocalBypass && (st.Mode != TrafficModeDirect || hasVPN) {
|
if autoLocalActive && (st.Mode != TrafficModeDirect || hasVPN) {
|
||||||
bypassCandidates = len(detectAutoLocalBypassRoutes(iface))
|
bypassCandidates = len(detectAutoLocalBypassRoutes(iface))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -976,7 +1138,11 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
|
|||||||
DesiredMode: st.Mode,
|
DesiredMode: st.Mode,
|
||||||
AppliedMode: applied,
|
AppliedMode: applied,
|
||||||
PreferredIface: st.PreferredIface,
|
PreferredIface: st.PreferredIface,
|
||||||
|
AdvancedActive: advancedActive,
|
||||||
AutoLocalBypass: st.AutoLocalBypass,
|
AutoLocalBypass: st.AutoLocalBypass,
|
||||||
|
AutoLocalActive: autoLocalActive,
|
||||||
|
IngressReplyBypass: ingressDesired,
|
||||||
|
IngressReplyActive: rules.IngressReply && ingressNft,
|
||||||
BypassCandidates: bypassCandidates,
|
BypassCandidates: bypassCandidates,
|
||||||
ForceVPNSubnets: append([]string(nil), st.ForceVPNSubnets...),
|
ForceVPNSubnets: append([]string(nil), st.ForceVPNSubnets...),
|
||||||
ForceVPNUIDs: append([]string(nil), st.ForceVPNUIDs...),
|
ForceVPNUIDs: append([]string(nil), st.ForceVPNUIDs...),
|
||||||
@@ -991,6 +1157,8 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
|
|||||||
IfaceReason: reason,
|
IfaceReason: reason,
|
||||||
RuleMark: rules.Mark,
|
RuleMark: rules.Mark,
|
||||||
RuleFull: rules.Full,
|
RuleFull: rules.Full,
|
||||||
|
IngressRulePresent: rules.IngressReply,
|
||||||
|
IngressNftActive: ingressNft,
|
||||||
TableDefault: tableDefault,
|
TableDefault: tableDefault,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,14 +1169,18 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
|
|||||||
// direct mode can still be healthy when vpn overrides exist
|
// direct mode can still be healthy when vpn overrides exist
|
||||||
// (base full/selective rules must be absent).
|
// (base full/selective rules must be absent).
|
||||||
if hasVPN {
|
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 {
|
} else {
|
||||||
res.Healthy = !rules.Mark && !rules.Full && res.ProbeOK
|
res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && res.ProbeOK
|
||||||
}
|
}
|
||||||
case TrafficModeFullTunnel:
|
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:
|
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:
|
default:
|
||||||
res.Healthy = false
|
res.Healthy = false
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1209,14 @@ func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
|
|||||||
res.Message = "conflicting traffic rules detected"
|
res.Message = "conflicting traffic rules detected"
|
||||||
return res
|
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"
|
res.Message = "traffic mode check failed"
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
@@ -1067,12 +1247,102 @@ func handleTrafficModeTest(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, evaluateTrafficMode(st))
|
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) {
|
func handleTrafficMode(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
st := loadTrafficModeState()
|
st := loadTrafficModeState()
|
||||||
writeJSON(w, http.StatusOK, evaluateTrafficMode(st))
|
writeJSON(w, http.StatusOK, evaluateTrafficMode(st))
|
||||||
case http.MethodPost:
|
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()
|
prev := loadTrafficModeState()
|
||||||
next := prev
|
next := prev
|
||||||
|
|
||||||
@@ -1094,6 +1364,9 @@ func handleTrafficMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
if body.AutoLocalBypass != nil {
|
if body.AutoLocalBypass != nil {
|
||||||
next.AutoLocalBypass = *body.AutoLocalBypass
|
next.AutoLocalBypass = *body.AutoLocalBypass
|
||||||
}
|
}
|
||||||
|
if body.IngressReplyBypass != nil {
|
||||||
|
next.IngressReplyBypass = *body.IngressReplyBypass
|
||||||
|
}
|
||||||
if body.ForceVPNSubnets != nil {
|
if body.ForceVPNSubnets != nil {
|
||||||
next.ForceVPNSubnets = append([]string(nil), (*body.ForceVPNSubnets)...)
|
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 {
|
if err := saveTrafficModeState(next); err != nil {
|
||||||
writeJSON(w, http.StatusOK, TrafficModeStatusResponse{
|
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
|
||||||
Mode: next.Mode,
|
_ = applyTrafficMode(prev, prevIface)
|
||||||
DesiredMode: next.Mode,
|
_ = saveTrafficModeState(prev)
|
||||||
PreferredIface: next.PreferredIface,
|
rolled := evaluateTrafficMode(prev)
|
||||||
AutoLocalBypass: next.AutoLocalBypass,
|
rolled.Message = "state save failed, rolled back: " + err.Error()
|
||||||
ForceVPNSubnets: append([]string(nil), next.ForceVPNSubnets...),
|
writeJSON(w, http.StatusOK, rolled)
|
||||||
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(),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1161,7 +1425,11 @@ func handleTrafficMode(w http.ResponseWriter, r *http.Request) {
|
|||||||
"applied": res.AppliedMode,
|
"applied": res.AppliedMode,
|
||||||
"active_iface": res.ActiveIface,
|
"active_iface": res.ActiveIface,
|
||||||
"healthy": res.Healthy,
|
"healthy": res.Healthy,
|
||||||
|
"advanced_active": res.AdvancedActive,
|
||||||
"auto_local_bypass": res.AutoLocalBypass,
|
"auto_local_bypass": res.AutoLocalBypass,
|
||||||
|
"auto_local_active": res.AutoLocalActive,
|
||||||
|
"ingress_reply": res.IngressReplyBypass,
|
||||||
|
"ingress_active": res.IngressReplyActive,
|
||||||
"overrides_applied": res.OverridesApplied,
|
"overrides_applied": res.OverridesApplied,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, res)
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
|||||||
@@ -1240,6 +1240,20 @@ class ApiClient:
|
|||||||
attempts: int = 1,
|
attempts: int = 1,
|
||||||
concurrency: int = 6,
|
concurrency: int = 6,
|
||||||
) -> DNSBenchmarkResponse:
|
) -> 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(
|
data = cast(
|
||||||
Dict[str, Any],
|
Dict[str, Any],
|
||||||
self._json(
|
self._json(
|
||||||
@@ -1253,6 +1267,7 @@ class ApiClient:
|
|||||||
"attempts": int(attempts),
|
"attempts": int(attempts),
|
||||||
"concurrency": int(concurrency),
|
"concurrency": int(concurrency),
|
||||||
},
|
},
|
||||||
|
timeout=bench_timeout,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
or {},
|
or {},
|
||||||
@@ -1412,13 +1427,40 @@ class ApiClient:
|
|||||||
lines = []
|
lines = []
|
||||||
return DomainsTable(lines=[str(x) for x in 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 {})
|
data = cast(Dict[str, Any], self._json(self._request("GET", "/api/v1/domains/file", params={"name": name})) or {})
|
||||||
content = str(data.get("content") or "")
|
content = str(data.get("content") or "")
|
||||||
source = str(data.get("source") or "")
|
source = str(data.get("source") or "")
|
||||||
return DomainsFile(name=name, content=content, source=source)
|
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})
|
self._request("POST", "/api/v1/domains/file", json_body={"name": name, "content": content})
|
||||||
|
|
||||||
# VPN
|
# VPN
|
||||||
|
|||||||
@@ -922,18 +922,65 @@ class DashboardController:
|
|||||||
|
|
||||||
def domains_file_load(self, name: str) -> DomainsFile:
|
def domains_file_load(self, name: str) -> DomainsFile:
|
||||||
nm = name.strip().lower()
|
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}")
|
raise ValueError(f"Invalid domains file name: {name}")
|
||||||
return self.client.domains_file_get(
|
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:
|
def domains_file_save(self, name: str, content: str) -> None:
|
||||||
nm = name.strip().lower()
|
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}")
|
raise ValueError(f"Invalid domains file name: {name}")
|
||||||
self.client.domains_file_set(
|
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 --------
|
# -------- Trace --------
|
||||||
|
|||||||
@@ -105,6 +105,74 @@ def infer_app_key(cmdline: str) -> str:
|
|||||||
return canonicalize_app_key("", cmdline)
|
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:
|
def canonicalize_app_key(app_key: str, cmdline: str) -> str:
|
||||||
key = (app_key or "").strip()
|
key = (app_key or "").strip()
|
||||||
cmd = (cmdline 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()
|
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
||||||
return int(p.returncode or 0), out
|
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:
|
def cgroup_path_from_pid(pid: int) -> str:
|
||||||
p = int(pid or 0)
|
p = int(pid or 0)
|
||||||
@@ -246,7 +327,13 @@ def run_systemd_unit(cmdline: str, *, unit: str) -> str:
|
|||||||
if p.returncode != 0:
|
if p.returncode != 0:
|
||||||
raise RuntimeError(f"systemd-run failed: rc={p.returncode}\n{out}".strip())
|
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
|
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)
|
res = api_request("POST", "/api/v1/traffic/appmarks", json_body=payload, timeout=4.0)
|
||||||
if not bool(res.get("ok", False)):
|
if not bool(res.get("ok", False)):
|
||||||
raise RuntimeError(f"appmark failed: {res.get('message')}")
|
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:
|
def main(argv: list[str]) -> int:
|
||||||
@@ -322,27 +410,36 @@ def main(argv: list[str]) -> int:
|
|||||||
cmd = str(prof.get("command") or "").strip()
|
cmd = str(prof.get("command") or "").strip()
|
||||||
if not cmd:
|
if not cmd:
|
||||||
raise RuntimeError("profile command is empty")
|
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()
|
target = str(prof.get("target") or "vpn").strip().lower()
|
||||||
if target not in ("vpn", "direct"):
|
if target not in ("vpn", "direct"):
|
||||||
target = "vpn"
|
target = "vpn"
|
||||||
|
|
||||||
app_key_raw = str(prof.get("app_key") or "").strip()
|
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)
|
ttl = int(prof.get("ttl_sec", 0) or 0)
|
||||||
if ttl <= 0:
|
if ttl < 0:
|
||||||
ttl = 24 * 60 * 60
|
ttl = 0
|
||||||
|
|
||||||
# Try refresh first if already running.
|
# 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:
|
if args.json:
|
||||||
print(json.dumps({"ok": True, "op": "refresh", "id": pid, "target": target, "app_key": app_key}))
|
print(json.dumps({"ok": True, "op": "refresh", "id": pid, "target": target, "app_key": app_key}))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
unit = f"svpn-{target}-{int(time.time())}.service"
|
unit = f"svpn-{target}-{int(time.time())}.service"
|
||||||
log(f"launching profile id={pid} target={target} app={app_key} unit={unit}")
|
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}")
|
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:
|
if args.json:
|
||||||
print(json.dumps({"ok": True, "op": "run", "id": pid, "target": target, "app_key": app_key, "unit": unit}))
|
print(json.dumps({"ok": True, "op": "run", "id": pid, "target": target, "app_key": app_key, "unit": unit}))
|
||||||
return 0
|
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)
|
self._last_app_cgroup_id: int = int(self._settings.value("traffic_app_last_cgroup_id", 0) or 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._last_app_cgroup_id = 0
|
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_group = QGroupBox("Mode behavior")
|
||||||
hint_layout = QVBoxLayout(hint_group)
|
hint_layout = QVBoxLayout(hint_group)
|
||||||
@@ -172,13 +174,32 @@ RU: Обновить список доступных интерфейсов (UP)
|
|||||||
row_iface.addStretch(1)
|
row_iface.addStretch(1)
|
||||||
mode_layout.addLayout(row_iface)
|
mode_layout.addLayout(row_iface)
|
||||||
|
|
||||||
self.chk_auto_local = QCheckBox("Auto-local bypass (LAN/container subnets)")
|
row_adv_button = QHBoxLayout()
|
||||||
self.chk_auto_local.setToolTip("""EN: Mirrors local/LAN/docker routes from main into agvpn table to prevent breakage in full tunnel.
|
self.btn_adv_bypass = QPushButton("Advanced bypass...")
|
||||||
EN: This does NOT force containers to use direct internet; use Force Direct subnets for that.
|
self.btn_adv_bypass.setToolTip(
|
||||||
RU: Копирует локальные/LAN/docker маршруты из main в agvpn, чтобы не ломалась локалка в full tunnel.
|
"EN: Open compact Full tunnel advanced bypass settings (auto-local + ingress-reply).\n"
|
||||||
RU: Это НЕ делает контейнеры direct в интернет; для этого используй Force Direct subnets.""")
|
"RU: Открыть компактные расширенные bypass-настройки Full tunnel (auto-local + ingress-reply)."
|
||||||
self.chk_auto_local.stateChanged.connect(lambda _state: self.on_auto_local_toggle())
|
)
|
||||||
mode_layout.addWidget(self.chk_auto_local)
|
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 = QLabel("Traffic mode: —")
|
||||||
self.lbl_state.setStyleSheet("color: gray;")
|
self.lbl_state.setStyleSheet("color: gray;")
|
||||||
@@ -371,6 +392,17 @@ RU: Восстанавливает маршруты/nft из последнег
|
|||||||
row_cmd.addWidget(self.btn_app_pick)
|
row_cmd.addWidget(self.btn_app_pick)
|
||||||
run_layout.addLayout(row_cmd)
|
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 = QHBoxLayout()
|
||||||
row_target.addWidget(QLabel("Route via"))
|
row_target.addWidget(QLabel("Route via"))
|
||||||
self.rad_app_vpn = QRadioButton("VPN")
|
self.rad_app_vpn = QRadioButton("VPN")
|
||||||
@@ -393,10 +425,20 @@ RU: Восстанавливает маршруты/nft из последнег
|
|||||||
run_layout.addLayout(row_target)
|
run_layout.addLayout(row_target)
|
||||||
|
|
||||||
row_ttl = QHBoxLayout()
|
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)"))
|
row_ttl.addWidget(QLabel("TTL (hours)"))
|
||||||
self.spn_app_ttl = QSpinBox()
|
self.spn_app_ttl = QSpinBox()
|
||||||
self.spn_app_ttl.setRange(1, 24 * 30) # up to ~30 days
|
self.spn_app_ttl.setRange(1, 24 * 30) # up to ~30 days
|
||||||
self.spn_app_ttl.setValue(24)
|
self.spn_app_ttl.setValue(24)
|
||||||
|
self.spn_app_ttl.setEnabled(False)
|
||||||
self.spn_app_ttl.setToolTip(
|
self.spn_app_ttl.setToolTip(
|
||||||
"EN: How long the runtime mark stays active (backend nftset element timeout).\n"
|
"EN: How long the runtime mark stays active (backend nftset element timeout).\n"
|
||||||
"RU: Сколько живет runtime-метка (timeout элемента в nftset)."
|
"RU: Сколько живет runtime-метка (timeout элемента в nftset)."
|
||||||
@@ -404,6 +446,7 @@ RU: Восстанавливает маршруты/nft из последнег
|
|||||||
row_ttl.addWidget(self.spn_app_ttl)
|
row_ttl.addWidget(self.spn_app_ttl)
|
||||||
row_ttl.addStretch(1)
|
row_ttl.addStretch(1)
|
||||||
run_layout.addLayout(row_ttl)
|
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_group = QGroupBox("Mark existing PID (no launch)")
|
||||||
pid_layout = QHBoxLayout(pid_group)
|
pid_layout = QHBoxLayout(pid_group)
|
||||||
@@ -483,7 +526,7 @@ RU: Восстанавливает маршруты/nft из последнег
|
|||||||
tab_run_layout.addStretch(1)
|
tab_run_layout.addStretch(1)
|
||||||
self.apps_tabs.addTab(tab_run, "Run")
|
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_layout = QVBoxLayout(marks_group)
|
||||||
|
|
||||||
marks_row = QHBoxLayout()
|
marks_row = QHBoxLayout()
|
||||||
@@ -509,8 +552,8 @@ RU: Восстанавливает маршруты/nft из последнег
|
|||||||
self.lst_marks = QListWidget()
|
self.lst_marks = QListWidget()
|
||||||
self.lst_marks.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
self.lst_marks.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||||
self.lst_marks.setToolTip(
|
self.lst_marks.setToolTip(
|
||||||
"EN: Active runtime marks. Stored by backend with TTL.\n"
|
"EN: Active runtime marks. Can be persistent or temporary (TTL).\n"
|
||||||
"RU: Активные runtime-метки. Хранятся backend с TTL."
|
"RU: Активные runtime-метки. Могут быть постоянными или временными (TTL)."
|
||||||
)
|
)
|
||||||
self.lst_marks.setFixedHeight(140)
|
self.lst_marks.setFixedHeight(140)
|
||||||
marks_layout.addWidget(self.lst_marks)
|
marks_layout.addWidget(self.lst_marks)
|
||||||
@@ -598,6 +641,14 @@ RU: Восстанавливает маршруты/nft из последнег
|
|||||||
tab_adv = QWidget()
|
tab_adv = QWidget()
|
||||||
tab_adv_layout = QVBoxLayout(tab_adv)
|
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 = QPlainTextEdit()
|
||||||
self.ed_vpn_subnets.setToolTip("""EN: Force VPN by source subnet. Useful for docker subnets when you want containers via VPN.
|
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.""")
|
RU: Принудительно через VPN по source subnet. Полезно для docker-подсетей, если хочешь контейнеры через VPN.""")
|
||||||
@@ -878,12 +929,18 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
desired_mode: str,
|
desired_mode: str,
|
||||||
applied_mode: str,
|
applied_mode: str,
|
||||||
preferred_iface: str,
|
preferred_iface: str,
|
||||||
|
advanced_active: bool,
|
||||||
auto_local_bypass: bool,
|
auto_local_bypass: bool,
|
||||||
|
auto_local_active: bool,
|
||||||
|
ingress_reply_bypass: bool,
|
||||||
|
ingress_reply_active: bool,
|
||||||
bypass_candidates: int,
|
bypass_candidates: int,
|
||||||
overrides_applied: int,
|
overrides_applied: int,
|
||||||
cgroup_resolved_uids: int,
|
cgroup_resolved_uids: int,
|
||||||
cgroup_warning: str,
|
cgroup_warning: str,
|
||||||
healthy: bool,
|
healthy: bool,
|
||||||
|
ingress_rule_present: bool,
|
||||||
|
ingress_nft_active: bool,
|
||||||
probe_ok: bool,
|
probe_ok: bool,
|
||||||
probe_message: str,
|
probe_message: str,
|
||||||
active_iface: str,
|
active_iface: str,
|
||||||
@@ -903,10 +960,16 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
text = f"Traffic mode: {desired} (applied: {applied}) [{health_txt}]"
|
text = f"Traffic mode: {desired} (applied: {applied}) [{health_txt}]"
|
||||||
diag_parts = []
|
diag_parts = []
|
||||||
diag_parts.append(f"preferred={preferred_iface or 'auto'}")
|
diag_parts.append(f"preferred={preferred_iface or 'auto'}")
|
||||||
|
diag_parts.append(f"advanced={'on' if advanced_active else 'off'}")
|
||||||
diag_parts.append(
|
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"bypass_routes={bypass_candidates}")
|
||||||
diag_parts.append(f"overrides={overrides_applied}")
|
diag_parts.append(f"overrides={overrides_applied}")
|
||||||
if cgroup_resolved_uids > 0:
|
if cgroup_resolved_uids > 0:
|
||||||
@@ -917,6 +980,10 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
diag_parts.append(f"iface={active_iface}")
|
diag_parts.append(f"iface={active_iface}")
|
||||||
if iface_reason:
|
if iface_reason:
|
||||||
diag_parts.append(f"source={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'}")
|
diag_parts.append(f"probe={'ok' if probe_ok else 'fail'}")
|
||||||
if probe_message:
|
if probe_message:
|
||||||
diag_parts.append(probe_message)
|
diag_parts.append(probe_message)
|
||||||
@@ -929,6 +996,20 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
self.lbl_diag.setText(diag)
|
self.lbl_diag.setText(diag)
|
||||||
self.lbl_diag.setStyleSheet("color: gray;")
|
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 refresh_state(self) -> None:
|
||||||
def work() -> None:
|
def work() -> None:
|
||||||
view = self.ctrl.traffic_mode_view()
|
view = self.ctrl.traffic_mode_view()
|
||||||
@@ -946,9 +1027,9 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
|
|
||||||
opts = self.ctrl.traffic_interfaces()
|
opts = self.ctrl.traffic_interfaces()
|
||||||
self._set_preferred_iface_options(opts, view.preferred_iface)
|
self._set_preferred_iface_options(opts, view.preferred_iface)
|
||||||
self.chk_auto_local.blockSignals(True)
|
self._set_full_tunnel_advanced_enabled(mode)
|
||||||
self.chk_auto_local.setChecked(bool(view.auto_local_bypass))
|
self._adv_auto_local_bypass = bool(view.auto_local_bypass)
|
||||||
self.chk_auto_local.blockSignals(False)
|
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_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_uids, list(view.force_vpn_uids or []))
|
||||||
self._set_lines(self.ed_vpn_cgroups, list(view.force_vpn_cgroups 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.desired_mode,
|
||||||
view.applied_mode,
|
view.applied_mode,
|
||||||
view.preferred_iface,
|
view.preferred_iface,
|
||||||
|
bool(view.advanced_active),
|
||||||
bool(view.auto_local_bypass),
|
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.bypass_candidates),
|
||||||
int(view.overrides_applied),
|
int(view.overrides_applied),
|
||||||
int(view.cgroup_resolved_uids),
|
int(view.cgroup_resolved_uids),
|
||||||
view.cgroup_warning,
|
view.cgroup_warning,
|
||||||
bool(view.healthy),
|
bool(view.healthy),
|
||||||
|
bool(view.ingress_rule_present),
|
||||||
|
bool(view.ingress_nft_active),
|
||||||
bool(view.probe_ok),
|
bool(view.probe_ok),
|
||||||
view.probe_message,
|
view.probe_message,
|
||||||
view.active_iface,
|
view.active_iface,
|
||||||
@@ -981,13 +1068,15 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
|
|
||||||
def work() -> None:
|
def work() -> None:
|
||||||
preferred = self._preferred_iface_value()
|
preferred = self._preferred_iface_value()
|
||||||
auto_local = self.chk_auto_local.isChecked()
|
auto_local = bool(self._adv_auto_local_bypass)
|
||||||
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local)
|
ingress_reply = bool(self._adv_ingress_reply_bypass)
|
||||||
|
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local, ingress_reply)
|
||||||
msg = (
|
msg = (
|
||||||
f"Traffic mode set: desired={view.desired_mode}, "
|
f"Traffic mode set: desired={view.desired_mode}, "
|
||||||
f"applied={view.applied_mode}, iface={view.active_iface or '-'}, "
|
f"applied={view.applied_mode}, iface={view.active_iface or '-'}, "
|
||||||
f"preferred={preferred or 'auto'}, probe_ok={view.probe_ok}, "
|
f"preferred={preferred or 'auto'}, probe_ok={view.probe_ok}, "
|
||||||
f"healthy={view.healthy}, auto_local_bypass={view.auto_local_bypass}, "
|
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"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, "
|
||||||
f"cgroup_uids={view.cgroup_resolved_uids}, message={view.message}"
|
f"cgroup_uids={view.cgroup_resolved_uids}, message={view.message}"
|
||||||
)
|
)
|
||||||
@@ -1027,35 +1116,158 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
return "direct"
|
return "direct"
|
||||||
return "selective"
|
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:
|
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()
|
preferred = self._preferred_iface_value()
|
||||||
auto_local = self.chk_auto_local.isChecked()
|
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local, ingress_reply)
|
||||||
view = self.ctrl.traffic_mode_set(mode, preferred, auto_local)
|
self._adv_auto_local_bypass = bool(view.auto_local_bypass)
|
||||||
msg = (
|
self._adv_ingress_reply_bypass = bool(view.ingress_reply_bypass)
|
||||||
f"Traffic auto-local set: mode={view.desired_mode}, "
|
self._emit_log(
|
||||||
f"auto_local_bypass={view.auto_local_bypass}, "
|
"Traffic advanced bypass set: "
|
||||||
f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, "
|
f"mode={view.desired_mode}, auto_local={view.auto_local_bypass}, "
|
||||||
f"cgroup_uids={view.cgroup_resolved_uids}, message={view.message}"
|
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)
|
op_ok = bool(view.healthy) and not self._is_operation_error(view.message)
|
||||||
self._set_action_status(
|
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,
|
ok=op_ok,
|
||||||
)
|
)
|
||||||
self.refresh_state()
|
self.refresh_state()
|
||||||
if self.refresh_cb:
|
if self.refresh_cb:
|
||||||
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 on_apply_overrides(self) -> None:
|
||||||
def work() -> None:
|
def work() -> None:
|
||||||
mode = self._selected_mode()
|
mode = self._selected_mode()
|
||||||
preferred = self._preferred_iface_value()
|
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_subnets = self._lines_from_text(self.ed_vpn_subnets.toPlainText())
|
||||||
vpn_uids = self._lines_from_text(self.ed_vpn_uids.toPlainText())
|
vpn_uids = self._lines_from_text(self.ed_vpn_uids.toPlainText())
|
||||||
vpn_cgroups = self._lines_from_text(self.ed_vpn_cgroups.toPlainText())
|
vpn_cgroups = self._lines_from_text(self.ed_vpn_cgroups.toPlainText())
|
||||||
@@ -1067,6 +1279,7 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
mode,
|
mode,
|
||||||
preferred,
|
preferred,
|
||||||
auto_local,
|
auto_local,
|
||||||
|
ingress_reply,
|
||||||
vpn_subnets,
|
vpn_subnets,
|
||||||
vpn_uids,
|
vpn_uids,
|
||||||
vpn_cgroups,
|
vpn_cgroups,
|
||||||
@@ -1076,6 +1289,7 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
)
|
)
|
||||||
msg = (
|
msg = (
|
||||||
f"Traffic overrides applied: mode={view.desired_mode}, "
|
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"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"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}, "
|
f"overrides={view.overrides_applied}, cgroup_uids={view.cgroup_resolved_uids}, "
|
||||||
@@ -1177,6 +1391,77 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
|
|
||||||
return primary
|
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(
|
def _launch_and_mark(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1193,8 +1478,12 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
raise ValueError("invalid target")
|
raise ValueError("invalid target")
|
||||||
ttl = int(ttl_sec or 0)
|
ttl = int(ttl_sec or 0)
|
||||||
if ttl <= 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)
|
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.
|
# EN: If we already have a running unit for the same app_key+target, refresh mark instead of spawning.
|
||||||
# RU: Если уже есть запущенный unit для того же app_key+target — обновляем метку, не плодим инстансы.
|
# RU: Если уже есть запущенный unit для того же app_key+target — обновляем метку, не плодим инстансы.
|
||||||
@@ -1221,7 +1510,7 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
target=tgt,
|
target=tgt,
|
||||||
cgroup=cg,
|
cgroup=cg,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
command=cmdline,
|
command=run_cmdline,
|
||||||
app_key=key,
|
app_key=key,
|
||||||
timeout_sec=ttl,
|
timeout_sec=ttl,
|
||||||
)
|
)
|
||||||
@@ -1235,7 +1524,7 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
unit=unit,
|
unit=unit,
|
||||||
target=tgt,
|
target=tgt,
|
||||||
app_key=key,
|
app_key=key,
|
||||||
cmdline=cmdline,
|
cmdline=run_cmdline,
|
||||||
cgroup_id=int(res.cgroup_id or 0),
|
cgroup_id=int(res.cgroup_id or 0),
|
||||||
)
|
)
|
||||||
self.refresh_appmarks_items(quiet=True)
|
self.refresh_appmarks_items(quiet=True)
|
||||||
@@ -1245,42 +1534,59 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
return
|
return
|
||||||
|
|
||||||
unit = f"svpn-{tgt}-{int(time.time())}.service"
|
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}")
|
self._append_app_log(f"[app] launching: app={key or '-'} target={tgt} ttl={ttl_log} unit={unit}")
|
||||||
cg, out = self._run_systemd_unit(cmdline, 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:
|
if out:
|
||||||
self._append_app_log(f"[app] systemd-run:\n{out}")
|
self._append_app_log(f"[app] systemd-run:\n{out}")
|
||||||
self._append_app_log(f"[app] ControlGroup: {cg}")
|
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(
|
res = self.ctrl.traffic_appmarks_apply(
|
||||||
op="add",
|
op="add",
|
||||||
target=tgt,
|
target=tgt,
|
||||||
cgroup=cg,
|
cgroup=cg,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
command=cmdline,
|
command=run_cmdline,
|
||||||
app_key=key,
|
app_key=key,
|
||||||
timeout_sec=ttl,
|
timeout_sec=ttl,
|
||||||
)
|
)
|
||||||
if not res.ok:
|
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()
|
low = (res.message or "").lower()
|
||||||
if "cgroupv2 path fails" in low or "no such file or directory" in low:
|
if "cgroupv2 path fails" in low or "no such file or directory" in low:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
(res.message or "appmark apply failed")
|
(res.message or "appmark apply failed")
|
||||||
|
+ stop_note
|
||||||
+ "\n\n"
|
+ "\n\n"
|
||||||
+ "EN: This usually means the app didn't stay inside the new systemd unit "
|
+ "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"
|
+ "(often because it was already running). Close the app completely and run again.\n"
|
||||||
+ "RU: Обычно это значит, что приложение не осталось в новом systemd unit "
|
+ "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_action_status(f"App mark added: target={tgt} cgroup_id={res.cgroup_id}", ok=True)
|
||||||
self._set_last_scope(
|
self._set_last_scope(
|
||||||
unit=unit,
|
unit=unit,
|
||||||
target=tgt,
|
target=tgt,
|
||||||
app_key=key,
|
app_key=key,
|
||||||
cmdline=cmdline,
|
cmdline=run_cmdline,
|
||||||
cgroup_id=int(res.cgroup_id or 0),
|
cgroup_id=int(res.cgroup_id or 0),
|
||||||
)
|
)
|
||||||
self.refresh_appmarks_items(quiet=True)
|
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"))
|
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.
|
# 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.
|
# 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()
|
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()
|
app_key = (getattr(p, "app_key", "") or "").strip()
|
||||||
cmd = (getattr(p, "command", "") or "").strip()
|
cmd = (getattr(p, "command", "") or "").strip()
|
||||||
ttl_sec = int(getattr(p, "ttl_sec", 0) or 0)
|
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)"
|
label = name or pid or "(unnamed)"
|
||||||
if target in ("vpn", "direct"):
|
if target in ("vpn", "direct"):
|
||||||
@@ -1434,7 +1741,7 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
f"id: {pid}\n"
|
f"id: {pid}\n"
|
||||||
f"app_key: {app_key}\n"
|
f"app_key: {app_key}\n"
|
||||||
f"target: {target}\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: {sc_state}\n"
|
||||||
f"shortcut_path: {sc_path}\n\n"
|
f"shortcut_path: {sc_path}\n\n"
|
||||||
f"runtime_marks: {len(items)}\n"
|
f"runtime_marks: {len(items)}\n"
|
||||||
@@ -1468,7 +1775,7 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
return
|
return
|
||||||
|
|
||||||
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
|
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()
|
name = (self.ed_app_profile_name.text() or "").strip()
|
||||||
app_key = self._infer_app_key_from_cmdline(cmdline)
|
app_key = self._infer_app_key_from_cmdline(cmdline)
|
||||||
|
|
||||||
@@ -1585,6 +1892,9 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
# UI uses hours; round up.
|
# UI uses hours; round up.
|
||||||
hours = max(1, (ttl_sec + 3599) // 3600)
|
hours = max(1, (ttl_sec + 3599) // 3600)
|
||||||
self.spn_app_ttl.setValue(int(hours))
|
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.ed_app_profile_name.setText(name)
|
||||||
self._set_action_status("Profile loaded into form", ok=True)
|
self._set_action_status("Profile loaded into form", ok=True)
|
||||||
@@ -1675,13 +1985,15 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
unit = (getattr(it, "unit", "") or "").strip()
|
unit = (getattr(it, "unit", "") or "").strip()
|
||||||
cmd = (getattr(it, "command", "") or "").strip()
|
cmd = (getattr(it, "command", "") or "").strip()
|
||||||
rem = int(getattr(it, "remaining_sec", 0) or 0)
|
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
|
label = f"{tgt} {app_key or unit or mid} ({rem_txt})"
|
||||||
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})"
|
|
||||||
q = QListWidgetItem(label)
|
q = QListWidgetItem(label)
|
||||||
q.setToolTip(
|
q.setToolTip(
|
||||||
(
|
(
|
||||||
@@ -1689,7 +2001,7 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
f"target: {tgt}\n"
|
f"target: {tgt}\n"
|
||||||
f"app_key: {app_key}\n"
|
f"app_key: {app_key}\n"
|
||||||
f"unit: {unit}\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}"
|
f"{cmd}"
|
||||||
).strip()
|
).strip()
|
||||||
)
|
)
|
||||||
@@ -2033,9 +2345,10 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
app_key = f"pid:{pid}"
|
app_key = f"pid:{pid}"
|
||||||
|
|
||||||
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
|
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}")
|
self._append_app_log(f"[pid] cgroup: {cg}")
|
||||||
if cmdline:
|
if cmdline:
|
||||||
self._append_app_log(f"[pid] cmdline: {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")
|
QMessageBox.critical(self, "Mark PID error", res.message or "mark failed")
|
||||||
return
|
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_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))
|
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
|
return
|
||||||
|
|
||||||
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
|
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)
|
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)
|
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",
|
"static-ips",
|
||||||
"last-ips-map-direct",
|
"last-ips-map-direct",
|
||||||
"last-ips-map-wildcard",
|
"last-ips-map-wildcard",
|
||||||
|
"wildcard-observed-hosts",
|
||||||
"smartdns.conf",
|
"smartdns.conf",
|
||||||
):
|
):
|
||||||
QListWidgetItem(name, self.lst_files)
|
QListWidgetItem(name, self.lst_files)
|
||||||
@@ -631,6 +632,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
|||||||
"static-ips": "static",
|
"static-ips": "static",
|
||||||
"last-ips-map-direct": "last-ips-map-direct",
|
"last-ips-map-direct": "last-ips-map-direct",
|
||||||
"last-ips-map-wildcard": "last-ips-map-wildcard",
|
"last-ips-map-wildcard": "last-ips-map-wildcard",
|
||||||
|
"wildcard-observed-hosts": "wildcard-observed-hosts",
|
||||||
"smartdns.conf": "smartdns",
|
"smartdns.conf": "smartdns",
|
||||||
}
|
}
|
||||||
if name in api_map:
|
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)"
|
path = "/var/lib/selective-vpn/last-ips-map-direct.txt (artifact: agvpn4)"
|
||||||
elif name == "last-ips-map-wildcard":
|
elif name == "last-ips-map-wildcard":
|
||||||
path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (artifact: agvpn_dyn4)"
|
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:
|
else:
|
||||||
path = f"/etc/selective-vpn/domains/{name}.txt"
|
path = f"/etc/selective-vpn/domains/{name}.txt"
|
||||||
return content, source, path
|
return content, source, path
|
||||||
@@ -1530,7 +1534,7 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
|||||||
def work():
|
def work():
|
||||||
name = self._get_selected_domains_file()
|
name = self._get_selected_domains_file()
|
||||||
content, source, path = self._load_file_content(name)
|
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.txt_domains.setReadOnly(is_readonly)
|
||||||
self.btn_domains_save.setEnabled(not is_readonly)
|
self.btn_domains_save.setEnabled(not is_readonly)
|
||||||
self._set_text(self.txt_domains, content)
|
self._set_text(self.txt_domains, content)
|
||||||
|
|||||||
Reference in New Issue
Block a user