Harden resolver and expand traffic runtime controls

This commit is contained in:
beckline
2026-02-24 00:17:46 +03:00
parent 89eaaf3f23
commit 50518a641d
18 changed files with 2048 additions and 181 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/netip"
"os"
"path/filepath"
"sort"
@@ -31,8 +32,11 @@ import (
const (
appMarksTable = "agvpn"
appMarksChain = "output_apps"
appMarksGuardChain = "output_guard"
appMarksLocalBypassSet = "svpn_local4"
appMarkCommentPrefix = "svpn_appmark"
defaultAppMarkTTLSeconds = 24 * 60 * 60
appGuardCommentPrefix = "svpn_appguard"
defaultAppMarkTTLSeconds = 0 // 0 = persistent until explicit unmark/clear
)
var appMarksMu sync.Mutex
@@ -129,9 +133,6 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
}
ttl := timeoutSec
if ttl == 0 {
ttl = defaultAppMarkTTLSeconds
}
rel, level, inodeID, cgAbs, err := resolveCgroupV2PathForNft(cgroup)
if err != nil {
@@ -145,6 +146,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
return
}
vpnIface := ""
if target == "vpn" {
traffic := loadTrafficModeState()
iface, _ := resolveTrafficIface(traffic.PreferredIface)
@@ -159,6 +161,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
})
return
}
vpnIface = strings.TrimSpace(iface)
if err := ensureTrafficRouteBase(iface, traffic.AutoLocalBypass); err != nil {
writeJSON(w, http.StatusOK, TrafficAppMarksResponse{
OK: false,
@@ -172,7 +175,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
}
}
if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl); err != nil {
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),
@@ -253,11 +256,16 @@ func handleTrafficAppMarksItems(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
items := make([]TrafficAppMarkItemView, 0, len(st.Items))
for _, it := range st.Items {
rem := 0
exp, err := time.Parse(time.RFC3339, strings.TrimSpace(it.ExpiresAt))
if err == nil {
rem = int(exp.Sub(now).Seconds())
if rem < 0 {
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
}
}
@@ -308,7 +316,7 @@ func appMarksGetStatus() (vpnCount int, directCount int) {
return vpnCount, directCount
}
func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int) error {
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")
@@ -333,30 +341,51 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
command = strings.TrimSpace(command)
appKey = canonicalizeAppKey(appKey, command)
// EN: Avoid unbounded growth of marks for the same app.
// RU: Не даём бесконечно плодить метки на одно и то же приложение.
if appKey != "" {
kept := st.Items[:0]
for _, it := range st.Items {
if strings.ToLower(strings.TrimSpace(it.Target)) == target &&
strings.TrimSpace(it.AppKey) == appKey &&
it.ID != id {
_ = nftDeleteAppMarkRule(target, it.ID)
changed = true
continue
}
kept = append(kept, it)
// 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
}
st.Items = kept
// 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); err != nil {
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,
@@ -367,13 +396,15 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
Command: command,
AppKey: appKey,
AddedAt: now.Format(time.RFC3339),
ExpiresAt: now.Add(time.Duration(ttlSec) * time.Second).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
}
}
@@ -479,7 +510,9 @@ 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) {
@@ -514,7 +547,102 @@ func appMarkComment(target string, id uint64) string {
return fmt.Sprintf("%s:%s:%d", appMarkCommentPrefix, target, id)
}
func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error {
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
@@ -527,6 +655,58 @@ func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error
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,
@@ -539,27 +719,71 @@ func nftInsertAppMarkRule(target string, rel string, level int, id uint64) error
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 {
comment := appMarkComment(target, id)
out, _, _, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain)
for _, line := range strings.Split(out, "\n") {
if !strings.Contains(line, comment) {
continue
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))
}
h := parseNftHandle(line)
if h <= 0 {
continue
}
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "delete", "rule", "inet", appMarksTable, appMarksChain, "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++ {
@@ -638,8 +862,20 @@ func pruneExpiredAppMarksLocked(st *appMarksState, now time.Time) (changed bool)
}
kept := st.Items[:0]
for _, it := range st.Items {
exp, err := time.Parse(time.RFC3339, strings.TrimSpace(it.ExpiresAt))
if err != nil || !exp.After(now) {
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
@@ -662,6 +898,116 @@ func upsertAppMarkItem(items []appMarkItem, next appMarkItem) []appMarkItem {
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)
@@ -679,18 +1025,88 @@ func loadAppMarksState() appMarksState {
// 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)