traffic: add per-app runtime app routing via cgroup marks

This commit is contained in:
beckline
2026-02-14 16:58:30 +03:00
parent 1fec4a51da
commit 90907219dc
10 changed files with 819 additions and 7 deletions

View File

@@ -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"

View File

@@ -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")

View File

@@ -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)

View 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
}

View File

@@ -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 {

View File

@@ -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"`
}