package app import ( "encoding/json" "fmt" "io" "net/http" "net/netip" "os" "path/filepath" "sort" "strconv" "strings" "sync" "syscall" "time" ) // --------------------------------------------------------------------- // traffic app marks (per-app routing via cgroupv2 path -> fwmark) // --------------------------------------------------------------------- // // EN: This module manages runtime per-app routing marks. // EN: We match cgroupv2 paths using nftables `socket cgroupv2` and set fwmark: // EN: - MARK_APP (VPN) or MARK_DIRECT (direct). // EN: TTL is kept in a JSON state file; expired entries are pruned. // RU: Этот модуль управляет runtime per-app маршрутизацией. // RU: Мы матчим cgroupv2 path через nftables `socket cgroupv2` и ставим fwmark: // RU: - MARK_APP (VPN) или MARK_DIRECT (direct). // RU: TTL хранится в JSON состоянии; просроченные записи удаляются. const ( appMarksTable = "agvpn" appMarksChain = "output_apps" appMarksGuardChain = "output_guard" appMarksLocalBypassSet = "svpn_local4" appMarkCommentPrefix = "svpn_appmark" appGuardCommentPrefix = "svpn_appguard" defaultAppMarkTTLSeconds = 0 // 0 = persistent until explicit unmark/clear ) var appMarksMu sync.Mutex type appMarksState struct { Version int `json:"version"` UpdatedAt string `json:"updated_at"` Items []appMarkItem `json:"items,omitempty"` } type appMarkItem struct { ID uint64 `json:"id"` Target string `json:"target"` // vpn|direct Cgroup string `json:"cgroup"` // absolute path ("/user.slice/..."), informational CgroupRel string `json:"cgroup_rel"` Level int `json:"level"` Unit string `json:"unit,omitempty"` Command string `json:"command,omitempty"` AppKey string `json:"app_key,omitempty"` AddedAt string `json:"added_at"` ExpiresAt string `json:"expires_at"` } func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: vpnCount, directCount := appMarksGetStatus() writeJSON(w, http.StatusOK, TrafficAppMarksStatusResponse{ VPNCount: vpnCount, DirectCount: directCount, Message: "ok", }) case http.MethodPost: var body TrafficAppMarksRequest if r.Body != nil { defer r.Body.Close() if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF { http.Error(w, "bad json", http.StatusBadRequest) return } } op := TrafficAppMarksOp(strings.ToLower(strings.TrimSpace(string(body.Op)))) target := strings.ToLower(strings.TrimSpace(body.Target)) cgroup := strings.TrimSpace(body.Cgroup) unit := strings.TrimSpace(body.Unit) command := strings.TrimSpace(body.Command) appKey := strings.TrimSpace(body.AppKey) timeoutSec := body.TimeoutSec if op == "" { http.Error(w, "missing op", http.StatusBadRequest) return } if target == "" { http.Error(w, "missing target", http.StatusBadRequest) return } if target != "vpn" && target != "direct" { http.Error(w, "target must be vpn|direct", http.StatusBadRequest) return } if (op == TrafficAppMarksAdd || op == TrafficAppMarksDel) && cgroup == "" { http.Error(w, "missing cgroup", http.StatusBadRequest) return } if timeoutSec < 0 { http.Error(w, "timeout_sec must be >= 0", http.StatusBadRequest) return } if err := ensureAppMarksNft(); err != nil { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, Op: string(op), Target: target, Cgroup: cgroup, Message: "nft init failed: " + err.Error(), }) return } switch op { case TrafficAppMarksAdd: if isAllDigits(cgroup) { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, Op: string(op), Target: target, Cgroup: cgroup, Message: "cgroup must be a cgroupv2 path (ControlGroup), not a numeric id", }) return } ttl := timeoutSec rel, level, inodeID, cgAbs, err := resolveCgroupV2PathForNft(cgroup) if err != nil { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, Op: string(op), Target: target, Cgroup: body.Cgroup, Message: err.Error(), }) return } vpnIface := "" if target == "vpn" { traffic := loadTrafficModeState() iface, _ := resolveTrafficIface(traffic.PreferredIface) if strings.TrimSpace(iface) == "" { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, Op: string(op), Target: target, Cgroup: cgAbs, CgroupID: inodeID, Message: "vpn interface not found (set preferred iface or bring VPN up)", }) return } vpnIface = strings.TrimSpace(iface) if err := ensureTrafficRouteBase(iface, traffic.AutoLocalBypass); err != nil { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, Op: string(op), Target: target, Cgroup: cgAbs, CgroupID: inodeID, Message: "ensure vpn route base failed: " + err.Error(), }) return } } if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl, vpnIface); err != nil { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, Op: string(op), Target: target, Cgroup: cgAbs, CgroupID: inodeID, TimeoutSec: ttl, Message: err.Error(), }) return } appendTraceLine("traffic", fmt.Sprintf("appmarks add target=%s cgroup=%s id=%d ttl=%ds", target, cgAbs, inodeID, ttl)) writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: true, Op: string(op), Target: target, Cgroup: cgAbs, CgroupID: inodeID, TimeoutSec: ttl, Message: "added", }) case TrafficAppMarksDel: if err := appMarksDel(target, cgroup); err != nil { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, Op: string(op), Target: target, Cgroup: cgroup, Message: err.Error(), }) return } appendTraceLine("traffic", fmt.Sprintf("appmarks del target=%s cgroup=%s", target, cgroup)) writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: true, Op: string(op), Target: target, Cgroup: cgroup, Message: "deleted", }) case TrafficAppMarksClear: if err := appMarksClear(target); err != nil { writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: false, Op: string(op), Target: target, Message: err.Error(), }) return } appendTraceLine("traffic", fmt.Sprintf("appmarks clear target=%s", target)) writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ OK: true, Op: string(op), Target: target, Message: "cleared", }) default: http.Error(w, "unknown op", http.StatusBadRequest) } default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func handleTrafficAppMarksItems(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } _ = pruneExpiredAppMarks() appMarksMu.Lock() st := loadAppMarksState() appMarksMu.Unlock() now := time.Now().UTC() items := make([]TrafficAppMarkItemView, 0, len(st.Items)) for _, it := range st.Items { rem := -1 // persistent by default expRaw := strings.TrimSpace(it.ExpiresAt) if expRaw != "" { exp, err := time.Parse(time.RFC3339, expRaw) if err == nil { rem = int(exp.Sub(now).Seconds()) if rem < 0 { rem = 0 } } else { rem = 0 } } items = append(items, TrafficAppMarkItemView{ ID: it.ID, Target: it.Target, Cgroup: it.Cgroup, CgroupRel: it.CgroupRel, Level: it.Level, Unit: it.Unit, Command: it.Command, AppKey: it.AppKey, AddedAt: it.AddedAt, ExpiresAt: it.ExpiresAt, RemainingSec: rem, }) } // Sort: target -> app_key -> remaining desc. sort.Slice(items, func(i, j int) bool { if items[i].Target != items[j].Target { return items[i].Target < items[j].Target } if items[i].AppKey != items[j].AppKey { return items[i].AppKey < items[j].AppKey } return items[i].RemainingSec > items[j].RemainingSec }) writeJSON(w, http.StatusOK, TrafficAppMarksItemsResponse{Items: items, Message: "ok"}) } func appMarksGetStatus() (vpnCount int, directCount int) { _ = pruneExpiredAppMarks() appMarksMu.Lock() defer appMarksMu.Unlock() st := loadAppMarksState() for _, it := range st.Items { switch strings.ToLower(strings.TrimSpace(it.Target)) { case "vpn": vpnCount++ case "direct": directCount++ } } return vpnCount, directCount } func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int, vpnIface string) error { target = strings.ToLower(strings.TrimSpace(target)) if target != "vpn" && target != "direct" { return fmt.Errorf("invalid target") } if id == 0 { return fmt.Errorf("invalid cgroup id") } if strings.TrimSpace(rel) == "" || level <= 0 { return fmt.Errorf("invalid cgroup path") } if ttlSec <= 0 { ttlSec = defaultAppMarkTTLSeconds } appMarksMu.Lock() defer appMarksMu.Unlock() st := loadAppMarksState() changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) unit = strings.TrimSpace(unit) command = strings.TrimSpace(command) appKey = canonicalizeAppKey(appKey, command) // EN: Keep only one effective mark per app and avoid cross-target conflicts. // EN: If the same app_key is re-marked with another target, old mark is removed first. // RU: Держим только одну эффективную метку на приложение и убираем конфликты между target. // RU: Если тот же app_key перемечается на другой target — старая метка удаляется. kept := st.Items[:0] for _, it := range st.Items { itTarget := strings.ToLower(strings.TrimSpace(it.Target)) itKey := strings.TrimSpace(it.AppKey) remove := false // Same cgroup id but different target => conflicting rules (mark+guard). if it.ID == id && it.ID != 0 && itTarget != target { remove = true } // Same app_key (if known) should not keep multiple active runtime routes. if !remove && appKey != "" && itKey != "" && itKey == appKey { if it.ID != id || itTarget != target { remove = true } } if remove { _ = nftDeleteAppMarkRule(itTarget, it.ID) changed = true continue } kept = append(kept, it) } st.Items = kept // Replace any existing rule/state for this (target,id). _ = nftDeleteAppMarkRule(target, id) if err := nftInsertAppMarkRule(target, rel, level, id, vpnIface); err != nil { return err } if !nftHasAppMarkRule(target, id) { _ = nftDeleteAppMarkRule(target, id) return fmt.Errorf("appmark rule not active after insert (target=%s id=%d)", target, id) } now := time.Now().UTC() expiresAt := "" if ttlSec > 0 { expiresAt = now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339) } item := appMarkItem{ ID: id, Target: target, Cgroup: cgAbs, CgroupRel: rel, Level: level, Unit: unit, Command: command, AppKey: appKey, AddedAt: now.Format(time.RFC3339), ExpiresAt: expiresAt, } st.Items = upsertAppMarkItem(st.Items, item) changed = true if changed { if err := saveAppMarksState(st); err != nil { // Keep runtime state and nft in sync on disk write errors. _ = nftDeleteAppMarkRule(target, id) return err } } return nil } func appMarksDel(target string, cgroup string) error { target = strings.ToLower(strings.TrimSpace(target)) if target != "vpn" && target != "direct" { return fmt.Errorf("invalid target") } cgroup = strings.TrimSpace(cgroup) if cgroup == "" { return fmt.Errorf("empty cgroup") } appMarksMu.Lock() defer appMarksMu.Unlock() st := loadAppMarksState() changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) var id uint64 var cgAbs string if isAllDigits(cgroup) { v, err := strconv.ParseUint(cgroup, 10, 64) if err == nil { id = v } } else { rel := normalizeCgroupRelOnly(cgroup) if rel != "" { cgAbs = "/" + rel // Try to resolve inode id if directory still exists. if inode, err := cgroupDirInode(rel); err == nil { id = inode } } } // Fallback to state lookup by cgroup string. idx := -1 for i, it := range st.Items { if strings.ToLower(strings.TrimSpace(it.Target)) != target { continue } if id != 0 && it.ID == id { idx = i break } if id == 0 && cgAbs != "" && strings.TrimSpace(it.Cgroup) == cgAbs { id = it.ID idx = i break } } if id != 0 { _ = nftDeleteAppMarkRule(target, id) } if idx >= 0 { st.Items = append(st.Items[:idx], st.Items[idx+1:]...) changed = true } if changed { return saveAppMarksState(st) } return nil } func appMarksClear(target string) error { target = strings.ToLower(strings.TrimSpace(target)) if target != "vpn" && target != "direct" { return fmt.Errorf("invalid target") } appMarksMu.Lock() defer appMarksMu.Unlock() st := loadAppMarksState() changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC()) kept := st.Items[:0] for _, it := range st.Items { if strings.ToLower(strings.TrimSpace(it.Target)) == target { _ = nftDeleteAppMarkRule(target, it.ID) changed = true continue } kept = append(kept, it) } st.Items = kept if changed { return saveAppMarksState(st) } return nil } func ensureAppMarksNft() error { // Best-effort "ensure": ignore "exists" errors and proceed. _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", appMarksTable) _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, appMarksGuardChain, "{", "type", "filter", "hook", "output", "priority", "filter;", "policy", "accept;", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", appMarksTable, appMarksChain) _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", appMarksTable, appMarksLocalBypassSet, "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "list", "chain", "inet", appMarksTable, "output") if !strings.Contains(out, "jump "+appMarksChain) { _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "insert", "rule", "inet", appMarksTable, "output", "jump", appMarksChain) } // Remove legacy rules that relied on `meta cgroup @svpn_cg_*` (broken on some kernels). _ = cleanupLegacyAppMarksRules() return nil } func cleanupLegacyAppMarksRules() error { out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain) for _, line := range strings.Split(out, "\n") { l := strings.ToLower(line) if !strings.Contains(l, "meta cgroup") { continue } if !strings.Contains(l, "svpn_cg_") { continue } h := parseNftHandle(line) if h <= 0 { continue } _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, appMarksChain, "handle", strconv.Itoa(h)) } return nil } func appMarkComment(target string, id uint64) string { return fmt.Sprintf("%s:%s:%d", appMarkCommentPrefix, target, id) } func appGuardComment(target string, id uint64) string { return fmt.Sprintf("%s:%s:%d", appGuardCommentPrefix, target, id) } func appGuardEnabled() bool { v := strings.ToLower(strings.TrimSpace(os.Getenv("SVPN_APP_GUARD"))) return v == "1" || v == "true" || v == "yes" || v == "on" } func updateAppMarkLocalBypassSet(vpnIface string) error { // EN: Keep a small allowlist for local/LAN/container destinations so VPN app kill-switch // EN: does not break host-local access. // RU: Держим небольшой allowlist локальных/LAN/container направлений, чтобы VPN kill-switch // RU: не ломал локальный доступ хоста. vpnIface = strings.TrimSpace(vpnIface) _ = ensureAppMarksNft() _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", appMarksTable, appMarksLocalBypassSet) elems := []string{"127.0.0.0/8"} for _, rt := range detectAutoLocalBypassRoutes(vpnIface) { dst := strings.TrimSpace(rt.Dst) if dst == "" || dst == "default" { continue } elems = append(elems, dst) } elems = compactIPv4IntervalElements(elems) for _, e := range elems { _, out, code, err := runCommandTimeout( 5*time.Second, "nft", "add", "element", "inet", appMarksTable, appMarksLocalBypassSet, "{", e, "}", ) if err != nil || code != 0 { if err == nil { err = fmt.Errorf("nft add element exited with %d", code) } return fmt.Errorf("failed to update %s: %w (%s)", appMarksLocalBypassSet, err, strings.TrimSpace(out)) } } return nil } func compactIPv4IntervalElements(raw []string) []string { pfxs := make([]netip.Prefix, 0, len(raw)) for _, v := range raw { s := strings.TrimSpace(v) if s == "" { continue } if strings.Contains(s, "/") { p, err := netip.ParsePrefix(s) if err != nil || !p.Addr().Is4() { continue } pfxs = append(pfxs, p.Masked()) continue } a, err := netip.ParseAddr(s) if err != nil || !a.Is4() { continue } pfxs = append(pfxs, netip.PrefixFrom(a, 32)) } sort.Slice(pfxs, func(i, j int) bool { ib, jb := pfxs[i].Bits(), pfxs[j].Bits() if ib != jb { return ib < jb // broader first } return pfxs[i].Addr().Less(pfxs[j].Addr()) }) out := make([]netip.Prefix, 0, len(pfxs)) for _, p := range pfxs { covered := false for _, ex := range out { if ex.Contains(p.Addr()) { covered = true break } } if covered { continue } out = append(out, p) } res := make([]string, 0, len(out)) for _, p := range out { res = append(res, p.String()) } return res } func nftInsertAppMarkRule(target string, rel string, level int, id uint64, vpnIface string) error { mark := MARK_DIRECT if target == "vpn" { mark = MARK_APP } comment := appMarkComment(target, id) // EN: nft requires a *string literal* for cgroupv2 path; paths with '@' (user@1000.service) // EN: break tokenization unless we pass quotes as part of nft language input. // RU: nft ожидает *строку* для cgroupv2 пути; пути с '@' (user@1000.service) // RU: ломают токенизацию, поэтому передаем кавычки как часть nft-выражения. pathLit := fmt.Sprintf("\"%s\"", rel) commentLit := fmt.Sprintf("\"%s\"", comment) if target == "vpn" { if !appGuardEnabled() { goto insertMark } iface := strings.TrimSpace(vpnIface) if iface == "" { return fmt.Errorf("vpn interface required for app guard") } if err := updateAppMarkLocalBypassSet(iface); err != nil { return err } guardComment := appGuardComment(target, id) guardCommentLit := fmt.Sprintf("\"%s\"", guardComment) // IPv4: drop non-tun egress except local bypass ranges. _, out, code, err := runCommandTimeout( 5*time.Second, "nft", "insert", "rule", "inet", appMarksTable, appMarksGuardChain, "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, "meta", "mark", MARK_APP, "oifname", "!=", iface, "ip", "daddr", "!=", "@"+appMarksLocalBypassSet, "drop", "comment", guardCommentLit, ) if err != nil || code != 0 { if err == nil { err = fmt.Errorf("nft insert guard(v4) exited with %d", code) } return fmt.Errorf("nft insert app guard(v4) failed: %w (%s)", err, strings.TrimSpace(out)) } // IPv6: default deny outside VPN iface to prevent WebRTC/STUN leaks on dual-stack hosts. _, out, code, err = runCommandTimeout( 5*time.Second, "nft", "insert", "rule", "inet", appMarksTable, appMarksGuardChain, "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, "meta", "mark", MARK_APP, "oifname", "!=", iface, "meta", "nfproto", "ipv6", "drop", "comment", guardCommentLit, ) if err != nil || code != 0 { if err == nil { err = fmt.Errorf("nft insert guard(v6) exited with %d", code) } return fmt.Errorf("nft insert app guard(v6) failed: %w (%s)", err, strings.TrimSpace(out)) } } insertMark: _, out, code, err := runCommandTimeout( 5*time.Second, "nft", "insert", "rule", "inet", appMarksTable, appMarksChain, "socket", "cgroupv2", "level", strconv.Itoa(level), pathLit, "meta", "mark", "set", mark, "accept", "comment", commentLit, ) if err != nil || code != 0 { if err == nil { err = fmt.Errorf("nft insert rule exited with %d", code) } _ = nftDeleteAppMarkRule(target, id) return fmt.Errorf("nft insert appmark rule failed: %w (%s)", err, strings.TrimSpace(out)) } return nil } func nftDeleteAppMarkRule(target string, id uint64) error { comments := []string{ appMarkComment(target, id), appGuardComment(target, id), } chains := []string{appMarksChain, appMarksGuardChain} for _, chain := range chains { out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, chain) for _, line := range strings.Split(out, "\n") { match := false for _, comment := range comments { if strings.Contains(line, comment) { match = true break } } if !match { continue } h := parseNftHandle(line) if h <= 0 { continue } _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, chain, "handle", strconv.Itoa(h)) } } return nil } func nftHasAppMarkRule(target string, id uint64) bool { markComment := appMarkComment(target, id) guardComment := appGuardComment(target, id) hasMark := false out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain) for _, line := range strings.Split(out, "\n") { if strings.Contains(line, markComment) { hasMark = true break } } if !hasMark { return false } if strings.EqualFold(strings.TrimSpace(target), "vpn") { if !appGuardEnabled() { return true } out, _, _, _ = runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksGuardChain) for _, line := range strings.Split(out, "\n") { if strings.Contains(line, guardComment) { return true } } return false } return true } func parseNftHandle(line string) int { fields := strings.Fields(line) for i := 0; i < len(fields)-1; i++ { if fields[i] == "handle" { n, _ := strconv.Atoi(fields[i+1]) return n } } return 0 } func resolveCgroupV2PathForNft(input string) (rel string, level int, inodeID uint64, abs string, err error) { raw := strings.TrimSpace(input) if raw == "" { return "", 0, 0, "", fmt.Errorf("empty cgroup") } rel = normalizeCgroupRelOnly(raw) if rel == "" { return "", 0, 0, raw, fmt.Errorf("invalid cgroup path: %s", raw) } inodeID, err = cgroupDirInode(rel) if err != nil { return "", 0, 0, raw, err } level = strings.Count(rel, "/") + 1 abs = "/" + rel return rel, level, inodeID, abs, nil } func normalizeCgroupRelOnly(raw string) string { rel := strings.TrimSpace(raw) rel = strings.TrimPrefix(rel, "/") rel = filepath.Clean(rel) if rel == "." || rel == "" { return "" } if strings.HasPrefix(rel, "..") || strings.Contains(rel, "../") { return "" } return rel } func cgroupDirInode(rel string) (uint64, error) { full := filepath.Join(cgroupRootPath, strings.TrimPrefix(rel, "/")) fi, err := os.Stat(full) if err != nil || fi == nil || !fi.IsDir() { return 0, fmt.Errorf("cgroup not found: %s", "/"+strings.TrimPrefix(rel, "/")) } st, ok := fi.Sys().(*syscall.Stat_t) if !ok || st == nil { return 0, fmt.Errorf("cannot stat cgroup: %s", "/"+strings.TrimPrefix(rel, "/")) } if st.Ino == 0 { return 0, fmt.Errorf("invalid cgroup inode id: %s", "/"+strings.TrimPrefix(rel, "/")) } return st.Ino, nil } func pruneExpiredAppMarks() error { appMarksMu.Lock() defer appMarksMu.Unlock() st := loadAppMarksState() if pruneExpiredAppMarksLocked(&st, time.Now().UTC()) { return saveAppMarksState(st) } return nil } func pruneExpiredAppMarksLocked(st *appMarksState, now time.Time) (changed bool) { if st == nil { return false } kept := st.Items[:0] for _, it := range st.Items { expRaw := strings.TrimSpace(it.ExpiresAt) if expRaw == "" { kept = append(kept, it) continue } exp, err := time.Parse(time.RFC3339, expRaw) if err != nil { // Corrupted timestamp: keep mark as persistent to avoid accidental route leak. it.ExpiresAt = "" kept = append(kept, it) changed = true continue } if !exp.After(now) { _ = nftDeleteAppMarkRule(strings.ToLower(strings.TrimSpace(it.Target)), it.ID) changed = true continue } kept = append(kept, it) } st.Items = kept return changed } func upsertAppMarkItem(items []appMarkItem, next appMarkItem) []appMarkItem { out := items[:0] for _, it := range items { if strings.ToLower(strings.TrimSpace(it.Target)) == strings.ToLower(strings.TrimSpace(next.Target)) && it.ID == next.ID { continue } out = append(out, it) } out = append(out, next) return out } func clearManagedAppMarkRules(chain string) { out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, chain) for _, line := range strings.Split(out, "\n") { l := strings.ToLower(line) if !strings.Contains(l, strings.ToLower(appMarkCommentPrefix)) && !strings.Contains(l, strings.ToLower(appGuardCommentPrefix)) { continue } h := parseNftHandle(line) if h <= 0 { continue } _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, chain, "handle", strconv.Itoa(h)) } } func restoreAppMarksFromState() error { appMarksMu.Lock() defer appMarksMu.Unlock() if err := ensureAppMarksNft(); err != nil { return err } st := loadAppMarksState() now := time.Now().UTC() changed := pruneExpiredAppMarksLocked(&st, now) clearManagedAppMarkRules(appMarksChain) clearManagedAppMarkRules(appMarksGuardChain) traffic := loadTrafficModeState() vpnIface, _ := resolveTrafficIface(traffic.PreferredIface) vpnIface = strings.TrimSpace(vpnIface) kept := make([]appMarkItem, 0, len(st.Items)) for _, it := range st.Items { target := strings.ToLower(strings.TrimSpace(it.Target)) if target != "vpn" && target != "direct" { changed = true continue } rel := normalizeCgroupRelOnly(it.CgroupRel) if rel == "" { rel = normalizeCgroupRelOnly(it.Cgroup) } if rel == "" { changed = true continue } id := it.ID if id == 0 { inode, err := cgroupDirInode(rel) if err != nil { changed = true continue } id = inode it.ID = inode changed = true } level := it.Level if level <= 0 { level = strings.Count(strings.Trim(rel, "/"), "/") + 1 it.Level = level changed = true } abs := "/" + strings.TrimPrefix(rel, "/") it.CgroupRel = rel it.Cgroup = abs if _, err := cgroupDirInode(rel); err != nil { changed = true continue } iface := "" if target == "vpn" { if vpnIface == "" { // Keep state for later retry when VPN interface appears. kept = append(kept, it) continue } iface = vpnIface } if err := nftInsertAppMarkRule(target, rel, level, id, iface); err != nil { appendTraceLine("traffic", fmt.Sprintf("appmarks restore failed target=%s id=%d err=%v", target, id, err)) kept = append(kept, it) continue } if !nftHasAppMarkRule(target, id) { appendTraceLine("traffic", fmt.Sprintf("appmarks restore post-check failed target=%s id=%d", target, id)) kept = append(kept, it) continue } kept = append(kept, it) } st.Items = kept if changed { return saveAppMarksState(st) } return nil } func loadAppMarksState() appMarksState { st := appMarksState{Version: 1} data, err := os.ReadFile(trafficAppMarksPath) if err != nil { return st } if err := json.Unmarshal(data, &st); err != nil { return appMarksState{Version: 1} } if st.Version == 0 { st.Version = 1 } // EN: Best-effort migration: normalize app keys to canonical form. // RU: Best-effort миграция: нормализуем app_key в канонический вид. changed := false for i := range st.Items { st.Items[i].Target = strings.ToLower(strings.TrimSpace(st.Items[i].Target)) canon := canonicalizeAppKey(st.Items[i].AppKey, st.Items[i].Command) if canon != "" && strings.TrimSpace(st.Items[i].AppKey) != canon { st.Items[i].AppKey = canon changed = true } } if deduped, dedupChanged := dedupeAppMarkItems(st.Items); dedupChanged { st.Items = deduped changed = true } if changed { _ = saveAppMarksState(st) } return st } func dedupeAppMarkItems(in []appMarkItem) ([]appMarkItem, bool) { if len(in) <= 1 { return in, false } out := make([]appMarkItem, 0, len(in)) byTargetID := map[string]int{} byTargetApp := map[string]int{} changed := false for _, raw := range in { it := raw it.Target = strings.ToLower(strings.TrimSpace(it.Target)) if it.Target != "vpn" && it.Target != "direct" { changed = true continue } it.AppKey = canonicalizeAppKey(it.AppKey, it.Command) if it.ID > 0 { idKey := fmt.Sprintf("%s:%d", it.Target, it.ID) if idx, ok := byTargetID[idKey]; ok { if preferAppMarkItem(it, out[idx]) { out[idx] = it } changed = true continue } byTargetID[idKey] = len(out) } if it.AppKey != "" { appKey := it.Target + "|" + it.AppKey if idx, ok := byTargetApp[appKey]; ok { if preferAppMarkItem(it, out[idx]) { out[idx] = it } changed = true continue } byTargetApp[appKey] = len(out) } out = append(out, it) } return out, changed } func preferAppMarkItem(cand, cur appMarkItem) bool { ca := strings.TrimSpace(cand.AddedAt) oa := strings.TrimSpace(cur.AddedAt) if ca != oa { if ca == "" { return false } if oa == "" { return true } return ca > oa } if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" { return true } return false } func saveAppMarksState(st appMarksState) error { st.Version = 1 st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) data, err := json.MarshalIndent(st, "", " ") if err != nil { return err } if err := os.MkdirAll(stateDir, 0o755); err != nil { return err } tmp := trafficAppMarksPath + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } return os.Rename(tmp, trafficAppMarksPath) } func isAllDigits(s string) bool { s = strings.TrimSpace(s) if s == "" { return false } for i := 0; i < len(s); i++ { ch := s[i] if ch < '0' || ch > '9' { return false } } return true }