diff --git a/selective-vpn-api/app/server.go b/selective-vpn-api/app/server.go index 0a634ec..14ffff5 100644 --- a/selective-vpn-api/app/server.go +++ b/selective-vpn-api/app/server.go @@ -152,6 +152,8 @@ func Run() { mux.HandleFunc("/api/v1/traffic/appmarks/items", handleTrafficAppMarksItems) // persistent app profiles (saved launch configs) mux.HandleFunc("/api/v1/traffic/app-profiles", handleTrafficAppProfiles) + // traffic audit (sanity checks / duplicates / nft consistency) + mux.HandleFunc("/api/v1/traffic/audit", handleTrafficAudit) // trace: хвост + JSON + append для GUI mux.HandleFunc("/api/v1/trace", handleTraceTailPlain) diff --git a/selective-vpn-api/app/traffic_app_profiles.go b/selective-vpn-api/app/traffic_app_profiles.go index 7ae847f..5045841 100644 --- a/selective-vpn-api/app/traffic_app_profiles.go +++ b/selective-vpn-api/app/traffic_app_profiles.go @@ -113,6 +113,7 @@ func upsertTrafficAppProfile(req TrafficAppProfileUpsertRequest) (TrafficAppProf appKey = strings.TrimSpace(fields[0]) } } + appKey = canonicalizeAppKey(appKey, cmd) if appKey == "" { return TrafficAppProfile{}, fmt.Errorf("cannot infer app_key") } @@ -284,6 +285,20 @@ func loadTrafficAppProfilesState() trafficAppProfilesState { if st.Profiles == nil { st.Profiles = nil } + + // EN: Best-effort migration: normalize app keys to canonical form. + // RU: Best-effort миграция: нормализуем app_key в канонический вид. + changed := false + for i := range st.Profiles { + canon := canonicalizeAppKey(st.Profiles[i].AppKey, st.Profiles[i].Command) + if canon != "" && strings.TrimSpace(st.Profiles[i].AppKey) != canon { + st.Profiles[i].AppKey = canon + changed = true + } + } + if changed { + _ = saveTrafficAppProfilesState(st) + } return st } diff --git a/selective-vpn-api/app/traffic_appkey.go b/selective-vpn-api/app/traffic_appkey.go new file mode 100644 index 0000000..8e0ca3b --- /dev/null +++ b/selective-vpn-api/app/traffic_appkey.go @@ -0,0 +1,134 @@ +package app + +import ( + "path/filepath" + "strings" +) + +// --------------------------------------------------------------------- +// traffic app key normalization +// --------------------------------------------------------------------- +// +// EN: app_key is used as a stable per-app identity for: +// EN: - deduplicating runtime marks (avoid unbounded growth) +// EN: - matching profiles <-> runtime marks in UI +// EN: +// EN: Raw command token[0] is not stable across launch methods: +// EN: - "/usr/bin/google-chrome-stable" vs "google-chrome-stable" +// EN: - "flatpak run org.mozilla.firefox" (token[0]="flatpak") +// EN: +// EN: We normalize app_key into a canonical form. +// RU: app_key используется как стабильный идентификатор приложения для: +// RU: - дедупликации runtime marks (не плодить бесконечно) +// RU: - сопоставления profiles <-> runtime marks в UI +// RU: +// RU: token[0] команды нестабилен для разных способов запуска: +// RU: - "/usr/bin/google-chrome-stable" vs "google-chrome-stable" +// RU: - "flatpak run org.mozilla.firefox" (token[0]="flatpak") +// RU: +// RU: Нормализуем app_key в канонический вид. + +func canonicalizeAppKey(appKey string, command string) string { + key := strings.TrimSpace(appKey) + cmd := strings.TrimSpace(command) + + fields := strings.Fields(cmd) + if len(fields) == 0 && key != "" { + fields = []string{key} + } + + primary := key + if len(fields) > 0 { + primary = fields[0] + } + primary = stripOuterQuotes(strings.TrimSpace(primary)) + if primary == "" { + return "" + } + + // Normalize common wrappers into stable identifiers. + base := strings.ToLower(filepath.Base(primary)) + // Build a cleaned field list for wrapper parsing. + clean := make([]string, 0, len(fields)) + for _, f := range fields { + f = stripOuterQuotes(strings.TrimSpace(f)) + if f == "" { + continue + } + clean = append(clean, f) + } + + switch base { + case "flatpak": + if id := extractRunTarget(clean); id != "" { + return "flatpak:" + id + } + return "flatpak" + case "snap": + if name := extractRunTarget(clean); name != "" { + return "snap:" + name + } + return "snap" + case "gtk-launch": + // gtk-launch + if len(clean) >= 2 { + id := strings.TrimSpace(clean[1]) + if id != "" && !strings.HasPrefix(id, "-") { + return "desktop:" + id + } + } + } + + // If it looks like a path, canonicalize to basename. + if strings.Contains(primary, "/") { + b := filepath.Base(primary) + if b != "" && b != "." && b != "/" { + return b + } + } + + return primary +} + +func stripOuterQuotes(s string) string { + in := strings.TrimSpace(s) + if len(in) >= 2 { + if (in[0] == '"' && in[len(in)-1] == '"') || (in[0] == '\'' && in[len(in)-1] == '\'') { + return strings.TrimSpace(in[1 : len(in)-1]) + } + } + return in +} + +// extractRunTarget finds the first non-flag token after "run". +// Example: flatpak run --branch=stable org.mozilla.firefox => org.mozilla.firefox +// Example: snap run chromium => chromium +func extractRunTarget(fields []string) string { + if len(fields) == 0 { + return "" + } + idx := -1 + for i := 0; i < len(fields); i++ { + if strings.TrimSpace(fields[i]) == "run" { + idx = i + break + } + } + if idx < 0 { + return "" + } + for j := idx + 1; j < len(fields); j++ { + tok := strings.TrimSpace(fields[j]) + if tok == "" { + continue + } + if tok == "--" { + continue + } + if strings.HasPrefix(tok, "-") { + continue + } + return tok + } + return "" +} diff --git a/selective-vpn-api/app/traffic_appmarks.go b/selective-vpn-api/app/traffic_appmarks.go index dbcbf01..49e5ed9 100644 --- a/selective-vpn-api/app/traffic_appmarks.go +++ b/selective-vpn-api/app/traffic_appmarks.go @@ -331,7 +331,7 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit = strings.TrimSpace(unit) command = strings.TrimSpace(command) - appKey = normalizeAppKey(appKey, command) + appKey = canonicalizeAppKey(appKey, command) // EN: Avoid unbounded growth of marks for the same app. // RU: Не даём бесконечно плодить метки на одно и то же приложение. @@ -674,6 +674,20 @@ func loadAppMarksState() appMarksState { 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 { + 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 changed { + _ = saveAppMarksState(st) + } return st } @@ -695,22 +709,6 @@ 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 == "" { diff --git a/selective-vpn-api/app/traffic_audit.go b/selective-vpn-api/app/traffic_audit.go new file mode 100644 index 0000000..d26a189 --- /dev/null +++ b/selective-vpn-api/app/traffic_audit.go @@ -0,0 +1,288 @@ +package app + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------- +// traffic audit (sanity checks / duplicates / nft consistency) +// --------------------------------------------------------------------- +// +// EN: Provides a pragmatic sanity-check endpoint for troubleshooting. +// EN: We check: +// EN: - traffic mode health (evaluateTrafficMode) +// EN: - runtime marks duplicates (target+app_key) +// EN: - nft chain consistency (state <-> output_apps rules) +// RU: Практичный sanity-check эндпоинт для диагностики. +// RU: Проверяем: +// RU: - health traffic mode (evaluateTrafficMode) +// RU: - дубли runtime marks (target+app_key) +// RU: - консистентность nft chain (state <-> output_apps rules) + +func handleTrafficAudit(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + now := time.Now().UTC().Format(time.RFC3339) + + // 1) Traffic mode status (includes route probe). + traffic := evaluateTrafficMode(loadTrafficModeState()) + + // 2) Profiles (persistent) duplicates by (target, app_key). + profiles := listTrafficAppProfiles() + profDups := findProfileDuplicates(profiles) + + // 3) Runtime marks state + duplicates. + _ = pruneExpiredAppMarks() + appMarksMu.Lock() + marksSt := loadAppMarksState() + appMarksMu.Unlock() + + markDups := findMarkDuplicates(marksSt.Items) + + // 4) nft output_apps rules check (state <-> nft). + nftIssues, nftSummary := auditNftAppMarks(marksSt.Items) + + issues := []string{} + if !traffic.Healthy { + issues = append(issues, "traffic_mode_unhealthy: "+strings.TrimSpace(traffic.Message)) + } + for _, d := range profDups { + issues = append(issues, "profile_duplicate: "+d) + } + for _, d := range markDups { + issues = append(issues, "mark_duplicate: "+d) + } + issues = append(issues, nftIssues...) + + ok := true + for _, it := range issues { + if strings.HasPrefix(it, "traffic_mode_unhealthy:") || + strings.HasPrefix(it, "nft_error:") || + strings.HasPrefix(it, "nft_missing_rule:") { + ok = false + break + } + } + + pretty := buildTrafficAuditPretty(now, traffic, profiles, marksSt.Items, issues, nftSummary) + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": ok, + "now": now, + "message": "ok", + "pretty": pretty, + "traffic": traffic, + "counts": map[string]any{ + "profiles": len(profiles), + "marks": len(marksSt.Items), + }, + "issues": issues, + "nft": nftSummary, + }) +} + +func findProfileDuplicates(profiles []TrafficAppProfile) []string { + seen := map[string]int{} + for _, p := range profiles { + tgt := strings.ToLower(strings.TrimSpace(p.Target)) + key := strings.TrimSpace(p.AppKey) + if tgt == "" || key == "" { + continue + } + seen[tgt+"|"+key]++ + } + var out []string + for k, n := range seen { + if n > 1 { + out = append(out, fmt.Sprintf("%s x%d", k, n)) + } + } + sort.Strings(out) + return out +} + +func findMarkDuplicates(items []appMarkItem) []string { + seen := map[string]int{} + for _, it := range items { + tgt := strings.ToLower(strings.TrimSpace(it.Target)) + key := strings.TrimSpace(it.AppKey) + if tgt == "" || key == "" { + continue + } + seen[tgt+"|"+key]++ + } + var out []string + for k, n := range seen { + if n > 1 { + out = append(out, fmt.Sprintf("%s x%d", k, n)) + } + } + sort.Strings(out) + return out +} + +func auditNftAppMarks(state []appMarkItem) (issues []string, summary map[string]any) { + summary = map[string]any{ + "output_jump_ok": false, + "output_apps_ok": false, + "state_items": len(state), + "nft_rules": 0, + "missing_rules": 0, + "orphan_rules": 0, + "missing_rule_ids": []string{}, + "orphan_rule_ids": []string{}, + } + + // Check output -> jump output_apps. + outOutput, _, codeOut, errOut := runCommandTimeout(3*time.Second, "nft", "list", "chain", "inet", appMarksTable, "output") + if errOut != nil || codeOut != 0 { + issues = append(issues, "nft_error: failed to read chain output") + } else { + ok := strings.Contains(outOutput, "jump "+appMarksChain) + summary["output_jump_ok"] = ok + if !ok { + issues = append(issues, "nft_missing_jump: output -> output_apps") + } + } + + outApps, _, codeApps, errApps := runCommandTimeout(3*time.Second, "nft", "-a", "list", "chain", "inet", appMarksTable, appMarksChain) + if errApps != nil || codeApps != 0 { + issues = append(issues, "nft_error: failed to read chain output_apps") + return issues, summary + } + summary["output_apps_ok"] = true + + rules := parseAppMarkRules(outApps) + summary["nft_rules"] = len(rules) + + stateIDs := map[string]struct{}{} + for _, it := range state { + tgt := strings.ToLower(strings.TrimSpace(it.Target)) + if tgt != "vpn" && tgt != "direct" { + continue + } + if it.ID == 0 { + continue + } + stateIDs[fmt.Sprintf("%s:%d", tgt, it.ID)] = struct{}{} + } + + ruleIDs := map[string]struct{}{} + for _, k := range rules { + ruleIDs[k] = struct{}{} + } + + missing := []string{} + for k := range stateIDs { + if _, ok := ruleIDs[k]; !ok { + missing = append(missing, k) + } + } + orphan := []string{} + for k := range ruleIDs { + if _, ok := stateIDs[k]; !ok { + orphan = append(orphan, k) + } + } + sort.Strings(missing) + sort.Strings(orphan) + + summary["missing_rules"] = len(missing) + summary["orphan_rules"] = len(orphan) + summary["missing_rule_ids"] = missing + summary["orphan_rule_ids"] = orphan + + for _, k := range missing { + issues = append(issues, "nft_missing_rule: "+k) + } + for _, k := range orphan { + issues = append(issues, "nft_orphan_rule: "+k) + } + + return issues, summary +} + +// parseAppMarkRules extracts "target:id" keys from output_apps chain dump. +func parseAppMarkRules(out string) []string { + var keys []string + for _, line := range strings.Split(out, "\n") { + // comment "svpn_appmark:vpn:123" + i := strings.Index(line, appMarkCommentPrefix+":") + if i < 0 { + continue + } + rest := line[i:] + end := len(rest) + for j := 0; j < len(rest); j++ { + ch := rest[j] + if ch == '"' || ch == ' ' || ch == '\t' { + end = j + break + } + } + tag := rest[:end] + parts := strings.Split(tag, ":") + if len(parts) != 3 { + continue + } + tgt := strings.ToLower(strings.TrimSpace(parts[1])) + idRaw := strings.TrimSpace(parts[2]) + if tgt != "vpn" && tgt != "direct" { + continue + } + id, err := strconv.ParseUint(idRaw, 10, 64) + if err != nil || id == 0 { + continue + } + keys = append(keys, fmt.Sprintf("%s:%d", tgt, id)) + } + sort.Strings(keys) + // Dedup. + outKeys := keys[:0] + var last string + for _, k := range keys { + if k == last { + continue + } + outKeys = append(outKeys, k) + last = k + } + return outKeys +} + +func buildTrafficAuditPretty(now string, traffic TrafficModeStatusResponse, profiles []TrafficAppProfile, marks []appMarkItem, issues []string, nft map[string]any) string { + var b strings.Builder + b.WriteString("Traffic audit\n") + b.WriteString("now=" + now + "\n\n") + + b.WriteString("traffic: desired=" + string(traffic.DesiredMode) + " applied=" + string(traffic.AppliedMode) + " iface=" + strings.TrimSpace(traffic.ActiveIface) + " healthy=" + strconv.FormatBool(traffic.Healthy) + "\n") + if strings.TrimSpace(traffic.Message) != "" { + b.WriteString("traffic_message: " + strings.TrimSpace(traffic.Message) + "\n") + } + b.WriteString("\n") + + b.WriteString(fmt.Sprintf("profiles=%d marks=%d\n", len(profiles), len(marks))) + if nft != nil { + b.WriteString(fmt.Sprintf("nft: rules=%v missing=%v orphan=%v jump_ok=%v\n", + nft["nft_rules"], nft["missing_rules"], nft["orphan_rules"], nft["output_jump_ok"])) + } + b.WriteString("\n") + + if len(issues) == 0 { + b.WriteString("issues: none\n") + return b.String() + } + b.WriteString("issues:\n") + for _, it := range issues { + b.WriteString("- " + it + "\n") + } + return b.String() +}