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() }