package app import ( "encoding/json" "fmt" "io" "net/http" "net/netip" "os" "path/filepath" "sort" "strconv" "strings" "syscall" "time" ) const ( trafficRulePrefMarkDirect = 11500 trafficRulePrefMarkIngressReply = 11505 trafficRulePrefMarkAppVPN = 11510 trafficRulePrefDirectSubnetStart = 11600 trafficRulePrefDirectUIDStart = 11680 trafficRulePrefVPNSubnetStart = 11720 trafficRulePrefVPNUIDStart = 11800 trafficRulePrefFull = 11900 trafficRulePrefSelective = 12000 trafficRulePrefManagedMin = 11500 trafficRulePrefManagedMax = 12099 trafficRulePerKindLimit = 70 trafficAutoLocalDefault = true trafficIngressReplyDefault = false trafficIngressPreroutingChain = "prerouting_ingress_reply" trafficIngressOutputChain = "output_ingress_reply" trafficIngressCaptureComment = "svpn_ingress_reply_capture" trafficIngressRestoreComment = "svpn_ingress_reply_restore" ) var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10") const cgroupRootPath = "/sys/fs/cgroup" // --------------------------------------------------------------------- // traffic mode (selective / full_tunnel / direct) // --------------------------------------------------------------------- // EN: Controls route-policy behavior independently from DNS mode. // EN: Uses a persisted desired state with runtime verification and rollback. // RU: Управляет policy routing независимо от DNS-режима. // RU: Использует сохраненное desired-state, runtime-проверку и откат. func normalizeTrafficMode(raw TrafficMode) TrafficMode { switch strings.ToLower(strings.TrimSpace(string(raw))) { case string(TrafficModeFullTunnel): return TrafficModeFullTunnel case string(TrafficModeDirect): return TrafficModeDirect case string(TrafficModeSelective): return TrafficModeSelective default: return TrafficModeSelective } } func normalizePreferredIface(raw string) string { v := strings.TrimSpace(raw) l := strings.ToLower(v) if l == "" || l == "auto" || l == "-" || l == "default" { return "" } return v } func tokenizeList(raw []string) []string { repl := strings.NewReplacer(",", " ", ";", " ", "\n", " ", "\t", " ") out := make([]string, 0, len(raw)) for _, line := range raw { for _, tok := range strings.Fields(repl.Replace(line)) { val := strings.TrimSpace(tok) if val != "" { out = append(out, val) } } } return out } func normalizeSubnetList(raw []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(raw)) for _, tok := range tokenizeList(raw) { var cidr string if strings.Contains(tok, "/") { pfx, err := netip.ParsePrefix(tok) if err != nil || !pfx.Addr().Is4() { continue } cidr = pfx.Masked().String() } else { ip, err := netip.ParseAddr(tok) if err != nil || !ip.Is4() { continue } cidr = netip.PrefixFrom(ip, 32).String() } if _, ok := seen[cidr]; ok { continue } seen[cidr] = struct{}{} out = append(out, cidr) } sort.Strings(out) return out } func normalizeUIDToken(tok string) (string, bool) { t := strings.TrimSpace(tok) if t == "" { return "", false } parseOne := func(s string) (uint64, bool) { n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 32) if err != nil { return 0, false } return n, true } if strings.Contains(t, "-") { parts := strings.SplitN(t, "-", 2) if len(parts) != 2 { return "", false } start, okA := parseOne(parts[0]) end, okB := parseOne(parts[1]) if !okA || !okB || end < start { return "", false } return fmt.Sprintf("%d-%d", start, end), true } n, ok := parseOne(t) if !ok { return "", false } return fmt.Sprintf("%d-%d", n, n), true } func normalizeUIDList(raw []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(raw)) for _, tok := range tokenizeList(raw) { v, ok := normalizeUIDToken(tok) if !ok { continue } if _, exists := seen[v]; exists { continue } seen[v] = struct{}{} out = append(out, v) } sort.Strings(out) return out } func normalizeCgroupList(raw []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(raw)) for _, tok := range tokenizeList(raw) { v := strings.TrimSpace(tok) if v == "" { continue } v = strings.TrimSuffix(v, "/") if v == "" { v = "/" } if _, exists := seen[v]; exists { continue } seen[v] = struct{}{} out = append(out, v) } sort.Strings(out) return out } func normalizeTrafficModeState(st TrafficModeState) TrafficModeState { st.Mode = normalizeTrafficMode(st.Mode) st.PreferredIface = normalizePreferredIface(st.PreferredIface) st.ForceVPNSubnets = normalizeSubnetList(st.ForceVPNSubnets) st.ForceVPNUIDs = normalizeUIDList(st.ForceVPNUIDs) st.ForceVPNCGroups = normalizeCgroupList(st.ForceVPNCGroups) st.ForceDirectSubnets = normalizeSubnetList(st.ForceDirectSubnets) st.ForceDirectUIDs = normalizeUIDList(st.ForceDirectUIDs) st.ForceDirectCGroups = normalizeCgroupList(st.ForceDirectCGroups) return st } func loadTrafficModeState() TrafficModeState { data, err := os.ReadFile(trafficModePath) if err != nil { return inferTrafficModeState() } type diskState struct { Mode TrafficMode `json:"mode"` PreferredIface string `json:"preferred_iface,omitempty"` AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"` IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"` ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"` ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"` ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"` ForceDirectSubnets []string `json:"force_direct_subnets,omitempty"` ForceDirectUIDs []string `json:"force_direct_uids,omitempty"` ForceDirectCGroups []string `json:"force_direct_cgroups,omitempty"` } var raw diskState if err := json.Unmarshal(data, &raw); err != nil { return inferTrafficModeState() } st := TrafficModeState{ Mode: raw.Mode, PreferredIface: raw.PreferredIface, AutoLocalBypass: trafficAutoLocalDefault, IngressReplyBypass: trafficIngressReplyDefault, ForceVPNSubnets: append([]string(nil), raw.ForceVPNSubnets...), ForceVPNUIDs: append([]string(nil), raw.ForceVPNUIDs...), ForceVPNCGroups: append([]string(nil), raw.ForceVPNCGroups...), ForceDirectSubnets: append([]string(nil), raw.ForceDirectSubnets...), ForceDirectUIDs: append([]string(nil), raw.ForceDirectUIDs...), ForceDirectCGroups: append([]string(nil), raw.ForceDirectCGroups...), } if raw.AutoLocalBypass != nil { st.AutoLocalBypass = *raw.AutoLocalBypass } if raw.IngressReplyBypass != nil { st.IngressReplyBypass = *raw.IngressReplyBypass } return normalizeTrafficModeState(st) } func saveTrafficModeState(st TrafficModeState) error { st = normalizeTrafficModeState(st) 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 := trafficModePath + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } return os.Rename(tmp, trafficModePath) } func inferTrafficModeState() TrafficModeState { rules := readTrafficRules() mode := detectAppliedTrafficMode(rules) iface, _ := resolveTrafficIface("") return normalizeTrafficModeState(TrafficModeState{ Mode: mode, PreferredIface: iface, AutoLocalBypass: trafficAutoLocalDefault, IngressReplyBypass: trafficIngressReplyDefault, ForceVPNSubnets: nil, ForceVPNUIDs: nil, ForceVPNCGroups: nil, ForceDirectSubnets: nil, ForceDirectUIDs: nil, ForceDirectCGroups: nil, }) } func ensureRoutesTableEntry() { data, _ := os.ReadFile("/etc/iproute2/rt_tables") want := fmt.Sprintf("%s %s", routesTableNum(), routesTableName()) if strings.Contains(string(data), "\n"+want) || strings.HasPrefix(string(data), want) { return } f, err := os.OpenFile("/etc/iproute2/rt_tables", os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return } defer f.Close() _, _ = fmt.Fprintf(f, "%s\n", want) } func ifaceExists(iface string) bool { iface = strings.TrimSpace(iface) if iface == "" { return false } _, _, code, _ := runCommand("ip", "link", "show", iface) return code == 0 } func statusIfaceFromFile() string { data, err := os.ReadFile(statusFilePath) if err != nil { return "" } var st Status if json.Unmarshal(data, &st) != nil { return "" } return strings.TrimSpace(st.Iface) } func listUpIfaces() []string { out, _, code, _ := runCommand("ip", "-o", "link", "show", "up") if code != 0 { return nil } seen := map[string]struct{}{} var outIfaces []string for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.SplitN(line, ":", 3) if len(parts) < 3 { continue } name := strings.TrimSpace(parts[1]) name = strings.SplitN(name, "@", 2)[0] name = strings.TrimSpace(name) if name == "" || name == "lo" { continue } if _, ok := seen[name]; ok { continue } seen[name] = struct{}{} outIfaces = append(outIfaces, name) } return outIfaces } func listSelectableIfaces(preferred string) []string { up := listUpIfaces() seen := map[string]struct{}{} var vpnLike []string var other []string add := func(dst *[]string, iface string) { iface = strings.TrimSpace(iface) if iface == "" { return } if _, ok := seen[iface]; ok { return } seen[iface] = struct{}{} *dst = append(*dst, iface) } for _, iface := range up { if isVPNLikeIface(iface) { add(&vpnLike, iface) } } for _, iface := range up { if !isVPNLikeIface(iface) { add(&other, iface) } } sort.Strings(vpnLike) sort.Strings(other) selected := make([]string, 0, len(vpnLike)+len(other)+1) selected = append(selected, vpnLike...) selected = append(selected, other...) pref := normalizePreferredIface(preferred) if pref != "" { if _, ok := seen[pref]; !ok { selected = append([]string{pref}, selected...) } } return selected } func isVPNLikeIface(iface string) bool { l := strings.ToLower(strings.TrimSpace(iface)) return strings.HasPrefix(l, "tun") || strings.HasPrefix(l, "wg") || strings.HasPrefix(l, "ppp") || strings.HasPrefix(l, "tap") || strings.HasPrefix(l, "utun") || strings.HasPrefix(l, "vpn") } func resolveTrafficIface(preferred string) (string, string) { pref := normalizePreferredIface(preferred) if pref != "" && ifaceExists(pref) { return pref, "preferred" } statusIface := statusIfaceFromFile() if statusIface != "" && ifaceExists(statusIface) { return statusIface, "status" } for _, iface := range listUpIfaces() { if isVPNLikeIface(iface) { return iface, "auto-vpn-like" } } if pref != "" { return "", "preferred-not-found" } return "", "iface-not-found" } type autoLocalRoute struct { Dst string Dev string } func parseRouteDevice(fields []string) string { for i := 0; i+1 < len(fields); i++ { if fields[i] == "dev" { return strings.TrimSpace(fields[i+1]) } } return "" } func isContainerIface(iface string) bool { l := strings.ToLower(strings.TrimSpace(iface)) return strings.HasPrefix(l, "docker") || strings.HasPrefix(l, "br-") || strings.HasPrefix(l, "veth") || strings.HasPrefix(l, "cni") } func isPrivateLikeAddr(a netip.Addr) bool { if !a.Is4() { return false } if a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() { return true } // Carrier-grade NAT block. return cgnatPrefix.Contains(a) } func isAutoBypassDestination(dst string) bool { dst = strings.TrimSpace(dst) if dst == "" || dst == "default" { return false } if strings.Contains(dst, "/") { pfx, err := netip.ParsePrefix(dst) if err != nil { return false } return isPrivateLikeAddr(pfx.Addr()) } addr, err := netip.ParseAddr(dst) if err != nil { return false } return isPrivateLikeAddr(addr) } func detectAutoLocalBypassRoutes(vpnIface string) []autoLocalRoute { vpnIface = strings.TrimSpace(vpnIface) out, _, code, _ := runCommand("ip", "-4", "route", "show", "table", "main") if code != 0 { return nil } seen := map[string]struct{}{} routes := make([]autoLocalRoute, 0, 8) add := func(dst, dev string) { dst = strings.TrimSpace(dst) dev = strings.TrimSpace(dev) if dst == "" || dev == "" { return } key := dst + "|" + dev if _, ok := seen[key]; ok { return } seen[key] = struct{}{} routes = append(routes, autoLocalRoute{Dst: dst, Dev: dev}) } for _, raw := range strings.Split(out, "\n") { line := strings.TrimSpace(raw) if line == "" { continue } fields := strings.Fields(line) if len(fields) == 0 { continue } dst := strings.TrimSpace(fields[0]) if dst == "" || dst == "default" { continue } dev := parseRouteDevice(fields) if dev == "" || dev == "lo" { continue } if vpnIface != "" && dev == vpnIface { continue } if isVPNLikeIface(dev) { continue } isScopeLink := strings.Contains(" "+line+" ", " scope link ") if isScopeLink || isContainerIface(dev) || isAutoBypassDestination(dst) { add(dst, dev) } } sort.Slice(routes, func(i, j int) bool { if routes[i].Dev == routes[j].Dev { return routes[i].Dst < routes[j].Dst } return routes[i].Dev < routes[j].Dev }) return routes } func applyAutoLocalBypass(vpnIface string) { for _, rt := range detectAutoLocalBypassRoutes(vpnIface) { _, _, _, _ = runCommand( "ip", "-4", "route", "replace", rt.Dst, "dev", rt.Dev, "table", routesTableName(), ) } } func nftObjectMissing(stdout, stderr string) bool { text := strings.ToLower(strings.TrimSpace(stdout + " " + stderr)) return strings.Contains(text, "no such file") || strings.Contains(text, "not found") } func ensureIngressReplyBypassChains() { _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", routesTableName()) _, _, _, _ = runCommandTimeout( 5*time.Second, "nft", "add", "chain", "inet", routesTableName(), trafficIngressPreroutingChain, "{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}", ) _, _, _, _ = runCommandTimeout( 5*time.Second, "nft", "add", "chain", "inet", routesTableName(), trafficIngressOutputChain, "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}", ) } func flushIngressReplyBypassChains() error { for _, chain := range []string{trafficIngressPreroutingChain, trafficIngressOutputChain} { out, errOut, code, err := runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", routesTableName(), chain) if err == nil && code == 0 { continue } if nftObjectMissing(out, errOut) { continue } if err == nil { err = fmt.Errorf("nft flush chain exited with %d", code) } return fmt.Errorf("flush %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut)) } return nil } func enableIngressReplyBypass(vpnIface string) error { vpnIface = strings.TrimSpace(vpnIface) if vpnIface == "" { return fmt.Errorf("empty vpn iface for ingress bypass") } ensureIngressReplyBypassChains() if err := flushIngressReplyBypassChains(); err != nil { return err } addRule := func(chain string, args ...string) error { out, errOut, code, err := runCommandTimeout(5*time.Second, "nft", append([]string{"add", "rule", "inet", routesTableName(), chain}, args...)...) if err != nil || code != 0 { if err == nil { err = fmt.Errorf("nft add rule exited with %d", code) } return fmt.Errorf("nft add rule %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut)) } return nil } // EN: Mark inbound NEW connections (except loopback/VPN iface) so reply path can stay direct in full tunnel. // RU: Помечаем входящие NEW-соединения (кроме loopback/VPN iface), чтобы ответ шел напрямую в full tunnel. if err := addRule( trafficIngressPreroutingChain, "iifname", "!=", "lo", "iifname", "!=", vpnIface, "fib", "daddr", "type", "local", "ct", "state", "new", "ct", "mark", "set", MARK_INGRESS, "comment", trafficIngressCaptureComment, ); err != nil { return err } // EN: Restore fwmark from ct mark in prerouting for forwarded reply traffic. // RU: Восстанавливаем fwmark из ct mark в prerouting для forwarded-ответов. if err := addRule( trafficIngressPreroutingChain, "ct", "mark", MARK_INGRESS, "meta", "mark", "set", MARK_INGRESS, "comment", trafficIngressRestoreComment, ); err != nil { return err } // EN: Restore fwmark from ct mark in output for local-process replies. // RU: Восстанавливаем fwmark из ct mark в output для ответов локальных процессов. if err := addRule( trafficIngressOutputChain, "ct", "mark", MARK_INGRESS, "meta", "mark", "set", MARK_INGRESS, "comment", trafficIngressRestoreComment, ); err != nil { return err } return nil } func disableIngressReplyBypass() error { ensureIngressReplyBypassChains() return flushIngressReplyBypassChains() } func ingressReplyNftActive() bool { outPre, _, codePre, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", routesTableName(), trafficIngressPreroutingChain) outOut, _, codeOut, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", routesTableName(), trafficIngressOutputChain) if codePre != 0 || codeOut != 0 { return false } return strings.Contains(outPre, trafficIngressCaptureComment) && strings.Contains(outPre, trafficIngressRestoreComment) && strings.Contains(outOut, trafficIngressRestoreComment) } func prefStr(v int) string { return strconv.Itoa(v) } func removeTrafficRulesForTable() { out, _, _, _ := runCommand("ip", "rule", "show") for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } fields := strings.Fields(line) if len(fields) == 0 { continue } pref := strings.TrimSuffix(fields[0], ":") if pref == "" { continue } prefNum, _ := strconv.Atoi(pref) low := strings.ToLower(line) managed := prefNum >= trafficRulePrefManagedMin && prefNum <= trafficRulePrefManagedMax legacy := strings.Contains(low, "lookup "+routesTableName()) if !managed && !legacy { continue } _, _, _, _ = runCommand("ip", "rule", "del", "pref", pref) } } func cgroupCandidates(entry string) []string { v := strings.TrimSpace(entry) if v == "" { return nil } vc := filepath.Clean(v) vals := []string{} if filepath.IsAbs(vc) { if strings.HasPrefix(vc, cgroupRootPath) { vals = append(vals, vc) } else { vals = append(vals, filepath.Join(cgroupRootPath, strings.TrimPrefix(vc, "/"))) } } else { vals = append(vals, filepath.Join(cgroupRootPath, strings.TrimPrefix(vc, "/")), filepath.Join(cgroupRootPath, "system.slice", strings.TrimPrefix(vc, "/")), filepath.Join(cgroupRootPath, "user.slice", strings.TrimPrefix(vc, "/")), ) } seen := map[string]struct{}{} out := make([]string, 0, len(vals)) for _, p := range vals { cp := filepath.Clean(p) if cp == "." || cp == "" { continue } if _, ok := seen[cp]; ok { continue } seen[cp] = struct{}{} out = append(out, cp) } return out } func resolveCgroupPath(entry string) (string, string) { for _, cand := range cgroupCandidates(entry) { fi, err := os.Stat(cand) if err != nil || !fi.IsDir() { continue } return cand, "" } return "", "cgroup not found: " + strings.TrimSpace(entry) } func collectPIDsFromCgroup(root string) (map[int]struct{}, string) { const ( maxDirs = 5000 maxPIDs = 50000 ) pids := map[int]struct{}{} dirs := 0 warn := "" _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil || d == nil || !d.IsDir() { return nil } dirs++ if dirs > maxDirs { warn = "cgroup scan truncated by directory limit" return filepath.SkipDir } data, err := os.ReadFile(filepath.Join(path, "cgroup.procs")) if err != nil { return nil } for _, ln := range strings.Split(string(data), "\n") { ln = strings.TrimSpace(ln) if ln == "" { continue } pid, err := strconv.Atoi(ln) if err != nil || pid <= 0 { continue } pids[pid] = struct{}{} if len(pids) > maxPIDs { warn = "cgroup scan truncated by pid limit" return filepath.SkipDir } } return nil }) return pids, warn } func uidRangeForPID(pid int) (string, bool) { data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) if err != nil { return "", false } for _, ln := range strings.Split(string(data), "\n") { ln = strings.TrimSpace(ln) if !strings.HasPrefix(ln, "Uid:") { continue } fields := strings.Fields(ln) if len(fields) < 2 { return "", false } v, ok := normalizeUIDToken(fields[1]) return v, ok } return "", false } func resolveCgroupUIDRanges(entries []string) ([]string, string) { var uids []string var warnings []string for _, entry := range normalizeCgroupList(entries) { root, warn := resolveCgroupPath(entry) if root == "" { if warn != "" { warnings = append(warnings, warn) } continue } pids, scanWarn := collectPIDsFromCgroup(root) if scanWarn != "" { warnings = append(warnings, scanWarn) } if len(pids) == 0 { warnings = append(warnings, "cgroup has no processes: "+entry) continue } for pid := range pids { uidRange, ok := uidRangeForPID(pid) if !ok || uidRange == "" { continue } uids = append(uids, uidRange) } } seenWarn := map[string]struct{}{} uniqWarn := make([]string, 0, len(warnings)) for _, w := range warnings { ww := strings.TrimSpace(w) if ww == "" { continue } if _, ok := seenWarn[ww]; ok { continue } seenWarn[ww] = struct{}{} uniqWarn = append(uniqWarn, ww) } return normalizeUIDList(uids), strings.Join(uniqWarn, "; ") } type effectiveTrafficOverrides struct { VPNSubnets []string VPNUIDs []string DirectSubnets []string DirectUIDs []string CgroupResolvedUIDs int CgroupWarning string } func buildEffectiveOverrides(st TrafficModeState) effectiveTrafficOverrides { st = normalizeTrafficModeState(st) e := effectiveTrafficOverrides{ VPNSubnets: append([]string(nil), st.ForceVPNSubnets...), VPNUIDs: append([]string(nil), st.ForceVPNUIDs...), DirectSubnets: append([]string(nil), st.ForceDirectSubnets...), DirectUIDs: append([]string(nil), st.ForceDirectUIDs...), } vpnUIDsFromCG, warnVPN := resolveCgroupUIDRanges(st.ForceVPNCGroups) directUIDsFromCG, warnDirect := resolveCgroupUIDRanges(st.ForceDirectCGroups) e.CgroupResolvedUIDs = len(vpnUIDsFromCG) + len(directUIDsFromCG) e.VPNUIDs = normalizeUIDList(append(e.VPNUIDs, vpnUIDsFromCG...)) e.DirectUIDs = normalizeUIDList(append(e.DirectUIDs, directUIDsFromCG...)) warns := make([]string, 0, 2) if strings.TrimSpace(warnVPN) != "" { warns = append(warns, strings.TrimSpace(warnVPN)) } if strings.TrimSpace(warnDirect) != "" { warns = append(warns, strings.TrimSpace(warnDirect)) } e.CgroupWarning = strings.Join(warns, "; ") return e } func applyRule(pref int, args ...string) error { if pref <= 0 { return fmt.Errorf("invalid pref: %d", pref) } cmd := []string{"rule", "add"} cmd = append(cmd, args...) cmd = append(cmd, "pref", prefStr(pref)) _, _, code, err := runCommand("ip", cmd...) if err != nil || code != 0 { if err == nil { err = fmt.Errorf("ip %s exited with %d", strings.Join(cmd, " "), code) } return err } return nil } func applyTrafficOverrides(e effectiveTrafficOverrides) (int, error) { applied := 0 if len(e.DirectSubnets) > trafficRulePerKindLimit || len(e.DirectUIDs) > trafficRulePerKindLimit || len(e.VPNSubnets) > trafficRulePerKindLimit || len(e.VPNUIDs) > trafficRulePerKindLimit { return 0, fmt.Errorf("override list too large (max %d entries per kind)", trafficRulePerKindLimit) } for i, cidr := range e.DirectSubnets { if err := applyRule(trafficRulePrefDirectSubnetStart+i, "from", cidr, "lookup", "main"); err != nil { return applied, err } applied++ } for i, uidr := range e.DirectUIDs { if err := applyRule(trafficRulePrefDirectUIDStart+i, "uidrange", uidr, "lookup", "main"); err != nil { return applied, err } applied++ } for i, cidr := range e.VPNSubnets { if err := applyRule(trafficRulePrefVPNSubnetStart+i, "from", cidr, "lookup", routesTableName()); err != nil { return applied, err } applied++ } for i, uidr := range e.VPNUIDs { if err := applyRule(trafficRulePrefVPNUIDStart+i, "uidrange", uidr, "lookup", routesTableName()); err != nil { return applied, err } applied++ } return applied, nil } func ensureTrafficRouteBase(iface string, autoLocalBypass bool) error { iface = strings.TrimSpace(iface) if iface == "" { return fmt.Errorf("empty interface") } if !ifaceExists(iface) { return fmt.Errorf("interface not found: %s", iface) } ensureRoutesTableEntry() if _, _, code, err := runCommand("ip", "-4", "route", "replace", "default", "dev", iface, "table", routesTableName(), "mtu", policyRouteMTU); err != nil || code != 0 { if err == nil { err = fmt.Errorf("ip route replace default exited with %d", code) } return err } if autoLocalBypass { applyAutoLocalBypass(iface) } return nil } func applyTrafficMode(st TrafficModeState, iface string) error { st = normalizeTrafficModeState(st) eff := buildEffectiveOverrides(st) advancedActive := st.Mode == TrafficModeFullTunnel autoLocalActive := advancedActive && st.AutoLocalBypass ingressReplyActive := advancedActive && st.IngressReplyBypass removeTrafficRulesForTable() // EN: Ensure the policy table name exists even in direct mode so mark-based rules can be installed. // RU: Гарантируем наличие имени policy-table даже в direct режиме, чтобы можно было ставить mark-правила. ensureRoutesTableEntry() if err := disableIngressReplyBypass(); err != nil { return err } needVPNTable := st.Mode != TrafficModeDirect || len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0 if needVPNTable { if err := ensureTrafficRouteBase(iface, autoLocalActive); err != nil { return err } } if _, err := applyTrafficOverrides(eff); err != nil { return err } // EN: Mark-based per-app routing support (cgroup-based marking in nftables). // EN: These rules are safe even when no packets are marked with MARK_APP/MARK_DIRECT. // RU: Поддержка per-app маршрутизации по mark (cgroup-based marking в nftables). // RU: Эти правила безопасны, если пакеты не помечаются MARK_APP/MARK_DIRECT. if err := applyRule(trafficRulePrefMarkDirect, "fwmark", MARK_DIRECT, "lookup", "main"); err != nil { return err } if ingressReplyActive { if err := applyRule(trafficRulePrefMarkIngressReply, "fwmark", MARK_INGRESS, "lookup", "main"); err != nil { return err } } if err := applyRule(trafficRulePrefMarkAppVPN, "fwmark", MARK_APP, "lookup", routesTableName()); err != nil { return err } switch st.Mode { case TrafficModeFullTunnel: if err := applyRule(trafficRulePrefFull, "lookup", routesTableName()); err != nil { return err } case TrafficModeSelective: if err := applyRule(trafficRulePrefSelective, "fwmark", MARK, "lookup", routesTableName()); err != nil { return err } case TrafficModeDirect: // direct mode relies only on optional direct/vpn overrides. default: return fmt.Errorf("unknown traffic mode: %s", st.Mode) } if ingressReplyActive { if err := enableIngressReplyBypass(iface); err != nil { return err } } if err := restoreAppMarksFromState(); err != nil { appendTraceLine("traffic", fmt.Sprintf("appmarks restore warning: %v", err)) } return nil } type trafficRulesState struct { Mark bool Full bool IngressReply bool } func readTrafficRules() trafficRulesState { out, _, _, _ := runCommand("ip", "rule", "show") var st trafficRulesState for _, line := range strings.Split(out, "\n") { l := strings.ToLower(strings.TrimSpace(line)) if l == "" { continue } fields := strings.Fields(l) if len(fields) == 0 { continue } prefRaw := strings.TrimSuffix(fields[0], ":") pref, _ := strconv.Atoi(prefRaw) switch pref { case trafficRulePrefSelective: if strings.Contains(l, "lookup "+routesTableName()) { st.Mark = true } case trafficRulePrefFull: 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 } func detectAppliedTrafficMode(rules trafficRulesState) TrafficMode { if rules.Full { return TrafficModeFullTunnel } if rules.Mark { return TrafficModeSelective } return TrafficModeDirect } func probeTrafficMode(mode TrafficMode, iface string) (bool, string) { mode = normalizeTrafficMode(mode) iface = strings.TrimSpace(iface) args := []string{"-4", "route", "get", "1.1.1.1"} if mode == TrafficModeSelective { args = append(args, "mark", MARK) } out, _, code, err := runCommand("ip", args...) if err != nil || code != 0 { if err == nil { err = fmt.Errorf("ip route get exited with %d", code) } return false, err.Error() } text := strings.ToLower(out) switch mode { case TrafficModeDirect: // direct mode must not be forced through agvpn rule table. if strings.Contains(text, " table "+strings.ToLower(routesTableName())) { return false, "route probe still uses agvpn table" } return true, "route probe direct path ok" case TrafficModeFullTunnel, TrafficModeSelective: if iface == "" { return false, "route probe has empty iface" } if !strings.Contains(text, "dev "+strings.ToLower(iface)) { return false, fmt.Sprintf("route probe mismatch: expected dev %s", iface) } return true, "route probe vpn path ok" default: return false, "route probe unknown mode" } } func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse { st = normalizeTrafficModeState(st) eff := buildEffectiveOverrides(st) advancedActive := st.Mode == TrafficModeFullTunnel autoLocalActive := advancedActive && st.AutoLocalBypass ingressDesired := st.IngressReplyBypass ingressExpected := advancedActive && ingressDesired hasVPN := len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0 iface, reason := resolveTrafficIface(st.PreferredIface) rules := readTrafficRules() applied := detectAppliedTrafficMode(rules) ingressNft := false if rules.IngressReply || st.Mode == TrafficModeFullTunnel || st.IngressReplyBypass { ingressNft = ingressReplyNftActive() } bypassCandidates := 0 if autoLocalActive && (st.Mode != TrafficModeDirect || hasVPN) { bypassCandidates = len(detectAutoLocalBypassRoutes(iface)) } overridesApplied := len(eff.VPNSubnets) + len(eff.VPNUIDs) + len(eff.DirectSubnets) + len(eff.DirectUIDs) tableDefault := false if iface != "" && (st.Mode != TrafficModeDirect || hasVPN) { ok, _ := checkPolicyRoute(iface, routesTableName()) tableDefault = ok } res := TrafficModeStatusResponse{ Mode: st.Mode, DesiredMode: st.Mode, AppliedMode: applied, PreferredIface: st.PreferredIface, AdvancedActive: advancedActive, AutoLocalBypass: st.AutoLocalBypass, AutoLocalActive: autoLocalActive, IngressReplyBypass: ingressDesired, IngressReplyActive: rules.IngressReply && ingressNft, BypassCandidates: bypassCandidates, ForceVPNSubnets: append([]string(nil), st.ForceVPNSubnets...), ForceVPNUIDs: append([]string(nil), st.ForceVPNUIDs...), ForceVPNCGroups: append([]string(nil), st.ForceVPNCGroups...), ForceDirectSubnets: append([]string(nil), st.ForceDirectSubnets...), ForceDirectUIDs: append([]string(nil), st.ForceDirectUIDs...), ForceDirectCGroups: append([]string(nil), st.ForceDirectCGroups...), OverridesApplied: overridesApplied, CgroupResolvedUIDs: eff.CgroupResolvedUIDs, CgroupWarning: eff.CgroupWarning, ActiveIface: iface, IfaceReason: reason, RuleMark: rules.Mark, RuleFull: rules.Full, IngressRulePresent: rules.IngressReply, IngressNftActive: ingressNft, TableDefault: tableDefault, } res.ProbeOK, res.ProbeMessage = probeTrafficMode(st.Mode, iface) switch st.Mode { case TrafficModeDirect: // direct mode can still be healthy when vpn overrides exist // (base full/selective rules must be absent). if hasVPN { res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK } else { res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && res.ProbeOK } case TrafficModeFullTunnel: if ingressExpected { res.Healthy = rules.Full && !rules.Mark && rules.IngressReply && ingressNft && tableDefault && iface != "" && res.ProbeOK } else { res.Healthy = rules.Full && !rules.Mark && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK } case TrafficModeSelective: res.Healthy = rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK default: res.Healthy = false } if res.Healthy { res.Message = "traffic mode applied" return res } if iface == "" && (st.Mode != TrafficModeDirect || hasVPN) { res.Message = "vpn interface not found" return res } if st.Mode != applied { res.Message = fmt.Sprintf("desired=%s applied=%s mismatch", st.Mode, applied) return res } if (st.Mode != TrafficModeDirect || hasVPN) && !tableDefault { res.Message = "policy table default route is missing" return res } if !res.ProbeOK { res.Message = res.ProbeMessage return res } if rules.Mark && rules.Full { res.Message = "conflicting traffic rules detected" return res } if ingressExpected && (!rules.IngressReply || !ingressNft) { res.Message = "ingress-reply bypass rule is not active" return res } if !ingressExpected && (rules.IngressReply || ingressNft) { res.Message = "stale ingress-reply bypass rule is active" return res } res.Message = "traffic mode check failed" return res } func handleTrafficInterfaces(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } st := loadTrafficModeState() active, reason := resolveTrafficIface(st.PreferredIface) resp := TrafficInterfacesResponse{ Interfaces: listSelectableIfaces(st.PreferredIface), PreferredIface: normalizePreferredIface(st.PreferredIface), ActiveIface: active, IfaceReason: reason, } writeJSON(w, http.StatusOK, resp) } func handleTrafficModeTest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } st := loadTrafficModeState() writeJSON(w, http.StatusOK, evaluateTrafficMode(st)) } func acquireTrafficApplyLock() (*os.File, *TrafficModeStatusResponse) { lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) if err != nil { msg := evaluateTrafficMode(loadTrafficModeState()) msg.Message = "traffic lock open failed: " + err.Error() return nil, &msg } if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { _ = lock.Close() msg := evaluateTrafficMode(loadTrafficModeState()) msg.Message = "traffic apply skipped: routes operation already running" return nil, &msg } return lock, nil } func handleTrafficAdvancedReset(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } lock, lockMsg := acquireTrafficApplyLock() if lockMsg != nil { writeJSON(w, http.StatusOK, *lockMsg) return } defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) _ = lock.Close() }() prev := normalizeTrafficModeState(loadTrafficModeState()) next := prev next.AutoLocalBypass = false next.IngressReplyBypass = false nextIface, _ := resolveTrafficIface(next.PreferredIface) if err := applyTrafficMode(next, nextIface); err != nil { prevIface, _ := resolveTrafficIface(prev.PreferredIface) _ = applyTrafficMode(prev, prevIface) msg := evaluateTrafficMode(prev) msg.Message = "advanced reset failed, rolled back: " + err.Error() writeJSON(w, http.StatusOK, msg) return } if err := saveTrafficModeState(next); err != nil { prevIface, _ := resolveTrafficIface(prev.PreferredIface) _ = applyTrafficMode(prev, prevIface) _ = saveTrafficModeState(prev) msg := evaluateTrafficMode(prev) msg.Message = "advanced reset save failed, rolled back: " + err.Error() writeJSON(w, http.StatusOK, msg) return } res := evaluateTrafficMode(next) if !res.Healthy { prevIface, _ := resolveTrafficIface(prev.PreferredIface) _ = applyTrafficMode(prev, prevIface) _ = saveTrafficModeState(prev) rolled := evaluateTrafficMode(prev) rolled.Message = "advanced reset verification failed, rolled back: " + res.Message writeJSON(w, http.StatusOK, rolled) return } events.push("traffic_advanced_reset", map[string]any{ "mode": res.Mode, "applied": res.AppliedMode, "active_iface": res.ActiveIface, "healthy": res.Healthy, "auto_local": res.AutoLocalBypass, "ingress_reply": res.IngressReplyBypass, "advanced_active": res.AdvancedActive, }) res.Message = "advanced bypass reset" writeJSON(w, http.StatusOK, res) } func handleTrafficMode(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: st := loadTrafficModeState() writeJSON(w, http.StatusOK, evaluateTrafficMode(st)) case http.MethodPost: lock, lockMsg := acquireTrafficApplyLock() if lockMsg != nil { writeJSON(w, http.StatusOK, *lockMsg) return } defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) _ = lock.Close() }() prev := loadTrafficModeState() next := prev var body TrafficModeRequest if r.Body != nil { defer r.Body.Close() if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } } if strings.TrimSpace(string(body.Mode)) != "" { next.Mode = normalizeTrafficMode(body.Mode) } if body.PreferredIface != nil { next.PreferredIface = normalizePreferredIface(*body.PreferredIface) } if body.AutoLocalBypass != nil { next.AutoLocalBypass = *body.AutoLocalBypass } if body.IngressReplyBypass != nil { next.IngressReplyBypass = *body.IngressReplyBypass } if body.ForceVPNSubnets != nil { next.ForceVPNSubnets = append([]string(nil), (*body.ForceVPNSubnets)...) } if body.ForceVPNUIDs != nil { next.ForceVPNUIDs = append([]string(nil), (*body.ForceVPNUIDs)...) } if body.ForceVPNCGroups != nil { next.ForceVPNCGroups = append([]string(nil), (*body.ForceVPNCGroups)...) } if body.ForceDirectSubnets != nil { next.ForceDirectSubnets = append([]string(nil), (*body.ForceDirectSubnets)...) } if body.ForceDirectUIDs != nil { next.ForceDirectUIDs = append([]string(nil), (*body.ForceDirectUIDs)...) } if body.ForceDirectCGroups != nil { next.ForceDirectCGroups = append([]string(nil), (*body.ForceDirectCGroups)...) } next = normalizeTrafficModeState(next) prev = normalizeTrafficModeState(prev) nextIface, _ := resolveTrafficIface(next.PreferredIface) if err := applyTrafficMode(next, nextIface); err != nil { prevIface, _ := resolveTrafficIface(prev.PreferredIface) _ = applyTrafficMode(prev, prevIface) msg := evaluateTrafficMode(prev) msg.Message = "apply 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) rolled := evaluateTrafficMode(prev) rolled.Message = "state save failed, rolled back: " + err.Error() writeJSON(w, http.StatusOK, rolled) return } res := evaluateTrafficMode(next) if !res.Healthy { prevIface, _ := resolveTrafficIface(prev.PreferredIface) _ = applyTrafficMode(prev, prevIface) _ = saveTrafficModeState(prev) rolled := evaluateTrafficMode(prev) rolled.Message = "verification failed, rolled back: " + res.Message writeJSON(w, http.StatusOK, rolled) return } events.push("traffic_mode_changed", map[string]any{ "mode": res.Mode, "applied": res.AppliedMode, "active_iface": res.ActiveIface, "healthy": res.Healthy, "advanced_active": res.AdvancedActive, "auto_local_bypass": res.AutoLocalBypass, "auto_local_active": res.AutoLocalActive, "ingress_reply": res.IngressReplyBypass, "ingress_active": res.IngressReplyActive, "overrides_applied": res.OverridesApplied, }) writeJSON(w, http.StatusOK, res) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } }