From 381c106f9db6cbdd7b50ea4a00a44b95aaeaf3cf Mon Sep 17 00:00:00 2001 From: Crimson Date: Mon, 11 May 2026 18:14:46 +0500 Subject: [PATCH] =?UTF-8?q?fix8:=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=88=D0=BD=20host=20firewall=20+=20dual=20reconcile=20firewal?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - добавлена секция host_firewall в конфиг и defaults\n- реализован host firewall reconcile в отдельной таблице\n- apply-firewall теперь делает host firewall + anti-torrent\n- убрана JSON-инспекция из full-table drift для anti-torrent\n- расширен status вывод drift по host firewall/anti-torrent --- config.example.toml | 8 + docs/CONFIGURATION.md | 22 +++ docs/MIGRATION.md | 12 +- traffic_core/src/config.rs | 134 +++++++++++++++++ traffic_core/src/firewall.rs | 283 ++++++++++++++++++++++++++++------- traffic_core/src/status.rs | 62 ++++++++ 6 files changed, 466 insertions(+), 55 deletions(-) diff --git a/config.example.toml b/config.example.toml index edc8c6c..7ad375c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -13,6 +13,14 @@ container_ready_timeout_secs = 120 class_start_id = 10 ifb_name = "ifb0" +[host_firewall] +enabled = true +ssh_port = 2323 +amnezia_port = 443 +amnezia_protocols = ["udp"] +allow_icmp = true +default_input_policy = "drop" + [anti_torrent] enabled = true max_connections_per_peer = 100 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 212b609..f421c5a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -20,6 +20,14 @@ container_ready_timeout_secs = 120 class_start_id = 10 ifb_name = "ifb0" +[host_firewall] +enabled = true +ssh_port = 2323 +amnezia_port = 443 +amnezia_protocols = ["udp"] +allow_icmp = true +default_input_policy = "drop" + [anti_torrent] enabled = true max_connections_per_peer = 100 @@ -65,6 +73,18 @@ swappiness = 1 `anti_torrent.enabled` — включает nftables connection-count guard против high-peer-count P2P-нагрузки. Это новое расширение, не часть old bash. +`host_firewall.enabled` — включает host-level INPUT firewall в отдельной AWG2-owned таблице `inet awg2_host_firewall`. + +`host_firewall.ssh_port` — SSH порт хоста, который обязательно должен быть открыт. Production default: `2323`. + +`host_firewall.amnezia_port` — порт Amnezia на хосте. Production default: `443`. + +`host_firewall.amnezia_protocols` — протоколы для порта Amnezia (`udp`, `tcp` или оба). + +`host_firewall.allow_icmp` — разрешать ICMP/ICMPv6 на входе. + +`host_firewall.default_input_policy` — политика INPUT chain (`drop` или `accept`). Для production рекомендуется `drop`. + `anti_torrent.max_connections_per_peer` — максимум одновременных conntrack flows на один VPN peer IP. Default `100`; `250` — более мягкий вариант. `anti_torrent.peer_ipv4_cidr` — подсеть, на которую распространяется правило. Обычно должна совпадать с `amnezia.peer_ipv4_cidr`. @@ -76,6 +96,8 @@ swappiness = 1 Для production рекомендуется `true`, если peer source IP (`10.8.1.x`) видны именно внутри netns контейнера. +`apply-firewall` в текущей модели делает **оба** reconcile: host firewall (host namespace) и anti-torrent firewall (configured namespace). + `reboot.enabled` — включает плановую перезагрузку. `reboot.window_start_utc` / `reboot.window_end_utc` — UTC-окно плановой перезагрузки. В старом Rust-модуле это было зашито как 01:00–02:00 UTC. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index df70d2c..0869e7a 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -189,6 +189,14 @@ container_ready_timeout_secs = 120 class_start_id = 10 ifb_name = "ifb0" +[host_firewall] +enabled = true +ssh_port = 2323 +amnezia_port = 443 +amnezia_protocols = ["udp"] +allow_icmp = true +default_input_policy = "drop" + [anti_torrent] enabled = true max_connections_per_peer = 100 @@ -204,7 +212,7 @@ window_end_utc = "02:00" enabled = true path = "/swapfile" size_mb = 2048 -priority = -2 +priority = 1 swappiness = 1 ``` @@ -249,7 +257,7 @@ WantedBy=multi-user.target ## 12. Reboot и swap -Логика reboot работает только через встроенный scheduler `traffic_core`. Окно перезагрузки задаётся в конфиге, включая корректные окна через полночь. Механика `systemctl --no-block reboot` сохраняется. +Логика reboot вынесена в отдельный production-бинарь `reboot_module` (`awg2-reboot-scheduler`). Окно перезагрузки задаётся в конфиге, включая корректные окна через полночь. Механика `systemctl --no-block reboot` сохраняется. `ensure-swap` работает как exclusive reconciler: при `swap.enabled=true` пакет полностью владеет swap-state хоста. После reconcile активен ровно один swap (`swap.path`), все прочие активные swap entries выключены (`swapoff`), а все старые swap-строки в `/etc/fstab` заменены на одну managed-запись AWG2. Для block-device swap выполняются только `swapoff` и удаление записи из `fstab`; device не удаляется. diff --git a/traffic_core/src/config.rs b/traffic_core/src/config.rs index f073165..53adee6 100644 --- a/traffic_core/src/config.rs +++ b/traffic_core/src/config.rs @@ -2,10 +2,36 @@ use std::{fs, net::Ipv4Addr, path::Path, process::Command}; use serde::Deserialize; +fn default_host_firewall_enabled() -> bool { + true +} + +fn default_ssh_port() -> u16 { + 2323 +} + +fn default_amnezia_port() -> u16 { + 443 +} + +fn default_amnezia_protocols() -> Vec { + vec!["udp".to_string()] +} + +fn default_allow_icmp() -> bool { + true +} + +fn default_input_policy() -> String { + "drop".to_string() +} + #[derive(Debug, Clone, Deserialize)] pub struct Config { pub amnezia: AmneziaConfig, pub traffic: TrafficConfig, + #[serde(default)] + pub host_firewall: HostFirewallConfig, pub anti_torrent: AntiTorrentConfig, pub reboot: RebootConfig, pub swap: SwapConfig, @@ -38,6 +64,35 @@ pub struct AntiTorrentConfig { pub apply_in_amnezia_netns: bool, } +#[derive(Debug, Clone, Deserialize)] +pub struct HostFirewallConfig { + #[serde(default = "default_host_firewall_enabled")] + pub enabled: bool, + #[serde(default = "default_ssh_port")] + pub ssh_port: u16, + #[serde(default = "default_amnezia_port")] + pub amnezia_port: u16, + #[serde(default = "default_amnezia_protocols")] + pub amnezia_protocols: Vec, + #[serde(default = "default_allow_icmp")] + pub allow_icmp: bool, + #[serde(default = "default_input_policy")] + pub default_input_policy: String, +} + +impl Default for HostFirewallConfig { + fn default() -> Self { + Self { + enabled: default_host_firewall_enabled(), + ssh_port: default_ssh_port(), + amnezia_port: default_amnezia_port(), + amnezia_protocols: default_amnezia_protocols(), + allow_icmp: default_allow_icmp(), + default_input_policy: default_input_policy(), + } + } +} + #[derive(Debug, Clone, Deserialize)] pub struct RebootConfig { pub enabled: bool, @@ -115,6 +170,37 @@ impl Config { if self.anti_torrent.enabled && self.anti_torrent.max_connections_per_peer == 0 { anyhow::bail!("anti_torrent.max_connections_per_peer must be > 0"); } + if self.host_firewall.ssh_port == 0 { + anyhow::bail!("host_firewall.ssh_port must be in 1..=65535"); + } + if self.host_firewall.amnezia_port == 0 { + anyhow::bail!("host_firewall.amnezia_port must be in 1..=65535"); + } + if self.host_firewall.amnezia_protocols.is_empty() { + anyhow::bail!( + "host_firewall.amnezia_protocols must not be empty; allowed values: tcp, udp" + ); + } + let mut normalized = std::collections::BTreeSet::new(); + for p in &self.host_firewall.amnezia_protocols { + let pp = p.trim().to_ascii_lowercase(); + if pp != "tcp" && pp != "udp" { + anyhow::bail!( + "host_firewall.amnezia_protocols contains invalid value: {p}; allowed: tcp, udp" + ); + } + if !normalized.insert(pp) { + anyhow::bail!("host_firewall.amnezia_protocols contains duplicate protocol: {p}"); + } + } + let policy = self + .host_firewall + .default_input_policy + .trim() + .to_ascii_lowercase(); + if policy != "drop" && policy != "accept" { + anyhow::bail!("host_firewall.default_input_policy must be one of: drop, accept"); + } if self.reboot.enabled && self.reboot.window_start_utc == self.reboot.window_end_utc { anyhow::bail!("reboot window start and end must differ"); } @@ -146,6 +232,9 @@ impl Config { if self.anti_torrent.enabled { required.push("nft"); } + if self.host_firewall.enabled { + required.push("nft"); + } if self.reboot.enabled { required.push("systemctl"); } @@ -319,6 +408,7 @@ mod tests { class_start_id: 10, ifb_name: "ifb0".into(), }, + host_firewall: HostFirewallConfig::default(), anti_torrent: AntiTorrentConfig { enabled: false, max_connections_per_peer: 100, @@ -340,4 +430,48 @@ mod tests { }; assert!(cfg.validate_config().is_err()); } + + #[test] + fn host_firewall_rejects_empty_protocols() { + let cfg = Config { + amnezia: AmneziaConfig { + container_names: vec!["x".into()], + interface_candidates: vec!["awg0".into()], + interface_patterns: vec!["awg*".into()], + peer_ipv4_cidr: "10.8.1.0/24".into(), + }, + traffic: TrafficConfig { + enabled: true, + peer_limit: "70mbit".into(), + root_rate: "1000mbit".into(), + reconcile_interval_secs: 15, + container_ready_timeout_secs: 30, + class_start_id: 10, + ifb_name: "ifb0".into(), + }, + host_firewall: HostFirewallConfig { + amnezia_protocols: vec![], + ..HostFirewallConfig::default() + }, + anti_torrent: AntiTorrentConfig { + enabled: false, + max_connections_per_peer: 100, + peer_ipv4_cidr: "10.8.1.0/24".into(), + apply_in_amnezia_netns: false, + }, + reboot: RebootConfig { + enabled: false, + window_start_utc: "01:00".into(), + window_end_utc: "02:00".into(), + }, + swap: SwapConfig { + enabled: false, + path: "/swapfile".into(), + size_mb: 2048, + priority: 1, + swappiness: 1, + }, + }; + assert!(cfg.validate_config().is_err()); + } } diff --git a/traffic_core/src/firewall.rs b/traffic_core/src/firewall.rs index 8a85c3c..40c33b9 100644 --- a/traffic_core/src/firewall.rs +++ b/traffic_core/src/firewall.rs @@ -7,6 +7,9 @@ use crate::{ use serde_json::Value; use std::time::Duration; +const ANTITORRENT_TABLE: &str = "awg2_harness"; +const HOST_FW_TABLE: &str = "awg2_host_firewall"; + #[derive(Debug, Clone)] struct FirewallActual { table_exists: bool, @@ -25,8 +28,14 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> { let _lock = FileLock::acquire_timeout("/run/awg2-harness/firewall.lock", Duration::from_secs(10))?; + apply_host_firewall(cfg)?; + apply_antitorrent_firewall(cfg)?; + Ok(()) +} + +fn apply_antitorrent_firewall(cfg: &Config) -> anyhow::Result<()> { if !cfg.anti_torrent.enabled { - apply_firewall_disabled(cfg)?; + apply_antitorrent_disabled(cfg)?; return Ok(()); } @@ -39,12 +48,12 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> { run_allow_notfound( transport.as_ref(), - &["nft", "delete", "table", "inet", "awg2_harness"], + &["nft", "delete", "table", "inet", ANTITORRENT_TABLE], )?; run_step( transport.as_ref(), - &["nft", "add", "table", "inet", "awg2_harness"], + &["nft", "add", "table", "inet", ANTITORRENT_TABLE], )?; run_step( transport.as_ref(), @@ -53,7 +62,7 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> { "add", "counter", "inet", - "awg2_harness", + ANTITORRENT_TABLE, "antitorrent_dropped_v4", ], )?; @@ -67,21 +76,21 @@ pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> { let verify = inspect_firewall(transport.as_ref(), cfg)?; if firewall_has_drift(&verify) { anyhow::bail!( - "firewall verify-after-apply failed: {}", + "anti-torrent firewall verify-after-apply failed: {}", firewall_drift_reason(&verify) ); } Ok(()) } -fn apply_firewall_disabled(cfg: &Config) -> anyhow::Result<()> { +fn apply_antitorrent_disabled(cfg: &Config) -> anyhow::Result<()> { if cfg.anti_torrent.apply_in_amnezia_netns { match docker::discover_target(cfg) { Ok(target) => { let transport = build_transport(target.container_pid, &target.container_name); run_allow_notfound( transport.as_ref(), - &["nft", "delete", "table", "inet", "awg2_harness"], + &["nft", "delete", "table", "inet", ANTITORRENT_TABLE], )?; } Err(_) => { @@ -92,21 +101,157 @@ fn apply_firewall_disabled(cfg: &Config) -> anyhow::Result<()> { let transport = build_host_transport(); run_allow_notfound( transport.as_ref(), - &["nft", "delete", "table", "inet", "awg2_harness"], + &["nft", "delete", "table", "inet", ANTITORRENT_TABLE], + )?; + } + Ok(()) +} + +fn apply_host_firewall(cfg: &Config) -> anyhow::Result<()> { + let transport = build_host_transport(); + if !cfg.host_firewall.enabled { + run_allow_notfound( + transport.as_ref(), + &["nft", "delete", "table", "inet", HOST_FW_TABLE], + )?; + return Ok(()); + } + + let policy = cfg + .host_firewall + .default_input_policy + .trim() + .to_ascii_lowercase(); + + run_allow_notfound( + transport.as_ref(), + &["nft", "delete", "table", "inet", HOST_FW_TABLE], + )?; + run_step( + transport.as_ref(), + &["nft", "add", "table", "inet", HOST_FW_TABLE], + )?; + run_step( + transport.as_ref(), + &[ + "nft", + "add", + "chain", + "inet", + HOST_FW_TABLE, + "input", + "{", + "type", + "filter", + "hook", + "input", + "priority", + "filter;", + "policy", + &format!("{};", policy), + "}", + ], + )?; + + run_step( + transport.as_ref(), + &[ + "nft", + "add", + "rule", + "inet", + HOST_FW_TABLE, + "input", + "iif", + "lo", + "accept", + ], + )?; + run_step( + transport.as_ref(), + &[ + "nft", + "add", + "rule", + "inet", + HOST_FW_TABLE, + "input", + "ct", + "state", + "established,related", + "accept", + ], + )?; + run_step( + transport.as_ref(), + &[ + "nft", + "add", + "rule", + "inet", + HOST_FW_TABLE, + "input", + "tcp", + "dport", + &cfg.host_firewall.ssh_port.to_string(), + "accept", + ], + )?; + for proto in &cfg.host_firewall.amnezia_protocols { + let p = proto.trim().to_ascii_lowercase(); + run_step( + transport.as_ref(), + &[ + "nft", + "add", + "rule", + "inet", + HOST_FW_TABLE, + "input", + &p, + "dport", + &cfg.host_firewall.amnezia_port.to_string(), + "accept", + ], + )?; + } + if cfg.host_firewall.allow_icmp { + run_step( + transport.as_ref(), + &[ + "nft", + "add", + "rule", + "inet", + HOST_FW_TABLE, + "input", + "ip", + "protocol", + "icmp", + "accept", + ], + )?; + run_step( + transport.as_ref(), + &[ + "nft", + "add", + "rule", + "inet", + HOST_FW_TABLE, + "input", + "ip6", + "nexthdr", + "icmpv6", + "accept", + ], )?; } Ok(()) } fn inspect_firewall(transport: &dyn NsTransport, cfg: &Config) -> anyhow::Result { - let json = transport.run(&["nft", "-j", "list", "table", "inet", "awg2_harness"]); - if let Ok(raw) = json { - if let Some(actual) = inspect_firewall_json(&raw, cfg)? { - return Ok(actual); - } - } - - let table = match transport.run(&["nft", "list", "table", "inet", "awg2_harness"]) { + let table = match transport.run(&["nft", "list", "table", "inet", ANTITORRENT_TABLE]) { Ok(out) => out, Err(e) if is_nft_not_found(&e.to_string()) => { return Ok(FirewallActual { @@ -168,13 +313,79 @@ fn firewall_transport(cfg: &Config) -> anyhow::Result> { } } +pub fn antitorrent_drift_reason(cfg: &Config) -> anyhow::Result> { + if !cfg.anti_torrent.enabled { + return Ok(None); + } + let transport = firewall_transport(cfg)?; + let actual = inspect_firewall(transport.as_ref(), cfg)?; + if firewall_has_drift(&actual) { + return Ok(Some(firewall_drift_reason(&actual))); + } + Ok(None) +} + +pub fn host_firewall_drift_reason(cfg: &Config) -> anyhow::Result> { + if !cfg.host_firewall.enabled { + return Ok(None); + } + let transport = build_host_transport(); + let table = match transport.run(&["nft", "list", "table", "inet", HOST_FW_TABLE]) { + Ok(out) => out, + Err(e) if is_nft_not_found(&e.to_string()) => return Ok(Some("missing nft table".into())), + Err(e) => return Err(anyhow::anyhow!("host firewall inspect failed: {e}")), + }; + let nt = normalize_nft_text(&table); + let ssh = format!("tcp dport {} accept", cfg.host_firewall.ssh_port); + if !nt.contains("chain input") { + return Ok(Some("missing input chain".into())); + } + if !nt.contains("type filter hook input") { + return Ok(Some("missing input hook".into())); + } + let pol = format!( + "policy {}", + cfg.host_firewall.default_input_policy.to_ascii_lowercase() + ); + if !nt.contains(&pol) { + return Ok(Some("wrong input policy".into())); + } + if !nt.contains("iif lo accept") { + return Ok(Some("missing loopback allow".into())); + } + if !(nt.contains("ct state established,related accept") + || nt.contains("ct state related,established accept")) + { + return Ok(Some("missing established/related allow".into())); + } + if !nt.contains(&ssh) { + return Ok(Some("missing ssh allow rule".into())); + } + for proto in &cfg.host_firewall.amnezia_protocols { + let p = proto.trim().to_ascii_lowercase(); + let need = format!("{p} dport {} accept", cfg.host_firewall.amnezia_port); + if !nt.contains(&need) { + return Ok(Some(format!("missing amnezia allow rule for {p}"))); + } + } + if cfg.host_firewall.allow_icmp { + if !nt.contains("ip protocol icmp accept") { + return Ok(Some("missing icmp allow rule".into())); + } + if !nt.contains("ip6 nexthdr icmpv6 accept") { + return Ok(Some("missing icmpv6 allow rule".into())); + } + } + Ok(None) +} + fn apply_rule(transport: &dyn NsTransport, cidr: &str, max_conn: u32) -> anyhow::Result<()> { let _ = transport.run(&[ "nft", "add", "rule", "inet", - "awg2_harness", + ANTITORRENT_TABLE, "forward_antitorrent", "ip", "saddr", @@ -213,7 +424,7 @@ fn run_step_chain(transport: &dyn NsTransport) -> anyhow::Result<()> { "add", "chain", "inet", - "awg2_harness", + ANTITORRENT_TABLE, "forward_antitorrent", "{", "type", @@ -330,40 +541,6 @@ fn normalize_nft_text(input: &str) -> String { .join(" ") } -fn inspect_firewall_json(raw: &str, cfg: &Config) -> anyhow::Result> { - let v: Value = serde_json::from_str(raw)?; - let Some(arr) = v.get("nftables").and_then(|x| x.as_array()) else { - return Ok(None); - }; - let mut text = String::new(); - for item in arr { - text.push_str(&item.to_string()); - text.push(' '); - } - let nt = normalize_nft_text(&text); - let table_exists = nt.contains("awg2_harness"); - if !table_exists { - return Ok(None); - } - let actual = FirewallActual { - table_exists, - counter_exists: nt.contains("antitorrent_dropped_v4"), - chain_exists: nt.contains("forward_antitorrent"), - rule_matches: firewall_rule_contract_matches( - &nt, - &cfg.anti_torrent.peer_ipv4_cidr, - cfg.anti_torrent.max_connections_per_peer, - ), - hook_forward_present: nt.contains("hook") && nt.contains("forward"), - policy_accept_present: nt.contains("policy") && nt.contains("accept"), - ct_state_new_present: nt.contains("ct") && nt.contains("state") && nt.contains("new"), - meter_present: nt.contains("awg2_peer_connlimit_v4"), - counter_name_used: nt.contains("antitorrent_dropped_v4"), - drop_present: nt.contains("drop"), - }; - Ok(Some(actual)) -} - pub fn antitorrent_drop_counter(cfg: &Config) -> anyhow::Result> { if !cfg.anti_torrent.enabled { return Ok(None); @@ -376,7 +553,7 @@ pub fn antitorrent_drop_counter(cfg: &Config) -> anyhow::Result 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.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})"), + } + } + println!("Anti-torrent:"); println!(" enabled: {}", cfg.anti_torrent.enabled); println!( @@ -173,6 +196,14 @@ pub fn print_status(cfg: &Config) -> anyhow::Result<()> { ); 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); @@ -243,6 +274,29 @@ fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> { println!("Traffic actual:"); println!(" drift: unknown (container/netns not discovered)"); + 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.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})"), + } + } + println!("Anti-torrent:"); println!(" enabled: {}", cfg.anti_torrent.enabled); println!( @@ -250,6 +304,14 @@ fn print_degraded_sections(cfg: &Config) -> anyhow::Result<()> { 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);