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