dbceda5c13
firewall.rs held two subsystems that share nothing but the nft command layer: the host INPUT firewall, applied in the host namespace and owning inet awg2_host_firewall, and the anti-torrent guard, applied in the namespace chosen by config and owning inet awg2_harness. The split follows that boundary rather than an inspect/apply one: firewall/mod.rs apply_firewall, namespace selection, shared table constants firewall/nft.rs text normalization and the nft command wrappers firewall/host.rs host INPUT firewall and its 5 tests firewall/antitorrent.rs connection-count guard and its 6 tests Production logic is unchanged. Function sets before and after are identical: 25 against 25, verified by extracting names from the old blob and the four new files and diffing. Not a pure move in one respect, stated here so it is not a surprise in review: the test module contained four near-identical 45-line Config literals, about 180 lines of copy-paste, differing only in the single field each test actually cared about. Carrying four copies into the new files would have knowingly propagated the duplication, so they collapse into one test_config() helper in firewall/mod.rs that tests mutate. This is why the four files total 875 lines against the old 890 despite gaining doc comments. Doc comments now record the reasoning that was only implicit before: why nft output is compared after normalization (rendering differs across versions - quoting, comma-separated ct state lists, brace layout), why the host firewall batch never contains a delete (a partially applied INPUT chain could lock out SSH), why ct state new is load-bearing in the anti-torrent rule, why connlimit_state_present accepts two renderings, and why the anti-torrent rule defaults to the container namespace. IMPLEMENTATION_SPEC section 3 is updated: it described the old flat layout and carried the split as an unfulfilled intention. Both traffic and firewall now match what it documents. cargo test --workspace: 110 + 4 passed, unchanged count. clippy -D warnings clean.
243 lines
7.9 KiB
Rust
243 lines
7.9 KiB
Rust
//! 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<Option<String>> {
|
|
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<String> {
|
|
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);
|
|
}
|
|
}
|