traffic: add per-app runtime app routing via cgroup marks
This commit is contained in:
@@ -58,6 +58,10 @@ const (
|
|||||||
heartbeatFile = stateDir + "/heartbeat"
|
heartbeatFile = stateDir + "/heartbeat"
|
||||||
lockFile = "/run/lock/selective-vpn.lock"
|
lockFile = "/run/lock/selective-vpn.lock"
|
||||||
MARK = "0x66"
|
MARK = "0x66"
|
||||||
|
// 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"
|
defaultDNS1 = "94.140.14.14"
|
||||||
defaultDNS2 = "94.140.15.15"
|
defaultDNS2 = "94.140.15.15"
|
||||||
defaultMeta1 = "46.243.231.30"
|
defaultMeta1 = "46.243.231.30"
|
||||||
|
|||||||
@@ -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", "agvpn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}")
|
||||||
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn_dyn4", "{", "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", "{", "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", "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", "jump", "output_apps")
|
||||||
_, _, _, _ = 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_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", "add", "chain", "inet", "agvpn", "prerouting", "{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}")
|
||||||
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "prerouting")
|
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "prerouting")
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ func Run() {
|
|||||||
mux.HandleFunc("/api/v1/traffic/mode/test", handleTrafficModeTest)
|
mux.HandleFunc("/api/v1/traffic/mode/test", handleTrafficModeTest)
|
||||||
mux.HandleFunc("/api/v1/traffic/interfaces", handleTrafficInterfaces)
|
mux.HandleFunc("/api/v1/traffic/interfaces", handleTrafficInterfaces)
|
||||||
mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates)
|
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
|
// trace: хвост + JSON + append для GUI
|
||||||
mux.HandleFunc("/api/v1/trace", handleTraceTailPlain)
|
mux.HandleFunc("/api/v1/trace", handleTraceTailPlain)
|
||||||
|
|||||||
368
selective-vpn-api/app/traffic_appmarks.go
Normal file
368
selective-vpn-api/app/traffic_appmarks.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -15,13 +15,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
trafficRulePrefMarkDirect = 11500
|
||||||
|
trafficRulePrefMarkAppVPN = 11510
|
||||||
trafficRulePrefDirectSubnetStart = 11600
|
trafficRulePrefDirectSubnetStart = 11600
|
||||||
trafficRulePrefDirectUIDStart = 11680
|
trafficRulePrefDirectUIDStart = 11680
|
||||||
trafficRulePrefVPNSubnetStart = 11720
|
trafficRulePrefVPNSubnetStart = 11720
|
||||||
trafficRulePrefVPNUIDStart = 11800
|
trafficRulePrefVPNUIDStart = 11800
|
||||||
trafficRulePrefFull = 11900
|
trafficRulePrefFull = 11900
|
||||||
trafficRulePrefSelective = 12000
|
trafficRulePrefSelective = 12000
|
||||||
trafficRulePrefManagedMin = 11600
|
trafficRulePrefManagedMin = 11500
|
||||||
trafficRulePrefManagedMax = 12099
|
trafficRulePrefManagedMax = 12099
|
||||||
trafficRulePerKindLimit = 70
|
trafficRulePerKindLimit = 70
|
||||||
trafficAutoLocalDefault = true
|
trafficAutoLocalDefault = true
|
||||||
@@ -828,6 +830,10 @@ func applyTrafficMode(st TrafficModeState, iface string) error {
|
|||||||
|
|
||||||
removeTrafficRulesForTable()
|
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
|
needVPNTable := st.Mode != TrafficModeDirect || len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0
|
||||||
if needVPNTable {
|
if needVPNTable {
|
||||||
if err := ensureTrafficRouteBase(iface, st.AutoLocalBypass); err != nil {
|
if err := ensureTrafficRouteBase(iface, st.AutoLocalBypass); err != nil {
|
||||||
@@ -839,6 +845,17 @@ func applyTrafficMode(st TrafficModeState, iface string) error {
|
|||||||
return err
|
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 {
|
switch st.Mode {
|
||||||
case TrafficModeFullTunnel:
|
case TrafficModeFullTunnel:
|
||||||
if err := applyRule(trafficRulePrefFull, "lookup", routesTableName()); err != nil {
|
if err := applyRule(trafficRulePrefFull, "lookup", routesTableName()); err != nil {
|
||||||
|
|||||||
@@ -185,6 +185,43 @@ type TrafficInterfacesResponse struct {
|
|||||||
IfaceReason string `json:"iface_reason,omitempty"`
|
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 {
|
type SystemdState struct {
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,24 @@ class TrafficInterfaces:
|
|||||||
iface_reason: str
|
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)
|
@dataclass(frozen=True)
|
||||||
class TrafficCandidateSubnet:
|
class TrafficCandidateSubnet:
|
||||||
@@ -790,6 +808,49 @@ class ApiClient:
|
|||||||
uids=uids,
|
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
|
# DNS / SmartDNS
|
||||||
def dns_upstreams_get(self) -> DnsUpstreams:
|
def dns_upstreams_get(self) -> DnsUpstreams:
|
||||||
|
|||||||
261
selective-vpn-gui/app_route_dialog.py
Normal file
261
selective-vpn-gui/app_route_dialog.py
Normal file
@@ -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()
|
||||||
|
|
||||||
@@ -32,6 +32,8 @@ from api_client import (
|
|||||||
LoginState,
|
LoginState,
|
||||||
Status,
|
Status,
|
||||||
TrafficCandidates,
|
TrafficCandidates,
|
||||||
|
TrafficAppMarksResult,
|
||||||
|
TrafficAppMarksStatus,
|
||||||
TrafficInterfaces,
|
TrafficInterfaces,
|
||||||
TrafficModeStatus,
|
TrafficModeStatus,
|
||||||
TraceDump,
|
TraceDump,
|
||||||
@@ -705,6 +707,23 @@ class DashboardController:
|
|||||||
def traffic_candidates(self) -> TrafficCandidates:
|
def traffic_candidates(self) -> TrafficCandidates:
|
||||||
return self.client.traffic_candidates_get()
|
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:
|
def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from api_client import ApiClient, DnsUpstreams
|
from api_client import ApiClient, DnsUpstreams
|
||||||
from dashboard_controller import DashboardController, TraceMode
|
from dashboard_controller import DashboardController, TraceMode
|
||||||
|
from app_route_dialog import AppRouteDialog
|
||||||
from traffic_mode_dialog import TrafficModeDialog
|
from traffic_mode_dialog import TrafficModeDialog
|
||||||
|
|
||||||
_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s")
|
_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 = QPushButton("Open traffic settings")
|
||||||
self.btn_traffic_settings.clicked.connect(self.on_open_traffic_settings)
|
self.btn_traffic_settings.clicked.connect(self.on_open_traffic_settings)
|
||||||
relay_row.addWidget(self.btn_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 = QPushButton("Test mode")
|
||||||
self.btn_traffic_test.clicked.connect(self.on_test_traffic_mode)
|
self.btn_traffic_test.clicked.connect(self.on_test_traffic_mode)
|
||||||
relay_row.addWidget(self.btn_traffic_test)
|
relay_row.addWidget(self.btn_traffic_test)
|
||||||
@@ -1343,6 +1351,18 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
|
|||||||
refresh_all_traffic()
|
refresh_all_traffic()
|
||||||
self._safe(work, title="Traffic mode dialog error")
|
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 on_test_traffic_mode(self) -> None:
|
||||||
def work():
|
def work():
|
||||||
view = self.ctrl.traffic_mode_test()
|
view = self.ctrl.traffic_mode_test()
|
||||||
|
|||||||
Reference in New Issue
Block a user