From 84275d0fe41f5f8b0f7cedcd00704bb9d1d90ab0 Mon Sep 17 00:00:00 2001 From: Crimson Date: Thu, 6 Aug 2026 18:41:39 +0500 Subject: [PATCH] status: deduplicate the healthy and degraded paths, keep the System block contiguous print_status and print_degraded_sections carried four byte-identical blocks - host firewall (25 lines), anti-torrent (24), last actions (9) and recent events (10) - plus a Conntrack block that was the same logic assembled differently and a Traffic desired block of four printlns. Roughly 80 lines of copy-paste with no test covering either copy, so a format change applied to one path would have silently diverged from the other. The divergence would only ever be visible while the container is down, which is the worst moment to discover the diagnostic output is wrong. Every shared section is now one function called from both paths: print_traffic_desired_section, print_host_firewall_section, print_antitorrent_section, print_conntrack_section, print_last_actions_section, print_recent_events_section. The file already had this shape for print_swap_status and print_reboot_section; the pattern is now followed consistently. The Docker/Amnezia port path block keeps two variants because they genuinely differ - real values against unknown - but the shared FORWARD ownership note is a single constant. The runtime state paths were also duplicated across modules: status.rs re-declared the literals that daemon.rs already owns. daemon::LAST_STATE_FILE and daemon::EVENTS_FILE are now pub and reused, and the recent-events window is a named constant instead of a bare 10. Ordering fix in the same function: print_network_tuning_status ran between the service line and the container lines, so container/container_id/pid/netns/interface appeared under the "Network tuning:" header at the same two-space indent as its fields. Container identity is now printed inside the discovery match, before any other section opens, in both the healthy and degraded paths. Field names are unchanged; only the order of two blocks moved. IMPLEMENTATION_SPEC section 15 documented the old order and is updated. print_degraded_sections returned anyhow::Result<()> while never failing; it returns unit now. status.rs: 645 -> 602 lines. cargo test --workspace: 110 + 4 passed. clippy -D warnings clean. --- docs/IMPLEMENTATION_SPEC.md | 12 +- traffic_core/src/daemon.rs | 4 +- traffic_core/src/status.rs | 287 +++++++++++++++--------------------- 3 files changed, 131 insertions(+), 172 deletions(-) diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md index dd85a7a..c8a8142 100644 --- a/docs/IMPLEMENTATION_SPEC.md +++ b/docs/IMPLEMENTATION_SPEC.md @@ -451,14 +451,14 @@ Status обязан показывать наличие таблицы/chain/п ```text System: service: active|inactive|unknown -Network tuning: - enabled, apply_on_daemon_start, tcp_bbr_mode, conf_path, modules_load_path, - значения управляемых ключей, bbr_available, bbr_active, drift[, reason][, warning] container: container_id: pid: netns: /proc//ns/net interface: +Network tuning: + enabled, apply_on_daemon_start, tcp_bbr_mode, conf_path, modules_load_path, + значения управляемых ключей, bbr_available, bbr_active, drift[, reason][, warning] Peers: total: N - @@ -493,9 +493,11 @@ Reboot scheduler: enabled, window_start_utc, window_end_utc, service, planned ``` -Блок `Network tuning` печатается между строкой `service` и строками `container`/`pid`/`netns`/`interface`, хотя последние логически относятся к `System`. Это текущее фактическое поведение; порядок зафиксирован здесь, чтобы автоматические проверки опирались на реальность. +Блок `System` обязан оставаться неразрывным: строки `container`/`container_id`/`pid`/`netns`/`interface` имеют тот же отступ, что и поля любой другой секции, поэтому открытие постороннего блока между ними и заголовком `System` делает вывод неоднозначным при чтении. -Если контейнер обнаружить не удалось, `status` не завершается ошибкой, а переходит в degraded-режим: секции `Peers` и `WireGuard clients` печатают `unavailable`, `Traffic actual` показывает `drift: unknown`, блок `Docker/Amnezia port path` заполняется значениями `unknown`, а блок `Peer traffic counters` не печатается вовсе. Секции host firewall, anti-torrent, swap, conntrack, событий и reboot остаются на месте. +Если контейнер обнаружить не удалось, `status` не завершается ошибкой, а переходит в degraded-режим. Строки `container`/`pid`/`netns`/`interface` печатаются со значениями `n/a`, секции `Peers` и `WireGuard clients` печатают `unavailable`, `Traffic actual` показывает `drift: unknown`, блок `Docker/Amnezia port path` заполняется значениями `unknown`, а блок `Peer traffic counters` не печатается вовсе. Секции host firewall, anti-torrent, swap, conntrack, событий и reboot остаются на месте. + +Секции, общие для здорового и degraded путей, рендерятся одними и теми же функциями. Это обязательное требование: раньше они были продублированы, и расхождение форматов проявилось бы ровно в тот момент, когда контейнер лежит, то есть в худший для диагностики момент. Отдельные секции деградируют независимо: ошибка чтения peer state не мешает вывести traffic, firewall и swap. diff --git a/traffic_core/src/daemon.rs b/traffic_core/src/daemon.rs index 9960b0c..f687802 100644 --- a/traffic_core/src/daemon.rs +++ b/traffic_core/src/daemon.rs @@ -19,8 +19,8 @@ struct LastActionState { last_error: Option, } -const LAST_STATE_FILE: &str = "/run/awg2-harness/last_action.state"; -const EVENTS_FILE: &str = "/run/awg2-harness/events.log"; +pub const LAST_STATE_FILE: &str = "/run/awg2-harness/last_action.state"; +pub const EVENTS_FILE: &str = "/run/awg2-harness/events.log"; const EVENTS_MAX_LINES: usize = 1000; const EVENTS_MAX_BYTES: usize = 256 * 1024; diff --git a/traffic_core/src/status.rs b/traffic_core/src/status.rs index 2f7e9b0..9e9a9b0 100644 --- a/traffic_core/src/status.rs +++ b/traffic_core/src/status.rs @@ -1,5 +1,9 @@ use crate::{ - config::Config, docker, firewall, netns::build_transport, swap, sysctl_tuning, traffic, wg, + config::Config, + daemon::{EVENTS_FILE, LAST_STATE_FILE}, + docker, firewall, + netns::build_transport, + swap, sysctl_tuning, traffic, wg, }; use std::{ @@ -10,6 +14,9 @@ use std::{ }; const ONLINE_HANDSHAKE_THRESHOLD_SECS: u64 = 180; +const RECENT_EVENTS_LIMIT: usize = 10; +const FORWARD_OWNERSHIP_NOTE: &str = + " note: FORWARD is Docker-managed; awg2-harness does not modify host FORWARD"; pub fn print_status(cfg: &Config) -> anyhow::Result<()> { println!("System:"); @@ -22,26 +29,33 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { Err(_) => println!(" service: unknown"), } - print_network_tuning_status(cfg); - + // The System block stays contiguous: container identity is printed before any other + // section opens, otherwise its two-space fields read as belonging to that section. let target = match docker::discover_target(cfg) { - Ok(t) => t, + Ok(t) => { + println!(" container: {}", t.container_name); + println!(" container_id: {}", t.container_id); + println!(" pid: {}", t.container_pid); + println!(" netns: {}", t.netns_path); + println!(" interface: {}", t.interface_name); + Some(t) + } Err(e) => { println!(" container: not available ({e})"); println!(" container_id: n/a"); println!(" pid: n/a"); println!(" netns: n/a"); println!(" interface: n/a"); - print_degraded_sections(cfg)?; - return Ok(()); + None } }; - println!(" container: {}", target.container_name); - println!(" container_id: {}", target.container_id); - println!(" pid: {}", target.container_pid); - println!(" netns: {}", target.netns_path); - println!(" interface: {}", target.interface_name); + print_network_tuning_status(cfg); + + let Some(target) = target else { + print_degraded_sections(cfg); + return Ok(()); + }; let docker_path_diag = docker::collect_port_path_diagnostics( &target.container_name, @@ -101,11 +115,7 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { (_, Err(e)) => println!(" unavailable ({e})"), } - println!("Traffic desired:"); - println!(" peer_limit: {}", cfg.traffic.peer_limit); - println!(" root_rate: {}", cfg.traffic.root_rate); - println!(" ifb_name: {}", cfg.traffic.ifb_name); - println!(" class_start_id: {}", cfg.traffic.class_start_id); + print_traffic_desired_section(cfg); println!("Traffic actual:"); match traffic::inspect_drift_summary(cfg) { @@ -170,99 +180,13 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { Err(_) => println!(" filter statistics: unavailable"), } - println!("Host firewall:"); - println!(" enabled: {}", cfg.host_firewall.enabled); - println!(" ssh_port: {}/tcp", cfg.host_firewall.ssh_port); - println!( - " amnezia_port: {}/{}", - cfg.host_firewall.amnezia_port, - cfg.host_firewall.amnezia_protocols.join(",") - ); - println!( - " input_policy: {}", - cfg.host_firewall - .default_input_policy - .trim() - .to_ascii_lowercase() - ); - if cfg.host_firewall.enabled { - match firewall::host_firewall_drift_reason(cfg) { - Ok(None) => println!(" drift: no"), - Ok(Some(reason)) => { - println!(" drift: yes"); - println!(" reason: {}", reason); - } - Err(e) => println!(" drift: unknown ({e})"), - } - } - + print_host_firewall_section(cfg); print_docker_amnezia_port_path(&docker_path_diag); - - println!("Anti-torrent:"); - println!(" enabled: {}", cfg.anti_torrent.enabled); - println!( - " max_connections_per_peer: {}", - cfg.anti_torrent.max_connections_per_peer - ); - - if cfg.anti_torrent.enabled { - match firewall::antitorrent_drift_reason(cfg) { - Ok(None) => println!(" drift: no"), - Ok(Some(reason)) => { - println!(" drift: yes"); - println!(" reason: {}", reason); - } - Err(e) => println!(" drift: unknown ({e})"), - } - match firewall::antitorrent_drop_counter(cfg) { - Ok(Some((packets, bytes))) => { - println!(" dropped_packets: {}", packets); - println!(" dropped_bytes: {}", bytes); - } - Ok(None) => println!(" dropped_packets: n/a"), - Err(e) => println!(" dropped_packets: inspect error ({e})"), - } - } - + print_antitorrent_section(cfg); print_swap_status(cfg); - - let ct_count = Command::new("sysctl") - .args(["-n", "net.netfilter.nf_conntrack_count"]) - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_else(|| "n/a".to_string()); - let ct_max = Command::new("sysctl") - .args(["-n", "net.netfilter.nf_conntrack_max"]) - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_else(|| "n/a".to_string()); - println!("Conntrack:"); - println!(" nf_conntrack_count: {}", ct_count); - println!(" nf_conntrack_max: {}", ct_max); - - println!("Last actions:"); - let last = fs::read_to_string("/run/awg2-harness/last_action.state").unwrap_or_default(); - if last.trim().is_empty() { - println!(" no persisted state"); - } else { - for line in last.lines() { - println!(" {}", line); - } - } - - println!("Recent events:"); - let events = fs::read_to_string("/run/awg2-harness/events.log").unwrap_or_default(); - let lines: Vec<&str> = events.lines().collect(); - if lines.is_empty() { - println!(" no recent events"); - } else { - for l in lines.iter().rev().take(10).rev() { - println!(" {l}"); - } - } - + print_conntrack_section(); + print_last_actions_section(); + print_recent_events_section(); print_reboot_section(cfg); Ok(()) @@ -279,22 +203,42 @@ fn print_network_tuning_status(cfg: &Config) { } } -fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> { +/// Sections that stay meaningful when the container was not discovered. +/// +/// Every section shared with the healthy path is rendered by the same helper, so the two +/// paths cannot drift apart in format. Only the sections that genuinely depend on the +/// container differ, and they say so explicitly. +fn print_degraded_sections(cfg: &Config) { println!("Peers:"); println!(" unavailable (container/netns not discovered)"); println!("WireGuard clients:"); println!(" unavailable (container/netns not discovered)"); + print_traffic_desired_section(cfg); + + println!("Traffic actual:"); + println!(" drift: unknown (container/netns not discovered)"); + + print_host_firewall_section(cfg); + print_unknown_docker_amnezia_port_path(); + print_antitorrent_section(cfg); + print_swap_status(cfg); + print_conntrack_section(); + print_last_actions_section(); + print_recent_events_section(); + print_reboot_section(cfg); +} + +fn print_traffic_desired_section(cfg: &Config) { println!("Traffic desired:"); println!(" peer_limit: {}", cfg.traffic.peer_limit); println!(" root_rate: {}", cfg.traffic.root_rate); println!(" ifb_name: {}", cfg.traffic.ifb_name); println!(" class_start_id: {}", cfg.traffic.class_start_id); +} - println!("Traffic actual:"); - println!(" drift: unknown (container/netns not discovered)"); - +fn print_host_firewall_section(cfg: &Config) { println!("Host firewall:"); println!(" enabled: {}", cfg.host_firewall.enabled); println!(" ssh_port: {}/tcp", cfg.host_firewall.ssh_port); @@ -320,80 +264,82 @@ fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> { Err(e) => println!(" drift: unknown ({e})"), } } +} - println!("Docker/Amnezia port path:"); - println!(" docker_userland_proxy: unknown"); - println!(" published_udp_port: unknown"); - println!(" host_forward_policy_drop: unknown"); - println!(" docker_forward_rules_present: unknown"); - println!(" note: FORWARD is Docker-managed; awg2-harness does not modify host FORWARD"); - +fn print_antitorrent_section(cfg: &Config) { println!("Anti-torrent:"); println!(" enabled: {}", cfg.anti_torrent.enabled); println!( " max_connections_per_peer: {}", cfg.anti_torrent.max_connections_per_peer ); - if cfg.anti_torrent.enabled { - match firewall::antitorrent_drift_reason(cfg) { - Ok(None) => println!(" drift: no"), - Ok(Some(reason)) => { - println!(" drift: yes"); - println!(" reason: {}", reason); - } - Err(e) => println!(" drift: unknown ({e})"), - } - match firewall::antitorrent_drop_counter(cfg) { - Ok(Some((packets, bytes))) => { - println!(" dropped_packets: {}", packets); - println!(" dropped_bytes: {}", bytes); - } - Ok(None) => println!(" dropped_packets: n/a"), - Err(e) => println!(" dropped_packets: inspect error ({e})"), - } + if !cfg.anti_torrent.enabled { + return; } + match firewall::antitorrent_drift_reason(cfg) { + Ok(None) => println!(" drift: no"), + Ok(Some(reason)) => { + println!(" drift: yes"); + println!(" reason: {}", reason); + } + Err(e) => println!(" drift: unknown ({e})"), + } + match firewall::antitorrent_drop_counter(cfg) { + Ok(Some((packets, bytes))) => { + println!(" dropped_packets: {}", packets); + println!(" dropped_bytes: {}", bytes); + } + Ok(None) => println!(" dropped_packets: n/a"), + Err(e) => println!(" dropped_packets: inspect error ({e})"), + } +} - print_swap_status(cfg); - +fn print_conntrack_section() { println!("Conntrack:"); - let ct_count = Command::new("sysctl") - .args(["-n", "net.netfilter.nf_conntrack_count"]) - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_else(|| "n/a".to_string()); - let ct_max = Command::new("sysctl") - .args(["-n", "net.netfilter.nf_conntrack_max"]) - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_else(|| "n/a".to_string()); - println!(" nf_conntrack_count: {}", ct_count); - println!(" nf_conntrack_max: {}", ct_max); + println!( + " nf_conntrack_count: {}", + read_sysctl_or_na("net.netfilter.nf_conntrack_count") + ); + println!( + " nf_conntrack_max: {}", + read_sysctl_or_na("net.netfilter.nf_conntrack_max") + ); +} +fn read_sysctl_or_na(key: &str) -> String { + Command::new("sysctl") + .args(["-n", key]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "n/a".to_string()) +} + +fn print_last_actions_section() { println!("Last actions:"); - let last = fs::read_to_string("/run/awg2-harness/last_action.state").unwrap_or_default(); + let last = fs::read_to_string(LAST_STATE_FILE).unwrap_or_default(); if last.trim().is_empty() { println!(" no persisted state"); - } else { - for line in last.lines() { - println!(" {}", line); - } + return; } + for line in last.lines() { + println!(" {}", line); + } +} +fn print_recent_events_section() { println!("Recent events:"); - let events = fs::read_to_string("/run/awg2-harness/events.log").unwrap_or_default(); + let events = fs::read_to_string(EVENTS_FILE).unwrap_or_default(); let lines: Vec<&str> = events.lines().collect(); if lines.is_empty() { println!(" no recent events"); - } else { - for l in lines.iter().rev().take(10).rev() { - println!(" {l}"); - } + return; + } + for l in lines.iter().rev().take(RECENT_EVENTS_LIMIT).rev() { + println!(" {l}"); } - - print_reboot_section(cfg); - Ok(()) } fn print_reboot_section(cfg: &Config) { @@ -487,12 +433,23 @@ fn print_docker_amnezia_port_path(diag: &docker::PortPathDiagnostics) { " docker_forward_rules_present: {}", diag.docker_forward_rules_present.as_str() ); - println!(" note: FORWARD is Docker-managed; awg2-harness does not modify host FORWARD"); + println!("{FORWARD_OWNERSHIP_NOTE}"); if let Some(w) = diag.warning() { println!(" warning: {w}"); } } +/// Same block as [`print_docker_amnezia_port_path`], but every probe needs the container, +/// so all four fields are reported as unknown rather than omitted. +fn print_unknown_docker_amnezia_port_path() { + println!("Docker/Amnezia port path:"); + println!(" docker_userland_proxy: unknown"); + println!(" published_udp_port: unknown"); + println!(" host_forward_policy_drop: unknown"); + println!(" docker_forward_rules_present: unknown"); + println!("{FORWARD_OWNERSHIP_NOTE}"); +} + fn handshake_is_online(epoch: Option, threshold_secs: u64) -> &'static str { if let Some(ts) = epoch { let now = SystemTime::now()