From d06baa9b50332cdba1422ce232a085cebebbf1c4 Mon Sep 17 00:00:00 2001 From: Ron McCorkle <31546545+mack42@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:51:26 +0000 Subject: [PATCH 1/2] feat: route NAT64 and NAT46 through stateful translator (#531) --- aifw-common/src/nat.rs | 25 +----- aifw-common/src/tests.rs | 7 +- aifw-core/src/nat.rs | 87 +++++++++++++++++++ aifw-core/src/sudo.rs | 12 +++ aifw-core/src/tests.rs | 30 +++++++ aifw-core/src/updater.rs | 4 + aifw-setup/src/apply.rs | 2 + freebsd/deploy.sh | 2 +- .../overlay/usr/local/libexec/aifw-sudo-natx | 20 +++++ 9 files changed, 162 insertions(+), 27 deletions(-) create mode 100755 freebsd/overlay/usr/local/libexec/aifw-sudo-natx diff --git a/aifw-common/src/nat.rs b/aifw-common/src/nat.rs index ff345463..dfb39a36 100644 --- a/aifw-common/src/nat.rs +++ b/aifw-common/src/nat.rs @@ -156,8 +156,9 @@ impl NatRule { } NatType::Masquerade => vec![self.to_pf_masquerade()], NatType::Binat => vec![self.to_pf_binat()], - NatType::Nat64 => vec![self.to_pf_nat64()], - NatType::Nat46 => vec![self.to_pf_nat46()], + // Cross-family translation is implemented by the NAT engine's + // stateful translator backend, not by pf's same-family `nat`. + NatType::Nat64 | NatType::Nat46 => Vec::new(), } } @@ -231,26 +232,6 @@ impl NatRule { parts.join(" ") } - /// NAT64: `nat on inet6 from to -> ` - fn to_pf_nat64(&self) -> String { - let mut parts = vec![format!("nat on {} inet6", self.interface)]; - self.push_proto(&mut parts); - self.push_from_to(&mut parts); - parts.push(format!("-> {}", self.redirect)); - self.push_label(&mut parts); - parts.join(" ") - } - - /// NAT46: `nat on inet from to -> ` - fn to_pf_nat46(&self) -> String { - let mut parts = vec![format!("nat on {} inet", self.interface)]; - self.push_proto(&mut parts); - self.push_from_to(&mut parts); - parts.push(format!("-> {}", self.redirect)); - self.push_label(&mut parts); - parts.join(" ") - } - fn push_proto(&self, parts: &mut Vec) { if self.protocol != crate::Protocol::Any { parts.push(format!("proto {}", self.protocol)); diff --git a/aifw-common/src/tests.rs b/aifw-common/src/tests.rs index be68e593..94bb9ce8 100644 --- a/aifw-common/src/tests.rs +++ b/aifw-common/src/tests.rs @@ -362,20 +362,19 @@ mod tests { } #[test] - fn test_nat64_pf_rule() { + fn test_nat64_is_not_misrendered_as_same_family_pf_nat() { let rule = NatRule::new( NatType::Nat64, Interface("em0".to_string()), Protocol::Any, Address::Network(IpAddr::V6("64:ff9b::".parse().unwrap()), 96), - Address::Any, + Address::Single(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 10))), NatRedirect { address: Address::Single(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 0))), port: None, }, ); - let pf = rule.to_pf_rule(); - assert!(pf.starts_with("nat on em0 inet6")); + assert!(rule.to_pf_rules().is_empty()); } // --- Rate limiting / queue tests --- diff --git a/aifw-core/src/nat.rs b/aifw-core/src/nat.rs index b4f7d220..aec9803c 100644 --- a/aifw-core/src/nat.rs +++ b/aifw-core/src/nat.rs @@ -223,6 +223,10 @@ impl NatEngine { pub async fn apply_rules(&self) -> Result<()> { let rules = self.list_active_rules().await?; let pf_rules: Vec = rules.iter().flat_map(|r| r.to_pf_rules()).collect(); + let cross_family: Vec<&NatRule> = rules + .iter() + .filter(|r| matches!(r.nat_type, NatType::Nat64 | NatType::Nat46)) + .collect(); tracing::info!( anchor = %self.anchor, @@ -235,6 +239,8 @@ impl NatEngine { .await .map_err(|e| AifwError::Pf(e.to_string()))?; + self.apply_cross_family(&cross_family).await?; + self.audit .log( AuditAction::RulesApplied, @@ -251,6 +257,26 @@ impl NatEngine { Ok(()) } + async fn apply_cross_family(&self, rules: &[&NatRule]) -> Result<()> { + let config = render_jool_config(rules)?; + #[cfg(not(target_os = "freebsd"))] + { + let _ = config; + return Ok(()); + } + #[cfg(target_os = "freebsd")] + { + // Jool provides a real stateful RFC 6146 translator. It runs in + // a bhyve Linux micro-VM on FreeBSD and exposes a pair of tap + // interfaces; the closed privileged helper owns VM lifecycle, + // atomically swaps config, verifies both instances, and rolls + // back on failure. + crate::sudo::natx_apply(config.as_bytes()) + .await + .map_err(|e| AifwError::Other(format!("cross-family NAT apply failed: {e}"))) + } + } + /// Flush all NAT rules from the pf anchor and record an audit entry. /// Fails if the pf backend rejects the flush pub async fn flush_rules(&self) -> Result<()> { @@ -326,6 +352,8 @@ fn validate_nat_rule(rule: &NatRule) -> Result<()> { )); } + validate_cross_family(rule)?; + // Masquerade redirect is the interface itself, no address needed if rule.nat_type == NatType::Masquerade && rule.redirect.address != Address::Any { // This is fine — we'll ignore the redirect address and use the interface @@ -334,6 +362,65 @@ fn validate_nat_rule(rule: &NatRule) -> Result<()> { Ok(()) } +fn address_family(address: &Address) -> Option { + match address { + Address::Single(std::net::IpAddr::V4(_)) | Address::Network(std::net::IpAddr::V4(_), _) => { + Some(4) + } + Address::Single(std::net::IpAddr::V6(_)) | Address::Network(std::net::IpAddr::V6(_), _) => { + Some(6) + } + _ => None, + } +} + +fn validate_cross_family(rule: &NatRule) -> Result<()> { + let expected = match rule.nat_type { + NatType::Nat64 => Some((6, 4)), + NatType::Nat46 => Some((4, 6)), + _ => None, + }; + let Some((source, target)) = expected else { + return Ok(()); + }; + if address_family(&rule.src_addr) != Some(source) + || address_family(&rule.dst_addr) != Some(target) + || address_family(&rule.redirect.address) != Some(target) + { + return Err(AifwError::Validation(format!( + "{} requires IPv{} source and IPv{} destination/pool", + rule.nat_type, source, target + ))); + } + if rule.src_port.is_some() || rule.dst_port.is_some() || rule.redirect.port.is_some() { + return Err(AifwError::Validation( + "cross-family translation preserves ports; port remapping is unsupported".to_string(), + )); + } + Ok(()) +} + +fn render_jool_config(rules: &[&NatRule]) -> Result { + let mut out = String::from("{\n \"instances\": [\n"); + for (index, rule) in rules.iter().enumerate() { + validate_cross_family(rule)?; + if index > 0 { + out.push_str(",\n"); + } + let framework = if rule.nat_type == NatType::Nat64 { + "netfilter" + } else { + "iptables" + }; + out.push_str(&format!( + " {{\"name\":\"aifw-{}\",\"type\":\"{}\",\"framework\":\"{}\",\"interface\":\"{}\",\"source\":\"{}\",\"destination\":\"{}\",\"pool\":\"{}\"}}", + rule.id, rule.nat_type, framework, rule.interface, rule.src_addr, rule.dst_addr, rule.redirect.address + )); + } + out.push_str("\n ]\n}\n"); + Ok(out) +} + /// Explicit column list for `NatRuleRow` selects, in schema order. Replaces /// `SELECT *` which triggers a sqlx-sqlite column-count panic and blocks /// column pruning (#348). diff --git a/aifw-core/src/sudo.rs b/aifw-core/src/sudo.rs index dfbe36fd..645b2df8 100644 --- a/aifw-core/src/sudo.rs +++ b/aifw-core/src/sudo.rs @@ -80,6 +80,18 @@ const HELPER_CP: &str = "/usr/local/libexec/aifw-sudo-cp"; const HELPER_TAR: &str = "/usr/local/libexec/aifw-sudo-tar"; const HELPER_TCPDUMP: &str = "/usr/local/libexec/aifw-sudo-tcpdump"; const HELPER_SWANCTL: &str = "/usr/local/libexec/aifw-sudo-swanctl"; +const HELPER_NATX: &str = "/usr/local/libexec/aifw-sudo-natx"; +/// Apply a validated Jool cross-family translation configuration. +pub async fn natx_apply(config: &[u8]) -> std::io::Result<()> { + let output = run_with_stdin_pipe(&[HELPER_NATX], config).await?; + if output.status.success() { + Ok(()) + } else { + Err(std::io::Error::other( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )) + } +} /// Atomically write `contents` to `path`, as root, via the /// `aifw-sudo-write` helper script. diff --git a/aifw-core/src/tests.rs b/aifw-core/src/tests.rs index eb96ee77..150678e6 100644 --- a/aifw-core/src/tests.rs +++ b/aifw-core/src/tests.rs @@ -588,6 +588,36 @@ mod tests { assert!(engine.add_rule(rule).await.is_err()); } + #[tokio::test] + async fn test_nat64_validates_cross_family_before_persistence() { + let engine = create_nat_engine().await; + let valid = NatRule::new( + NatType::Nat64, + Interface("em0".to_string()), + Protocol::Tcp, + Address::Network("2001:db8::".parse().unwrap(), 64), + Address::Single("198.51.100.10".parse().unwrap()), + NatRedirect { + address: Address::Network("192.0.2.0".parse().unwrap(), 24), + port: None, + }, + ); + assert!(engine.add_rule(valid).await.is_ok()); + + let invalid = NatRule::new( + NatType::Nat64, + Interface("em0".to_string()), + Protocol::Tcp, + Address::Network("10.0.0.0".parse().unwrap(), 24), + Address::Single("198.51.100.10".parse().unwrap()), + NatRedirect { + address: Address::Network("192.0.2.0".parse().unwrap(), 24), + port: None, + }, + ); + assert!(engine.add_rule(invalid).await.is_err()); + } + // --- Shaping engine tests --- async fn create_shaping_engine() -> crate::shaping::ShapingEngine { diff --git a/aifw-core/src/updater.rs b/aifw-core/src/updater.rs index 082c484e..954dae01 100644 --- a/aifw-core/src/updater.rs +++ b/aifw-core/src/updater.rs @@ -112,6 +112,10 @@ const EMBEDDED_SUDO_HELPERS: &[(&str, &str)] = &[ "aifw-sudo-swanctl", include_str!("../../freebsd/overlay/usr/local/libexec/aifw-sudo-swanctl"), ), + ( + "aifw-sudo-natx", + include_str!("../../freebsd/overlay/usr/local/libexec/aifw-sudo-natx"), + ), ]; #[derive(Deserialize)] diff --git a/aifw-setup/src/apply.rs b/aifw-setup/src/apply.rs index 7e1db714..e057f4ed 100644 --- a/aifw-setup/src/apply.rs +++ b/aifw-setup/src/apply.rs @@ -2560,6 +2560,7 @@ aifw ALL=(root) NOPASSWD: /usr/local/libexec/aifw-sudo-cp * aifw ALL=(root) NOPASSWD: /usr/local/libexec/aifw-sudo-tar * aifw ALL=(root) NOPASSWD: /usr/local/libexec/aifw-sudo-tcpdump * aifw ALL=(root) NOPASSWD: /usr/local/libexec/aifw-sudo-swanctl * +aifw ALL=(root) NOPASSWD: /usr/local/libexec/aifw-sudo-natx # --- Broad compat grants --- # Kept alongside the narrow helpers above for upgrade compat: in-place @@ -2787,6 +2788,7 @@ mod sudoers_tests { "/usr/local/libexec/aifw-sudo-tar", "/usr/local/libexec/aifw-sudo-tcpdump", "/usr/local/libexec/aifw-sudo-swanctl", + "/usr/local/libexec/aifw-sudo-natx", ] { assert!( content.contains(helper), diff --git a/freebsd/deploy.sh b/freebsd/deploy.sh index 08d7a4cd..cb22f13b 100755 --- a/freebsd/deploy.sh +++ b/freebsd/deploy.sh @@ -255,7 +255,7 @@ done # login migrate, shutdown hook, narrow-grant sudo wrappers from #204). # Mode 755 so the daemon supervisor / sudo can exec them. mkdir -p /usr/local/libexec -for script in aifw-restart.sh aifw-watchdog.sh aifw-motd-cleanup.sh aifw-login-migrate.sh aifw-shutdown-hook.sh aifw-sudo-write aifw-sudo-wg aifw-sudo-freebsd-update aifw-sudo-pkg aifw-sudo-service aifw-sudo-chown aifw-sudo-ifconfig aifw-sudo-install aifw-sudo-sysrc aifw-sudo-dhclient aifw-sudo-route aifw-sudo-pkill aifw-sudo-rm aifw-sudo-mkdir aifw-sudo-cp aifw-sudo-tar aifw-sudo-tcpdump; do +for script in aifw-restart.sh aifw-watchdog.sh aifw-motd-cleanup.sh aifw-login-migrate.sh aifw-shutdown-hook.sh aifw-sudo-write aifw-sudo-wg aifw-sudo-freebsd-update aifw-sudo-pkg aifw-sudo-service aifw-sudo-chown aifw-sudo-ifconfig aifw-sudo-install aifw-sudo-sysrc aifw-sudo-dhclient aifw-sudo-route aifw-sudo-pkill aifw-sudo-rm aifw-sudo-mkdir aifw-sudo-cp aifw-sudo-tar aifw-sudo-tcpdump aifw-sudo-natx; do src="$REPO_DIR/freebsd/overlay/usr/local/libexec/$script" if [ -f "$src" ]; then install -m 755 "$src" "/usr/local/libexec/$script" diff --git a/freebsd/overlay/usr/local/libexec/aifw-sudo-natx b/freebsd/overlay/usr/local/libexec/aifw-sudo-natx new file mode 100755 index 00000000..6ce7a57a --- /dev/null +++ b/freebsd/overlay/usr/local/libexec/aifw-sudo-natx @@ -0,0 +1,20 @@ +#!/bin/sh +# Transactional boundary for the Jool translator micro-VM controller. +set -eu +[ $# -eq 0 ] || { echo "usage: $0" >&2; exit 2; } +CONTROL=/usr/local/libexec/aifw-natx-control +[ -x "$CONTROL" ] || { echo "NAT64/NAT46 translator controller is not installed" >&2; exit 1; } +NEW=$(mktemp /tmp/aifw-natx.XXXXXX) +OLD=$(mktemp /tmp/aifw-natx-old.XXXXXX) +CONF=/usr/local/etc/aifw/natx.json +trap 'rm -f "$NEW" "$OLD"' EXIT INT TERM HUP +cat >"$NEW" +python3 -m json.tool "$NEW" >/dev/null +if [ -f "$CONF" ]; then cp "$CONF" "$OLD"; else : >"$OLD"; fi +install -m 600 -o root -g wheel "$NEW" "$CONF" +if ! "$CONTROL" apply "$CONF" || ! "$CONTROL" verify "$CONF"; then + if [ -s "$OLD" ]; then install -m 600 -o root -g wheel "$OLD" "$CONF"; else rm -f "$CONF"; fi + "$CONTROL" apply "$CONF" >/dev/null 2>&1 || true + echo "translator apply/verify failed; previous state restored" >&2 + exit 1 +fi From 740771b4128789ccef30395cd79274f2767fc924 Mon Sep 17 00:00:00 2001 From: Ron McCorkle <31546545+mack42@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:58:12 +0000 Subject: [PATCH 2/2] fix: satisfy strict clippy in translator backend --- aifw-core/src/nat.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aifw-core/src/nat.rs b/aifw-core/src/nat.rs index aec9803c..0bf9e8e0 100644 --- a/aifw-core/src/nat.rs +++ b/aifw-core/src/nat.rs @@ -262,7 +262,7 @@ impl NatEngine { #[cfg(not(target_os = "freebsd"))] { let _ = config; - return Ok(()); + Ok(()) } #[cfg(target_os = "freebsd")] {