Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 3 additions & 22 deletions aifw-common/src/nat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -231,26 +232,6 @@ impl NatRule {
parts.join(" ")
}

/// NAT64: `nat on <iface> inet6 from <src> to <dst> -> <redirect>`
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 <iface> inet from <src> to <dst> -> <redirect>`
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<String>) {
if self.protocol != crate::Protocol::Any {
parts.push(format!("proto {}", self.protocol));
Expand Down
7 changes: 3 additions & 4 deletions aifw-common/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
87 changes: 87 additions & 0 deletions aifw-core/src/nat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ impl NatEngine {
pub async fn apply_rules(&self) -> Result<()> {
let rules = self.list_active_rules().await?;
let pf_rules: Vec<String> = 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,
Expand All @@ -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,
Expand All @@ -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;
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<()> {
Expand Down Expand Up @@ -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
Expand All @@ -334,6 +362,65 @@ fn validate_nat_rule(rule: &NatRule) -> Result<()> {
Ok(())
}

fn address_family(address: &Address) -> Option<u8> {
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<String> {
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).
Expand Down
12 changes: 12 additions & 0 deletions aifw-core/src/sudo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions aifw-core/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions aifw-core/src/updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
2 changes: 2 additions & 0 deletions aifw-setup/src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion freebsd/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions freebsd/overlay/usr/local/libexec/aifw-sudo-natx
Original file line number Diff line number Diff line change
@@ -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
Loading