diff --git a/docs/lxc-support/lxc-backend.md b/docs/lxc-support/lxc-backend.md index b94cd2861..72c186c57 100644 --- a/docs/lxc-support/lxc-backend.md +++ b/docs/lxc-support/lxc-backend.md @@ -109,18 +109,30 @@ Filesystem policies are enforced via bind mounts in the container configuration: ## Network Policy -Network policies are enforced via iptables/nftables rules applied to the container's virtual ethernet (veth) interface: +Network policies are enforced with parallel `iptables` and `ip6tables` chains scoped to the container's virtual ethernet (veth) interface: | Policy | Implementation | |--------|---------------| -| `defaultPolicy: "block"` | Default DROP rule on container veth | -| `defaultPolicy: "allow"` | Default ACCEPT rule on container veth | -| `allowedHosts` | ACCEPT rules for specific IPs/CIDRs | -| `blockedHosts` | DROP rules for specific IPs/CIDRs | +| `defaultPolicy: "block"` | Final DROP rule in the container chain | +| `defaultPolicy: "allow"` | Final ACCEPT rule in the container chain | +| `allowedHosts` | ACCEPT rules for IP literals, CIDR blocks, or resolved hostnames | +| `blockedHosts` | DROP rules for IP literals, CIDR blocks, or resolved hostnames | -Rules are automatically cleaned up when the container exits (if `removeRulesOnExit` is `true`). +`allowedHosts` and `blockedHosts` entries may be bare IPv4/IPv6 literals, IPv4/IPv6 CIDR blocks, or hostnames. Hostnames are resolved to both A and AAAA records; IPv4 destinations are applied to the `iptables` chain and IPv6 destinations are applied to the `ip6tables` chain. Entries whose CIDR prefix is out of range for its family (or otherwise malformed) are reported as unresolved and skipped, leaving the rest of the policy in force. Host-list rules match all ports and protocols; port- and protocol-specific egress rules are not supported. -**IPv4 only.** Firewall mode resolves `allowedHosts` / `blockedHosts` to IPv4 addresses only; AAAA (IPv6) records and IPv6 literals are silently dropped. A host that has only AAAA records is effectively unreachable from the sandbox under firewall mode. +Before programming the IPv6 chain, MXC probes `ip6tables` with a read-only `ip6tables -S` and classifies the result three ways: + +| Classification | Condition | Behavior | +|----------------|-----------|----------| +| `Available` | The `ip6tables` probe succeeds | Programs the parallel `ip6tables` chain alongside the IPv4 chain | +| `KernelIpv6Disabled` | The probe fails **and** the host has no active IPv6 | Skips the IPv6 chain and logs that there is no IPv6 egress to filter — safe, because there is nothing to filter | +| `UnusableButIpv6Active` | The probe fails **and** the host has active IPv6 | **Fails firewall setup** rather than applying an IPv4-only policy that would silently leave IPv6 egress unfiltered | + +Host IPv6 activity is read from `/proc/net/if_inet6`: a non-loopback interface with an IPv6 address counts as active, while loopback-only `::1` on `lo` (present even on IPv4-only hosts) does not. If that file cannot be read at all — as opposed to being absent, which means IPv6 is disabled — the state is treated as *unknown* rather than as a confirmed "IPv6 is off", so an unreadable IPv6 state fails closed instead of leaving IPv6 unfiltered. + +The chains are hooked into `FORWARD` for container egress by matching the host-side veth as the input interface. If MXC cannot discover the container veth, it skips the `FORWARD` hook with a warning rather than applying host-wide rules. + +Firewall state is torn down automatically with best-effort removal of the `FORWARD` hooks and both per-container chains; there is no network-policy opt-out field. Setup failures after partial creation are rolled back before returning an error, so retries do not trip over leftover chains. ## Usage diff --git a/src/backends/lxc/common/src/network_iptables.rs b/src/backends/lxc/common/src/network_iptables.rs index 83374c1e8..79f5fe96f 100644 --- a/src/backends/lxc/common/src/network_iptables.rs +++ b/src/backends/lxc/common/src/network_iptables.rs @@ -4,14 +4,169 @@ //! Network policy enforcement via iptables rules scoped to the LXC container. //! //! Maps the platform-agnostic `ContainerPolicy` network settings to iptables -//! rules applied to the container's virtual ethernet (veth) interface. +//! and ip6tables rules applied to the container's virtual ethernet (veth) +//! interface. -use std::net::ToSocketAddrs; +use std::net::{IpAddr, Ipv6Addr, ToSocketAddrs}; use std::process::Command; use wxc_common::logger::Logger; use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode, NetworkPolicy}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IpFamily { + V4, + V6, +} + +/// Whether a host-list entry produces an ACCEPT or a DROP rule. Local to this +/// backend: it distinguishes `allowedHosts` from `blockedHosts` and is not a +/// policy-schema type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RuleAction { + Allow, + Deny, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct ResolvedDestinations { + ipv4: Vec, + ipv6: Vec, +} + +impl ResolvedDestinations { + fn is_empty(&self) -> bool { + self.ipv4.is_empty() && self.ipv6.is_empty() + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct FirewallRuleArgs { + ipv4: Vec>, + ipv6: Vec>, +} + +impl FirewallRuleArgs { + fn extend(&mut self, other: FirewallRuleArgs) { + self.ipv4.extend(other.ipv4); + self.ipv6.extend(other.ipv6); + } +} + +/// Records exactly which per-family chains and FORWARD hooks a single apply +/// attempt created, so rollback and teardown remove only what this manager +/// installed. Without this, a partial-failure rollback would tear down chains +/// this attempt never created, and because chain names truncate at 20 chars a +/// torn-down chain can belong to a different container. +/// +/// Visible to the crate (with private fields) purely so `signal_cleanup` can +/// carry the value from the runner thread to the watchdog thread. The watchdog +/// never inspects it; it only hands it back to [`NetworkIptablesManager::force_cleanup`]. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CreatedResources { + v4_chain: bool, + v6_chain: bool, + v4_hook: bool, + v6_hook: bool, +} + +/// Flush and delete the chain, reporting whether it is still owned afterward. +/// +/// `hooks_remain` gates the entire step, flush included. That ordering is the +/// point of the function: a hook that survived its own delete still jumps to +/// this chain, and a flushed chain returns to the caller instead of reaching +/// its closing DROP. Flushing first would therefore fail a still-running +/// container open, and `-X` would fail anyway because iptables refuses to +/// delete a referenced chain -- so the flush buys nothing and costs the +/// container its filtering. Leaving the chain populated keeps the intermediate +/// state fail closed, and returning `true` keeps it published so a later pass +/// retries. +fn teardown_chain( + created_chain: bool, + hooks_remain: bool, + logger: &mut Logger, + mut flush: impl FnMut(&mut Logger), + mut delete: impl FnMut(&mut Logger) -> bool, +) -> bool { + if !created_chain { + return false; + } + if hooks_remain { + return true; + } + flush(logger); + !delete(logger) +} + +impl CreatedResources { + /// Whether nothing was created, in which case there is nothing to tear + /// down and teardown must not run a single iptables command. + /// + /// Only reachable from the signal path, which is Linux-only; kept + /// compiled on every target so Windows and macOS CI still type-check it. + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + fn is_empty(&self) -> bool { + !self.v4_chain && !self.v6_chain && !self.v4_hook && !self.v6_hook + } + + /// Test-only constructor so `signal_cleanup`'s tests can build a + /// distinguishable, non-default ownership record without widening the + /// production API. Production code only ever obtains one of these by + /// creating the resources it names. + #[cfg(test)] + pub(crate) fn for_test(v4_chain: bool, v6_chain: bool, v4_hook: bool, v6_hook: bool) -> Self { + Self { + v4_chain, + v6_chain, + v4_hook, + v6_hook, + } + } +} + +/// Three-way classification of whether `ip6tables` can be used on this host. +/// +/// The old boolean probe collapsed two very different situations into "skip +/// IPv6": a kernel with IPv6 disabled (nothing to filter, safe to skip) and an +/// IPv6-capable host whose `ip6tables` userspace tool is missing or broken +/// (IPv6 egress is live but unfiltered, which is a silent fail-open on a +/// security control). They must be handled differently. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Ip6tablesStatus { + /// `ip6tables` works; program the parallel IPv6 chain. + Available, + /// The kernel has no active IPv6, so there is no IPv6 traffic to filter. + /// Skipping the IPv6 chain is safe. + KernelIpv6Disabled, + /// The host has active IPv6 but `ip6tables` is missing or broken. Applying + /// only the IPv4 policy would leave IPv6 egress unfiltered, so setup must + /// fail closed instead. + UnusableButIpv6Active, +} + +/// Whether the host has egress-capable IPv6, or whether that could not be +/// determined. +/// +/// Distinguishing `Unknown` from `Inactive` keeps a failed read of +/// `/proc/net/if_inet6` from being silently converted into a confirmed "IPv6 +/// is off". That conflation would fail open — proceeding with an IPv4-only +/// policy that leaves IPv6 egress unfiltered — on a host whose IPv6 state we +/// could not actually read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HostIpv6State { + /// A non-loopback interface carries an IPv6 address, so IPv6 egress is + /// possible and must be filtered. + Active, + /// No IPv6 addresses beyond loopback (`::1` on `lo`), or the kernel never + /// created `/proc/net/if_inet6` at all (IPv6 disabled at boot). Either way + /// there is no IPv6 egress to filter. + Inactive, + /// The IPv6 state could not be read. This is deliberately **not** treated + /// as a confirmed negative: an unreadable `/proc/net/if_inet6` means "we + /// do not know", not "IPv6 is off". + Unknown, +} + /// Manages iptables rules for an LXC container's network policy. pub struct NetworkIptablesManager { /// Chain name unique to this container (e.g., "MXC-"). @@ -20,6 +175,9 @@ pub struct NetworkIptablesManager { rules_applied: bool, /// The container's veth interface name on the host. veth_interface: Option, + /// Chains and FORWARD hooks this manager successfully created, so teardown + /// and rollback remove only resources this attempt actually installed. + created: CreatedResources, } impl NetworkIptablesManager { @@ -36,6 +194,7 @@ impl NetworkIptablesManager { chain_name: format!("MXC-{}", sanitized), rules_applied: false, veth_interface: None, + created: CreatedResources::default(), } } @@ -81,84 +240,171 @@ impl NetworkIptablesManager { self.veth_interface = Some(iface.to_string()); } - /// Resolve a hostname to IPv4 addresses. + /// Resolve a destination string to IPv4 and IPv6 firewall destinations. /// - /// IPv6 records (AAAA from DNS, or IPv6 literals like `"::1"` / - /// IPv4-mapped IPv6 like `"::ffff:127.0.0.1"`) are silently dropped - /// because `apply_firewall_rules` only invokes `iptables` (the IPv4 - /// tool), which rejects IPv6 destinations. Full dual-stack support - /// via parallel `ip6tables` rules would require a separate change. - /// A host that resolves only to AAAA records will return an empty - /// vec, meaning no allow/deny rule is emitted and the host is - /// effectively unreachable from the sandbox under firewall mode. - fn resolve_host(host: &str) -> Vec { - // Try as IP address first - if let Ok(addr) = host.parse::() { - return if addr.is_ipv4() { - vec![host.to_string()] - } else { - Vec::new() + /// Bare IPv4/IPv6 literals are retained in their matching family. CIDR + /// strings are accepted after validating that the address parses and the + /// prefix length is within range for its family; the host bits are not + /// required to be zero, since `iptables`/`ip6tables` apply the prefix mask + /// themselves. Validated CIDRs are passed through unchanged. Hostnames are + /// resolved to both A and AAAA records so IPv4 destinations route to + /// `iptables` and IPv6 destinations route to `ip6tables`. + fn resolve_host(host: &str) -> ResolvedDestinations { + // An empty entry is not a hostname. Without this guard the DNS branch + // below formats ":0", which Winsock resolves to every local interface + // address, so an empty policy entry would emit rules for the host's + // own addresses. glibc rejects ":0", so this only shows up on Windows. + if host.trim().is_empty() { + return ResolvedDestinations::default(); + } + + // Rewrite IPv4-mapped destinations to their embedded IPv4 form before + // the family split, so they are filed under IPv4 and programmed with + // `iptables`. + let rewritten = Self::ipv4_mapped_destination(host); + let host = rewritten.as_deref().unwrap_or(host); + + if host.contains('/') { + return match Self::destination_family(host) { + Some(IpFamily::V4) => ResolvedDestinations { + ipv4: vec![host.to_string()], + ipv6: Vec::new(), + }, + Some(IpFamily::V6) => ResolvedDestinations { + ipv4: Vec::new(), + ipv6: vec![host.to_string()], + }, + None => ResolvedDestinations::default(), }; } - // Try DNS resolution - match format!("{}:0", host).to_socket_addrs() { - Ok(addrs) => addrs - .map(|a| a.ip()) - .filter(|ip| ip.is_ipv4()) - .map(|ip| ip.to_string()) - .collect(), - Err(_) => Vec::new(), + // Try as IP address first. + if let Ok(addr) = host.parse::() { + return match addr { + IpAddr::V4(_) => ResolvedDestinations { + ipv4: vec![host.to_string()], + ipv6: Vec::new(), + }, + IpAddr::V6(_) => ResolvedDestinations { + ipv4: Vec::new(), + ipv6: vec![host.to_string()], + }, + }; } - } - /// Run an iptables command and return success/failure. - fn run_iptables(args: &[&str], logger: &mut Logger) -> Result { - let output = Command::new("iptables") - .args(args) - .output() - .map_err(|e| format!("Failed to run iptables: {}", e))?; + // Try DNS resolution. The family split is factored into + // `bucket_resolved_addrs` so it can be exercised with injected + // addresses, independent of whether this host has live IPv6 DNS. + if let Ok(addrs) = format!("{}:0", host).to_socket_addrs() { + return Self::bucket_resolved_addrs(addrs.map(|addr| addr.ip())); + } + ResolvedDestinations::default() + } - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let msg = format!("iptables {} failed: {}", args.join(" "), stderr); - logger.log_line(&msg); - return Err(msg); + /// Split resolved addresses into per-family destination buckets: every A + /// record lands in the IPv4 bucket and every AAAA record in the IPv6 + /// bucket. Pure so the bucketing — the step that keeps an AAAA record from + /// being handed to `iptables` (the dual-stack bypass AB#62830559 exists to + /// close) — can be asserted with injected input rather than depending on + /// the host having live IPv6 DNS. + fn bucket_resolved_addrs>(addrs: I) -> ResolvedDestinations { + let mut resolved = ResolvedDestinations::default(); + for ip in addrs { + match ip { + IpAddr::V4(ip) => resolved.ipv4.push(ip.to_string()), + // A resolver can return a AAAA record in mapped form. It + // travels as IPv4 on the wire, so it belongs in the IPv4 + // bucket — see `ipv4_mapped_destination`. + IpAddr::V6(ip) => match ip.to_ipv4_mapped() { + Some(v4) => resolved.ipv4.push(v4.to_string()), + None => resolved.ipv6.push(ip.to_string()), + }, + } } + resolved + } - Ok(true) + /// Rewrite an IPv4-mapped IPv6 destination to its embedded IPv4 form, + /// returning `None` when `destination` is not mapped. + /// + /// Linux puts a genuine IPv4 packet on the wire for a mapped destination, + /// so an `ip6tables -d ::ffff:a.b.c.d` rule names traffic that never + /// reaches the IPv6 table and therefore never matches. Under a + /// `defaultPolicy: allow` policy a mapped `blockedHosts` entry would fail + /// open: the operator sees a rule programmed, and the traffic is allowed + /// anyway. Rewriting to `a.b.c.d` files the entry under `iptables`, where + /// it matches. + /// + /// Handles CIDRs inside `::ffff:0:0/96` as well. Because the mapped range + /// is the final 32 bits of that /96, an IPv6 prefix of `96 + n` is exactly + /// an IPv4 prefix of `n`. A prefix shorter than 96 covers addresses + /// outside the mapped range and cannot be expressed as one IPv4 CIDR, so + /// it is left as IPv6. + fn ipv4_mapped_destination(destination: &str) -> Option { + let Some((network, prefix)) = destination.split_once('/') else { + return destination + .parse::() + .ok()? + .to_ipv4_mapped() + .map(|v4| v4.to_string()); + }; + + // Match `destination_family`'s digits-only rule so this rewrite cannot + // launder a malformed prefix (`/+120`) into a well-formed IPv4 CIDR. + if prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let mapped = network.parse::().ok()?.to_ipv4_mapped()?; + let v4_prefix = prefix.parse::().ok()?.checked_sub(96)?; + if v4_prefix > 32 { + return None; + } + Some(format!("{}/{}", mapped, v4_prefix)) } - /// Apply network firewall rules based on the container policy. - pub fn apply_firewall_rules( - &mut self, - policy: &ContainerPolicy, - logger: &mut Logger, - ) -> Result { - // Skip if network enforcement doesn't use firewall - let use_firewall = matches!( - policy.network_enforcement_mode, - NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both - ); - if !use_firewall { - logger.log_line("Network enforcement mode does not use firewall, skipping iptables."); - return Ok(true); + fn destination_family(destination: &str) -> Option { + if let Some((network, prefix)) = destination.split_once('/') { + // The prefix must be digits only. `u8::from_str` would otherwise + // accept a leading `+`, so `10.0.0.0/+24` would be forwarded to + // iptables, which silently canonicalizes it to `10.0.0.0/24`. A + // typo in a policy file would then be applied instead of being + // reported by the unresolved-host warning. Also subsumes the + // embedded-slash case, e.g. `10.0.0.0/20/8`. + if network.is_empty() + || prefix.is_empty() + || !prefix.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + + let addr = network.parse::().ok()?; + let prefix = prefix.parse::().ok()?; + return match addr { + IpAddr::V4(_) if prefix <= 32 => Some(IpFamily::V4), + IpAddr::V6(_) if prefix <= 128 => Some(IpFamily::V6), + _ => None, + }; } - logger.log_line(&format!("Creating iptables chain: {}", self.chain_name)); + match destination.parse::().ok()? { + IpAddr::V4(_) => Some(IpFamily::V4), + IpAddr::V6(_) => Some(IpFamily::V6), + } + } - // Create custom chain - Self::run_iptables(&["-N", &self.chain_name], logger)?; + fn rule_action_arg(action: &RuleAction) -> &'static str { + match action { + RuleAction::Allow => "ACCEPT", + RuleAction::Deny => "DROP", + } + } - // Always allow loopback and established connections - Self::run_iptables( - &["-A", &self.chain_name, "-i", "lo", "-j", "ACCEPT"], - logger, - )?; - Self::run_iptables( - &[ + fn build_base_chain_rule_args(chain_name: &str) -> Vec> { + vec![ + vec!["-A", chain_name, "-i", "lo", "-j", "ACCEPT"], + vec![ "-A", - &self.chain_name, + chain_name, "-m", "state", "--state", @@ -166,196 +412,2645 @@ impl NetworkIptablesManager { "-j", "ACCEPT", ], - logger, - )?; - - // Allow DNS (needed for hostname resolution) - Self::run_iptables( - &[ - "-A", - &self.chain_name, - "-p", - "udp", - "--dport", - "53", - "-j", - "ACCEPT", + vec![ + "-A", chain_name, "-p", "udp", "--dport", "53", "-j", "ACCEPT", ], - logger, - )?; - Self::run_iptables( - &[ - "-A", - &self.chain_name, - "-p", - "tcp", - "--dport", - "53", - "-j", - "ACCEPT", + vec![ + "-A", chain_name, "-p", "tcp", "--dport", "53", "-j", "ACCEPT", ], - logger, - )?; + ] + .into_iter() + .map(|args| args.into_iter().map(String::from).collect()) + .collect() + } + + fn build_default_policy_rule_arg(chain_name: &str, policy: NetworkPolicy) -> Vec { + let default_action = match policy { + NetworkPolicy::Block => "DROP", + NetworkPolicy::Allow => "ACCEPT", + }; + vec!["-A", chain_name, "-j", default_action] + .into_iter() + .map(String::from) + .collect() + } + + fn build_resolved_destination_rule_args( + chain_name: &str, + destinations: &ResolvedDestinations, + action: &RuleAction, + ) -> FirewallRuleArgs { + let mut args = FirewallRuleArgs::default(); + for destination in &destinations.ipv4 { + args.ipv4.push(Self::build_single_rule_args( + chain_name, + destination, + action, + )); + } + for destination in &destinations.ipv6 { + args.ipv6.push(Self::build_single_rule_args( + chain_name, + destination, + action, + )); + } + args + } + + fn build_single_rule_args( + chain_name: &str, + destination: &str, + action: &RuleAction, + ) -> Vec { + vec![ + "-A".to_string(), + chain_name.to_string(), + "-d".to_string(), + destination.to_string(), + "-j".to_string(), + Self::rule_action_arg(action).to_string(), + ] + } + + /// Build the allow/deny rule args for a single host by resolving it once. + /// Test-only: production goes through [`Self::build_policy_rules_logged`], + /// which resolves every entry exactly once and reuses that result for both + /// the unresolved-host warning and rule construction. + #[cfg(test)] + fn build_host_rule_args(chain_name: &str, host: &str, action: &RuleAction) -> FirewallRuleArgs { + let destinations = Self::resolve_host(host); + Self::build_resolved_destination_rule_args(chain_name, &destinations, action) + } + + /// Build the allow/deny rule args for a container policy. + /// + /// Test-only shim over the shipping path [`Self::build_policy_rules_logged`] + /// so the rulegen spec assertions — including the allow-before-block + /// ordering that is a tracked security-semantics contract (AB#62830341) — + /// bind to the code that actually runs, not to a duplicate iteration. The + /// unresolved-host warning is irrelevant to rule generation, so it is + /// discarded to a buffer logger. Production must never call this: it takes + /// no logger and would resolve entries a second time relative to the + /// warning pass. + #[cfg(test)] + fn build_policy_rule_args(chain_name: &str, policy: &ContainerPolicy) -> FirewallRuleArgs { + let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); + Self::build_policy_rules_logged(chain_name, policy, &mut logger) + } - // Add allowed host rules - for host in &policy.allowed_hosts { - let ips = Self::resolve_host(host); - if ips.is_empty() { + /// Resolve every allow/block entry exactly once and build the rule args + /// from that single resolution, logging a warning for any entry that + /// resolved to nothing. This is the shipping rule-generation path. + /// + /// Resolving once is a correctness requirement, not just an optimization: + /// the previous apply path resolved each host once for the warning pass + /// and again inside rule construction, and two lookups of the same name + /// can disagree — DNS round-robin returns a different address, or a TTL + /// expires between the calls — so the rule installed would not match the + /// rule that was validated and logged. + /// + /// NOTE — interim ordering (tracked by AB#62830341): rules are emitted in + /// allow-list then block-list order, and iptables/ip6tables apply + /// first-match-wins within the chain. This model-1 change therefore does + /// **not** yet implement deny-precedence: a destination present in both + /// the allow and block lists is ACCEPTed. Reconciling this to the GA + /// "deny-wins" ordering is owned by net-model-2 (AB#62830341); until then + /// callers must not assume deny-precedence. + fn build_policy_rules_logged( + chain_name: &str, + policy: &ContainerPolicy, + logger: &mut Logger, + ) -> FirewallRuleArgs { + let mut args = FirewallRuleArgs::default(); + let entries = policy + .allowed_hosts + .iter() + .map(|host| (host, RuleAction::Allow)) + .chain( + policy + .blocked_hosts + .iter() + .map(|host| (host, RuleAction::Deny)), + ); + for (host, action) in entries { + let destinations = Self::resolve_host(host); + if destinations.is_empty() { logger.log_line(&format!("Warning: could not resolve host '{}'", host)); - continue; } - for ip in &ips { - logger.log_line(&format!("Allowing host: {} ({})", host, ip)); - Self::run_iptables(&["-A", &self.chain_name, "-d", ip, "-j", "ACCEPT"], logger)?; + let rule_args = + Self::build_resolved_destination_rule_args(chain_name, &destinations, &action); + // Log each destination rule that will be programmed, derived from + // the built args rather than from `destinations`, so that removing + // destination-rule emission also removes these lines. This is the + // observable surface the end-to-end scripts assert on to prove a + // rule for a specific destination was actually generated while the + // chain is live (a warning-only or chain-only run would not). + for rule in &rule_args.ipv4 { + logger.log_line(&format!("Programmed iptables rule: {}", rule.join(" "))); + } + for rule in &rule_args.ipv6 { + logger.log_line(&format!("Programmed ip6tables rule: {}", rule.join(" "))); } + args.extend(rule_args); } + args + } - // Add blocked host rules - for host in &policy.blocked_hosts { - let ips = Self::resolve_host(host); - if ips.is_empty() { - logger.log_line(&format!("Warning: could not resolve host '{}'", host)); - continue; + /// Run an iptables command and return success/failure. + fn run_iptables(args: &[&str], logger: &mut Logger) -> Result { + Self::run_firewall_command("iptables", args, logger) + } + + /// Run an ip6tables command and return success/failure. + fn run_ip6tables(args: &[&str], logger: &mut Logger) -> Result { + Self::run_firewall_command("ip6tables", args, logger) + } + + /// Classify whether `ip6tables` is usable, given whether the read-only + /// probe succeeded and whether the host currently has active IPv6. Pure so + /// the fail-open-vs-fail-closed decision can be unit-tested without a + /// privileged Linux host. + /// + /// A working probe means the tool is usable regardless of address state. + /// A failed probe splits on whether IPv6 is live: if the kernel has no + /// active IPv6 there is nothing to filter and skipping is safe, but if + /// IPv6 is live the tool is genuinely missing or broken and setup must + /// fail closed rather than leave IPv6 egress unfiltered. + fn classify_ip6tables_status(probe_succeeded: bool, host_ipv6_active: bool) -> Ip6tablesStatus { + match (probe_succeeded, host_ipv6_active) { + (true, _) => Ip6tablesStatus::Available, + (false, true) => Ip6tablesStatus::UnusableButIpv6Active, + (false, false) => Ip6tablesStatus::KernelIpv6Disabled, + } + } + + /// Whether the host has an active, egress-capable IPv6 stack, independent + /// of `ip6tables`. + /// + /// Reads `/proc/net/if_inet6` and defers the parse/classify decision to + /// [`Self::classify_host_ipv6_state`] so the file-content → state mapping + /// is unit-testable without a privileged Linux host. Also reports whether + /// `/proc/net` exists, which is what separates "the kernel has IPv6 off" + /// from "`/proc` is not mounted here". + fn host_ipv6_state() -> HostIpv6State { + Self::classify_host_ipv6_state( + std::fs::read_to_string("/proc/net/if_inet6"), + std::path::Path::new("/proc/net").is_dir(), + ) + } + + /// Classify host IPv6 activity from the result of reading + /// `/proc/net/if_inet6`. Pure so every branch — including the read-error + /// case — can be exercised with injected input. + /// + /// `/proc/net/if_inet6` is populated by the kernel only when the IPv6 + /// module is loaded, and lists one interface IPv6 address per line with + /// the device name in the final whitespace-delimited field. Loopback + /// (`::1` on `lo`) is present even on IPv4-only hosts and is not + /// egress-capable, so a line is treated as evidence of active IPv6 only + /// when its device is something other than `lo`. + /// + /// The error handling is deliberate: + /// - A `NotFound` error **while `/proc/net` exists** means the kernel + /// never created the file (IPv6 disabled at boot via `ipv6.disable=1`, + /// or the module is not loaded). That is a genuine, confirmed negative + /// → `Inactive`. + /// - A `NotFound` error when `/proc/net` is *also* absent says nothing + /// about IPv6: `/proc` is not mounted, so the probe never ran. Both + /// cases surface as the same `ErrorKind`, so without the directory + /// check an unmounted `/proc` would be read as a confirmed "IPv6 is + /// off" → `Unknown`. + /// - Any other read error (permission denied, I/O error) likewise leaves + /// the state `Unknown` rather than asserting IPv6 is off. Converting + /// such an error into `Inactive` would fail open. + fn classify_host_ipv6_state( + read_result: std::io::Result, + proc_net_present: bool, + ) -> HostIpv6State { + match read_result { + Ok(contents) => { + let has_egress_capable_interface = contents.lines().any(|line| { + let line = line.trim(); + if line.is_empty() { + return false; + } + // The device name is the final field; loopback carries only + // `::1`, which is not egress-capable. + match line.split_whitespace().last() { + Some(device) => device != "lo", + None => false, + } + }); + if has_egress_capable_interface { + HostIpv6State::Active + } else { + HostIpv6State::Inactive + } } - for ip in &ips { - logger.log_line(&format!("Blocking host: {} ({})", host, ip)); - Self::run_iptables(&["-A", &self.chain_name, "-d", ip, "-j", "DROP"], logger)?; + // A missing `/proc/net/if_inet6` is only evidence that IPv6 is off + // when `/proc/net` itself is there. Both an IPv6-disabled kernel + // and an unmounted `/proc` report `NotFound` for the file, and + // treating the second as "IPv6 is off" would fail open on a host + // whose IPv6 state was never actually read. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if proc_net_present { + HostIpv6State::Inactive + } else { + HostIpv6State::Unknown + } } + Err(_) => HostIpv6State::Unknown, } + } - // Append default policy at end of chain - let default_action = match policy.default_network_policy { - NetworkPolicy::Block => "DROP", - NetworkPolicy::Allow => "ACCEPT", - }; - logger.log_line(&format!("Default network policy: {}", default_action)); - Self::run_iptables(&["-A", &self.chain_name, "-j", default_action], logger)?; - - // Hook the chain into FORWARD for the container's traffic - if let Some(ref iface) = self.veth_interface { - Self::run_iptables( - &["-I", "FORWARD", "-o", iface, "-j", &self.chain_name], - logger, - )?; - } else { - // Without a veth interface, we cannot safely scope rules to the container. - // Refuse to apply host-wide rules to avoid affecting all host traffic. + /// Whether the IPv6 status probe should treat the host as capable of IPv6 + /// egress. Reads the host state, logs the `Unknown` case distinctly so the + /// uncertainty is visible in the run output, then defers the mapping to the + /// pure [`Self::ipv6_state_treated_as_active`]. + fn host_ipv6_egress_possible(logger: &mut Logger) -> bool { + let state = Self::host_ipv6_state(); + if state == HostIpv6State::Unknown { logger.log_line( - "Warning: No veth interface set for container. \ - Cannot scope iptables rules. Skipping FORWARD hook.", + "Could not read /proc/net/if_inet6 to determine host IPv6 state; \ + treating IPv6 as potentially active and refusing to fail open.", ); } - - self.rules_applied = true; - Ok(true) + Self::ipv6_state_treated_as_active(state) } - /// Remove all iptables rules created by this manager. - pub fn remove_firewall_rules(&mut self, logger: &mut Logger) -> Result<(), String> { - if !self.rules_applied { - return Ok(()); + /// Map a host IPv6 state to whether the `ip6tables` probe should treat IPv6 + /// as active. `Active` obviously counts; `Unknown` also counts, because an + /// unreadable IPv6 state must not be silently downgraded to "IPv6 is off" — + /// under a drop-required stance the safe reaction to "we do not know" is to + /// keep filtering (and, if `ip6tables` is then unusable, to fail closed) + /// rather than to leave IPv6 egress unfiltered. Pure so the decision is + /// unit-testable. + fn ipv6_state_treated_as_active(state: HostIpv6State) -> bool { + match state { + HostIpv6State::Active | HostIpv6State::Unknown => true, + HostIpv6State::Inactive => false, } + } - logger.log_line(&format!("Removing iptables chain: {}", self.chain_name)); + /// Probe whether `ip6tables` can be used on this host and classify the + /// result. Runs a harmless, read-only `ip6tables -S` (list the filter + /// table), then distinguishes a kernel with IPv6 disabled (safe to skip + /// the parallel v6 chain) from an IPv6-capable host whose `ip6tables` is + /// missing or broken (must fail setup, since applying only the v4 policy + /// would silently leave IPv6 egress unfiltered). + fn ip6tables_status(logger: &mut Logger) -> Ip6tablesStatus { + let probe_succeeded = Self::ip6tables_probe_succeeded(logger); - // Remove from FORWARD (only if we had a veth interface and hooked it) - if let Some(ref iface) = self.veth_interface { - let _ = Self::run_iptables( - &["-D", "FORWARD", "-o", iface, "-j", &self.chain_name], - logger, - ); + let status = Self::classify_ip6tables_status( + probe_succeeded, + Self::host_ipv6_egress_possible(logger), + ); + match status { + Ip6tablesStatus::Available => {} + Ip6tablesStatus::KernelIpv6Disabled => { + logger.log_line( + "Kernel IPv6 is not active; skipping IPv6 firewall rules \ + (no IPv6 egress to filter).", + ); + } + Ip6tablesStatus::UnusableButIpv6Active => { + logger.log_line( + "ip6tables is unusable but the host has active IPv6; \ + failing firewall setup to avoid leaving IPv6 egress unfiltered.", + ); + } } + status + } - // Flush and delete the chain - let _ = Self::run_iptables(&["-F", &self.chain_name], logger); - let _ = Self::run_iptables(&["-X", &self.chain_name], logger); + /// Run the read-only `ip6tables -S` probe, reporting whether the tool is + /// usable. + /// + /// Split out from [`Self::ip6tables_status`] because it is the second of + /// this file's two process spawns and the apply path reaches it before any + /// chain exists, so a test that does not intercept it takes a different + /// branch depending on the host's `ip6tables`. + fn ip6tables_probe_succeeded(logger: &mut Logger) -> bool { + #[cfg(test)] + if let Some(succeeded) = test_firewall::intercept_ip6tables_probe() { + return succeeded; + } - self.rules_applied = false; - Ok(()) + match Command::new("ip6tables").arg("-S").output() { + Ok(output) if output.status.success() => true, + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + logger.log_line(&format!("ip6tables probe failed ({})", stderr.trim())); + false + } + Err(e) => { + logger.log_line(&format!("ip6tables not found ({})", e)); + false + } + } } - /// Best-effort cleanup of any iptables state the runner may have - /// installed for a container, used when the original - /// `NetworkIptablesManager` instance isn't reachable (e.g. signal-time - /// cleanup from the watchdog thread). Builds a fresh manager pointed at - /// the same chain name so `remove_firewall_rules` does its work - /// regardless of whether rules were actually installed; iptables itself - /// is the source of truth. - pub fn force_cleanup(container_name: &str, veth_interface: Option<&str>, logger: &mut Logger) { - let mut mgr = Self::new(container_name); - if let Some(v) = veth_interface { - mgr.set_veth_interface(v); + fn run_firewall_command( + command: &str, + args: &[&str], + logger: &mut Logger, + ) -> Result { + // A unit test may install a fake to record this command and supply its + // outcome, so the test states its own precondition instead of + // inheriting one from whatever `iptables` the host happens to have. + // Interception is opt-in: with no fake installed the real binary runs + // exactly as it did before, so tests that do not opt in are unaffected. + #[cfg(test)] + if let Some(outcome) = test_firewall::intercept(command, args) { + return match outcome { + Ok(()) => Ok(true), + Err(stderr) => Err(Self::log_command_failure(command, args, &stderr, logger)), + }; } - // Bypass the rules_applied gate; if there's nothing to remove the - // iptables `-D`/`-F`/`-X` calls just no-op. - mgr.rules_applied = true; - let _ = mgr.remove_firewall_rules(logger); + + let output = Command::new(command) + .args(args) + .output() + .map_err(|e| format!("Failed to run {}: {}", command, e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Self::log_command_failure(command, args, &stderr, logger)); + } + + Ok(true) } -} -impl Drop for NetworkIptablesManager { - fn drop(&mut self) { - if self.rules_applied { - let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); - let _ = self.remove_firewall_rules(&mut logger); + /// Log a failed firewall command and return the message that becomes the + /// error. Shared by the real path and the test fake so a scripted failure + /// produces the same log line and error text as a genuine one, rather than + /// the fake reimplementing the format and drifting from it. + fn log_command_failure( + command: &str, + args: &[&str], + stderr: &str, + logger: &mut Logger, + ) -> String { + let msg = format!("{} {} failed: {}", command, args.join(" "), stderr); + logger.log_line(&msg); + msg + } + + fn run_iptables_rule_args(args: &[Vec], logger: &mut Logger) -> Result<(), String> { + for rule in args { + let rule_args: Vec<&str> = rule.iter().map(String::as_str).collect(); + Self::run_iptables(&rule_args, logger)?; } + Ok(()) } -} -#[cfg(test)] -mod tests { - use super::*; + fn run_ip6tables_rule_args(args: &[Vec], logger: &mut Logger) -> Result<(), String> { + for rule in args { + let rule_args: Vec<&str> = rule.iter().map(String::as_str).collect(); + Self::run_ip6tables(&rule_args, logger)?; + } + Ok(()) + } - #[test] - fn chain_name_sanitization() { - let mgr = NetworkIptablesManager::new("my-container_123"); - assert_eq!(mgr.chain_name, "MXC-my-container_123"); + /// Whether the given enforcement mode is served by the iptables firewall + /// backend. Pure and side-effect-free so the gate can be exercised without + /// invoking the host firewall. + fn enforcement_mode_uses_firewall(mode: &NetworkEnforcementMode) -> bool { + matches!( + mode, + NetworkEnforcementMode::Firewall | NetworkEnforcementMode::Both + ) } - #[test] - fn chain_name_truncation() { - let long_name = "a".repeat(50); - let mgr = NetworkIptablesManager::new(&long_name); - // 4 chars for "MXC-" + 20 chars max - assert!(mgr.chain_name.len() <= 24); + /// Apply network firewall rules based on the container policy. + /// + /// On any failure after resources are created, the inner call rolls back + /// exactly the per-family chains and FORWARD hooks this attempt installed + /// before the error is returned, so a retry does not trip over a leftover + /// `MXC-` chain ("chain already exists") and a partial failure never + /// tears down a chain this attempt did not create. + pub fn apply_firewall_rules( + &mut self, + policy: &ContainerPolicy, + logger: &mut Logger, + ) -> Result { + // Skip if network enforcement doesn't use firewall. + if !Self::enforcement_mode_uses_firewall(&policy.network_enforcement_mode) { + logger.log_line("Network enforcement mode does not use firewall, skipping iptables."); + return Ok(true); + } + + // Both arms below replace `self.created` with this attempt's set, so a + // second apply on a manager that still owns resources would drop the + // earlier record and strand whatever it named. Every caller builds a + // manager immediately before its single apply, so refusing here costs + // nothing and makes the hazard unreachable rather than leaving it to + // callers to avoid. + if self.rules_applied { + return Err(format!( + "Firewall rules are already applied for chain {}; remove them before applying \ + again. Re-applying would replace the record of what this process created and \ + strand whatever the earlier attempt left behind.", + self.chain_name + )); + } + + let outcome = self.apply_firewall_rules_inner(policy, logger); + self.record_apply_outcome(outcome, logger) } - #[test] - fn resolve_ip_address() { - let ips = NetworkIptablesManager::resolve_host("127.0.0.1"); - assert_eq!(ips, vec!["127.0.0.1"]); + /// Record what an apply attempt left behind and turn it into the public + /// result. + /// + /// Split out from [`Self::apply_firewall_rules`] so a test can drive the + /// failure arm directly. On a real host the inner call fails on its very + /// first command, which rolls back nothing and so never produces the + /// residual this arm exists to adopt -- the branch that matters is the one + /// that is hardest to reach by accident. + fn record_apply_outcome( + &mut self, + outcome: Result, + logger: &mut Logger, + ) -> Result { + match outcome { + Ok(created) => { + self.created = created; + self.rules_applied = true; + Ok(true) + } + Err((e, residual)) => { + // The inner call rolled back exactly what it created, but a + // removal command can itself fail. Whatever survived is still + // ours, so adopt it rather than reporting a clean failure: + // otherwise `remove_firewall_rules` and `Drop` are both gated + // off and the leaked chain is never retried. + if self.retain_residual_ownership(residual) { + logger.log_line(&format!( + "Firewall setup failed: {}. Rollback left iptables state behind; \ + retained ownership so teardown retries it.", + e + )); + } else { + logger.log_line(&format!( + "Firewall setup failed: {}. Partial iptables state rolled back.", + e + )); + } + Err(e) + } + } } - #[test] - fn resolve_host_drops_ipv6_literal() { - // IPv6 literals must be silently dropped — `iptables` (v4) would - // reject them and fail the whole `apply_firewall_rules` call. - let ips = NetworkIptablesManager::resolve_host("::1"); - assert!( - ips.is_empty(), - "expected empty vec for IPv6 literal, got {:?}", - ips - ); + /// Take ownership of whatever a teardown could not remove, so the + /// remaining cleanup paths retry it. + /// + /// Returns whether anything was retained. `rules_applied` is the gate on + /// both [`Self::remove_firewall_rules`] and `Drop`, so clearing it after a + /// teardown that only partly succeeded strands the survivors: no later path + /// would know they were ours to remove. This is shared by the failed-apply + /// rollback and the ordinary removal path, which have the same obligation. + fn retain_residual_ownership(&mut self, residual: CreatedResources) -> bool { + self.created = residual; + self.rules_applied = !residual.is_empty(); + self.rules_applied } - #[test] - fn resolve_host_drops_ipv4_mapped_ipv6_literal() { - // `::ffff:127.0.0.1` parses as `IpAddr::V6` and is the v6 - // wire-format encoding of an v4 address — `iptables` would - // still reject it as a v6 destination, so we drop it. - let ips = NetworkIptablesManager::resolve_host("::ffff:127.0.0.1"); - assert!( - ips.is_empty(), - "expected empty vec for v4-mapped-v6 literal, got {:?}", - ips - ); + /// Fallible body of [`Self::apply_firewall_rules`]. Tracks the chains and + /// hooks it creates, rolls back exactly those on the error path, and + /// returns the created set on success so the manager can tear down only + /// what it installed. + /// + /// On failure it returns the error alongside the **residual** set: the + /// resources whose rollback command itself failed and which therefore may + /// still exist. A failed rollback is not a clean failure, so the caller + /// must adopt the residual instead of discarding it. + fn apply_firewall_rules_inner( + &self, + policy: &ContainerPolicy, + logger: &mut Logger, + ) -> Result { + let mut created = CreatedResources::default(); + match self.install_firewall_rules(policy, logger, &mut created) { + Ok(()) => Ok(created), + Err(e) => { + let residual = Self::teardown_created( + &self.chain_name, + self.veth_interface.as_deref(), + &created, + logger, + ); + Err((e, residual)) + } + } } - #[test] - fn resolve_host_keeps_ipv4_literal_unchanged() { - // Round-trip: v4 literals must pass through verbatim — the - // IPv4-only filter must not regress the happy path. - let ips = NetworkIptablesManager::resolve_host("10.0.0.1"); - assert_eq!(ips, vec!["10.0.0.1"]); + /// Install the per-family chains, rules, and FORWARD hooks, recording each + /// resource in `created` immediately after it is successfully installed so + /// the caller can roll back precisely on any later failure. + fn install_firewall_rules( + &self, + policy: &ContainerPolicy, + logger: &mut Logger, + created: &mut CreatedResources, + ) -> Result<(), String> { + logger.log_line(&format!( + "Creating iptables/ip6tables chain: {}", + self.chain_name + )); + + // Probe ip6tables once. Skip the v6 chain when the kernel has no + // active IPv6 (nothing to filter), but fail closed when IPv6 is live + // and ip6tables is missing or broken rather than silently leaving + // IPv6 egress unfiltered. + let ipv6_enabled = match Self::ip6tables_status(logger) { + Ip6tablesStatus::Available => true, + Ip6tablesStatus::KernelIpv6Disabled => false, + Ip6tablesStatus::UnusableButIpv6Active => { + return Err( + "ip6tables is unusable but the host has active IPv6; refusing to \ + apply an IPv4-only policy that would leave IPv6 egress unfiltered" + .to_string(), + ); + } + }; + + // Create custom chains, recording each family as created so rollback + // removes only the chains this attempt installed. + Self::run_iptables(&["-N", &self.chain_name], logger)?; + created.v4_chain = true; + Self::publish_created(created); + if ipv6_enabled { + Self::run_ip6tables(&["-N", &self.chain_name], logger)?; + created.v6_chain = true; + Self::publish_created(created); + } + + let base_rules = Self::build_base_chain_rule_args(&self.chain_name); + Self::run_iptables_rule_args(&base_rules, logger)?; + if ipv6_enabled { + Self::run_ip6tables_rule_args(&base_rules, logger)?; + } + + // Resolve every allow/block entry exactly once and reuse that single + // resolution for both the unresolved-host warning and rule + // construction, so the rule installed matches the entry that was + // validated and logged. + let policy_rules = Self::build_policy_rules_logged(&self.chain_name, policy, logger); + Self::run_iptables_rule_args(&policy_rules.ipv4, logger)?; + if ipv6_enabled { + Self::run_ip6tables_rule_args(&policy_rules.ipv6, logger)?; + } else if !policy_rules.ipv6.is_empty() { + logger.log_line(&format!( + "Warning: {} IPv6 firewall rule(s) not applied because ip6tables \ + is unavailable; IPv6 egress is unfiltered on this host.", + policy_rules.ipv6.len() + )); + } + + // Append default policy at end of each chain. + let default_rule = Self::build_default_policy_rule_arg( + &self.chain_name, + policy.default_network_policy.clone(), + ); + let default_args: Vec<&str> = default_rule.iter().map(String::as_str).collect(); + let default_action = default_args.last().copied().unwrap_or("ACCEPT"); + logger.log_line(&format!("Default network policy: {}", default_action)); + Self::run_iptables(&default_args, logger)?; + if ipv6_enabled { + Self::run_ip6tables(&default_args, logger)?; + } + + // Hook the chains into FORWARD for the container's egress traffic. + // Packets originating in the container arrive at the host on the + // host-side veth, so they match FORWARD by input interface (`-i`); + // `-o` would instead match traffic flowing toward the container. + if let Some(ref iface) = self.veth_interface { + Self::run_iptables( + &["-I", "FORWARD", "-i", iface, "-j", &self.chain_name], + logger, + )?; + created.v4_hook = true; + Self::publish_created(created); + logger.log_line(&format!( + "FORWARD hook installed on {} for chain {} (iptables).", + iface, self.chain_name + )); + if ipv6_enabled { + Self::run_ip6tables( + &["-I", "FORWARD", "-i", iface, "-j", &self.chain_name], + logger, + )?; + created.v6_hook = true; + Self::publish_created(created); + logger.log_line(&format!( + "FORWARD hook installed on {} for chain {} (ip6tables).", + iface, self.chain_name + )); + } + } else { + // Without a veth interface, we cannot safely scope rules to the container. + // Refuse to apply host-wide rules to avoid affecting all host traffic. + logger.log_line( + "Warning: No veth interface set for container. \ + Cannot scope iptables rules. Skipping FORWARD hook.", + ); + } + + Ok(()) + } + + /// Publish the set of resources created so far to the signal-cleanup + /// registry, so a fatal signal tears down exactly what exists. + /// + /// Called after **each** individual resource is installed rather than once + /// at the end of a successful apply. Publishing only on success would mean + /// a signal arriving mid-apply sees an empty set, removes nothing, and + /// leaks the partially created chain. + fn publish_created(created: &CreatedResources) { + crate::signal_cleanup::set_active_created(*created); + } + + /// Best-effort removal of the FORWARD hooks and per-container chains that + /// `created` records were installed, in both tables. Only resources marked + /// as created are touched, so a partial-failure rollback never tears down + /// a chain this attempt did not create — which matters because chain names + /// truncate at 20 characters and can collide across containers. A missing + /// rule/chain still makes an individual `-D`/`-F`/`-X` call a no-op, so it + /// doubles as the rollback path for a failed apply. + /// + /// Returns the **residual** set: the resources whose removal command + /// failed and which therefore may still exist. Clearing ownership for a + /// deletion that failed would strand the resource, because nothing would + /// then know it was ours to remove. The residual is published before + /// returning, so signal-time cleanup retries exactly the leftovers. + fn teardown_created( + chain_name: &str, + veth_interface: Option<&str>, + created: &CreatedResources, + logger: &mut Logger, + ) -> CreatedResources { + let mut residual = *created; + + // Remove from FORWARD only for families this attempt hooked. Must + // match the `-i` direction used at insertion so the delete finds the + // rule; a `-o` delete would leak the FORWARD hook. + if let Some(iface) = veth_interface { + if created.v4_hook + && Self::run_iptables(&["-D", "FORWARD", "-i", iface, "-j", chain_name], logger) + .is_ok() + { + residual.v4_hook = false; + } + if created.v6_hook + && Self::run_ip6tables(&["-D", "FORWARD", "-i", iface, "-j", chain_name], logger) + .is_ok() + { + residual.v6_hook = false; + } + } + + // Flush and delete only the chains this attempt created, and only once + // that family's FORWARD hook is confirmed gone. `-X` is the command + // that actually relinquishes the chain, so ownership is only cleared + // when it succeeds. The gate is per family because the two chains live + // in different tables and are referenced independently. + residual.v4_chain = teardown_chain( + created.v4_chain, + residual.v4_hook, + logger, + |logger| { + let _ = Self::run_iptables(&["-F", chain_name], logger); + }, + |logger| Self::run_iptables(&["-X", chain_name], logger).is_ok(), + ); + residual.v6_chain = teardown_chain( + created.v6_chain, + residual.v6_hook, + logger, + |logger| { + let _ = Self::run_ip6tables(&["-F", chain_name], logger); + }, + |logger| Self::run_ip6tables(&["-X", chain_name], logger).is_ok(), + ); + + Self::publish_created(&residual); + residual + } + + /// Remove all iptables/ip6tables rules created by this manager. + pub fn remove_firewall_rules(&mut self, logger: &mut Logger) -> Result<(), String> { + if !self.rules_applied { + return Ok(()); + } + + logger.log_line(&format!( + "Removing iptables/ip6tables chain: {}", + self.chain_name + )); + + let residual = Self::teardown_created( + &self.chain_name, + self.veth_interface.as_deref(), + &self.created, + logger, + ); + + // A removal command can fail, and what survived is still ours. Clearing + // the gate here regardless would strand it: Drop would then skip the + // retry that is the last chance to remove it. + self.retain_residual_ownership(residual); + Ok(()) + } + + /// Best-effort cleanup of any iptables state the runner installed for a + /// container, used when the original `NetworkIptablesManager` instance + /// isn't reachable (e.g. signal-time cleanup from the watchdog thread). + /// + /// `created` is the ownership record the runner published as it installed + /// each resource, carried across the thread boundary by `signal_cleanup`. + /// Using it — rather than assuming every chain and hook exists — is what + /// keeps this path from flushing a *different* container's live chain: + /// chain names sanitize and truncate to 20 characters, so a name collision + /// would otherwise let a signal delivered to container A empty container + /// B's chain, silently failing B open. + /// + /// The sole caller (`signal_cleanup::run_watchdog`) is Linux-only, so this + /// is dead code elsewhere. It stays compiled on every target rather than + /// being `cfg`-gated so Windows and macOS CI still type-check it. + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub(crate) fn force_cleanup( + container_name: &str, + veth_interface: Option<&str>, + created: CreatedResources, + logger: &mut Logger, + ) { + // This process created nothing, so there is nothing of ours to remove. + // Anything present under this chain name belongs to someone else. + if created.is_empty() { + return; + } + let mut mgr = Self::new(container_name); + if let Some(v) = veth_interface { + mgr.set_veth_interface(v); + } + // Bypass the rules_applied gate: the manager that set it is on another + // thread and unreachable from here. + mgr.rules_applied = true; + mgr.created = created; + let _ = mgr.remove_firewall_rules(logger); + } +} + +impl Drop for NetworkIptablesManager { + fn drop(&mut self) { + if self.rules_applied { + let mut logger = wxc_common::logger::Logger::new(wxc_common::logger::Mode::Buffer); + let _ = self.remove_firewall_rules(&mut logger); + } + } +} + +/// Test-only interception of this file's two process spawns. +/// +/// Unit tests must not reach the real `iptables` binary. As root it would +/// flush and delete whatever live chain answers to a colliding `MXC-`, +/// and a test that needs a command to *fail* would otherwise inherit that +/// outcome from the host rather than arranging it -- so the same test passes +/// on a machine without `iptables` and fails on one with it. +/// +/// Interception is opt-in. A test that installs no fake behaves exactly as it +/// did before this seam existed, so the ~70 tests in this file that never +/// reach a firewall command are untouched. +/// +/// The installed fake lives in thread-local storage because the firewall entry +/// points are associated functions with no `self` to carry a runner, and +/// because `cargo test` runs tests in parallel -- a process-global fake would +/// have to be serialized behind a lock and would let one test observe +/// another's commands. +#[cfg(test)] +mod test_firewall { + use std::cell::RefCell; + use std::collections::VecDeque; + + struct State { + issued: Vec>, + /// Outcome for the next commands, in order. An exhausted queue falls + /// back to `fallback`. + scripted: VecDeque>, + fallback: Result<(), String>, + } + + thread_local! { + static STATE: RefCell> = const { RefCell::new(None) }; + } + + /// Installs the fake for the current thread and uninstalls it on drop. + /// + /// Declare the guard **before** any manager whose `Drop` tears down: locals + /// drop in reverse declaration order, so a guard declared first is still + /// installed while the manager runs its teardown. + pub(super) struct FakeFirewall; + + /// Intercept every firewall command on this thread. Commands succeed and + /// the `ip6tables` probe reports the tool available, so a test that cares + /// about neither gets a deterministic dual-stack host. + pub(super) fn install() -> FakeFirewall { + STATE.with(|slot| { + *slot.borrow_mut() = Some(State { + issued: Vec::new(), + scripted: VecDeque::new(), + fallback: Ok(()), + }); + }); + FakeFirewall + } + + impl Drop for FakeFirewall { + fn drop(&mut self) { + STATE.with(|slot| *slot.borrow_mut() = None); + } + } + + impl FakeFirewall { + /// Every command from here on fails with `stderr`. + pub(super) fn fail_every_command(&self, stderr: &str) -> &Self { + Self::with_state(|state| state.fallback = Err(stderr.to_string())); + self + } + + /// Every command issued so far, in order, each as `[binary, args..]`. + pub(super) fn issued(&self) -> Vec> { + Self::with_state(|state| state.issued.clone()) + } + + /// Forget the commands issued so far, so a later assertion covers only + /// what was issued after this point. + pub(super) fn forget_issued(&self) -> &Self { + Self::with_state(|state| state.issued.clear()); + self + } + + fn with_state(f: impl FnOnce(&mut State) -> T) -> T { + STATE.with(|slot| { + let mut slot = slot.borrow_mut(); + let state = slot + .as_mut() + .expect("the FakeFirewall guard must still be in scope"); + f(state) + }) + } + } + + /// Record a command and return its scripted outcome, or `None` when no + /// fake is installed so the caller runs the real binary. + pub(super) fn intercept(command: &str, args: &[&str]) -> Option> { + STATE.with(|slot| { + let mut slot = slot.borrow_mut(); + let state = slot.as_mut()?; + let mut argv = Vec::with_capacity(args.len() + 1); + argv.push(command.to_string()); + argv.extend(args.iter().map(|arg| arg.to_string())); + state.issued.push(argv); + Some( + state + .scripted + .pop_front() + .unwrap_or_else(|| state.fallback.clone()), + ) + }) + } + + /// The scripted result of the `ip6tables -S` probe, or `None` when no fake + /// is installed so the caller runs the real probe. + /// + /// A fake always reports the tool available. `classify_ip6tables_status` + /// maps `(true, _)` to `Available` without consulting the host's IPv6 + /// state, so this is the one answer that makes the apply path independent + /// of the machine the test runs on. Reporting the probe as *failed* would + /// not be: the classification then turns on `/proc/net/if_inet6`, which + /// this seam does not fake. Those branches are covered by the pure-function + /// tests of `classify_ip6tables_status` instead. + /// + /// The probe is recorded like any other command so `issued` stays a + /// complete account of what the code under test would have run. + pub(super) fn intercept_ip6tables_probe() -> Option { + STATE.with(|slot| { + let mut slot = slot.borrow_mut(); + let state = slot.as_mut()?; + state + .issued + .push(vec!["ip6tables".to_string(), "-S".to_string()]); + Some(true) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Error, ErrorKind}; + use wxc_common::logger::{Logger, Mode}; + use wxc_common::models::{ContainerPolicy, NetworkEnforcementMode}; + + #[test] + fn an_empty_ownership_record_is_recognized_as_nothing_to_tear_down() { + assert!( + CreatedResources::default().is_empty(), + "a manager that created nothing must report an empty ownership record" + ); + for created in [ + CreatedResources::for_test(true, false, false, false), + CreatedResources::for_test(false, true, false, false), + CreatedResources::for_test(false, false, true, false), + CreatedResources::for_test(false, false, false, true), + ] { + assert!( + !created.is_empty(), + "{created:?} names a real resource and must not be treated as empty" + ); + } + } + + #[test] + fn a_signal_arriving_before_anything_was_created_removes_nothing() { + // force_cleanup is ownership-blind once it starts: it rebuilds the + // chain name and removes whatever answers to it. The empty-record guard + // is the only thing standing between a process that created nothing and + // the chain of a concurrent start that did. Asserting the guard's + // predicate in isolation would not catch its deletion, so this drives + // force_cleanup itself and observes the commands it issued. + let fake = test_firewall::install(); + let mut quiet = Logger::new(Mode::Buffer); + NetworkIptablesManager::force_cleanup( + "racer-that-lost", + Some("mxcv-loser"), + CreatedResources::default(), + &mut quiet, + ); + assert_eq!( + quiet.get_buffer(), + "", + "a process holding no ownership must not begin a teardown at all" + ); + assert!( + fake.issued().is_empty(), + "...and must not issue a single command, got: {:?}", + fake.issued() + ); + + // Positive control: the same call with one resource published does + // reach the teardown, so the assertion above discriminates between the + // two cases rather than observing a permanently silent function. + let mut noisy = Logger::new(Mode::Buffer); + NetworkIptablesManager::force_cleanup( + "racer-that-won", + Some("mxcv-winner"), + CreatedResources::for_test(true, false, false, false), + &mut noisy, + ); + assert_eq!( + fake.issued(), + vec![ + strings(&["iptables", "-F", "MXC-racer-that-won"]), + strings(&["iptables", "-X", "MXC-racer-that-won"]), + ], + "only the published chain may be flushed and deleted, and only it" + ); + } + + #[test] + fn a_rollback_that_could_not_finish_keeps_ownership_of_what_survived() { + // A failed apply is not automatically a clean failure: teardown_created + // reports a residual when its own removal commands fail, and those + // survivors are still this manager's to remove. rules_applied gates + // both remove_firewall_rules and Drop, so dropping the residual on the + // floor strands the chain -- nothing afterward knows it was ours. + // + // Asserting rules_applied directly would only restate the assignment. + // This drives the downstream path instead and observes the commands it + // issued, since removing the chain is what the ownership is for. + let fake = test_firewall::install(); + fake.fail_every_command("iptables: chain is not empty"); + + let mut manager = NetworkIptablesManager::new("survivor"); + let retained = manager + .retain_residual_ownership(CreatedResources::for_test(true, false, false, false)); + assert!(retained, "a non-empty residual must be retained"); + + let mut after_partial = Logger::new(Mode::Buffer); + let _ = manager.remove_firewall_rules(&mut after_partial); + assert_eq!( + fake.issued(), + vec![ + strings(&["iptables", "-F", "MXC-survivor"]), + strings(&["iptables", "-X", "MXC-survivor"]), + ], + "a chain that survived rollback must still be torn down later" + ); + + // Negative control: a rollback that removed everything leaves nothing + // owned, so teardown must not run at all. Without this the assertion + // above would pass even if ownership were retained unconditionally, + // which would resurrect the collision the ownership record exists to + // prevent. + fake.forget_issued(); + let mut clean = NetworkIptablesManager::new("fully-rolled-back"); + let retained_clean = clean.retain_residual_ownership(CreatedResources::default()); + assert!(!retained_clean, "an empty residual must not be retained"); + + let mut after_clean = Logger::new(Mode::Buffer); + let _ = clean.remove_firewall_rules(&mut after_clean); + assert_eq!( + after_clean.get_buffer(), + "", + "a fully rolled-back apply must not begin a teardown" + ); + assert!( + fake.issued().is_empty(), + "...and must not issue a single command, got: {:?}", + fake.issued() + ); + } + + #[test] + fn a_failed_apply_adopts_the_residual_its_rollback_left_behind() { + // The test above starts from an already-retained residual, so it proves + // only that ownership works once held -- it would still pass if the + // failure arm threw the residual away before getting there. This one + // drives that arm: it hands the recording step exactly what a rollback + // whose own removal command failed reports, and asserts the manager + // adopts it. The observable is the same downstream one, because + // teardown is what the ownership is for. + let fake = test_firewall::install(); + fake.fail_every_command("iptables: chain is not empty"); + + let mut manager = NetworkIptablesManager::new("adopted"); + let mut apply_log = Logger::new(Mode::Buffer); + let outcome = Err(( + "append failed".to_string(), + CreatedResources::for_test(true, false, false, false), + )); + let result = manager.record_apply_outcome(outcome, &mut apply_log); + + assert!(result.is_err(), "a failed apply must still report failure"); + assert!( + apply_log.get_buffer().contains("retained ownership"), + "a failed rollback must be reported as retained, not as clean, got: {:?}", + apply_log.get_buffer() + ); + + let mut teardown_log = Logger::new(Mode::Buffer); + let _ = manager.remove_firewall_rules(&mut teardown_log); + assert_eq!( + fake.issued(), + vec![ + strings(&["iptables", "-F", "MXC-adopted"]), + strings(&["iptables", "-X", "MXC-adopted"]), + ], + "what the rollback could not remove must still be torn down later" + ); + + // Negative control: a rollback that removed everything must leave the + // manager owning nothing, so the assertion above cannot be satisfied by + // retaining unconditionally. + fake.forget_issued(); + let mut clean = NetworkIptablesManager::new("clean-failure"); + let mut clean_log = Logger::new(Mode::Buffer); + let clean_result = clean.record_apply_outcome( + Err(("boom".to_string(), CreatedResources::default())), + &mut clean_log, + ); + + assert!(clean_result.is_err()); + assert!( + clean_log.get_buffer().contains("rolled back"), + "a complete rollback must be reported as clean, got: {:?}", + clean_log.get_buffer() + ); + + let mut after_clean = Logger::new(Mode::Buffer); + let _ = clean.remove_firewall_rules(&mut after_clean); + assert_eq!( + after_clean.get_buffer(), + "", + "a failure that left nothing behind must not begin a teardown" + ); + assert!( + fake.issued().is_empty(), + "...and must not issue a single command, got: {:?}", + fake.issued() + ); + } + + #[test] + fn a_flush_is_withheld_while_the_chain_is_still_hooked() { + // -F succeeds no matter who references the chain, and an emptied user + // chain returns to its caller instead of reaching its own closing DROP. + // So flushing a chain FORWARD still jumps to unfilters a container that + // may still be running -- a fail-open. -X would fail anyway on a + // referenced chain, so the flush buys nothing and costs the filtering. + // The whole step is gated on the hook being confirmed gone. + let mut logger = Logger::new(Mode::Buffer); + let mut flushed = false; + let mut deleted = false; + let still_owned = teardown_chain( + true, + true, + &mut logger, + |_| flushed = true, + |_| { + deleted = true; + true + }, + ); + + assert!( + !flushed, + "a chain something still jumps to must not be flushed" + ); + assert!(!deleted, "a referenced chain must not be deleted"); + assert!( + still_owned, + "a chain left populated is still ours, so a later pass retries it" + ); + + // Negative control: once the hook is gone the step must actually run + // and must release ownership, so the assertions above cannot be + // satisfied by never flushing at all. + let mut logger = Logger::new(Mode::Buffer); + let mut flushed = false; + let still_owned = teardown_chain(true, false, &mut logger, |_| flushed = true, |_| true); + + assert!(flushed, "an unreferenced chain must be flushed"); + assert!(!still_owned, "a chain whose -X succeeded is no longer ours"); + + // A chain this attempt never created is not ours to touch at all -- + // chain names truncate and can collide with a live container. + let mut logger = Logger::new(Mode::Buffer); + let mut flushed = false; + let still_owned = teardown_chain(false, false, &mut logger, |_| flushed = true, |_| true); + + assert!(!flushed, "a chain we did not create must not be flushed"); + assert!(!still_owned); + } + + #[test] + fn a_removal_whose_commands_failed_stays_owned_for_the_drop_retry() { + // remove_firewall_rules used to clear rules_applied unconditionally, so + // a teardown whose commands failed reported itself done while the chain + // was still installed. Drop is gated on the same flag, so that threw + // away the last retry. The fake scripts the failure, so the test states + // its own precondition rather than depending on the host's iptables + // refusing the command -- which is what made this pass on a machine + // without iptables and fail on one with it. + let fake = test_firewall::install(); + fake.fail_every_command("iptables: permission denied"); + + let mut manager = NetworkIptablesManager::new("stubborn"); + manager.retain_residual_ownership(CreatedResources::for_test(true, false, false, false)); + + let mut first = Logger::new(Mode::Buffer); + let _ = manager.remove_firewall_rules(&mut first); + assert_eq!( + fake.issued(), + vec![ + strings(&["iptables", "-F", "MXC-stubborn"]), + strings(&["iptables", "-X", "MXC-stubborn"]), + ], + "the first removal must attempt the teardown" + ); + + // The observable for "still owned" is that a second removal still + // issues the commands rather than short-circuiting on the gate. That + // second call is what Drop makes. + fake.forget_issued(); + let mut second = Logger::new(Mode::Buffer); + let _ = manager.remove_firewall_rules(&mut second); + assert_eq!( + fake.issued(), + vec![ + strings(&["iptables", "-F", "MXC-stubborn"]), + strings(&["iptables", "-X", "MXC-stubborn"]), + ], + "a removal that failed must leave the chain owned so Drop retries it" + ); + } + + #[test] + fn a_removal_whose_commands_all_succeeded_releases_ownership() { + // The mirror of the test above, and the arm that could not be reached + // before the fake existed: `-X` is what actually relinquishes the + // chain, so a teardown whose commands all succeed must clear the gate + // and leave Drop nothing to retry. Without it, "still owned after a + // failure" would be satisfied by never releasing ownership at all. + let fake = test_firewall::install(); + let mut manager = NetworkIptablesManager::new("released"); + manager.retain_residual_ownership(CreatedResources::for_test(true, false, false, false)); + + let mut first = Logger::new(Mode::Buffer); + let _ = manager.remove_firewall_rules(&mut first); + assert_eq!( + fake.issued(), + vec![ + strings(&["iptables", "-F", "MXC-released"]), + strings(&["iptables", "-X", "MXC-released"]), + ], + "the teardown must flush the chain and then delete it" + ); + + fake.forget_issued(); + let mut second = Logger::new(Mode::Buffer); + let _ = manager.remove_firewall_rules(&mut second); + assert!( + fake.issued().is_empty(), + "a chain whose -X succeeded is no longer ours to remove, got: {:?}", + fake.issued() + ); + } + + #[test] + fn a_second_apply_is_refused_while_the_first_still_owns_resources() { + // Both arms of apply_firewall_rules replace self.created with the new + // attempt's set, so a second apply on a manager that still owns + // something would drop the earlier record and strand whatever it named. + // Refusing makes that unreachable instead of relying on callers to + // build a fresh manager each time. + // + // The fake is declared first so it outlives `manager`: locals drop in + // reverse declaration order, and this manager still owns a chain, so + // its Drop runs a teardown that must not reach the real binary. + let _fake = test_firewall::install(); + let mut manager = NetworkIptablesManager::new("already-owned"); + manager.retain_residual_ownership(CreatedResources::for_test(true, false, false, false)); + + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + let result = manager.apply_firewall_rules(&policy, &mut logger); + + assert!( + result.is_err(), + "applying over live ownership must be refused, got {:?}", + result + ); + assert!( + result.unwrap_err().contains("already applied"), + "the refusal must say why" + ); + } + + #[test] + fn a_manager_that_owns_nothing_still_reaches_the_apply_path() { + // Negative control for the guard above: it must key on live ownership, + // not refuse every apply. The observable is that the apply actually + // issued its chain-creation commands rather than short-circuiting. + // + // The fake is declared first so it outlives `manager`, whose Drop tears + // down the chains this apply creates. + let fake = test_firewall::install(); + let mut manager = NetworkIptablesManager::new("fresh"); + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Firewall); + let mut logger = Logger::new(Mode::Buffer); + let result = manager.apply_firewall_rules(&policy, &mut logger); + + if let Err(e) = &result { + assert!( + !e.contains("already applied"), + "a fresh manager must not hit the ownership guard, got: {}", + e + ); + } + let issued = fake.issued(); + assert!( + issued.contains(&strings(&["iptables", "-N", "MXC-fresh"])), + "the apply must create the IPv4 chain, got: {:?}", + issued + ); + assert!( + issued.contains(&strings(&["ip6tables", "-N", "MXC-fresh"])), + "a host whose ip6tables probe succeeds must get the parallel v6 chain, got: {:?}", + issued + ); + } + + fn strings(args: &[&str]) -> Vec { + args.iter().map(|arg| arg.to_string()).collect() + } + + #[test] + fn chain_name_sanitization() { + let mgr = NetworkIptablesManager::new("my-container_123"); + assert_eq!(mgr.chain_name, "MXC-my-container_123"); + } + + #[test] + fn chain_name_truncation() { + let long_name = "a".repeat(50); + let mgr = NetworkIptablesManager::new(&long_name); + // 4 chars for "MXC-" + 20 chars max + assert!(mgr.chain_name.len() <= 24); + } + + #[test] + fn resolve_ip_address() { + let ips = NetworkIptablesManager::resolve_host("127.0.0.1"); + assert_eq!(ips.ipv4, vec!["127.0.0.1"]); + assert!(ips.ipv6.is_empty()); + } + + #[test] + fn resolve_host_retains_ipv6_literal() { + let ips = NetworkIptablesManager::resolve_host("::1"); + assert!(ips.ipv4.is_empty()); + assert_eq!(ips.ipv6, vec!["::1"]); + } + + #[test] + fn resolve_host_rewrites_ipv4_mapped_ipv6_literal_to_ipv4() { + // A mapped destination is emitted as an IPv4 packet, so it must be + // programmed with iptables; an ip6tables rule would never match it. + let ips = NetworkIptablesManager::resolve_host("::ffff:127.0.0.1"); + assert_eq!(ips.ipv4, vec!["127.0.0.1"]); + assert!(ips.ipv6.is_empty()); + } + + #[test] + fn resolve_host_keeps_ipv4_literal_unchanged() { + // Round-trip: v4 literals must pass through verbatim. + let ips = NetworkIptablesManager::resolve_host("10.0.0.1"); + assert_eq!(ips.ipv4, vec!["10.0.0.1"]); + assert!(ips.ipv6.is_empty()); + } + + #[test] + fn resolve_host_retains_valid_cidr_by_family() { + let v4 = NetworkIptablesManager::resolve_host("140.82.112.0/20"); + assert_eq!(v4.ipv4, vec!["140.82.112.0/20"]); + assert!(v4.ipv6.is_empty()); + + let v6 = NetworkIptablesManager::resolve_host("2606:50c0::/32"); + assert!(v6.ipv4.is_empty()); + assert_eq!(v6.ipv6, vec!["2606:50c0::/32"]); + } + + #[test] + fn resolve_host_rejects_invalid_cidr_prefix() { + // Out-of-range prefixes and non-numeric prefixes are dropped rather + // than passed to iptables, which would reject them at apply time. + assert!(NetworkIptablesManager::resolve_host("140.82.112.0/33").is_empty()); + assert!(NetworkIptablesManager::resolve_host("2606:50c0::/129").is_empty()); + assert!(NetworkIptablesManager::resolve_host("140.82.112.0/not-a-prefix").is_empty()); + } + + #[test] + fn resolve_host_rejects_malformed_cidr_syntax() { + assert!(NetworkIptablesManager::resolve_host("/20").is_empty()); + assert!(NetworkIptablesManager::resolve_host("140.82.112.0/").is_empty()); + assert!(NetworkIptablesManager::resolve_host("140.82.112.0/20/8").is_empty()); + } + + #[test] + fn host_rule_args_route_ipv4_to_iptables_args() { + let args = NetworkIptablesManager::build_host_rule_args( + "MXC-test", + "140.82.112.4", + &RuleAction::Allow, + ); + + assert_eq!( + args.ipv4, + vec![strings(&[ + "-A", + "MXC-test", + "-d", + "140.82.112.4", + "-j", + "ACCEPT", + ])] + ); + assert!(args.ipv6.is_empty()); + } + + #[test] + fn host_rule_args_route_ipv6_to_ip6tables_args() { + let args = NetworkIptablesManager::build_host_rule_args( + "MXC-test", + "2606:50c0:8000::64", + &RuleAction::Deny, + ); + + assert!(args.ipv4.is_empty()); + assert_eq!( + args.ipv6, + vec![strings(&[ + "-A", + "MXC-test", + "-d", + "2606:50c0:8000::64", + "-j", + "DROP", + ])] + ); + } + + #[test] + fn host_rule_args_pass_cidr_through_unchanged() { + // iptables/ip6tables apply the prefix mask themselves, so the CIDR is + // forwarded verbatim rather than expanded or normalized. + let v4 = NetworkIptablesManager::build_host_rule_args( + "MXC-test", + "140.82.112.0/20", + &RuleAction::Allow, + ); + assert_eq!( + v4.ipv4, + vec![strings(&[ + "-A", + "MXC-test", + "-d", + "140.82.112.0/20", + "-j", + "ACCEPT", + ])] + ); + assert!(v4.ipv6.is_empty()); + + let v6 = NetworkIptablesManager::build_host_rule_args( + "MXC-test", + "2606:50c0::/32", + &RuleAction::Allow, + ); + assert!(v6.ipv4.is_empty()); + assert_eq!( + v6.ipv6, + vec![strings(&[ + "-A", + "MXC-test", + "-d", + "2606:50c0::/32", + "-j", + "ACCEPT", + ])] + ); + } + + #[test] + fn host_rule_args_drop_unresolvable_destination() { + let args = NetworkIptablesManager::build_host_rule_args( + "MXC-test", + "140.82.112.0/33", + &RuleAction::Allow, + ); + + assert!(args.ipv4.is_empty()); + assert!(args.ipv6.is_empty()); + } + + #[test] + fn build_policy_rule_args_splits_allow_and_block_lists_by_family() { + let policy = ContainerPolicy { + allowed_hosts: vec!["140.82.112.0/20".to_string(), "2606:50c0::/32".to_string()], + blocked_hosts: vec!["10.0.0.0/8".to_string(), "2001:db8::/32".to_string()], + ..Default::default() + }; + + let args = NetworkIptablesManager::build_policy_rule_args("MXC-test", &policy); + + assert_eq!( + args.ipv4, + vec![ + strings(&["-A", "MXC-test", "-d", "140.82.112.0/20", "-j", "ACCEPT"]), + strings(&["-A", "MXC-test", "-d", "10.0.0.0/8", "-j", "DROP"]), + ] + ); + assert_eq!( + args.ipv6, + vec![ + strings(&["-A", "MXC-test", "-d", "2606:50c0::/32", "-j", "ACCEPT"]), + strings(&["-A", "MXC-test", "-d", "2001:db8::/32", "-j", "DROP"]), + ] + ); + } + + #[test] + fn base_chain_rule_args_are_family_agnostic() { + // The same base rules are fed to both iptables and ip6tables, so they + // must not name an address family or a v4-only protocol. + let base = NetworkIptablesManager::build_base_chain_rule_args("MXC-test"); + + assert_eq!(base.len(), 4); + for rule in &base { + assert!(!rule.iter().any(|arg| arg == "icmp")); + } + } + + // ----------------------------------------------------------------------- + // Spec-derived tests: resolution + // ----------------------------------------------------------------------- + + fn assert_resolved_exact(input: &str, expected_ipv4: &[&str], expected_ipv6: &[&str]) { + let resolved = NetworkIptablesManager::resolve_host(input); + let expected_ipv4: Vec = expected_ipv4 + .iter() + .map(|value| value.to_string()) + .collect(); + let expected_ipv6: Vec = expected_ipv6 + .iter() + .map(|value| value.to_string()) + .collect(); + + assert_eq!( + resolved.ipv4, expected_ipv4, + "unexpected IPv4 destinations for {input:?}" + ); + assert_eq!( + resolved.ipv6, expected_ipv6, + "unexpected IPv6 destinations for {input:?}" + ); + } + + fn assert_destination_family(input: &str, expected: Option) { + assert_eq!( + NetworkIptablesManager::destination_family(input), + expected, + "unexpected destination family for {input:?}" + ); + } + + #[test] + fn bare_ip_literals_are_routed_only_to_their_matching_family() { + let cases = [ + ("192.0.2.1", &["192.0.2.1"][..], &[][..]), + ("127.0.0.1", &["127.0.0.1"][..], &[][..]), + ("2606:50c0::153", &[][..], &["2606:50c0::153"][..]), + ( + "2606:50c0:0000:0000:0000:0000:0000:0153", + &[][..], + &["2606:50c0:0000:0000:0000:0000:0000:0153"][..], + ), + ("::1", &[][..], &["::1"][..]), + ]; + + for (input, expected_ipv4, expected_ipv6) in cases { + assert_resolved_exact(input, expected_ipv4, expected_ipv6); + } + } + + #[test] + fn ipv4_mapped_ipv6_literal_is_filed_as_ipv4() { + // An IPv4-mapped destination travels as an IPv4 packet, so an ip6tables + // rule naming it would never match and a blocked entry would fail open + // under default-allow. It must be programmed with iptables instead. + assert_resolved_exact("::ffff:127.0.0.1", &["127.0.0.1"], &[]); + } + + #[test] + fn ipv4_mapped_cidr_is_translated_to_its_ipv4_prefix() { + // The mapped range is the last 32 bits of ::ffff:0:0/96, so an IPv6 + // prefix of 96 + n is exactly an IPv4 prefix of n. + assert_resolved_exact("::ffff:192.0.2.0/120", &["192.0.2.0/24"], &[]); + assert_resolved_exact("::ffff:198.51.100.42/128", &["198.51.100.42/32"], &[]); + } + + #[test] + fn an_ipv6_prefix_shorter_than_the_mapped_range_stays_ipv6() { + // A /95 covers addresses outside ::ffff:0:0/96, so it cannot be expressed + // as a single IPv4 CIDR and must not be rewritten. + assert_resolved_exact("::ffff:0:0/95", &[], &["::ffff:0:0/95"]); + } + + #[test] + fn valid_cidrs_are_passed_through_unchanged_in_their_matching_family() { + // SPEC_BRIEF §3 requires validated CIDRs to be passed through unchanged. + let cases = [ + ("140.82.112.0/20", &["140.82.112.0/20"][..], &[][..]), + ("2606:50c0::/32", &[][..], &["2606:50c0::/32"][..]), + ]; + + for (input, expected_ipv4, expected_ipv6) in cases { + assert_resolved_exact(input, expected_ipv4, expected_ipv6); + } + } + + #[test] + fn v4_cidr_with_host_bits_set_is_passed_through_unchanged() { + // SPEC_BRIEF §3 says host bits are not required to be zero because iptables applies the mask. + assert_resolved_exact("140.82.112.5/20", &["140.82.112.5/20"], &[]); + } + + #[test] + fn cidr_prefix_lengths_accept_only_family_specific_bounds() { + let cases = [ + ("0.0.0.0/0", Some(IpFamily::V4), &["0.0.0.0/0"][..], &[][..]), + ( + "192.0.2.1/32", + Some(IpFamily::V4), + &["192.0.2.1/32"][..], + &[][..], + ), + ("192.0.2.1/33", None, &[][..], &[][..]), + ("192.0.2.1/129", None, &[][..], &[][..]), + ("::/0", Some(IpFamily::V6), &[][..], &["::/0"][..]), + ( + "2001:db8::1/128", + Some(IpFamily::V6), + &[][..], + &["2001:db8::1/128"][..], + ), + ("2001:db8::1/129", None, &[][..], &[][..]), + ]; + + for (input, expected_family, expected_ipv4, expected_ipv6) in cases { + assert_resolved_exact(input, expected_ipv4, expected_ipv6); + assert_destination_family(input, expected_family); + } + } + + #[test] + fn v6_prefix_length_on_v4_address_is_rejected() { + assert_resolved_exact("10.0.0.0/64", &[], &[]); + assert_destination_family("10.0.0.0/64", None); + } + + #[test] + fn malformed_cidr_syntax_and_garbage_resolve_to_nothing() { + let cases = [ + "/24", + "10.0.0.0/", + "10.0.0.0//24", + "10.0.0.0/abc", + "10.0.0.0/-1", + "10.0.0.0/ 24", + "not-a-valid-firewall-destination", + ]; + + for input in cases { + let resolved = NetworkIptablesManager::resolve_host(input); + assert!( + resolved.is_empty(), + "malformed destination {input:?} should resolve to nothing, got {resolved:?}" + ); + assert_destination_family(input, None); + } + } + + #[test] + fn cidr_prefix_with_plus_sign_resolves_to_nothing() { + let input = "10.0.0.0/+24"; + let resolved = NetworkIptablesManager::resolve_host(input); + assert!( + resolved.is_empty(), + "malformed destination {input:?} should resolve to nothing, got {resolved:?}" + ); + assert_destination_family(input, None); + } + + // Independent of the leading-`+` rejection above, the family range check must + // still reject an out-of-range prefix. + #[test] + fn leading_plus_does_not_smuggle_an_out_of_range_prefix_past_validation() { + let input = "10.0.0.0/+33"; + let resolved = NetworkIptablesManager::resolve_host(input); + assert!( + resolved.is_empty(), + "a leading `+` must not smuggle an out-of-range prefix past validation, got {resolved:?}" + ); + assert_destination_family(input, None); + } + + #[test] + fn empty_input_resolves_to_nothing() { + let resolved = NetworkIptablesManager::resolve_host(""); + assert!( + resolved.is_empty(), + "empty input should resolve to nothing, got {resolved:?}" + ); + assert_destination_family("", None); + } + + /// Every string in a bucket must be a destination of that bucket's family. + /// + /// This is the invariant that keeps an AAAA record from being handed to + /// `iptables` (and an A record to `ip6tables`). It is asserted as a property so + /// it holds whatever the resolver happens to return. + fn assert_buckets_are_family_pure(input: &str, resolved: &ResolvedDestinations) { + for destination in &resolved.ipv4 { + assert_eq!( + NetworkIptablesManager::destination_family(destination), + Some(IpFamily::V4), + "{input:?}: {destination:?} is in the ipv4 bucket but is not an IPv4 destination" + ); + } + for destination in &resolved.ipv6 { + assert_eq!( + NetworkIptablesManager::destination_family(destination), + Some(IpFamily::V6), + "{input:?}: {destination:?} is in the ipv6 bucket but is not an IPv6 destination" + ); + } + } + + // The dual-stack bypass lived in the DNS family split: an AAAA record must land + // in the v6 bucket and must never leak into the v4 bucket. The split is a pure + // function (`bucket_resolved_addrs`), so it is exercised here with injected A + // and AAAA addresses -- no dependency on the host having live IPv6 DNS -- and + // the presence of a v6 destination is asserted **hard**. If the split routed + // AAAA records into the v4 bucket, `resolved.ipv6` would be empty (failing the + // non-empty assertion) and the v4 bucket would hold a value that does not parse + // as IPv4 (failing family purity). + #[test] + fn aaaa_records_land_in_the_v6_bucket_and_never_in_the_v4_bucket() { + let injected: Vec = [ + "93.184.216.34", + "2606:2800:220:1:248:1893:25c8:1946", + "8.8.8.8", + "2001:4860:4860::8888", + ] + .iter() + .map(|value| { + value + .parse::() + .expect("injected test address must parse") + }) + .collect(); + + let resolved = NetworkIptablesManager::bucket_resolved_addrs(injected); + + assert_eq!( + resolved.ipv4.len(), + 2, + "both injected A records must land in the v4 bucket, got {:?}", + resolved.ipv4 + ); + assert_eq!( + resolved.ipv6.len(), + 2, + "both injected AAAA records must land in the v6 bucket, got {:?}", + resolved.ipv6 + ); + assert!( + !resolved.ipv6.is_empty(), + "AAAA records must produce at least one v6 destination; an empty v6 \ + bucket means the IPv6 arm was dropped or misrouted into the v4 bucket" + ); + assert_buckets_are_family_pure("injected A/AAAA mix", &resolved); + } + + // Live characterization: over whatever the host's resolver returns for + // well-known dual-stack names, the buckets must stay family-pure. This does not + // depend on the host having IPv6 DNS -- the purity invariant holds for any + // result -- and it does not paper over a missing v6 arm with a warning that + // still passes. The deterministic proof that AAAA records reach the v6 bucket + // lives in `aaaa_records_land_in_the_v6_bucket_and_never_in_the_v4_bucket`, and + // end-to-end IPv6 rule coverage lives in run_lxc_network_dualstack_test.sh. + #[test] + fn live_dual_stack_resolution_keeps_buckets_family_pure() { + for host in ["dns.google", "one.one.one.one", "localhost"] { + let resolved = NetworkIptablesManager::resolve_host(host); + assert_buckets_are_family_pure(host, &resolved); + } + } + + #[test] + fn localhost_resolution_populates_available_loopback_families() { + let resolved = NetworkIptablesManager::resolve_host("localhost"); + + // SPEC_BRIEF §3 requires hostnames to resolve to both A and AAAA. Some + // minimal hosts can have a degenerate /etc/hosts, so this accepts whichever + // localhost family is configured while checking that no other address leaks in. + assert!( + !resolved.is_empty(), + "localhost should resolve to at least one loopback family" + ); + assert!( + resolved + .ipv4 + .iter() + .all(|destination| destination == "127.0.0.1"), + "localhost IPv4 results should all be 127.0.0.1, got {:?}", + resolved.ipv4 + ); + assert!( + resolved.ipv6.iter().all(|destination| destination == "::1"), + "localhost IPv6 results should all be ::1, got {:?}", + resolved.ipv6 + ); + assert_buckets_are_family_pure("localhost", &resolved); + } + + #[test] + fn unresolvable_invalid_tld_hostname_resolves_to_nothing() { + let input = "mxc-resolution-spec-7f3b2d9c4a1e6f80.invalid"; + let resolved = NetworkIptablesManager::resolve_host(input); + + assert!( + resolved.is_empty(), + "reserved .invalid hostname {input:?} should resolve to nothing, got {resolved:?}" + ); + assert_destination_family(input, None); + } + + #[test] + fn destination_family_agrees_with_every_resolved_destination() { + let inputs = [ + "192.0.2.44", + "2606:50c0::153", + "140.82.112.5/20", + "2606:50c0::/32", + "::ffff:127.0.0.1", + "localhost", + ]; + + for input in inputs { + let resolved = NetworkIptablesManager::resolve_host(input); + + for destination in &resolved.ipv4 { + assert_eq!( + NetworkIptablesManager::destination_family(destination), + Some(IpFamily::V4), + "destination_family disagreed with IPv4 filing for input {input:?}, destination {destination:?}" + ); + } + + for destination in &resolved.ipv6 { + assert_eq!( + NetworkIptablesManager::destination_family(destination), + Some(IpFamily::V6), + "destination_family disagreed with IPv6 filing for input {input:?}, destination {destination:?}" + ); + } + } + } + + // ----------------------------------------------------------------------- + // Spec-derived tests: rule generation + // ----------------------------------------------------------------------- + + fn joined(rule: &[String]) -> String { + rule.join(" ") + } + + fn assert_rule_contains(rule: &[String], expected: &str, input: &str) { + assert!( + rule.iter().any(|arg| arg == expected), + "rule for {input} should contain {expected:?}; actual: {rule:?}" + ); + } + + fn assert_rule_omits(rule: &[String], unexpected: &str, input: &str) { + assert!( + !rule.iter().any(|arg| arg == unexpected), + "rule for {input} should not contain {unexpected:?}; actual: {rule:?}" + ); + } + + fn policy_with_hosts(allowed_hosts: &[&str], blocked_hosts: &[&str]) -> ContainerPolicy { + ContainerPolicy { + allowed_hosts: strings(allowed_hosts), + blocked_hosts: strings(blocked_hosts), + ..Default::default() + } + } + + #[test] + fn allow_and_deny_actions_map_to_exact_iptables_jump_targets() { + assert_eq!( + NetworkIptablesManager::rule_action_arg(&RuleAction::Allow), + "ACCEPT", + "RuleAction::Allow should map to ACCEPT exactly" + ); + assert_eq!( + NetworkIptablesManager::rule_action_arg(&RuleAction::Deny), + "DROP", + "RuleAction::Deny should map to DROP exactly" + ); + } + + #[test] + fn destination_literals_and_cidrs_land_only_in_their_address_family_bucket() { + let cases = [ + ("192.0.2.10", "ipv4 bare literal", true), + ("192.0.2.10/24", "ipv4 CIDR", true), + ("2001:db8::10", "ipv6 bare literal", false), + ("2001:db8::10/64", "ipv6 CIDR", false), + ]; + + for (destination, label, is_ipv4) in cases { + let rules = NetworkIptablesManager::build_host_rule_args( + "MXC-family-split", + destination, + &RuleAction::Allow, + ); + + if is_ipv4 { + assert_eq!( + rules.ipv4.len(), + 1, + "{label} {destination} should produce one IPv4 rule; actual: {rules:?}" + ); + assert!( + rules.ipv6.is_empty(), + "{label} {destination} should leave IPv6 rules empty; actual: {rules:?}" + ); + assert_rule_contains(&rules.ipv4[0], destination, destination); + } else { + assert!( + rules.ipv4.is_empty(), + "{label} {destination} must not leak into IPv4 rules; actual: {rules:?}" + ); + assert_eq!( + rules.ipv6.len(), + 1, + "{label} {destination} should produce one IPv6 rule; actual: {rules:?}" + ); + assert_rule_contains(&rules.ipv6[0], destination, destination); + } + } + } + + #[test] + fn mixed_family_host_list_produces_matching_rule_count_in_each_bucket() { + let policy = policy_with_hosts( + &[ + "192.0.2.10", + "198.51.100.0/24", + "2001:db8::10", + "2001:db8:abcd::/48", + ], + &[], + ); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-mixed", &policy); + + assert_eq!( + rules.ipv4.len(), + 2, + "mixed host list should produce two IPv4 rules; actual: {rules:?}" + ); + assert_eq!( + rules.ipv6.len(), + 2, + "mixed host list should produce two IPv6 rules; actual: {rules:?}" + ); + } + + #[test] + fn generated_destination_rules_append_to_chain_match_destination_and_jump_target() { + let chain_name = "MXC-shape"; + let destination = "203.0.113.0/24"; + let rule = NetworkIptablesManager::build_single_rule_args( + chain_name, + destination, + &RuleAction::Deny, + ); + + assert_eq!( + rule.first().map(String::as_str), + Some("-A"), + "rule for {destination} should append with -A; actual: {rule:?}" + ); + assert_rule_contains(&rule, chain_name, destination); + assert_rule_contains(&rule, "-d", destination); + assert_rule_contains(&rule, destination, destination); + assert_rule_contains(&rule, "-j", destination); + assert_rule_contains(&rule, "DROP", destination); + + let rendered = joined(&rule); + assert!( + rendered.contains("-A MXC-shape"), + "rule for {destination} should append to the requested chain; actual: {rendered}" + ); + assert!( + rendered.contains("-d 203.0.113.0/24"), + "CIDR destination should be passed through unchanged in rule; actual: {rendered}" + ); + assert!( + rendered.contains("-j DROP"), + "deny rule for {destination} should jump to DROP; actual: {rendered}" + ); + } + + #[test] + fn resolved_destinations_are_split_into_ipv4_and_ipv6_rule_args() { + let destinations = ResolvedDestinations { + ipv4: strings(&["192.0.2.10", "198.51.100.0/24"]), + ipv6: strings(&["2001:db8::10", "2001:db8:abcd::/48"]), + }; + let rules = NetworkIptablesManager::build_resolved_destination_rule_args( + "MXC-resolved", + &destinations, + &RuleAction::Allow, + ); + + assert_eq!( + rules.ipv4.len(), + 2, + "resolved destinations should keep both IPv4 rules in IPv4 bucket; actual: {rules:?}" + ); + assert_eq!( + rules.ipv6.len(), + 2, + "resolved destinations should keep both IPv6 rules in IPv6 bucket; actual: {rules:?}" + ); + for destination in &destinations.ipv4 { + assert!( + rules.ipv4.iter().any(|rule| rule.contains(destination)), + "IPv4 destination {destination} should appear in IPv4 rules; actual: {rules:?}" + ); + assert!( + !rules.ipv6.iter().any(|rule| rule.contains(destination)), + "IPv4 destination {destination} should not appear in IPv6 rules; actual: {rules:?}" + ); + } + for destination in &destinations.ipv6 { + assert!( + rules.ipv6.iter().any(|rule| rule.contains(destination)), + "IPv6 destination {destination} should appear in IPv6 rules; actual: {rules:?}" + ); + assert!( + !rules.ipv4.iter().any(|rule| rule.contains(destination)), + "IPv6 destination {destination} must not appear in IPv4 rules; actual: {rules:?}" + ); + } + } + + #[test] + fn allow_list_rules_are_emitted_before_block_list_rules_for_same_ipv4_destination() { + let destination = "203.0.113.44"; + let policy = policy_with_hosts(&[destination], &[destination]); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-order-v4", &policy); + let rendered: Vec = rules.ipv4.iter().map(|rule| joined(rule)).collect(); + + let accept_index = rendered + .iter() + .position(|rule| rule.contains(destination) && rule.contains("-j ACCEPT")) + .expect("IPv4 ACCEPT rule for duplicate destination should exist"); + let drop_index = rendered + .iter() + .position(|rule| rule.contains(destination) && rule.contains("-j DROP")) + .expect("IPv4 DROP rule for duplicate destination should exist"); + + // SPEC_BRIEF §3 pins this interim AB#62830341 behavior until deny-precedence lands. + assert!( + accept_index < drop_index, + "IPv4 duplicate {destination} should ACCEPT before DROP; actual order: {rendered:?}" + ); + } + + #[test] + fn allow_list_rules_are_emitted_before_block_list_rules_for_same_ipv6_destination() { + let destination = "2001:db8::44"; + let policy = policy_with_hosts(&[destination], &[destination]); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-order-v6", &policy); + let rendered: Vec = rules.ipv6.iter().map(|rule| joined(rule)).collect(); + + let accept_index = rendered + .iter() + .position(|rule| rule.contains(destination) && rule.contains("-j ACCEPT")) + .expect("IPv6 ACCEPT rule for duplicate destination should exist"); + let drop_index = rendered + .iter() + .position(|rule| rule.contains(destination) && rule.contains("-j DROP")) + .expect("IPv6 DROP rule for duplicate destination should exist"); + + // SPEC_BRIEF §3 says allow-before-block ordering applies to both iptables buckets. + assert!( + accept_index < drop_index, + "IPv6 duplicate {destination} should ACCEPT before DROP; actual order: {rendered:?}" + ); + } + + #[test] + fn base_chain_rules_are_four_family_agnostic_rules_in_documented_order() { + let chain_name = "MXC-base"; + let rules = NetworkIptablesManager::build_base_chain_rule_args(chain_name); + let expected = vec![ + strings(&["-A", chain_name, "-i", "lo", "-j", "ACCEPT"]), + strings(&[ + "-A", + chain_name, + "-m", + "state", + "--state", + "ESTABLISHED,RELATED", + "-j", + "ACCEPT", + ]), + strings(&[ + "-A", chain_name, "-p", "udp", "--dport", "53", "-j", "ACCEPT", + ]), + strings(&[ + "-A", chain_name, "-p", "tcp", "--dport", "53", "-j", "ACCEPT", + ]), + ]; + + assert_eq!( + rules, expected, + "base chain rules should be the documented four rules in order" + ); + for (index, rule) in rules.iter().enumerate() { + assert_rule_omits(rule, "-d", &format!("base rule {index}")); + assert!( + !rule.iter().any(|arg| arg == "icmp" || arg == "icmpv6"), + "base rule {index} must be family-agnostic; -p icmp is invalid for ip6tables and would make the v6 chain fail: {rule:?}" + ); + } + } + + #[test] + fn default_network_policy_maps_to_exact_terminal_rule_vector() { + let chain_name = "MXC-default"; + + assert_eq!( + NetworkIptablesManager::build_default_policy_rule_arg(chain_name, NetworkPolicy::Block), + strings(&["-A", chain_name, "-j", "DROP"]), + "NetworkPolicy::Block should produce the exact DROP terminal rule" + ); + assert_eq!( + NetworkIptablesManager::build_default_policy_rule_arg(chain_name, NetworkPolicy::Allow), + strings(&["-A", chain_name, "-j", "ACCEPT"]), + "NetworkPolicy::Allow should produce the exact ACCEPT terminal rule" + ); + } + + #[test] + fn chain_names_have_mxc_prefix_and_total_length_cap_of_twenty_four() { + let short_name = "short"; + let short_manager = NetworkIptablesManager::new(short_name); + assert_eq!( + short_manager.chain_name, "MXC-short", + "short container name {short_name} should be preserved after MXC- prefix" + ); + + let long_name = "abcdefghijklmnopqrstuvwxyz"; + let long_manager = NetworkIptablesManager::new(long_name); + let expected = "MXC-abcdefghijklmnopqrst"; + assert_eq!( + long_manager.chain_name, expected, + "long container name should be truncated to 20 chars after MXC- prefix" + ); + assert_eq!( + long_manager.chain_name.len(), + 24, + "chain name length cap should apply to total length including MXC- prefix" + ); + assert!( + long_manager.chain_name.starts_with("MXC-"), + "long chain name should keep MXC- prefix; actual: {}", + long_manager.chain_name + ); + } + + #[test] + fn empty_policy_produces_no_destination_rules_in_either_bucket() { + let policy = policy_with_hosts(&[], &[]); + let rules = NetworkIptablesManager::build_policy_rule_args("MXC-empty", &policy); + + assert!( + rules.ipv4.is_empty(), + "empty policy should produce no IPv4 destination rules; actual: {rules:?}" + ); + assert!( + rules.ipv6.is_empty(), + "empty policy should produce no IPv6 destination rules; actual: {rules:?}" + ); + } + + #[test] + fn unresolvable_invalid_hostname_contributes_no_destination_rules() { + let host = "definitely-unresolvable-mxc-rulegen-spec.invalid"; + let rules = + NetworkIptablesManager::build_host_rule_args("MXC-invalid", host, &RuleAction::Allow); + + assert!( + rules.ipv4.is_empty(), + "unresolvable host {host} should produce no IPv4 rules; actual: {rules:?}" + ); + assert!( + rules.ipv6.is_empty(), + "unresolvable host {host} should produce no IPv6 rules; actual: {rules:?}" + ); + } + + // ----------------------------------------------------------------------- + // Spec-derived tests: lifecycle + // ----------------------------------------------------------------------- + + #[test] + fn a_new_manager_reports_no_rules_applied() { + let manager = NetworkIptablesManager::new("fresh"); + + assert!( + !manager.rules_applied(), + "a newly constructed manager must not report firewall state needing cleanup" + ); + } + + #[test] + fn a_non_firewall_policy_is_a_successful_no_op() { + let mut manager = NetworkIptablesManager::new("skip-noop"); + manager.set_veth_interface("veth-skip"); + let policy = policy_with_enforcement_mode(NetworkEnforcementMode::Capabilities); + let mut logger = Logger::new(Mode::Buffer); + + let result = manager.apply_firewall_rules(&policy, &mut logger); + + assert_eq!( + result, + Ok(true), + "a policy that does not use firewall enforcement must be reported as a successful no-op" + ); + assert!( + !manager.rules_applied(), + "a no-op firewall skip must leave no rules marked as applied" + ); + } + + #[test] + fn every_enforcement_mode_takes_the_contractual_firewall_gate() { + for (mode, uses_firewall) in enforcement_modes_with_firewall_contract() { + assert_eq!( + NetworkIptablesManager::enforcement_mode_uses_firewall(&mode), + uses_firewall, + "{mode:?} firewall-gate predicate mismatch" + ); + } + } + + fn policy_with_enforcement_mode( + network_enforcement_mode: NetworkEnforcementMode, + ) -> ContainerPolicy { + ContainerPolicy { + network_enforcement_mode, + ..Default::default() + } + } + + /// The expected answers are written out as literals rather than derived from + /// a second copy of the predicate. A test that recomputes the contract it is + /// checking passes even when both copies are wrong in the same way. + fn enforcement_modes_with_firewall_contract() -> [(NetworkEnforcementMode, bool); 3] { + use NetworkEnforcementMode::{Both, Capabilities, Firewall}; + + [(Capabilities, false), (Firewall, true), (Both, true)] + } + + // ----------------------------------------------------------------------- + // Spec-derived tests: ip6tables status + // ----------------------------------------------------------------------- + + // ----------------------------------------------------------------------- + // Truth table — all four input combinations are enumerated and pinned. + // ----------------------------------------------------------------------- + + #[test] + fn working_probe_with_active_ipv6_reports_available() { + // "A working probe means the tool is usable regardless of address state." + let result = NetworkIptablesManager::classify_ip6tables_status(true, true); + assert_eq!( + result, + Ip6tablesStatus::Available, + "classify_ip6tables_status(probe=true, ipv6_active=true) should be Available; got {result:?}" + ); + } + + #[test] + fn working_probe_without_active_ipv6_still_reports_available() { + // "A working probe means the tool is usable regardless of address state." + let result = NetworkIptablesManager::classify_ip6tables_status(true, false); + assert_eq!( + result, + Ip6tablesStatus::Available, + "classify_ip6tables_status(probe=true, ipv6_active=false) should be Available; got {result:?}" + ); + } + + #[test] + fn failed_probe_with_no_active_ipv6_reports_kernel_ipv6_disabled() { + // "if the kernel has no active IPv6 there is nothing to filter and skipping is safe" + let result = NetworkIptablesManager::classify_ip6tables_status(false, false); + assert_eq!( + result, + Ip6tablesStatus::KernelIpv6Disabled, + "classify_ip6tables_status(probe=false, ipv6_active=false) should be KernelIpv6Disabled; got {result:?}" + ); + } + + #[test] + fn live_ipv6_with_a_broken_tool_must_fail_closed_not_skip() { + // "if IPv6 is live the tool is genuinely missing or broken and setup must + // fail closed rather than leave IPv6 egress unfiltered" + let result = NetworkIptablesManager::classify_ip6tables_status(false, true); + assert_eq!( + result, + Ip6tablesStatus::UnusableButIpv6Active, + "classify_ip6tables_status(probe=false, ipv6_active=true) should be UnusableButIpv6Active (fail-closed); got {result:?}" + ); + } + + // ----------------------------------------------------------------------- + // Invariants — properties that must hold across the whole domain. + // ----------------------------------------------------------------------- + + /// A working probe always yields Available, regardless of IPv6 address state. + #[test] + fn working_probe_always_yields_available_regardless_of_ipv6_state() { + for ipv6_active in [false, true] { + let result = NetworkIptablesManager::classify_ip6tables_status(true, ipv6_active); + assert_eq!( + result, + Ip6tablesStatus::Available, + "probe_succeeded=true, ipv6_active={ipv6_active}: expected Available, got {result:?}" + ); + } + } + + /// A failed probe must never return Available — it can only be KernelIpv6Disabled + /// or UnusableButIpv6Active. + #[test] + fn failed_probe_never_reports_available() { + for ipv6_active in [false, true] { + let result = NetworkIptablesManager::classify_ip6tables_status(false, ipv6_active); + assert_ne!( + result, + Ip6tablesStatus::Available, + "probe_succeeded=false, ipv6_active={ipv6_active}: Available must not be returned when the probe failed; got {result:?}" + ); + } + } + + /// UnusableButIpv6Active is ONLY reachable when the probe failed AND IPv6 is + /// live. If a mutation makes the fail-closed branch unreachable (silent + /// fail-open), this test catches it. + #[test] + fn fail_closed_outcome_is_reachable_only_when_probe_failed_and_ipv6_is_live() { + // The one combination that MUST produce UnusableButIpv6Active. + let fail_closed = NetworkIptablesManager::classify_ip6tables_status(false, true); + assert_eq!( + fail_closed, + Ip6tablesStatus::UnusableButIpv6Active, + "classify_ip6tables_status(probe=false, ipv6_active=true) must be UnusableButIpv6Active; got {fail_closed:?}" + ); + + // All other combinations must NOT produce UnusableButIpv6Active. + let other_pairs = [(true, true), (true, false), (false, false)]; + for (probe, active) in other_pairs { + let result = NetworkIptablesManager::classify_ip6tables_status(probe, active); + assert_ne!( + result, + Ip6tablesStatus::UnusableButIpv6Active, + "classify_ip6tables_status(probe={probe}, ipv6_active={active}) must not be UnusableButIpv6Active; got {result:?}" + ); + } + } + + /// KernelIpv6Disabled is ONLY reachable when the probe failed AND IPv6 is + /// inactive. It must not surface as a safe-skip when IPv6 is actually live. + #[test] + fn safe_skip_outcome_is_reachable_only_when_probe_failed_and_ipv6_is_inactive() { + // The one combination that MUST produce KernelIpv6Disabled. + let safe_skip = NetworkIptablesManager::classify_ip6tables_status(false, false); + assert_eq!( + safe_skip, + Ip6tablesStatus::KernelIpv6Disabled, + "classify_ip6tables_status(probe=false, ipv6_active=false) must be KernelIpv6Disabled; got {safe_skip:?}" + ); + + // All other combinations must NOT produce KernelIpv6Disabled. + let other_pairs = [(true, true), (true, false), (false, true)]; + for (probe, active) in other_pairs { + let result = NetworkIptablesManager::classify_ip6tables_status(probe, active); + assert_ne!( + result, + Ip6tablesStatus::KernelIpv6Disabled, + "classify_ip6tables_status(probe={probe}, ipv6_active={active}) must not be KernelIpv6Disabled; got {result:?}" + ); + } + } + + // ----------------------------------------------------------------------- + // Discriminant distinctness — a mutation that collapses two variants must + // be caught before PartialEq-based assertions below would silently accept it. + // ----------------------------------------------------------------------- + + #[test] + fn ip6tables_status_variants_are_all_distinct_from_each_other() { + assert_ne!( + Ip6tablesStatus::Available, + Ip6tablesStatus::KernelIpv6Disabled, + "Available and KernelIpv6Disabled must be distinct variants" + ); + assert_ne!( + Ip6tablesStatus::Available, + Ip6tablesStatus::UnusableButIpv6Active, + "Available and UnusableButIpv6Active must be distinct variants" + ); + assert_ne!( + Ip6tablesStatus::KernelIpv6Disabled, + Ip6tablesStatus::UnusableButIpv6Active, + "KernelIpv6Disabled and UnusableButIpv6Active must be distinct variants" + ); + } + + // ----------------------------------------------------------------------- + // Spec-derived tests: host IPv6 state + // ----------------------------------------------------------------------- + + /// `/proc/net` exists, which is the ordinary case on any Linux host and the + /// precondition that makes a missing `if_inet6` mean "IPv6 is off". + const PROC_NET_MOUNTED: bool = true; + + /// `/proc` is not mounted, so no IPv6 probe ever ran. + const PROC_NET_ABSENT: bool = false; + + // A real `/proc/net/if_inet6` line: 32-hex-char address, if_index, prefix_len, + // scope, flags, and the device name in the final field. These samples mirror + // the kernel's actual formatting (space-separated fields). + const LOOPBACK_LINE: &str = "00000000000000000000000000000001 01 80 10 80 lo"; + const ETH0_GLOBAL_LINE: &str = "2606280002200001024818932c5c1946 03 40 00 80 eth0"; + const ETH0_LINKLOCAL_LINE: &str = "fe80000000000000020000fffe000001 03 40 20 80 eth0"; + + #[test] + fn a_real_interface_address_is_classified_active() { + // "a line is treated as evidence of active IPv6 only when its device is + // something other than `lo`" -- a global address on eth0 is egress-capable. + let contents = format!("{LOOPBACK_LINE}\n{ETH0_GLOBAL_LINE}\n"); + let state = + NetworkIptablesManager::classify_host_ipv6_state(Ok(contents), PROC_NET_MOUNTED); + assert_eq!( + state, + HostIpv6State::Active, + "a non-loopback interface with an IPv6 address must classify as Active; got {state:?}" + ); + } + + #[test] + fn a_link_local_address_on_a_real_interface_is_still_active() { + // The kernel lists the link-local `fe80::` address on any interface with + // IPv6 up; its device is not `lo`, so the host has an IPv6 stack to filter. + let contents = format!("{ETH0_LINKLOCAL_LINE}\n"); + let state = + NetworkIptablesManager::classify_host_ipv6_state(Ok(contents), PROC_NET_MOUNTED); + assert_eq!( + state, + HostIpv6State::Active, + "a link-local address on eth0 must classify as Active; got {state:?}" + ); + } + + #[test] + fn loopback_only_is_not_a_basis_for_claiming_egress_capable_ipv6() { + // An IPv4-only host commonly still lists `::1` on `lo`. Loopback is not + // egress-capable, so it must NOT be treated as active IPv6. + let contents = format!("{LOOPBACK_LINE}\n"); + let state = + NetworkIptablesManager::classify_host_ipv6_state(Ok(contents), PROC_NET_MOUNTED); + assert_eq!( + state, + HostIpv6State::Inactive, + "loopback-only `::1` on `lo` must classify as Inactive, not Active; got {state:?}" + ); + assert_ne!( + state, + HostIpv6State::Active, + "loopback-only `::1` must never be reported as egress-capable IPv6" + ); + } + + #[test] + fn empty_contents_are_inactive() { + let state = + NetworkIptablesManager::classify_host_ipv6_state(Ok(String::new()), PROC_NET_MOUNTED); + assert_eq!( + state, + HostIpv6State::Inactive, + "an empty `/proc/net/if_inet6` means no IPv6 addresses; got {state:?}" + ); + } + + #[test] + fn whitespace_only_contents_are_inactive() { + let state = NetworkIptablesManager::classify_host_ipv6_state( + Ok("\n \n".to_string()), + PROC_NET_MOUNTED, + ); + assert_eq!( + state, + HostIpv6State::Inactive, + "blank lines carry no interface, so the state is Inactive; got {state:?}" + ); + } + + #[test] + fn a_missing_file_is_a_confirmed_negative() { + // A `NotFound` read *while `/proc/net` exists* means the kernel never + // created the file (IPv6 disabled at boot), which IS a genuine + // "IPv6 is off" -> Inactive. + let state = NetworkIptablesManager::classify_host_ipv6_state( + Err(Error::from(ErrorKind::NotFound)), + PROC_NET_MOUNTED, + ); + assert_eq!( + state, + HostIpv6State::Inactive, + "a NotFound read (IPv6 disabled at boot) is a confirmed negative; got {state:?}" + ); + } + + #[test] + fn a_missing_file_on_an_unmounted_proc_is_unknown_not_a_confirmed_negative() { + // An unmounted /proc reports the same NotFound as an IPv6-disabled + // kernel, but says nothing at all about IPv6: the probe never ran. + // Reading it as "IPv6 is off" would apply an IPv4-only policy and leave + // IPv6 egress unfiltered. + let state = NetworkIptablesManager::classify_host_ipv6_state( + Err(Error::from(ErrorKind::NotFound)), + PROC_NET_ABSENT, + ); + assert_eq!( + state, + HostIpv6State::Unknown, + "NotFound with no /proc/net must be Unknown, not a confirmed negative; got {state:?}" + ); + assert_ne!( + state, + HostIpv6State::Inactive, + "an unmounted /proc must never be reported as a confirmed 'IPv6 is off'" + ); + } + + #[test] + fn an_unreadable_file_is_unknown_not_a_confirmed_negative() { + // Any read error other than NotFound (permission denied, I/O error, /proc + // not mounted) means "we could not determine the state", which must NOT be + // silently converted into "IPv6 is off". This is the fail-open guard. + let state = NetworkIptablesManager::classify_host_ipv6_state( + Err(Error::from(ErrorKind::PermissionDenied)), + PROC_NET_MOUNTED, + ); + assert_eq!( + state, + HostIpv6State::Unknown, + "a PermissionDenied read must be Unknown, not Inactive; got {state:?}" + ); + assert_ne!( + state, + HostIpv6State::Inactive, + "an unreadable IPv6 state must never be treated as a confirmed 'IPv6 is off'" + ); + } + + #[test] + fn a_generic_io_error_is_unknown_not_a_confirmed_negative() { + let state = NetworkIptablesManager::classify_host_ipv6_state( + Err(Error::from(ErrorKind::Other)), + PROC_NET_MOUNTED, + ); + assert_eq!( + state, + HostIpv6State::Unknown, + "a generic I/O error must be Unknown, not Inactive; got {state:?}" + ); + } + + // The three states must be distinct, or the PartialEq-based assertions above + // could silently accept a mutation that collapses two of them. + #[test] + fn host_ipv6_states_are_all_distinct() { + assert_ne!(HostIpv6State::Active, HostIpv6State::Inactive); + assert_ne!(HostIpv6State::Active, HostIpv6State::Unknown); + assert_ne!(HostIpv6State::Inactive, HostIpv6State::Unknown); + } + + // ----------------------------------------------------------------------- + // State -> "treat as active" mapping. This is the fail-open guard: Unknown + // must be treated as active so an unreadable IPv6 state fails closed rather + // than leaving IPv6 egress unfiltered. + // ----------------------------------------------------------------------- + + #[test] + fn active_state_is_treated_as_active() { + assert!( + NetworkIptablesManager::ipv6_state_treated_as_active(HostIpv6State::Active), + "Active must be treated as active" + ); + } + + #[test] + fn inactive_state_is_not_treated_as_active() { + assert!( + !NetworkIptablesManager::ipv6_state_treated_as_active(HostIpv6State::Inactive), + "Inactive must not be treated as active; there is genuinely nothing to filter" + ); + } + + #[test] + fn unknown_state_is_treated_as_active_to_fail_closed() { + // The fail-open guard: "we could not determine IPv6 state" must NOT become + // "IPv6 is off". Treating Unknown as active means a failed ip6tables probe + // then fails setup closed instead of leaving IPv6 egress unfiltered. + assert!( + NetworkIptablesManager::ipv6_state_treated_as_active(HostIpv6State::Unknown), + "Unknown must be treated as active so an unreadable IPv6 state fails closed" + ); } } diff --git a/src/backends/lxc/common/src/signal_cleanup.rs b/src/backends/lxc/common/src/signal_cleanup.rs index 764ca4cf7..a2ebfab05 100644 --- a/src/backends/lxc/common/src/signal_cleanup.rs +++ b/src/backends/lxc/common/src/signal_cleanup.rs @@ -24,19 +24,26 @@ use nix::sys::signal::{SigSet, Signal}; #[cfg(target_os = "linux")] use crate::lxc_bindings::LxcContainer; +use crate::network_iptables::CreatedResources; #[cfg(target_os = "linux")] use crate::network_iptables::NetworkIptablesManager; #[cfg(target_os = "linux")] use wxc_common::logger::{Logger, Mode}; /// What the watchdog needs to roll back on a fatal signal: the container -/// name (so we can `lxc-destroy` it) plus, when known, the host-side veth -/// interface (so we can also remove the iptables FORWARD hook the runner -/// installed against it). +/// name (so we can `lxc-destroy` it), the host-side veth interface when +/// known (so we can also remove the iptables FORWARD hook the runner +/// installed against it), and the set of chains and hooks the runner has +/// actually created so far (so we remove only those). +/// +/// All three live behind one mutex on purpose. The watchdog takes a single +/// snapshot of the whole struct, so it can never pair one container's +/// identity with another's ownership record. #[derive(Default)] struct ActiveSandbox { name: Option, veth: Option, + created: CreatedResources, } static ACTIVE_CONTAINER: OnceLock> = OnceLock::new(); @@ -52,12 +59,14 @@ fn lock_slot() -> std::sync::MutexGuard<'static, ActiveSandbox> { /// Records `name` as the currently active container so the cleanup watchdog /// can destroy it if a fatal signal arrives. Replaces any previous value -/// (including any previously registered veth, since the new container has -/// not had its veth discovered yet). +/// (including any previously registered veth and created-resource record, +/// since the new container has not had its veth discovered and has not +/// created anything yet). pub fn set_active(name: &str) { let mut slot = lock_slot(); slot.name = Some(name.to_owned()); slot.veth = None; + slot.created = CreatedResources::default(); } /// Records the host-side veth interface for the active container so the @@ -70,6 +79,34 @@ pub fn set_active_veth(veth: &str) { } } +/// Records which iptables chains and FORWARD hooks the runner has created so +/// far, so signal-time cleanup removes exactly those and nothing else. +/// +/// No-op when no container is registered. Backends that never call +/// [`set_active`] — Bubblewrap builds the same firewall manager but installs +/// no watchdog — therefore publish nothing, which keeps the watchdog from +/// acting on a lifecycle it does not manage. +pub(crate) fn set_active_created(created: CreatedResources) { + let mut slot = lock_slot(); + if slot.name.is_some() { + slot.created = created; + } +} + +/// Reads back what the watchdog would act on. Test-only: production code has +/// exactly one reader, and it is the watchdog itself. +#[cfg(test)] +fn active_snapshot() -> (Option, Option, CreatedResources) { + let slot = lock_slot(); + (slot.name.clone(), slot.veth.clone(), slot.created) +} + +/// Returns the slot to its process-start state so a test leaves nothing behind. +#[cfg(test)] +fn clear_active() { + *lock_slot() = ActiveSandbox::default(); +} + /// Block SIGHUP/SIGTERM/SIGINT in the calling thread and spawn a watchdog /// that synchronously waits (`sigwait`) for any of them. On delivery the /// watchdog destroys the active container, then exits with `128 + signo`. @@ -135,9 +172,78 @@ fn run_watchdog(mask: SigSet) -> ! { // signal-time output doesn't interleave with whatever else // might still be writing to the host's stdio. let mut buf_logger = Logger::new(Mode::Buffer); - NetworkIptablesManager::force_cleanup(&name, active.veth.as_deref(), &mut buf_logger); + NetworkIptablesManager::force_cleanup( + &name, + active.veth.as_deref(), + active.created, + &mut buf_logger, + ); let _ = LxcContainer::new(&name, None).destroy(); } std::process::exit(128 + sig as i32); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// `ACTIVE_CONTAINER` is process-global and the test binary runs tests in + /// parallel, so the whole publication contract is asserted in one test. + /// Splitting it would let two tests race on the same slot. + #[test] + fn the_watchdogs_view_of_a_container_is_built_and_reset_as_a_single_unit() { + clear_active(); + + // Before any container registers, ownership publication is a no-op. + // Bubblewrap builds the same firewall manager but never registers, so + // its resources must not become something the watchdog would remove. + set_active_created(CreatedResources::for_test(true, true, true, true)); + let (name, veth, created) = active_snapshot(); + assert_eq!(name, None, "no container should be registered yet"); + assert_eq!( + created, + CreatedResources::default(), + "ownership published with no registered container must be discarded" + ); + assert_eq!(veth, None); + + // Registering a container opens the slot. + set_active("ctr-a"); + set_active_veth("veth-a"); + let v4_chain_only = CreatedResources::for_test(true, false, false, false); + set_active_created(v4_chain_only); + assert_eq!( + active_snapshot(), + ( + Some("ctr-a".to_owned()), + Some("veth-a".to_owned()), + v4_chain_only + ), + "a registered container must see its own identity and ownership" + ); + + // Publication is incremental: each creation site republishes the whole + // record, so a later publish supersedes an earlier one rather than + // merging with it. + let both_chains = CreatedResources::for_test(true, true, false, false); + set_active_created(both_chains); + assert_eq!( + active_snapshot().2, + both_chains, + "the most recent publication is what the watchdog must act on" + ); + + // A new container must not inherit the previous one's ownership record. + // Chain names truncate, so acting on a stale record can tear down a + // different container's chain. + set_active("ctr-b"); + assert_eq!( + active_snapshot(), + (Some("ctr-b".to_owned()), None, CreatedResources::default()), + "registering a new container must reset veth and ownership" + ); + + clear_active(); + } +} diff --git a/tests/configs/lxc_network_cidr_boundary.json b/tests/configs/lxc_network_cidr_boundary.json new file mode 100644 index 000000000..b5434d949 --- /dev/null +++ b/tests/configs/lxc_network_cidr_boundary.json @@ -0,0 +1,33 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-LXC-Network-CIDR-Boundary", + "containment": "lxc", + "process": { + "commandLine": "true" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "allow", + "enforcementMode": "firewall", + "allowedHosts": [ + "0.0.0.0/0", + "::/0", + "140.82.112.5", + "140.82.112.5/20", + "140.82.112.5/32", + "2606:50c0:8000::153/32" + ], + "blockedHosts": [ + "198.51.100.42", + "198.51.100.42/32", + "2001:db8::5", + "2001:db8::5/128" + ] + } +} diff --git a/tests/configs/lxc_network_dualstack_hostname.json b/tests/configs/lxc_network_dualstack_hostname.json new file mode 100644 index 000000000..7e080fd6d --- /dev/null +++ b/tests/configs/lxc_network_dualstack_hostname.json @@ -0,0 +1,30 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-LXC-Network-Dualstack-Hostname", + "containment": "lxc", + "process": { + "commandLine": "wget -qO- https://dns.google/" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "allowedHosts": [ + "localhost", + "dns.google", + "one.one.one.one", + "8.8.8.8", + "2001:4860:4860::8888" + ], + "blockedHosts": [ + "10.0.0.0/8", + "2001:db8::/32" + ] + } +} diff --git a/tests/configs/lxc_network_invalid_cidr.json b/tests/configs/lxc_network_invalid_cidr.json new file mode 100644 index 000000000..8160a6140 --- /dev/null +++ b/tests/configs/lxc_network_invalid_cidr.json @@ -0,0 +1,25 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-LXC-Network-Invalid-CIDR", + "containment": "lxc", + "process": { + "commandLine": "wget -qO- https://api.github.com/zen" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "allowedHosts": [ + "140.82.112.0/33", + "2606:50c0::/129", + "140.82.112.0/not-a-prefix" + ], + "blockedHosts": [] + } +} diff --git a/tests/configs/lxc_network_ipv6_cidr.json b/tests/configs/lxc_network_ipv6_cidr.json new file mode 100644 index 000000000..0ed8918b2 --- /dev/null +++ b/tests/configs/lxc_network_ipv6_cidr.json @@ -0,0 +1,29 @@ +{ + "version": "0.8.0-alpha", + "containerId": "CLI-LXC-Network-IPv6-CIDR", + "containment": "lxc", + "process": { + "commandLine": "wget -qO- https://api.github.com/zen" + }, + "lifecycle": { + "destroyOnExit": true + }, + "lxc": { + "distribution": "alpine", + "release": "3.23" + }, + "network": { + "defaultPolicy": "block", + "enforcementMode": "firewall", + "allowedHosts": [ + "140.82.112.0/20", + "2606:50c0::/32", + "2606:50c0:8000::153" + ], + "blockedHosts": [ + "10.0.0.0/8", + "2001:db8::/32", + "fe80::1" + ] + } +} diff --git a/tests/scripts/run_lxc_all_tests.sh b/tests/scripts/run_lxc_all_tests.sh index 4121fb574..0510bb4d8 100644 --- a/tests/scripts/run_lxc_all_tests.sh +++ b/tests/scripts/run_lxc_all_tests.sh @@ -12,7 +12,14 @@ fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PASSED=0 FAILED=0 +SKIPPED=0 FAILURES="" +SKIPS="" + +# Exit status a child test uses to report an honest skip (missing prerequisite +# such as no root, no ip6tables, or an unbuilt binary). Matches the GNU +# Automake convention. A skip must never be counted as a pass. +SKIP_EXIT=77 # Check for Windows line endings in test scripts check_line_endings() { @@ -29,9 +36,18 @@ run_test() { local name="$1" local script="$2" echo "=== $name ===" - if bash "$script"; then + # Do not let a nonzero exit abort the runner; classify it instead. + set +e + bash "$script" + local status=$? + set -e + if [ "$status" -eq 0 ]; then echo "PASS: $name" PASSED=$((PASSED + 1)) + elif [ "$status" -eq "$SKIP_EXIT" ]; then + echo "SKIP: $name" + SKIPPED=$((SKIPPED + 1)) + SKIPS="$SKIPS\n - $name" else echo "FAIL: $name" FAILED=$((FAILED + 1)) @@ -46,11 +62,23 @@ run_test "LXC Object Validation" "$SCRIPT_DIR/run_lxc_object_test.sh" run_test "LXC Most-Specific Path" "$SCRIPT_DIR/run_lxc_most_specific_test.sh" run_test "LXC Denied Masking" "$SCRIPT_DIR/run_lxc_denied_masking_test.sh" run_test "LXC Network" "$SCRIPT_DIR/run_lxc_network_test.sh" +run_test "LXC Network IPv6+CIDR" "$SCRIPT_DIR/run_lxc_network_ipv6_cidr_test.sh" +run_test "LXC Network Invalid CIDR" "$SCRIPT_DIR/run_lxc_network_invalid_cidr_test.sh" +run_test "LXC Network Dual-Stack Hostname" "$SCRIPT_DIR/run_lxc_network_dualstack_test.sh" +run_test "LXC Network CIDR Boundary" "$SCRIPT_DIR/run_lxc_network_cidr_boundary_test.sh" run_test "LXC Timeout" "$SCRIPT_DIR/run_lxc_timeout_test.sh" run_test "LXC Env+Cwd" "$SCRIPT_DIR/run_lxc_env_cwd_test.sh" echo "================================" -echo "Results: $PASSED passed, $FAILED failed" +echo "Results: $PASSED passed, $FAILED failed, $SKIPPED skipped" +if [ "$SKIPPED" -gt 0 ]; then + echo -e "Skipped (prerequisite missing, not run):$SKIPS" +fi +# A suite that ran nothing must not look green. Make an all-skip (or empty) run +# visibly distinct from a real pass. +if [ "$PASSED" -eq 0 ] && [ "$FAILED" -eq 0 ]; then + echo "WARNING: no tests actually executed; every test was skipped." +fi if [ $FAILED -gt 0 ]; then echo -e "Failures:$FAILURES" exit 1 diff --git a/tests/scripts/run_lxc_network_cidr_boundary_test.sh b/tests/scripts/run_lxc_network_cidr_boundary_test.sh new file mode 100644 index 000000000..6234d2a8b --- /dev/null +++ b/tests/scripts/run_lxc_network_cidr_boundary_test.sh @@ -0,0 +1,209 @@ +#!/bin/bash +# LXC CIDR boundary network filtering test +# +# Proves roadmap item 19 / AB#62830559 accepts boundary-valid CIDR +# destinations while using the default-allow firewall path. The boundary values +# pinned here are IPv4/IPv6 /0, IPv4 /32, IPv6 /128, non-zero host-bit CIDRs, +# and a bare literal plus matching single-address CIDR spelling in one policy. +# +# NOTE: this fixture asserts that boundary prefixes are accepted and programmed, +# not effective reachability. Allow-list rules are emitted before block-list rules +# and iptables is first-match-wins (interim behaviour, AB#62830341), so the +# `0.0.0.0/0` and `::/0` allow entries shadow every blockedHosts entry here. +# Do not add reachability assertions to this file expecting the block list to win. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" + +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than PASS. A suite that could not run must not look green. +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +[ "$(id -u)" -eq 0 ] || skip "requires root for iptables/ip6tables and LXC." +command -v iptables >/dev/null 2>&1 || skip "iptables is not installed." +command -v ip6tables >/dev/null 2>&1 || skip "ip6tables is not installed." +command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed." +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." + +CONFIG="$REPO_DIR/tests/configs/lxc_network_cidr_boundary.json" +CHAIN_NAME="MXC-CLI-LXC-Network-CIDR" +EXPECTED_ALLOWED_HOSTS=( + "0.0.0.0/0" + "::/0" + "140.82.112.5" + "140.82.112.5/20" + "140.82.112.5/32" + "2606:50c0:8000::153/32" +) +EXPECTED_BLOCKED_HOSTS=( + "198.51.100.42" + "198.51.100.42/32" + "2001:db8::5" + "2001:db8::5/128" +) + +fail() { + echo "FAIL: $1" + exit 1 +} + +assert_programmed_rule() { + local table="$1" dest="$2" target="$3" + # The --debug log emits one line per destination rule actually generated, + # derived from the built rule args. Asserting it here fails if + # destination-rule emission is deleted while chain/default/hook logging is + # kept. This inspects the rule contents while the chain is being programmed + # rather than only checking post-run cleanup. + if ! grep -Fq "Programmed $table rule: -A $CHAIN_NAME -d $dest -j $target" <<<"$OUTPUT"; then + fail "expected $table rule for '$dest' -> $target was not programmed." + fi +} + +assert_firewall_chain_cleaned_up() { + if iptables -S "$CHAIN_NAME" >/dev/null 2>&1; then + fail "iptables chain '$CHAIN_NAME' was left behind after lxc-exec completed." + fi + if ip6tables -S "$CHAIN_NAME" >/dev/null 2>&1; then + fail "ip6tables chain '$CHAIN_NAME' was left behind after lxc-exec completed." + fi +} + +load_config_hosts() { + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import json, sys; data=json.load(open(sys.argv[1], encoding="utf-8")); net=data["network"]; [print(f"allowed\t{h}") for h in net.get("allowedHosts", [])]; [print(f"blocked\t{h}") for h in net.get("blockedHosts", [])]' "$CONFIG" + else + awk ' + /"allowedHosts"[[:space:]]*:/ { list="allowed"; next } + /"blockedHosts"[[:space:]]*:/ { list="blocked"; next } + list && /]/ { list=""; next } + list { print list "\t" $0 } + ' "$CONFIG" | sed -n 's/^\([^[:space:]]*\)[[:space:]]*"\([^"]*\)".*/\1\t\2/p' + fi +} + +contains_host() { + local needle="$1" + shift + local host + for host in "$@"; do + if [ "$host" = "$needle" ]; then + return 0 + fi + done + return 1 +} + +mapfile -t CONFIG_HOST_LINES < <(load_config_hosts) +CONFIG_ALLOWED_HOSTS=() +CONFIG_BLOCKED_HOSTS=() +for line in "${CONFIG_HOST_LINES[@]}"; do + list="${line%%$'\t'*}" + host="${line#*$'\t'}" + case "$list" in + allowed) CONFIG_ALLOWED_HOSTS+=("$host") ;; + blocked) CONFIG_BLOCKED_HOSTS+=("$host") ;; + *) fail "unexpected host list '$list' in $CONFIG." ;; + esac +done + +if [ "${#CONFIG_ALLOWED_HOSTS[@]}" -ne "${#EXPECTED_ALLOWED_HOSTS[@]}" ]; then + fail "allowed host count ${#CONFIG_ALLOWED_HOSTS[@]} does not match expected count ${#EXPECTED_ALLOWED_HOSTS[@]}." +fi +if [ "${#CONFIG_BLOCKED_HOSTS[@]}" -ne "${#EXPECTED_BLOCKED_HOSTS[@]}" ]; then + fail "blocked host count ${#CONFIG_BLOCKED_HOSTS[@]} does not match expected count ${#EXPECTED_BLOCKED_HOSTS[@]}." +fi +for expected in "${EXPECTED_ALLOWED_HOSTS[@]}"; do + if ! contains_host "$expected" "${CONFIG_ALLOWED_HOSTS[@]}"; then + fail "expected allowed host '$expected' is missing from $CONFIG." + fi +done +for expected in "${EXPECTED_BLOCKED_HOSTS[@]}"; do + if ! contains_host "$expected" "${CONFIG_BLOCKED_HOSTS[@]}"; then + fail "expected blocked host '$expected' is missing from $CONFIG." + fi +done + +ALL_CONFIG_HOSTS=("${CONFIG_ALLOWED_HOSTS[@]}" "${CONFIG_BLOCKED_HOSTS[@]}") + +echo "Running LXC CIDR boundary network filtering test..." + +set +e +OUTPUT=$("$LXC_EXEC" --debug "$CONFIG" 2>&1) +STATUS=$? +set -e +echo "$OUTPUT" + +# The container command is a local success command (see the fixture), so a +# non-zero status reflects a firewall-setup failure on boundary-valid prefixes +# rather than an unrelated network outage. +if [ "$STATUS" -ne 0 ]; then + fail "lxc-exec exited with status $STATUS for boundary-valid prefixes." +fi + +# SPEC_BRIEF §3 accepts prefix lengths at the inclusive family bounds, including /0. +for host in "${ALL_CONFIG_HOSTS[@]}"; do + if echo "$OUTPUT" | grep -Fq "Warning: could not resolve host '$host'"; then + fail "host '$host' was not resolved." + fi +done + +# Inspect the actual destination rules generated -- not merely the absence of an +# unresolved-host warning. Each allow entry must yield an ACCEPT rule and each +# block entry a DROP rule, in the correct family's table, so that deleting +# destination-rule emission fails this test even though chain/default/hook +# logging is unchanged. +assert_programmed_rule iptables "0.0.0.0/0" ACCEPT +assert_programmed_rule ip6tables "::/0" ACCEPT +assert_programmed_rule iptables "140.82.112.5" ACCEPT +assert_programmed_rule iptables "140.82.112.5/20" ACCEPT +assert_programmed_rule iptables "140.82.112.5/32" ACCEPT +assert_programmed_rule ip6tables "2606:50c0:8000::153/32" ACCEPT +assert_programmed_rule iptables "198.51.100.42" DROP +assert_programmed_rule iptables "198.51.100.42/32" DROP +assert_programmed_rule ip6tables "2001:db8::5" DROP +assert_programmed_rule ip6tables "2001:db8::5/128" DROP + +if ! echo "$OUTPUT" | grep -q "Default network policy: ACCEPT"; then + fail "default-allow policy was not applied." +fi +if echo "$OUTPUT" | grep -q "Default network policy: DROP"; then + fail "default-deny policy was applied unexpectedly." +fi + +# The v6 half is required by roadmap item 19 / AB#62830559; skipping it would be a dual-stack bypass. +if echo "$OUTPUT" | grep -q "IPv6 firewall rule(s) not applied"; then + fail "IPv6 rules were skipped; ip6tables is unusable on this host." +fi + +if ! echo "$OUTPUT" | grep -q "Creating iptables/ip6tables chain:"; then + fail "firewall chain creation was not logged." +fi + +if echo "$OUTPUT" | grep -qE "^(ip6?tables) .* failed:|Firewall setup failed:"; then + fail "iptables/ip6tables rejected a boundary-valid rule." +fi + +# The FORWARD hook is what scopes the chain to this container's egress; a run +# that skipped it enforces nothing, so PASS must require it. Fail on the +# skipped-hook warning and require the positive install confirmation. +if echo "$OUTPUT" | grep -Fq "Skipping FORWARD hook"; then + fail "FORWARD hook was skipped; the container's veth interface was not discovered." +fi +if ! echo "$OUTPUT" | grep -Fq "FORWARD hook installed"; then + fail "FORWARD hook installation was not confirmed." +fi + +assert_firewall_chain_cleaned_up + +echo "PASS: CIDR boundary entries were resolved and programmed with default allow." +echo "LXC CIDR boundary network filtering test complete." diff --git a/tests/scripts/run_lxc_network_dualstack_test.sh b/tests/scripts/run_lxc_network_dualstack_test.sh new file mode 100644 index 000000000..e9f48d066 --- /dev/null +++ b/tests/scripts/run_lxc_network_dualstack_test.sh @@ -0,0 +1,230 @@ +#!/bin/bash +# LXC dual-stack hostname network filtering test +# +# Proves AB#62830559 / roadmap item 19: hostname allow-list entries are +# resolved to both A and AAAA records so IPv6 traffic to a dual-stack +# destination cannot bypass the firewall. The same run also keeps IPv4/IPv6 +# literals and IPv4/IPv6 CIDRs in the policy to catch mixed-family regressions. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" + +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than PASS. A suite that could not run must not look green. +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +[ "$(id -u)" -eq 0 ] || skip "requires root for iptables/ip6tables and LXC." +command -v iptables >/dev/null 2>&1 || skip "iptables is not installed." +command -v ip6tables >/dev/null 2>&1 || skip "ip6tables is not installed." +command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed." +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." + +CONFIG="$REPO_DIR/tests/configs/lxc_network_dualstack_hostname.json" +CHAIN_NAME="MXC-CLI-LXC-Network-Dual" +EXPECTED_ALLOWED_HOST_COUNT=5 +EXPECTED_BLOCKED_HOST_COUNT=2 +EXPECTED_ALLOWED_HOSTS=( + "localhost" + "dns.google" + "one.one.one.one" + "8.8.8.8" + "2001:4860:4860::8888" +) +EXPECTED_BLOCKED_HOSTS=( + "10.0.0.0/8" + "2001:db8::/32" +) +EXTERNAL_DUALSTACK_HOSTNAMES=( + "dns.google" + "one.one.one.one" +) +OFFLINE_SAFE_HOSTS=( + "localhost" + "8.8.8.8" + "2001:4860:4860::8888" + "10.0.0.0/8" + "2001:db8::/32" +) + +fail() { + echo "FAIL: $1" + exit 1 +} + +assert_programmed_rule() { + local table="$1" dest="$2" target="$3" + # The --debug log emits one line per destination rule actually generated, + # derived from the built rule args. Asserting it here fails if + # destination-rule emission is deleted while chain/default/hook logging is + # kept. This inspects the rule contents while the chain is being programmed + # rather than only checking that an unresolved-host warning is absent. + if ! grep -Fq "Programmed $table rule: -A $CHAIN_NAME -d $dest -j $target" <<<"$OUTPUT"; then + fail "expected $table rule for '$dest' -> $target was not programmed." + fi +} + +assert_firewall_chain_cleaned_up() { + if iptables -S "$CHAIN_NAME" >/dev/null 2>&1; then + fail "iptables chain '$CHAIN_NAME' was left behind after lxc-exec completed." + fi + if ip6tables -S "$CHAIN_NAME" >/dev/null 2>&1; then + fail "ip6tables chain '$CHAIN_NAME' was left behind after lxc-exec completed." + fi +} + +load_config_counts() { + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import json, sys; data=json.load(open(sys.argv[1], encoding="utf-8")); net=data["network"]; print(len(net.get("allowedHosts", [])), len(net.get("blockedHosts", [])))' "$CONFIG" + else + awk ' + /"allowedHosts"[[:space:]]*:/ { section="allowed"; next } + /"blockedHosts"[[:space:]]*:/ { section="blocked"; next } + section && /]/ { section=""; next } + section && /^[[:space:]]*"/ { counts[section]++ } + END { printf "%d %d\n", counts["allowed"] + 0, counts["blocked"] + 0 } + ' "$CONFIG" + fi +} + +load_config_hosts() { + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import json, sys; data=json.load(open(sys.argv[1], encoding="utf-8")); net=data["network"]; print("\n".join(net.get("allowedHosts", []) + net.get("blockedHosts", [])))' "$CONFIG" + else + awk ' + /"allowedHosts"[[:space:]]*:/ { in_hosts=1; next } + /"blockedHosts"[[:space:]]*:/ { in_hosts=1; next } + in_hosts && /]/ { in_hosts=0; next } + in_hosts { print } + ' "$CONFIG" | sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' + fi +} + +host_has_records() { + local family="$1" + local host="$2" + + if command -v timeout >/dev/null 2>&1; then + timeout 10s getent "$family" "$host" >/dev/null 2>&1 + else + getent "$family" "$host" >/dev/null 2>&1 + fi +} + +external_dualstack_hosts_resolve() { + local host + + for host in "${EXTERNAL_DUALSTACK_HOSTNAMES[@]}"; do + if ! host_has_records ahostsv4 "$host" || ! host_has_records ahostsv6 "$host"; then + return 1 + fi + done + + return 0 +} + +read -r allowed_count blocked_count < <(load_config_counts) +if [ "$allowed_count" -ne "$EXPECTED_ALLOWED_HOST_COUNT" ]; then + fail "config allowedHosts count $allowed_count does not match expected count $EXPECTED_ALLOWED_HOST_COUNT." +fi +if [ "$blocked_count" -ne "$EXPECTED_BLOCKED_HOST_COUNT" ]; then + fail "config blockedHosts count $blocked_count does not match expected count $EXPECTED_BLOCKED_HOST_COUNT." +fi + +mapfile -t CONFIG_HOSTS < <(load_config_hosts) +EXPECTED_HOSTS=("${EXPECTED_ALLOWED_HOSTS[@]}" "${EXPECTED_BLOCKED_HOSTS[@]}") +for expected in "${EXPECTED_HOSTS[@]}"; do + found=0 + for actual in "${CONFIG_HOSTS[@]}"; do + if [ "$actual" = "$expected" ]; then + found=1 + break + fi + done + if [ "$found" -ne 1 ]; then + fail "expected host '$expected' is missing from $CONFIG." + fi +done + +ASSERT_RESOLVED_HOSTS=("${OFFLINE_SAFE_HOSTS[@]}") +if external_dualstack_hosts_resolve; then + ASSERT_RESOLVED_HOSTS=("${EXPECTED_HOSTS[@]}") + EXTERNAL_DUALSTACK=1 +else + EXTERNAL_DUALSTACK=0 + echo "SKIP: external dual-stack DNS unavailable; skipping external hostname resolution assertions." +fi + +echo "Running LXC dual-stack hostname network filtering test..." + +# The container command may fail if the host has no outbound route; this test is +# only asserting firewall setup and hostname family handling. +OUTPUT=$("$LXC_EXEC" --debug "$CONFIG" 2>&1 || true) +echo "$OUTPUT" + +for host in "${ASSERT_RESOLVED_HOSTS[@]}"; do + if grep -Fq "Warning: could not resolve host '$host'" <<<"$OUTPUT"; then + fail "host '$host' was not resolved." + fi +done + +# Prove a positive IPv6 rule exists rather than only checking that no +# unresolved-host warning appeared. The IPv6 literal and the IPv6 CIDR are +# offline-safe and deterministic, so their v6 rules are always asserted. +assert_programmed_rule ip6tables "2001:4860:4860::8888" ACCEPT +assert_programmed_rule ip6tables "2001:db8::/32" DROP + +# The dual-stack point of AB#62830559: a hostname's AAAA record must become an +# IPv6 rule. When external DNS is available, resolve an AAAA for a dual-stack +# hostname ourselves and require the firewall to have programmed an IPv6 ACCEPT +# rule for that exact address -- proving the hostname's AAAA became a v6 rule, +# not merely that a warning was absent. When external DNS is unavailable this is +# skipped loudly (never silently passed). +if [ "$EXTERNAL_DUALSTACK" -eq 1 ]; then + aaaa=$(getent ahostsv6 dns.google 2>/dev/null | awk 'NF {print $1; exit}') + if [ -n "${aaaa:-}" ]; then + assert_programmed_rule ip6tables "$aaaa" ACCEPT + else + echo "SKIP: could not obtain an AAAA for dns.google; hostname-derived IPv6 rule not asserted." + fi +else + echo "SKIP: external dual-stack DNS unavailable; hostname-derived IPv6 rule not asserted." +fi + +if ! grep -Fq "Creating iptables/ip6tables chain:" <<<"$OUTPUT"; then + fail "iptables/ip6tables chain creation was not logged." +fi + +if ! grep -Fq "Default network policy: DROP" <<<"$OUTPUT"; then + fail "default-deny policy was not applied." +fi + +# This warning means the IPv6 half was skipped, which is the dual-stack bypass +# AB#62830559 exists to prevent. +if grep -Fq "IPv6 firewall rule(s) not applied" <<<"$OUTPUT"; then + fail "IPv6 rules were skipped; the dual-stack bypass is still open on this run." +fi + +# The FORWARD hook is what scopes the chain to this container's egress; a run +# that skipped it enforces nothing, so PASS must require it. Fail on the +# skipped-hook warning and require the positive install confirmation. +if grep -Fq "Skipping FORWARD hook" <<<"$OUTPUT"; then + fail "FORWARD hook was skipped; the container's veth interface was not discovered." +fi +if ! grep -Fq "FORWARD hook installed" <<<"$OUTPUT"; then + fail "FORWARD hook installation was not confirmed." +fi + +assert_firewall_chain_cleaned_up + +echo "PASS: dual-stack hostnames and mixed-family destinations were resolved and programmed." +echo "LXC dual-stack hostname network filtering test complete." diff --git a/tests/scripts/run_lxc_network_invalid_cidr_test.sh b/tests/scripts/run_lxc_network_invalid_cidr_test.sh new file mode 100644 index 000000000..784026e79 --- /dev/null +++ b/tests/scripts/run_lxc_network_invalid_cidr_test.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# LXC invalid CIDR network filtering test +# +# Invalid CIDR entries should be reported as unresolved hosts and then skipped; +# they must not make firewall setup fail for the rest of the policy. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" + +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than PASS. A suite that could not run must not look green. +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +[ "$(id -u)" -eq 0 ] || skip "requires root for iptables/ip6tables and LXC." +command -v iptables >/dev/null 2>&1 || skip "iptables is not installed." +command -v ip6tables >/dev/null 2>&1 || skip "ip6tables is not installed." +command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed." +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." + +CONFIG="$REPO_DIR/tests/configs/lxc_network_invalid_cidr.json" +CHAIN_NAME="MXC-CLI-LXC-Network-Inva" +INVALID_HOSTS=( + "140.82.112.0/33" + "2606:50c0::/129" + "140.82.112.0/not-a-prefix" +) + +fail() { + echo "FAIL: $1" + exit 1 +} + +assert_firewall_chain_cleaned_up() { + if iptables -S "$CHAIN_NAME" >/dev/null 2>&1; then + fail "iptables chain '$CHAIN_NAME' was left behind after lxc-exec completed." + fi + if ip6tables -S "$CHAIN_NAME" >/dev/null 2>&1; then + fail "ip6tables chain '$CHAIN_NAME' was left behind after lxc-exec completed." + fi +} + +echo "Running LXC invalid CIDR network filtering test..." + +# The process may fail because the default policy blocks egress; this test is +# only asserting firewall validation and setup behavior. +OUTPUT=$("$LXC_EXEC" --debug "$CONFIG" 2>&1 || true) +echo "$OUTPUT" + +for host in "${INVALID_HOSTS[@]}"; do + if ! echo "$OUTPUT" | grep -Fq "Warning: could not resolve host '$host'"; then + fail "invalid host '$host' did not produce an unresolved-host warning." + fi +done + +# Invalid CIDRs are warned about and omitted from rule generation; applying the +# remaining firewall policy should still succeed. +if echo "$OUTPUT" | grep -qE "^(ip6?tables) .* failed:|Firewall setup failed:"; then + fail "invalid CIDR entry caused firewall setup to fail." +fi + +if ! echo "$OUTPUT" | grep -q "Default network policy: DROP"; then + fail "default-deny policy was not applied." +fi + +# The FORWARD hook is what scopes the chain to this container's egress; a run +# that skipped it enforces nothing, so PASS must require it. Fail on the +# skipped-hook warning and require the positive install confirmation. +if echo "$OUTPUT" | grep -Fq "Skipping FORWARD hook"; then + fail "FORWARD hook was skipped; the container's veth interface was not discovered." +fi +if ! echo "$OUTPUT" | grep -Fq "FORWARD hook installed"; then + fail "FORWARD hook installation was not confirmed." +fi + +assert_firewall_chain_cleaned_up + +echo "PASS: invalid CIDR entries were warned about without failing firewall setup." +echo "LXC invalid CIDR network filtering test complete." diff --git a/tests/scripts/run_lxc_network_ipv6_cidr_test.sh b/tests/scripts/run_lxc_network_ipv6_cidr_test.sh new file mode 100644 index 000000000..0a42c3c95 --- /dev/null +++ b/tests/scripts/run_lxc_network_ipv6_cidr_test.sh @@ -0,0 +1,159 @@ +#!/bin/bash +# LXC IPv6 + CIDR network filtering test +# +# Exercises tests/configs/lxc_network_ipv6_cidr.json, whose allow/block lists +# carry IPv4 CIDRs, IPv6 CIDRs, and IPv6 literals. The assertions are on the +# firewall setup rather than on whether the container reaches the network: +# reachability depends on the host's uplink, but rule programming does not. +# +# A misrouted address family is a hard failure, not a silent one -- +# `run_firewall_command` returns Err when iptables/ip6tables rejects a rule, so +# handing an IPv6 CIDR to iptables (or a v4 CIDR to ip6tables) aborts setup and +# is caught here. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" + +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +# An honest skip for a missing prerequisite: exit 77 so run_lxc_all_tests.sh +# records SKIPPED rather than PASS. A suite that could not run must not look green. +SKIP_EXIT=77 +skip() { + echo "SKIP: $1" + exit "$SKIP_EXIT" +} + +[ "$(id -u)" -eq 0 ] || skip "requires root for iptables/ip6tables and LXC." +command -v iptables >/dev/null 2>&1 || skip "iptables is not installed." +command -v ip6tables >/dev/null 2>&1 || skip "ip6tables is not installed." +command -v lxc-create >/dev/null 2>&1 || skip "LXC (lxc-create) is not installed." +[ -f "$LXC_EXEC" ] || skip "lxc-exec binary not built; run build.sh first." + +CONFIG="$REPO_DIR/tests/configs/lxc_network_ipv6_cidr.json" +CHAIN_NAME="MXC-CLI-LXC-Network-IPv6" +EXPECTED_HOSTS=( + "140.82.112.0/20" + "2606:50c0::/32" + "2606:50c0:8000::153" + "10.0.0.0/8" + "2001:db8::/32" + "fe80::1" +) + +fail() { + echo "FAIL: $1" + exit 1 +} + +assert_programmed_rule() { + local table="$1" dest="$2" target="$3" + # The --debug log emits one line per destination rule actually generated, + # derived from the built rule args. Asserting it here fails if + # destination-rule emission is deleted while chain/default/hook logging is + # kept -- the exact vacuity flagged in review. This inspects the rule + # contents while the chain is being programmed rather than only checking + # post-run cleanup. + if ! grep -Fq "Programmed $table rule: -A $CHAIN_NAME -d $dest -j $target" <<<"$OUTPUT"; then + fail "expected $table rule for '$dest' -> $target was not programmed." + fi +} + +assert_firewall_chain_cleaned_up() { + if iptables -S "$CHAIN_NAME" >/dev/null 2>&1; then + fail "iptables chain '$CHAIN_NAME' was left behind after lxc-exec completed." + fi + if ip6tables -S "$CHAIN_NAME" >/dev/null 2>&1; then + fail "ip6tables chain '$CHAIN_NAME' was left behind after lxc-exec completed." + fi +} + +load_config_hosts() { + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import json, sys; data=json.load(open(sys.argv[1], encoding="utf-8")); net=data["network"]; print("\n".join(net.get("allowedHosts", []) + net.get("blockedHosts", [])))' "$CONFIG" + else + awk ' + /"allowedHosts"[[:space:]]*:/ { in_hosts=1; next } + /"blockedHosts"[[:space:]]*:/ { in_hosts=1; next } + in_hosts && /]/ { in_hosts=0; next } + in_hosts { print } + ' "$CONFIG" | sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' + fi +} + +mapfile -t CONFIG_HOSTS < <(load_config_hosts) +if [ "${#CONFIG_HOSTS[@]}" -ne "${#EXPECTED_HOSTS[@]}" ]; then + fail "config host count ${#CONFIG_HOSTS[@]} does not match expected count ${#EXPECTED_HOSTS[@]}." +fi +for expected in "${EXPECTED_HOSTS[@]}"; do + found=0 + for actual in "${CONFIG_HOSTS[@]}"; do + if [ "$actual" = "$expected" ]; then + found=1 + break + fi + done + if [ "$found" -ne 1 ]; then + fail "expected host '$expected' is missing from $CONFIG." + fi +done + +echo "Running LXC IPv6/CIDR network filtering test..." + +# The container command may fail on a host with no outbound route; the firewall +# assertions below are what this test is about. +OUTPUT=$("$LXC_EXEC" --debug "$CONFIG" 2>&1 || true) +echo "$OUTPUT" + +# Every allow/block entry must survive resolution. An unparsed CIDR or IPv6 +# literal is reported here instead of silently dropping a rule. +for host in "${EXPECTED_HOSTS[@]}"; do + if echo "$OUTPUT" | grep -Fq "Warning: could not resolve host '$host'"; then + fail "host '$host' was not resolved." + fi +done + +# Inspect the actual destination rules that were generated -- not merely the +# absence of an unresolved-host warning. Each allow entry must yield an ACCEPT +# rule and each block entry a DROP rule, in the correct family's table, so that +# deleting destination-rule emission fails this test. +assert_programmed_rule iptables "140.82.112.0/20" ACCEPT +assert_programmed_rule ip6tables "2606:50c0::/32" ACCEPT +assert_programmed_rule ip6tables "2606:50c0:8000::153" ACCEPT +assert_programmed_rule iptables "10.0.0.0/8" DROP +assert_programmed_rule ip6tables "2001:db8::/32" DROP +assert_programmed_rule ip6tables "fe80::1" DROP + +# A rejected rule aborts setup. +if echo "$OUTPUT" | grep -qE "^(ip6?tables) .* failed:|Firewall setup failed:"; then + fail "iptables/ip6tables rejected a rule." +fi + +if ! echo "$OUTPUT" | grep -q "Default network policy: DROP"; then + fail "default-deny policy was not applied." +fi + +# The v6 half is the point of the test: if ip6tables is unusable the v6 rules +# are skipped with a warning, which would make this a v4-only run. +if echo "$OUTPUT" | grep -q "IPv6 firewall rule(s) not applied"; then + fail "IPv6 rules were skipped; ip6tables is unusable on this host." +fi + +# The FORWARD hook is what scopes the chain to this container's egress; a run +# that skipped it enforces nothing, so PASS must require it. Fail on the +# skipped-hook warning and require the positive install confirmation. +if echo "$OUTPUT" | grep -Fq "Skipping FORWARD hook"; then + fail "FORWARD hook was skipped; the container's veth interface was not discovered." +fi +if ! echo "$OUTPUT" | grep -Fq "FORWARD hook installed"; then + fail "FORWARD hook installation was not confirmed." +fi + +assert_firewall_chain_cleaned_up + +echo "PASS: IPv6 and CIDR entries were resolved and programmed." +echo "LXC IPv6/CIDR network filtering test complete."