ui+api: dedup per-app marks by app_key; auto-refresh runtime

This commit is contained in:
beckline
2026-02-15 16:31:19 +03:00
parent b77adb153a
commit 70c5eea935
5 changed files with 206 additions and 16 deletions

View File

@@ -48,6 +48,9 @@ type appMarkItem struct {
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"`
}
@@ -74,6 +77,9 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
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 == "" {
@@ -165,7 +171,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
}
}
if err := appMarksAdd(target, inodeID, cgAbs, rel, level, ttl); err != nil {
if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl); err != nil {
writeJSON(w, http.StatusOK, TrafficAppMarksResponse{
OK: false,
Op: string(op),
@@ -250,7 +256,7 @@ func appMarksGetStatus() (vpnCount int, directCount int) {
return vpnCount, directCount
}
func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, ttlSec int) error {
func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int) error {
target = strings.ToLower(strings.TrimSpace(target))
if target != "vpn" && target != "direct" {
return fmt.Errorf("invalid target")
@@ -271,6 +277,27 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
st := loadAppMarksState()
changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC())
unit = strings.TrimSpace(unit)
command = strings.TrimSpace(command)
appKey = normalizeAppKey(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)
}
st.Items = kept
}
// Replace any existing rule/state for this (target,id).
_ = nftDeleteAppMarkRule(target, id)
if err := nftInsertAppMarkRule(target, rel, level, id); err != nil {
@@ -284,6 +311,9 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
Cgroup: cgAbs,
CgroupRel: rel,
Level: level,
Unit: unit,
Command: command,
AppKey: appKey,
AddedAt: now.Format(time.RFC3339),
ExpiresAt: now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339),
}
@@ -613,6 +643,22 @@ func saveAppMarksState(st appMarksState) error {
return os.Rename(tmp, trafficAppMarksPath)
}
func normalizeAppKey(appKey string, command string) string {
key := strings.TrimSpace(appKey)
if key != "" {
return key
}
cmd := strings.TrimSpace(command)
if cmd == "" {
return ""
}
fields := strings.Fields(cmd)
if len(fields) > 0 {
return strings.TrimSpace(fields[0])
}
return cmd
}
func isAllDigits(s string) bool {
s = strings.TrimSpace(s)
if s == "" {

View File

@@ -200,10 +200,15 @@ const (
// 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
Op TrafficAppMarksOp `json:"op"`
Target string `json:"target"` // vpn|direct
Cgroup string `json:"cgroup,omitempty"`
// EN: Optional metadata to deduplicate marks per-app across restarts / re-runs.
// RU: Опциональные метаданные, чтобы не плодить метки на одно и то же приложение.
Unit string `json:"unit,omitempty"`
Command string `json:"command,omitempty"`
AppKey string `json:"app_key,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add
}
type TrafficAppMarksResponse struct {