traffic: audit endpoint + canonical app_key
This commit is contained in:
@@ -152,6 +152,8 @@ func Run() {
|
|||||||
mux.HandleFunc("/api/v1/traffic/appmarks/items", handleTrafficAppMarksItems)
|
mux.HandleFunc("/api/v1/traffic/appmarks/items", handleTrafficAppMarksItems)
|
||||||
// persistent app profiles (saved launch configs)
|
// persistent app profiles (saved launch configs)
|
||||||
mux.HandleFunc("/api/v1/traffic/app-profiles", handleTrafficAppProfiles)
|
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
|
// trace: хвост + JSON + append для GUI
|
||||||
mux.HandleFunc("/api/v1/trace", handleTraceTailPlain)
|
mux.HandleFunc("/api/v1/trace", handleTraceTailPlain)
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func upsertTrafficAppProfile(req TrafficAppProfileUpsertRequest) (TrafficAppProf
|
|||||||
appKey = strings.TrimSpace(fields[0])
|
appKey = strings.TrimSpace(fields[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
appKey = canonicalizeAppKey(appKey, cmd)
|
||||||
if appKey == "" {
|
if appKey == "" {
|
||||||
return TrafficAppProfile{}, fmt.Errorf("cannot infer app_key")
|
return TrafficAppProfile{}, fmt.Errorf("cannot infer app_key")
|
||||||
}
|
}
|
||||||
@@ -284,6 +285,20 @@ func loadTrafficAppProfilesState() trafficAppProfilesState {
|
|||||||
if st.Profiles == nil {
|
if st.Profiles == nil {
|
||||||
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
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
selective-vpn-api/app/traffic_appkey.go
Normal file
134
selective-vpn-api/app/traffic_appkey.go
Normal file
@@ -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 <desktop-id>
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
@@ -331,7 +331,7 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
|
|||||||
|
|
||||||
unit = strings.TrimSpace(unit)
|
unit = strings.TrimSpace(unit)
|
||||||
command = strings.TrimSpace(command)
|
command = strings.TrimSpace(command)
|
||||||
appKey = normalizeAppKey(appKey, command)
|
appKey = canonicalizeAppKey(appKey, command)
|
||||||
|
|
||||||
// EN: Avoid unbounded growth of marks for the same app.
|
// EN: Avoid unbounded growth of marks for the same app.
|
||||||
// RU: Не даём бесконечно плодить метки на одно и то же приложение.
|
// RU: Не даём бесконечно плодить метки на одно и то же приложение.
|
||||||
@@ -674,6 +674,20 @@ func loadAppMarksState() appMarksState {
|
|||||||
if st.Version == 0 {
|
if st.Version == 0 {
|
||||||
st.Version = 1
|
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
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,22 +709,6 @@ func saveAppMarksState(st appMarksState) error {
|
|||||||
return os.Rename(tmp, trafficAppMarksPath)
|
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 {
|
func isAllDigits(s string) bool {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
|
|||||||
288
selective-vpn-api/app/traffic_audit.go
Normal file
288
selective-vpn-api/app/traffic_audit.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user