Harden resolver and expand traffic runtime controls
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user