diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index 78dd9c7..160db89 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -117,6 +117,18 @@ - [ ] Известно, как вернуть `amnezia-tc.service` и `amnezia-tc-watcher.service`. - [ ] Rollback проверен на тестовом сервере. +## L. Host firewall (ports/SSH safety) + +- [ ] `awg2-harness validate-config` падает, если отсутствует секция `[host_firewall]`. +- [ ] `apply-firewall` не блокирует SSH на `host_firewall.ssh_port` (default `2323`). +- [ ] После `apply-firewall` порт `host_firewall.amnezia_port` (default `443`) доступен по всем протоколам из `host_firewall.amnezia_protocols`. +- [ ] Если эксплуатационно требуется закрыть legacy SSH `22`, это явно подтверждено проверкой доступа на `ssh_port` перед закрытием `22`. +- [ ] При `host_firewall.enabled = false` удаляется только таблица `inet awg2_host_firewall`. +- [ ] Повторный `apply-firewall` идемпотентен и не вносит лишних изменений в healthy state. +- [ ] При невалидном `host_firewall.amnezia_protocols` (`icmp`, `foo`, дубли) `validate-config` завершается ошибкой. +- [ ] После `docker restart` anti-torrent правила продолжают восстанавливаться корректно и не ломаются host firewall логикой. +- [ ] Проверено, где реально проходит VPN-трафик в вашей Docker-схеме (`INPUT` vs `FORWARD`) и это отражено в эксплуатационной заметке. + ## K. Packaging / release - [ ] Package собирается на отдельной Debian 13 amd64 build-machine. diff --git a/traffic_core/src/config.rs b/traffic_core/src/config.rs index 53adee6..57aecaa 100644 --- a/traffic_core/src/config.rs +++ b/traffic_core/src/config.rs @@ -30,7 +30,6 @@ fn default_input_policy() -> String { pub struct Config { pub amnezia: AmneziaConfig, pub traffic: TrafficConfig, - #[serde(default)] pub host_firewall: HostFirewallConfig, pub anti_torrent: AntiTorrentConfig, pub reboot: RebootConfig, @@ -474,4 +473,44 @@ mod tests { }; assert!(cfg.validate_config().is_err()); } + + #[test] + fn config_requires_host_firewall_section() { + let raw = r#" +[amnezia] +container_names = ["amnezia-awg2"] +interface_candidates = ["awg0"] +interface_patterns = ["awg*"] +peer_ipv4_cidr = "10.8.1.0/24" + +[traffic] +enabled = true +peer_limit = "70mbit" +root_rate = "1000mbit" +reconcile_interval_secs = 15 +container_ready_timeout_secs = 30 +class_start_id = 10 +ifb_name = "ifb0" + +[anti_torrent] +enabled = false +max_connections_per_peer = 100 +peer_ipv4_cidr = "10.8.1.0/24" +apply_in_amnezia_netns = false + +[reboot] +enabled = false +window_start_utc = "01:00" +window_end_utc = "02:00" + +[swap] +enabled = false +path = "/swapfile" +size_mb = 2048 +priority = 1 +swappiness = 1 +"#; + let parsed = toml::from_str::(raw); + assert!(parsed.is_err()); + } } diff --git a/traffic_core/src/firewall.rs b/traffic_core/src/firewall.rs index 40c33b9..edf998c 100644 --- a/traffic_core/src/firewall.rs +++ b/traffic_core/src/firewall.rs @@ -5,7 +5,11 @@ use crate::{ netns::{NsTransport, build_host_transport, build_transport}, }; use serde_json::Value; -use std::time::Duration; +use std::{ + io::Write, + process::{Command, Stdio}, + time::Duration, +}; const ANTITORRENT_TABLE: &str = "awg2_harness"; const HOST_FW_TABLE: &str = "awg2_host_firewall"; @@ -123,129 +127,72 @@ fn apply_host_firewall(cfg: &Config) -> anyhow::Result<()> { .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), - "}", - ], - )?; + apply_host_firewall_batch(cfg, &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", - ], - )?; + if let Some(reason) = host_firewall_drift_reason(cfg)? { + anyhow::bail!("host firewall verify-after-apply failed: {reason}"); + } + Ok(()) +} + +fn apply_host_firewall_batch(cfg: &Config, policy: &str) -> anyhow::Result<()> { + let mut batch = String::new(); + batch.push_str(&format!("delete table inet {HOST_FW_TABLE}\n")); + batch.push_str(&format!("add table inet {HOST_FW_TABLE}\n")); + batch.push_str(&format!( + "add chain inet {HOST_FW_TABLE} input {{ type filter hook input priority filter; policy accept; }}\n" + )); + batch.push_str(&format!( + "add rule inet {HOST_FW_TABLE} input iif \"lo\" accept\n" + )); + batch.push_str(&format!( + "add rule inet {HOST_FW_TABLE} input ct state established,related accept\n" + )); + batch.push_str(&format!( + "add rule inet {HOST_FW_TABLE} input tcp dport {} accept\n", + cfg.host_firewall.ssh_port + )); 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", - ], - )?; + batch.push_str(&format!( + "add rule inet {HOST_FW_TABLE} input {p} dport {} accept\n", + cfg.host_firewall.amnezia_port + )); } 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", - ], - )?; + batch.push_str(&format!( + "add rule inet {HOST_FW_TABLE} input ip protocol icmp accept\n" + )); + batch.push_str(&format!( + "add rule inet {HOST_FW_TABLE} input ip6 nexthdr icmpv6 accept\n" + )); + } + batch.push_str(&format!( + "set chain inet {HOST_FW_TABLE} input {{ policy {policy}; }}\n" + )); + run_nft_batch(&batch) +} + +fn run_nft_batch(script: &str) -> anyhow::Result<()> { + let mut child = Command::new("nft") + .args(["-f", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(script.as_bytes())?; + } else { + anyhow::bail!("failed to open stdin for nft batch"); + } + + let out = child.wait_with_output()?; + if !out.status.success() { + anyhow::bail!( + "nft batch apply failed: {}", + String::from_utf8_lossy(&out.stderr) + ); } Ok(()) } @@ -336,47 +283,53 @@ pub fn host_firewall_drift_reason(cfg: &Config) -> anyhow::Result Err(e) => return Err(anyhow::anyhow!("host firewall inspect failed: {e}")), }; let nt = normalize_nft_text(&table); + Ok(host_firewall_drift_reason_from_text(&nt, cfg)) +} + +fn host_firewall_drift_reason_from_text(nt: &str, cfg: &Config) -> Option { let ssh = format!("tcp dport {} accept", cfg.host_firewall.ssh_port); if !nt.contains("chain input") { - return Ok(Some("missing input chain".into())); + return Some("missing input chain".into()); } if !nt.contains("type filter hook input") { - return Ok(Some("missing input hook".into())); + return 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())); + return Some("wrong input policy".into()); } if !nt.contains("iif lo accept") { - return Ok(Some("missing loopback allow".into())); + return 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())); + return Some("missing established/related allow".into()); } if !nt.contains(&ssh) { - return Ok(Some("missing ssh allow rule".into())); + return 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}"))); + return 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())); + return Some("missing icmp allow rule".into()); } - if !nt.contains("ip6 nexthdr icmpv6 accept") { - return Ok(Some("missing icmpv6 allow rule".into())); + if !(nt.contains("ip6 nexthdr icmpv6 accept") + || nt.contains("ip6 nexthdr ipv6-icmp accept")) + { + return Some("missing icmpv6 allow rule".into()); } } - Ok(None) + None } fn apply_rule(transport: &dyn NsTransport, cidr: &str, max_conn: u32) -> anyhow::Result<()> { @@ -535,7 +488,7 @@ fn firewall_rule_contract_matches(table: &str, cidr: &str, max_conn: u32) -> boo fn normalize_nft_text(input: &str) -> String { input - .replace(['\n', '\t', '{', '}', ';', ','], " ") + .replace(['\n', '\t', '{', '}', ';', ',', '"'], " ") .split_whitespace() .collect::>() .join(" ") @@ -661,4 +614,59 @@ table inet awg2_harness { "#; assert!(firewall_rule_contract_matches(table, "10.8.1.0/24", 100)); } + + #[test] + fn host_firewall_parser_tolerates_quoted_lo_and_ipv6_icmp_alias() { + let table = r#" +table inet awg2_host_firewall { + chain input { + type filter hook input priority filter; policy drop; + iif "lo" accept + ct state established,related accept + tcp dport 2323 accept + udp dport 443 accept + ip protocol icmp accept + ip6 nexthdr ipv6-icmp accept + } +} +"#; + let cfg = Config { + amnezia: crate::config::AmneziaConfig { + container_names: vec!["amnezia-awg2".into()], + interface_candidates: vec!["awg0".into()], + interface_patterns: vec!["awg*".into()], + peer_ipv4_cidr: "10.8.1.0/24".into(), + }, + traffic: crate::config::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: crate::config::HostFirewallConfig::default(), + anti_torrent: crate::config::AntiTorrentConfig { + enabled: false, + max_connections_per_peer: 100, + peer_ipv4_cidr: "10.8.1.0/24".into(), + apply_in_amnezia_netns: false, + }, + reboot: crate::config::RebootConfig { + enabled: false, + window_start_utc: "01:00".into(), + window_end_utc: "02:00".into(), + }, + swap: crate::config::SwapConfig { + enabled: false, + path: "/swapfile".into(), + size_mb: 2048, + priority: 1, + swappiness: 1, + }, + }; + let nt = normalize_nft_text(table); + assert_eq!(host_firewall_drift_reason_from_text(&nt, &cfg), None); + } }