//! Host INPUT firewall, owned table `inet awg2_host_firewall`. //! //! Applied in the host namespace only. The table is scoped so that disabling the module //! removes exactly what it created and nothing that Docker, Amnezia or the operator owns. use crate::{config::Config, netns::build_host_transport}; use super::{ HOST_FW_TABLE, nft::{is_nft_not_found, normalize_nft_text, run_allow_notfound, run_nft_batch}, }; pub(super) 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(()); } if host_firewall_drift_reason(cfg)?.is_none() { 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], )?; apply_host_firewall_batch(cfg, &policy)?; 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 batch = build_host_firewall_batch_script(cfg, policy); run_nft_batch(&batch) } /// Renders the desired INPUT chain. /// /// The script never contains `delete table`: deletion happens as a separate step before /// the batch, so a batch that fails to parse cannot take the existing rules down with it. fn build_host_firewall_batch_script(cfg: &Config, policy: &str) -> String { let mut batch = String::new(); 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 {policy}; }}\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(); 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 { 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 } 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); Ok(host_firewall_drift_reason_from_text(&nt, cfg)) } /// Checks the normalized ruleset against config. Returns the first missing piece. /// /// SSH is checked before anything optional: locking the operator out is the one failure /// mode of this module that cannot be repaired remotely. 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 Some("missing input chain".into()); } if !nt.contains("type filter hook input") { return Some("missing input hook".into()); } let pol = format!( "policy {}", cfg.host_firewall .default_input_policy .trim() .to_ascii_lowercase() ); if !nt.contains(&pol) { return Some("wrong input policy".into()); } if !nt.contains("iif lo accept") { return Some("missing loopback allow".into()); } if !has_established_related_rule(nt) { return Some("missing established/related allow".into()); } if !nt.contains(&ssh) { 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 Some(format!("missing amnezia allow rule for {p}")); } } if cfg.host_firewall.allow_icmp { if !nt.contains("ip protocol icmp accept") { return Some("missing icmp allow rule".into()); } if !(nt.contains("ip6 nexthdr icmpv6 accept") || nt.contains("ip6 nexthdr ipv6-icmp accept")) { return Some("missing icmpv6 allow rule".into()); } } None } /// `nft` renders the state list in either order depending on version. fn has_established_related_rule(nt: &str) -> bool { nt.contains("ct state established related accept") || nt.contains("ct state related established accept") } #[cfg(test)] mod tests { use super::*; use crate::firewall::tests::test_config; #[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 = test_config(); let nt = normalize_nft_text(table); assert_eq!(host_firewall_drift_reason_from_text(&nt, &cfg), None); } #[test] fn normalized_established_related_rule_matches() { let nt = normalize_nft_text("ct state established,related accept"); assert!(nt.contains("ct state established related accept")); assert!(has_established_related_rule(&nt)); } #[test] fn host_firewall_drift_detects_missing_established_related_rule() { let table = r#" table inet awg2_host_firewall { chain input { type filter hook input priority filter; policy drop; iif "lo" accept tcp dport 2323 accept udp dport 443 accept ip protocol icmp accept ip6 nexthdr icmpv6 accept } } "#; let cfg = test_config(); let nt = normalize_nft_text(table); assert_eq!( host_firewall_drift_reason_from_text(&nt, &cfg), Some("missing established/related allow".into()) ); } #[test] fn host_firewall_batch_script_does_not_include_delete_table() { let cfg = test_config(); let script = build_host_firewall_batch_script(&cfg, "drop"); assert!(script.contains("add table inet awg2_host_firewall")); assert!(!script.contains("delete table inet awg2_host_firewall")); } #[test] fn host_firewall_policy_drift_check_trims_policy_value() { 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 icmpv6 accept } } "#; let mut cfg = test_config(); cfg.host_firewall.default_input_policy = " drop ".into(); let nt = normalize_nft_text(table); assert_eq!(host_firewall_drift_reason_from_text(&nt, &cfg), None); } }