From 90907219dc773c04ffa9e417a257540dfad4d5d1 Mon Sep 17 00:00:00 2001 From: beckline Date: Sat, 14 Feb 2026 16:58:30 +0300 Subject: [PATCH] traffic: add per-app runtime app routing via cgroup marks --- selective-vpn-api/app/config.go | 12 +- selective-vpn-api/app/routes_update.go | 27 +- selective-vpn-api/app/server.go | 2 + selective-vpn-api/app/traffic_appmarks.go | 368 ++++++++++++++++++++++ selective-vpn-api/app/traffic_mode.go | 19 +- selective-vpn-api/app/types.go | 37 +++ selective-vpn-gui/api_client.py | 61 ++++ selective-vpn-gui/app_route_dialog.py | 261 +++++++++++++++ selective-vpn-gui/dashboard_controller.py | 19 ++ selective-vpn-gui/vpn_dashboard_qt.py | 20 ++ 10 files changed, 819 insertions(+), 7 deletions(-) create mode 100644 selective-vpn-api/app/traffic_appmarks.go create mode 100644 selective-vpn-gui/app_route_dialog.py diff --git a/selective-vpn-api/app/config.go b/selective-vpn-api/app/config.go index 92d9177..729d0f5 100644 --- a/selective-vpn-api/app/config.go +++ b/selective-vpn-api/app/config.go @@ -58,10 +58,14 @@ const ( heartbeatFile = stateDir + "/heartbeat" lockFile = "/run/lock/selective-vpn.lock" MARK = "0x66" - defaultDNS1 = "94.140.14.14" - defaultDNS2 = "94.140.15.15" - defaultMeta1 = "46.243.231.30" - defaultMeta2 = "46.243.231.41" + // EN: Extra marks reserved for per-app routing (systemd scope / cgroup-based). + // RU: Дополнительные метки для per-app маршрутизации (systemd scope / cgroup). + MARK_DIRECT = "0x67" // force direct (bypass VPN table even in full tunnel) + MARK_APP = "0x68" // force VPN for app-scoped traffic (works even in traffic-mode=direct) + defaultDNS1 = "94.140.14.14" + defaultDNS2 = "94.140.15.15" + defaultMeta1 = "46.243.231.30" + defaultMeta2 = "46.243.231.41" smartDNSDefaultAddr = "127.0.0.1#6053" smartDNSAddrEnv = "SVPN_SMARTDNS_ADDR" diff --git a/selective-vpn-api/app/routes_update.go b/selective-vpn-api/app/routes_update.go index 9878f8c..8df1ed5 100644 --- a/selective-vpn-api/app/routes_update.go +++ b/selective-vpn-api/app/routes_update.go @@ -151,10 +151,33 @@ func routesUpdate(iface string) cmdResult { _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn_dyn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") + // EN: Per-app routing support (cgroup-mark sets). Output chain jumps into: + // EN: - output_apps: app-scoped marks (MARK_DIRECT / MARK_APP) + // EN: - output_ips: selective domain IP sets (MARK) + // RU: Поддержка per-app (cgroup-mark sets). Output chain прыгает в: + // RU: - output_apps: per-app marks (MARK_DIRECT / MARK_APP) + // RU: - output_ips: селективные доменные IP сеты (MARK) + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "svpn_cg_vpn", "{", "typeof", "meta", "cgroup", ";", "flags", "timeout", ";", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "svpn_cg_direct", "{", "typeof", "meta", "cgroup", ";", "flags", "timeout", ";", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output_apps") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output_ips") + + // Base chain: stable jumps only. _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output") - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "ip", "daddr", "@agvpn4", "meta", "mark", "set", MARK) - _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "ip", "daddr", "@agvpn_dyn4", "meta", "mark", "set", MARK) + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "jump", "output_apps") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "jump", "output_ips") + + // App chain: mark + accept to stop further evaluation in this base chain. + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output_apps") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_apps", "meta", "cgroup", "@svpn_cg_direct", "meta", "mark", "set", MARK_DIRECT, "accept") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_apps", "meta", "cgroup", "@svpn_cg_vpn", "meta", "mark", "set", MARK_APP, "accept") + + // Domain chain: selective IP sets (resolver output). + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output_ips") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_ips", "ip", "daddr", "@agvpn4", "meta", "mark", "set", MARK) + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_ips", "ip", "daddr", "@agvpn_dyn4", "meta", "mark", "set", MARK) _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "prerouting", "{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "prerouting") diff --git a/selective-vpn-api/app/server.go b/selective-vpn-api/app/server.go index 272b226..68dff4c 100644 --- a/selective-vpn-api/app/server.go +++ b/selective-vpn-api/app/server.go @@ -146,6 +146,8 @@ func Run() { mux.HandleFunc("/api/v1/traffic/mode/test", handleTrafficModeTest) mux.HandleFunc("/api/v1/traffic/interfaces", handleTrafficInterfaces) mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates) + // per-app runtime marks (systemd scope / cgroup -> fwmark) + mux.HandleFunc("/api/v1/traffic/appmarks", handleTrafficAppMarks) // trace: хвост + JSON + append для GUI mux.HandleFunc("/api/v1/trace", handleTraceTailPlain) diff --git a/selective-vpn-api/app/traffic_appmarks.go b/selective-vpn-api/app/traffic_appmarks.go new file mode 100644 index 0000000..aa90d79 --- /dev/null +++ b/selective-vpn-api/app/traffic_appmarks.go @@ -0,0 +1,368 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// --------------------------------------------------------------------- +// traffic app marks (per-app routing via cgroup -> fwmark) +// --------------------------------------------------------------------- +// +// EN: This module manages runtime cgroup-id sets used by nftables rules in +// EN: routes_update.go (output_apps chain). GUI/clients can add/remove cgroup IDs +// EN: to force traffic through VPN (MARK_APP) or force direct (MARK_DIRECT). +// RU: Этот модуль управляет runtime cgroup-id сетами для nftables правил из +// RU: routes_update.go (цепочка output_apps). GUI/клиенты могут добавлять/удалять +// RU: cgroup IDs, чтобы форсировать трафик через VPN (MARK_APP) или в direct (MARK_DIRECT). + +const ( + nftSetCgroupVPN = "svpn_cg_vpn" + nftSetCgroupDirect = "svpn_cg_direct" + cgroupRootFS = "/sys/fs/cgroup" +) + +func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + vpnElems, _ := readNftSetElements(nftSetCgroupVPN) + directElems, _ := readNftSetElements(nftSetCgroupDirect) + writeJSON(w, http.StatusOK, TrafficAppMarksStatusResponse{ + VPNCount: len(vpnElems), + DirectCount: len(directElems), + 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) + 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 + } + + // Ensure nft objects exist even if routes-update hasn't run yet. + 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 + } + + var ( + cgID uint64 + err error + ) + if cgroup != "" { + cgID, cgroup, err = resolveCgroupIDForNft(cgroup) + if err != nil { + writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ + OK: false, + Op: string(op), + Target: target, + Cgroup: body.Cgroup, + Message: err.Error(), + }) + return + } + } + + if op == TrafficAppMarksAdd && target == "vpn" { + // Ensure VPN policy table has a base route. This matters when current traffic-mode=direct. + traffic := loadTrafficModeState() + iface, _ := resolveTrafficIface(traffic.PreferredIface) + if strings.TrimSpace(iface) == "" { + writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ + OK: false, + Op: string(op), + Target: target, + Cgroup: cgroup, + CgroupID: cgID, + Message: "vpn interface not found (set preferred iface or bring VPN up)", + }) + return + } + if err := ensureTrafficRouteBase(iface, traffic.AutoLocalBypass); err != nil { + writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ + OK: false, + Op: string(op), + Target: target, + Cgroup: cgroup, + CgroupID: cgID, + Message: "ensure vpn route base failed: " + err.Error(), + }) + return + } + } + + setName := nftSetCgroupDirect + if target == "vpn" { + setName = nftSetCgroupVPN + } + + switch op { + case TrafficAppMarksAdd: + ttl := timeoutSec + if ttl == 0 { + ttl = 24 * 60 * 60 // 24h default if client didn't specify + } + if err := nftAddCgroupElement(setName, cgID, ttl); err != nil { + writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ + OK: false, + Op: string(op), + Target: target, + Cgroup: cgroup, + CgroupID: cgID, + Message: err.Error(), + }) + return + } + appendTraceLine("traffic", fmt.Sprintf("appmarks add target=%s cgroup=%s id=%d ttl=%ds", target, cgroup, cgID, ttl)) + writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ + OK: true, + Op: string(op), + Target: target, + Cgroup: cgroup, + CgroupID: cgID, + TimeoutSec: ttl, + Message: "added", + }) + case TrafficAppMarksDel: + if err := nftDelCgroupElement(setName, cgID); err != nil { + writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ + OK: false, + Op: string(op), + Target: target, + Cgroup: cgroup, + CgroupID: cgID, + Message: err.Error(), + }) + return + } + appendTraceLine("traffic", fmt.Sprintf("appmarks del target=%s cgroup=%s id=%d", target, cgroup, cgID)) + writeJSON(w, http.StatusOK, TrafficAppMarksResponse{ + OK: true, + Op: string(op), + Target: target, + Cgroup: cgroup, + CgroupID: cgID, + Message: "deleted", + }) + case TrafficAppMarksClear: + if err := nftFlushSet(setName); 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 ensureAppMarksNft() error { + // Best-effort "ensure": ignore "exists" errors and proceed. + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", "agvpn") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", nftSetCgroupVPN, "{", "typeof", "meta", "cgroup", ";", "flags", "timeout", ";", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", nftSetCgroupDirect, "{", "typeof", "meta", "cgroup", ";", "flags", "timeout", ";", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output_apps") + + // Keep output_apps deterministic (no duplicates). Safe because this chain is dedicated. + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output_apps") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_apps", "meta", "cgroup", "@"+nftSetCgroupDirect, "meta", "mark", "set", MARK_DIRECT, "accept") + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_apps", "meta", "cgroup", "@"+nftSetCgroupVPN, "meta", "mark", "set", MARK_APP, "accept") + + // Ensure output chain has a jump into output_apps (routes-update may also manage this). + out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "list", "chain", "inet", "agvpn", "output") + if !strings.Contains(out, "jump output_apps") { + _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "jump", "output_apps") + } + return nil +} + +func resolveCgroupIDForNft(input string) (uint64, string, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return 0, "", fmt.Errorf("empty cgroup") + } + + // Allow numeric cgroup id input. + if isAllDigits(raw) { + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil || id == 0 { + return 0, raw, fmt.Errorf("invalid cgroup id: %s", raw) + } + return id, raw, nil + } + + // Normalize into a safe relative path under /sys/fs/cgroup. + rel := strings.TrimPrefix(raw, "/") + rel = filepath.Clean(rel) + if rel == "." || rel == "" { + return 0, raw, fmt.Errorf("invalid cgroup path: %s", raw) + } + if strings.HasPrefix(rel, "..") || strings.Contains(rel, "../") { + return 0, raw, fmt.Errorf("invalid cgroup path (traversal): %s", raw) + } + + full := filepath.Join(cgroupRootFS, rel) + fi, err := os.Stat(full) + if err != nil || fi == nil || !fi.IsDir() { + return 0, raw, fmt.Errorf("cgroup not found: %s", raw) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok || st == nil { + return 0, raw, fmt.Errorf("cannot stat cgroup: %s", raw) + } + if st.Ino == 0 { + return 0, raw, fmt.Errorf("invalid cgroup inode id: %s", raw) + } + // EN: For cgroup v2, the directory inode is used as cgroup id (matches meta cgroup / bpf_get_current_cgroup_id). + // RU: Для cgroup v2 inode директории используется как cgroup id (соответствует meta cgroup / bфункции bpf_get_current_cgroup_id). + return st.Ino, "/" + rel, nil +} + +func nftAddCgroupElement(setName string, cgroupID uint64, timeoutSec int) error { + if strings.TrimSpace(setName) == "" { + return fmt.Errorf("empty setName") + } + if cgroupID == 0 { + return fmt.Errorf("invalid cgroup id") + } + if timeoutSec < 0 { + return fmt.Errorf("invalid timeout_sec") + } + + // NOTE: set has flags timeout; element can include timeout. + ttl := fmt.Sprintf("%ds", timeoutSec) + _, out, code, err := runCommandTimeout( + 5*time.Second, + "nft", "add", "element", "inet", "agvpn", setName, + "{", fmt.Sprintf("%d", cgroupID), "timeout", ttl, "}", + ) + if err != nil || code != 0 { + msg := strings.ToLower(out) + if strings.Contains(msg, "file exists") || strings.Contains(msg, "exists") { + return nil + } + if err == nil { + err = fmt.Errorf("nft add element exited with %d", code) + } + return fmt.Errorf("nft add element failed: %w", err) + } + return nil +} + +func nftDelCgroupElement(setName string, cgroupID uint64) error { + if strings.TrimSpace(setName) == "" { + return fmt.Errorf("empty setName") + } + if cgroupID == 0 { + return fmt.Errorf("invalid cgroup id") + } + _, out, code, err := runCommandTimeout( + 5*time.Second, + "nft", "delete", "element", "inet", "agvpn", setName, + "{", fmt.Sprintf("%d", cgroupID), "}", + ) + if err != nil || code != 0 { + msg := strings.ToLower(out) + if strings.Contains(msg, "no such file") || + strings.Contains(msg, "not found") || + strings.Contains(msg, "does not exist") { + return nil + } + if err == nil { + err = fmt.Errorf("nft delete element exited with %d", code) + } + return fmt.Errorf("nft delete element failed: %w", err) + } + return nil +} + +func nftFlushSet(setName string) error { + if strings.TrimSpace(setName) == "" { + return fmt.Errorf("empty setName") + } + _, out, code, err := runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", "agvpn", setName) + if err != nil || code != 0 { + msg := strings.ToLower(out) + if strings.Contains(msg, "no such file") || + strings.Contains(msg, "not found") || + strings.Contains(msg, "does not exist") { + return nil + } + if err == nil { + err = fmt.Errorf("nft flush set exited with %d", code) + } + return fmt.Errorf("nft flush set failed: %w", err) + } + return nil +} + +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 +} diff --git a/selective-vpn-api/app/traffic_mode.go b/selective-vpn-api/app/traffic_mode.go index fe79de2..88f42df 100644 --- a/selective-vpn-api/app/traffic_mode.go +++ b/selective-vpn-api/app/traffic_mode.go @@ -15,13 +15,15 @@ import ( ) const ( + trafficRulePrefMarkDirect = 11500 + trafficRulePrefMarkAppVPN = 11510 trafficRulePrefDirectSubnetStart = 11600 trafficRulePrefDirectUIDStart = 11680 trafficRulePrefVPNSubnetStart = 11720 trafficRulePrefVPNUIDStart = 11800 trafficRulePrefFull = 11900 trafficRulePrefSelective = 12000 - trafficRulePrefManagedMin = 11600 + trafficRulePrefManagedMin = 11500 trafficRulePrefManagedMax = 12099 trafficRulePerKindLimit = 70 trafficAutoLocalDefault = true @@ -828,6 +830,10 @@ func applyTrafficMode(st TrafficModeState, iface string) error { 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() + needVPNTable := st.Mode != TrafficModeDirect || len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0 if needVPNTable { if err := ensureTrafficRouteBase(iface, st.AutoLocalBypass); err != nil { @@ -839,6 +845,17 @@ func applyTrafficMode(st TrafficModeState, iface string) error { 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 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 { diff --git a/selective-vpn-api/app/types.go b/selective-vpn-api/app/types.go index 10a0048..6229ac6 100644 --- a/selective-vpn-api/app/types.go +++ b/selective-vpn-api/app/types.go @@ -185,6 +185,43 @@ type TrafficInterfacesResponse struct { IfaceReason string `json:"iface_reason,omitempty"` } +// --------------------------------------------------------------------- +// traffic app marks (per-app routing via cgroup -> fwmark) +// --------------------------------------------------------------------- + +type TrafficAppMarksOp string + +const ( + TrafficAppMarksAdd TrafficAppMarksOp = "add" + TrafficAppMarksDel TrafficAppMarksOp = "del" + TrafficAppMarksClear TrafficAppMarksOp = "clear" +) + +// EN: Runtime app marking request. Used by per-app launcher wrappers. +// RU: Runtime app marking запрос. Используется wrapper-лаунчером per-app. +type TrafficAppMarksRequest struct { + Op TrafficAppMarksOp `json:"op"` + Target string `json:"target"` // vpn|direct + Cgroup string `json:"cgroup,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` // only for add +} + +type TrafficAppMarksResponse struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` + Op string `json:"op,omitempty"` + Target string `json:"target,omitempty"` + Cgroup string `json:"cgroup,omitempty"` + CgroupID uint64 `json:"cgroup_id,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` +} + +type TrafficAppMarksStatusResponse struct { + VPNCount int `json:"vpn_count"` + DirectCount int `json:"direct_count"` + Message string `json:"message,omitempty"` +} + type SystemdState struct { State string `json:"state"` } diff --git a/selective-vpn-gui/api_client.py b/selective-vpn-gui/api_client.py index ac50f92..c8a861f 100644 --- a/selective-vpn-gui/api_client.py +++ b/selective-vpn-gui/api_client.py @@ -120,6 +120,24 @@ class TrafficInterfaces: iface_reason: str +@dataclass(frozen=True) +class TrafficAppMarksStatus: + vpn_count: int + direct_count: int + message: str + + +@dataclass(frozen=True) +class TrafficAppMarksResult: + ok: bool + message: str + op: str = "" + target: str = "" + cgroup: str = "" + cgroup_id: int = 0 + timeout_sec: int = 0 + + @dataclass(frozen=True) class TrafficCandidateSubnet: @@ -790,6 +808,49 @@ class ApiClient: uids=uids, ) + def traffic_appmarks_status(self) -> TrafficAppMarksStatus: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/traffic/appmarks")) or {}, + ) + return TrafficAppMarksStatus( + vpn_count=int(data.get("vpn_count", 0) or 0), + direct_count=int(data.get("direct_count", 0) or 0), + message=str(data.get("message") or ""), + ) + + def traffic_appmarks_apply( + self, + *, + op: str, + target: str, + cgroup: str = "", + timeout_sec: int = 0, + ) -> TrafficAppMarksResult: + payload: Dict[str, Any] = { + "op": str(op or "").strip().lower(), + "target": str(target or "").strip().lower(), + } + if cgroup: + payload["cgroup"] = str(cgroup).strip() + if int(timeout_sec or 0) > 0: + payload["timeout_sec"] = int(timeout_sec) + + data = cast( + Dict[str, Any], + self._json(self._request("POST", "/api/v1/traffic/appmarks", json_body=payload)) + or {}, + ) + return TrafficAppMarksResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or ""), + op=str(data.get("op") or payload["op"]), + target=str(data.get("target") or payload["target"]), + cgroup=str(data.get("cgroup") or payload.get("cgroup") or ""), + cgroup_id=int(data.get("cgroup_id", 0) or 0), + timeout_sec=int(data.get("timeout_sec", 0) or 0), + ) + # DNS / SmartDNS def dns_upstreams_get(self) -> DnsUpstreams: diff --git a/selective-vpn-gui/app_route_dialog.py b/selective-vpn-gui/app_route_dialog.py new file mode 100644 index 0000000..7289753 --- /dev/null +++ b/selective-vpn-gui/app_route_dialog.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import shlex +import subprocess +import time +from dataclasses import dataclass +from typing import Callable, Optional + +from PySide6 import QtCore +from PySide6.QtWidgets import ( + QButtonGroup, + QDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPlainTextEdit, + QPushButton, + QRadioButton, + QSpinBox, + QVBoxLayout, +) + +from dashboard_controller import DashboardController + + +@dataclass(frozen=True) +class RunScopeResult: + ok: bool + unit: str = "" + cgroup: str = "" + message: str = "" + stdout: str = "" + + +class AppRouteDialog(QDialog): + """ + EN: Launch an app inside a systemd --user scope and register its cgroup id in backend nftsets. + RU: Запускает приложение в systemd --user scope и регистрирует его cgroup id в nftset'ах backend-а. + """ + + def __init__( + self, + controller: DashboardController, + *, + log_cb: Callable[[str], None] | None = None, + parent=None, + ) -> None: + super().__init__(parent) + self.ctrl = controller + self.log_cb = log_cb + + self.setWindowTitle("Run app via VPN / Direct (runtime)") + self.resize(720, 520) + + root = QVBoxLayout(self) + + hint = QLabel( + "Runtime per-app routing (Wayland-friendly):\n" + "- Launch uses systemd-run --user --scope.\n" + "- Backend adds the scope cgroup into nftset -> fwmark rules.\n" + "- Marks are temporary (TTL). Use Traffic overrides for persistent policy." + ) + hint.setWordWrap(True) + hint.setStyleSheet("color: gray;") + root.addWidget(hint) + + grp = QGroupBox("Run") + gl = QVBoxLayout(grp) + + row_cmd = QHBoxLayout() + row_cmd.addWidget(QLabel("Command")) + self.ed_cmd = QLineEdit() + self.ed_cmd.setPlaceholderText("e.g. firefox --private-window https://example.com") + self.ed_cmd.setToolTip( + "EN: Command line to run. This runs as current user in a systemd --user scope.\n" + "RU: Команда запуска. Запускается от текущего пользователя в systemd --user scope." + ) + row_cmd.addWidget(self.ed_cmd, stretch=1) + gl.addLayout(row_cmd) + + row_target = QHBoxLayout() + row_target.addWidget(QLabel("Route via")) + + self.rad_vpn = QRadioButton("VPN") + self.rad_vpn.setToolTip( + "EN: Force this app traffic via VPN policy table (agvpn).\n" + "RU: Форсировать трафик приложения через VPN policy-table (agvpn)." + ) + self.rad_direct = QRadioButton("Direct") + self.rad_direct.setToolTip( + "EN: Force this app traffic to bypass VPN (lookup main), even in full tunnel.\n" + "RU: Форсировать трафик приложения мимо VPN (lookup main), даже в full tunnel." + ) + + bg = QButtonGroup(self) + bg.addButton(self.rad_vpn) + bg.addButton(self.rad_direct) + self.rad_vpn.setChecked(True) + row_target.addWidget(self.rad_vpn) + row_target.addWidget(self.rad_direct) + row_target.addStretch(1) + + row_ttl = QHBoxLayout() + row_ttl.addWidget(QLabel("TTL (hours)")) + self.spn_ttl = QSpinBox() + self.spn_ttl.setRange(1, 24 * 30) # up to ~30 days + self.spn_ttl.setValue(24) + self.spn_ttl.setToolTip( + "EN: How long the runtime mark stays active (backend nftset element timeout).\n" + "RU: Сколько живет runtime-метка (timeout элемента в nftset)." + ) + row_ttl.addWidget(self.spn_ttl) + row_ttl.addStretch(1) + + gl.addLayout(row_target) + gl.addLayout(row_ttl) + + row_btn = QHBoxLayout() + self.btn_run = QPushButton("Run in scope + apply mark") + self.btn_run.clicked.connect(self.on_run_clicked) + row_btn.addWidget(self.btn_run) + self.btn_refresh = QPushButton("Refresh counts") + self.btn_refresh.clicked.connect(self.on_refresh_counts) + row_btn.addWidget(self.btn_refresh) + row_btn.addStretch(1) + gl.addLayout(row_btn) + + self.lbl_counts = QLabel("Marks: —") + self.lbl_counts.setStyleSheet("color: gray;") + gl.addWidget(self.lbl_counts) + + root.addWidget(grp) + + self.txt = QPlainTextEdit() + self.txt.setReadOnly(True) + root.addWidget(self.txt, stretch=1) + + row_bottom = QHBoxLayout() + row_bottom.addStretch(1) + btn_close = QPushButton("Close") + btn_close.clicked.connect(self.accept) + row_bottom.addWidget(btn_close) + root.addLayout(row_bottom) + + QtCore.QTimer.singleShot(0, self.on_refresh_counts) + + def _emit_log(self, msg: str) -> None: + text = (msg or "").strip() + if not text: + return + if self.log_cb: + self.log_cb(text) + return + try: + self.ctrl.log_gui(text) + except Exception: + pass + + def _append(self, msg: str) -> None: + text = (msg or "").rstrip() + if not text: + return + self.txt.appendPlainText(text) + self._emit_log(text) + + def on_refresh_counts(self) -> None: + try: + st = self.ctrl.traffic_appmarks_status() + self.lbl_counts.setText(f"Marks: VPN={st.vpn_count}, Direct={st.direct_count}") + except Exception as e: + self.lbl_counts.setText(f"Marks: error: {e}") + + def _run_scope(self, cmdline: str, *, unit: str) -> RunScopeResult: + args = shlex.split(cmdline) + if not args: + return RunScopeResult(ok=False, message="empty command") + + # Launch the scope. + run_cmd = [ + "systemd-run", + "--user", + "--scope", + "--unit", + unit, + "--collect", + "--same-dir", + ] + args + + p = subprocess.run( + run_cmd, + capture_output=True, + text=True, + check=False, + ) + out = (p.stdout or "") + (p.stderr or "") + if p.returncode != 0: + return RunScopeResult(ok=False, unit=unit, message=f"systemd-run failed: {p.returncode}", stdout=out.strip()) + + # Get cgroup path for this unit. + p2 = subprocess.run( + ["systemctl", "--user", "show", "-p", "ControlGroup", "--value", unit], + capture_output=True, + text=True, + check=False, + ) + cg = (p2.stdout or "").strip() + out2 = (p2.stdout or "") + (p2.stderr or "") + if p2.returncode != 0 or not cg: + return RunScopeResult(ok=False, unit=unit, message="failed to query ControlGroup", stdout=(out + "\n" + out2).strip()) + + return RunScopeResult(ok=True, unit=unit, cgroup=cg, message="ok", stdout=out.strip()) + + def on_run_clicked(self) -> None: + cmdline = self.ed_cmd.text().strip() + if not cmdline: + QMessageBox.warning(self, "Missing command", "Please enter a command to run.") + return + + target = "vpn" if self.rad_vpn.isChecked() else "direct" + ttl_sec = int(self.spn_ttl.value()) * 3600 + unit = f"svpn-{target}-{int(time.time())}.scope" + + self._append(f"[app] launching: target={target} ttl={ttl_sec}s unit={unit}") + + try: + rr = self._run_scope(cmdline, unit=unit) + except Exception as e: + QMessageBox.critical(self, "Run failed", str(e)) + return + + if not rr.ok: + self._append(f"[app] ERROR: {rr.message}\n{rr.stdout}".rstrip()) + QMessageBox.critical(self, "Run failed", rr.message) + return + + self._append(f"[app] scope started: unit={rr.unit}") + self._append(f"[app] ControlGroup: {rr.cgroup}") + + try: + res = self.ctrl.traffic_appmarks_apply( + op="add", + target=target, + cgroup=rr.cgroup, + timeout_sec=ttl_sec, + ) + except Exception as e: + self._append(f"[appmarks] ERROR calling API: {e}") + QMessageBox.critical(self, "API error", str(e)) + return + + if res.ok: + self._append(f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s") + else: + self._append(f"[appmarks] ERROR: {res.message}") + QMessageBox.critical(self, "App mark error", res.message or "unknown error") + + self.on_refresh_counts() + diff --git a/selective-vpn-gui/dashboard_controller.py b/selective-vpn-gui/dashboard_controller.py index ded7fbd..91d34a3 100644 --- a/selective-vpn-gui/dashboard_controller.py +++ b/selective-vpn-gui/dashboard_controller.py @@ -32,6 +32,8 @@ from api_client import ( LoginState, Status, TrafficCandidates, + TrafficAppMarksResult, + TrafficAppMarksStatus, TrafficInterfaces, TrafficModeStatus, TraceDump, @@ -705,6 +707,23 @@ class DashboardController: def traffic_candidates(self) -> TrafficCandidates: return self.client.traffic_candidates_get() + def traffic_appmarks_status(self) -> TrafficAppMarksStatus: + return self.client.traffic_appmarks_status() + + def traffic_appmarks_apply( + self, + *, + op: str, + target: str, + cgroup: str = "", + timeout_sec: int = 0, + ) -> TrafficAppMarksResult: + return self.client.traffic_appmarks_apply( + op=op, + target=target, + cgroup=cgroup, + timeout_sec=timeout_sec, + ) def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView: """ diff --git a/selective-vpn-gui/vpn_dashboard_qt.py b/selective-vpn-gui/vpn_dashboard_qt.py index 4fef265..3bf2676 100755 --- a/selective-vpn-gui/vpn_dashboard_qt.py +++ b/selective-vpn-gui/vpn_dashboard_qt.py @@ -36,6 +36,7 @@ from PySide6.QtWidgets import ( from api_client import ApiClient, DnsUpstreams from dashboard_controller import DashboardController, TraceMode +from app_route_dialog import AppRouteDialog from traffic_mode_dialog import TrafficModeDialog _NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s") @@ -352,6 +353,13 @@ class MainWindow(QMainWindow): self.btn_traffic_settings = QPushButton("Open traffic settings") self.btn_traffic_settings.clicked.connect(self.on_open_traffic_settings) relay_row.addWidget(self.btn_traffic_settings) + self.btn_app_route = QPushButton("Run app via VPN/Direct") + self.btn_app_route.setToolTip( + "EN: Launch an app in a systemd --user scope and apply a temporary per-app routing mark (Wayland-friendly).\n" + "RU: Запуск приложения в systemd --user scope + временная per-app метка маршрутизации." + ) + self.btn_app_route.clicked.connect(self.on_open_app_route) + relay_row.addWidget(self.btn_app_route) self.btn_traffic_test = QPushButton("Test mode") self.btn_traffic_test.clicked.connect(self.on_test_traffic_mode) relay_row.addWidget(self.btn_traffic_test) @@ -1343,6 +1351,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и refresh_all_traffic() self._safe(work, title="Traffic mode dialog error") + def on_open_app_route(self) -> None: + def work(): + dlg = AppRouteDialog( + self.ctrl, + log_cb=self._append_routes_log, + parent=self, + ) + dlg.exec() + self.refresh_routes_tab() + self.refresh_status_tab() + self._safe(work, title="App route dialog error") + def on_test_traffic_mode(self) -> None: def work(): view = self.ctrl.traffic_mode_test()