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