diff --git a/src/bin/fips-gateway.rs b/src/bin/fips-gateway.rs index 810d1e6..7b6486b 100644 --- a/src/bin/fips-gateway.rs +++ b/src/bin/fips-gateway.rs @@ -100,7 +100,17 @@ async fn main() { } }; - info!(pool = %gw_config.pool, lan_interface = %gw_config.lan_interface, "Gateway config loaded"); + if let Err(e) = gw_config.validate_port_forwards() { + error!("Invalid gateway.port_forwards: {e}"); + std::process::exit(1); + } + + info!( + pool = %gw_config.pool, + lan_interface = %gw_config.lan_interface, + port_forwards = gw_config.port_forwards.len(), + "Gateway config loaded" + ); // --- Prerequisites --- @@ -221,7 +231,7 @@ async fn main() { }; // NAT manager - let mut nat_mgr = match nat::NatManager::new() { + let mut nat_mgr = match nat::NatManager::new(gw_config.lan_interface.clone()) { Ok(n) => n, Err(e) => { error!(error = %e, "Failed to create nftables table — do you have CAP_NET_ADMIN?"); @@ -229,6 +239,13 @@ async fn main() { } }; + // Install inbound port-forward rules (TASK-2026-0061). + if let Err(e) = nat_mgr.set_port_forwards(&gw_config.port_forwards) { + error!(error = %e, "Failed to install port-forward rules"); + let _ = nat_mgr.cleanup(); + std::process::exit(1); + } + // Network setup let mut net_setup = net::NetSetup::new(gw_config.lan_interface.clone(), gw_config.pool.clone()); diff --git a/src/config/gateway.rs b/src/config/gateway.rs index 3afdd37..e3f483b 100644 --- a/src/config/gateway.rs +++ b/src/config/gateway.rs @@ -2,6 +2,9 @@ //! //! Configuration for the outbound LAN gateway (`gateway.*`). +use std::collections::HashSet; +use std::net::SocketAddrV6; + use serde::{Deserialize, Serialize}; /// Default gateway DNS listen address. @@ -52,6 +55,10 @@ pub struct GatewayConfig { /// Conntrack timeout overrides. #[serde(default)] pub conntrack: ConntrackConfig, + + /// Inbound mesh port forwarding rules. See TASK-2026-0061. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub port_forwards: Vec, } impl GatewayConfig { @@ -59,6 +66,46 @@ impl GatewayConfig { pub fn grace_period(&self) -> u64 { self.pool_grace_period.unwrap_or(DEFAULT_GRACE_PERIOD) } + + /// Validate inbound port-forward rules: non-zero listen ports and + /// uniqueness of `(listen_port, proto)` pairs across the list. + /// IPv6-only targets are enforced by `SocketAddrV6` at deserialize + /// time. + pub fn validate_port_forwards(&self) -> Result<(), String> { + let mut seen = HashSet::new(); + for pf in &self.port_forwards { + if pf.listen_port == 0 { + return Err("port_forward listen_port must be non-zero".to_string()); + } + if !seen.insert((pf.listen_port, pf.proto)) { + return Err(format!( + "duplicate port_forward ({:?} {}) — each (listen_port, proto) must be unique", + pf.proto, pf.listen_port + )); + } + } + Ok(()) + } +} + +/// Transport protocol for an inbound port forward. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Proto { + Tcp, + Udp, +} + +/// An inbound port-forward rule: `fips0:listen_port/proto` → `target`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortForward { + /// Port on `fips0` that mesh peers connect to. + pub listen_port: u16, + /// Transport protocol to match. + pub proto: Proto, + /// IPv6 LAN destination (`[addr]:port`). IPv4 targets are rejected + /// at parse time by `SocketAddrV6`. + pub target: SocketAddrV6, } /// Gateway DNS resolver configuration (`gateway.dns.*`). @@ -206,4 +253,109 @@ gateway: let config: crate::Config = serde_yaml::from_str(yaml).unwrap(); assert!(config.gateway.is_none()); } + + #[test] + fn test_port_forwards_default_empty() { + let yaml = r#" +pool: "fd01::/112" +lan_interface: "eth0" +"#; + let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(config.port_forwards.is_empty()); + config.validate_port_forwards().unwrap(); + } + + #[test] + fn test_port_forwards_parse() { + let yaml = r#" +pool: "fd01::/112" +lan_interface: "eth0" +port_forwards: + - listen_port: 8080 + proto: tcp + target: "[fd12:3456::10]:80" + - listen_port: 2222 + proto: tcp + target: "[fd12:3456::20]:22" + - listen_port: 5353 + proto: udp + target: "[fd12:3456::10]:53" +"#; + let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.port_forwards.len(), 3); + assert_eq!(config.port_forwards[0].listen_port, 8080); + assert_eq!(config.port_forwards[0].proto, Proto::Tcp); + assert_eq!( + config.port_forwards[0].target, + "[fd12:3456::10]:80".parse::().unwrap() + ); + assert_eq!(config.port_forwards[2].proto, Proto::Udp); + config.validate_port_forwards().unwrap(); + } + + #[test] + fn test_port_forwards_reject_ipv4_target() { + let yaml = r#" +pool: "fd01::/112" +lan_interface: "eth0" +port_forwards: + - listen_port: 8080 + proto: tcp + target: "192.168.1.10:80" +"#; + let result: Result = serde_yaml::from_str(yaml); + assert!( + result.is_err(), + "IPv4 target must fail to deserialize as SocketAddrV6" + ); + } + + #[test] + fn test_port_forwards_reject_zero_listen_port() { + let yaml = r#" +pool: "fd01::/112" +lan_interface: "eth0" +port_forwards: + - listen_port: 0 + proto: tcp + target: "[fd12:3456::10]:80" +"#; + let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(config.validate_port_forwards().is_err()); + } + + #[test] + fn test_port_forwards_reject_duplicate() { + let yaml = r#" +pool: "fd01::/112" +lan_interface: "eth0" +port_forwards: + - listen_port: 8080 + proto: tcp + target: "[fd12:3456::10]:80" + - listen_port: 8080 + proto: tcp + target: "[fd12:3456::20]:80" +"#; + let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap(); + let err = config.validate_port_forwards().unwrap_err(); + assert!(err.contains("duplicate"), "got: {err}"); + } + + #[test] + fn test_port_forwards_same_port_different_proto_ok() { + let yaml = r#" +pool: "fd01::/112" +lan_interface: "eth0" +port_forwards: + - listen_port: 53 + proto: tcp + target: "[fd12:3456::10]:53" + - listen_port: 53 + proto: udp + target: "[fd12:3456::10]:53" +"#; + let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap(); + config.validate_port_forwards().unwrap(); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 5542a1e..53b27d3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -31,7 +31,7 @@ use std::path::{Path, PathBuf}; use thiserror::Error; #[cfg(feature = "gateway")] -pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig}; +pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto}; pub use node::{ BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig, NodeConfig, RateLimitConfig, RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig, diff --git a/src/gateway/nat.rs b/src/gateway/nat.rs index 0fe1b68..b908e3c 100644 --- a/src/gateway/nat.rs +++ b/src/gateway/nat.rs @@ -9,10 +9,12 @@ use tracing::{debug, info}; use rustables::expr::{ Cmp, CmpOp, HighLevelPayload, IPv6HeaderField, Immediate, Masquerade, Meta, MetaType, Nat, - NatType, NetworkHeaderField, Register, + NatType, NetworkHeaderField, Register, TCPHeaderField, TransportHeaderField, UDPHeaderField, }; use rustables::{Batch, Chain, ChainType, Hook, HookClass, MsgType, ProtocolFamily, Rule, Table}; +use crate::config::{PortForward, Proto}; + const TABLE_NAME: &str = "fips_gateway"; const PREROUTING_CHAIN: &str = "prerouting"; const POSTROUTING_CHAIN: &str = "postrouting"; @@ -59,8 +61,13 @@ pub struct NatManager { table: Table, pre_chain: Chain, post_chain: Chain, + /// LAN interface name, used to gate the port-forward LAN-side + /// masquerade rule (distinct from the fips0 egress masquerade). + lan_interface: String, /// Active mappings keyed by virtual IP. mappings: HashMap, + /// Inbound port-forward rules (TASK-2026-0061). + port_forwards: Vec, } impl NatManager { @@ -69,7 +76,10 @@ impl NatManager { /// Installs a masquerade rule for traffic exiting via `fips0` so that /// LAN client source addresses are rewritten to the gateway's mesh /// address, allowing return traffic to route back through the mesh. - pub fn new() -> Result { + /// + /// `lan_interface` is the gateway's LAN-facing interface name, + /// needed by the port-forward LAN-side masquerade rule. + pub fn new(lan_interface: String) -> Result { let table = Table::new(ProtocolFamily::Inet).with_name(TABLE_NAME); let pre_chain = Chain::new(&table) .with_name(PREROUTING_CHAIN) @@ -84,7 +94,9 @@ impl NatManager { table, pre_chain, post_chain, + lan_interface, mappings: HashMap::new(), + port_forwards: Vec::new(), }; mgr.rebuild()?; @@ -92,6 +104,18 @@ impl NatManager { Ok(mgr) } + /// Replace the current inbound port-forward rule set and rebuild + /// the nftables table atomically. Pass an empty slice to clear. + pub fn set_port_forwards(&mut self, forwards: &[PortForward]) -> Result<(), NatError> { + self.port_forwards = forwards.to_vec(); + self.rebuild()?; + info!( + count = self.port_forwards.len(), + "Applied inbound port forwards" + ); + Ok(()) + } + /// Add DNAT and SNAT rules for a virtual IP ↔ mesh address mapping. pub fn add_mapping( &mut self, @@ -211,6 +235,62 @@ impl NatManager { batch.add(&snat_rule, MsgType::Add); } + // Inbound port-forward rules (TASK-2026-0061). Each forward is + // one DNAT rule in prerouting keyed on (iif fips0, nfproto ipv6, + // l4proto, th dport). When any forwards are configured, emit a + // single LAN-side masquerade in postrouting so the LAN target + // host sees the gateway's LAN address as source and replies + // flow back through conntrack. + for pf in &self.port_forwards { + let l4proto: u8 = match pf.proto { + Proto::Tcp => libc::IPPROTO_TCP as u8, + Proto::Udp => libc::IPPROTO_UDP as u8, + }; + let dport_field = match pf.proto { + Proto::Tcp => TransportHeaderField::Tcp(TCPHeaderField::Dport), + Proto::Udp => TransportHeaderField::Udp(UDPHeaderField::Dport), + }; + let target_ip = *pf.target.ip(); + let target_port_be = pf.target.port().to_be_bytes(); + + let dnat_rule = Rule::new(&self.pre_chain)? + .with_expr(Meta::new(MetaType::IifName)) + .with_expr(Cmp::new(CmpOp::Eq, b"fips0\0".to_vec())) + .with_expr(Meta::new(MetaType::NfProto)) + .with_expr(Cmp::new(CmpOp::Eq, [libc::NFPROTO_IPV6 as u8])) + .with_expr(Meta::new(MetaType::L4Proto)) + .with_expr(Cmp::new(CmpOp::Eq, [l4proto])) + .with_expr(HighLevelPayload::Transport(dport_field).build()) + .with_expr(Cmp::new(CmpOp::Eq, pf.listen_port.to_be_bytes().to_vec())) + .with_expr(Immediate::new_data( + target_ip.octets().to_vec(), + Register::Reg1, + )) + .with_expr(Immediate::new_data(target_port_be.to_vec(), Register::Reg2)) + .with_expr( + Nat::default() + .with_nat_type(NatType::DNat) + .with_family(ProtocolFamily::Ipv6) + .with_ip_register(Register::Reg1) + .with_port_register(Register::Reg2), + ); + batch.add(&dnat_rule, MsgType::Add); + } + + if !self.port_forwards.is_empty() { + let mut lan_iface = self.lan_interface.clone().into_bytes(); + lan_iface.push(0); + let lan_masq = Rule::new(&self.post_chain)? + .with_expr(Meta::new(MetaType::IifName)) + .with_expr(Cmp::new(CmpOp::Eq, b"fips0\0".to_vec())) + .with_expr(Meta::new(MetaType::OifName)) + .with_expr(Cmp::new(CmpOp::Eq, lan_iface)) + .with_expr(Meta::new(MetaType::NfProto)) + .with_expr(Cmp::new(CmpOp::Eq, [libc::NFPROTO_IPV6 as u8])) + .with_expr(Masquerade::default()); + batch.add(&lan_masq, MsgType::Add); + } + batch .send() .map_err(|e| NatError::Nftables(e.to_string()))?; diff --git a/testing/ci-local.sh b/testing/ci-local.sh index 222eae4..b873b38 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -149,8 +149,8 @@ record() { run_build() { stage "Stage 1: Build" - info "cargo build --release" - if cargo build --release 2>&1; then + info "cargo build --release --features gateway" + if cargo build --release --features gateway 2>&1; then record "build" 0 else record "build" 1 @@ -165,8 +165,8 @@ run_build() { return 1 fi - info "cargo clippy --all -- -D warnings" - if cargo clippy --all -- -D warnings 2>&1; then + info "cargo clippy --all --features gateway -- -D warnings" + if cargo clippy --all --features gateway -- -D warnings 2>&1; then record "clippy" 0 else record "clippy" 1 @@ -181,7 +181,7 @@ run_tests() { local cmd if command -v cargo-nextest &>/dev/null; then - cmd="cargo nextest run --all" + cmd="cargo nextest run --all --features gateway" info "$cmd" if $cmd 2>&1; then record "unit-tests" 0 @@ -189,7 +189,7 @@ run_tests() { record "unit-tests" 1 fi else - cmd="cargo test --all" + cmd="cargo test --all --features gateway" info "$cmd (nextest not found, using cargo test)" if $cmd 2>&1; then record "unit-tests" 0 diff --git a/testing/static/scripts/gateway-test.sh b/testing/static/scripts/gateway-test.sh index 81b3652..afd8e21 100755 --- a/testing/static/scripts/gateway-test.sh +++ b/testing/static/scripts/gateway-test.sh @@ -44,12 +44,21 @@ with open('$config_file') as f: cfg['gateway'] = { 'enabled': True, 'pool': 'fd01::/112', - 'lan_interface': 'eth0', + # Docker assigns gateway-lan to eth1 (fips-net is eth0). The + # LAN-side masquerade for inbound port forwards gates on this. + 'lan_interface': 'eth1', 'dns': { 'listen': '[::]:53', 'ttl': 5, }, 'pool_grace_period': 5, + 'port_forwards': [ + { + 'listen_port': 18080, + 'proto': 'tcp', + 'target': '[fd02::20]:8080', + }, + ], } with open('$config_file', 'w') as f: @@ -163,9 +172,62 @@ else check "nftables DNAT rules" 1 fi -# Phase 7: TTL expiration and pool reclamation +# Phase 7: Inbound port forwarding (TASK-2026-0061) +# +# Mesh peer (gw-server) → gw-gateway fips0:18080 → DNAT → [fd02::20]:8080 +# (gw-client LAN HTTP server). Exercises the DNAT rule + LAN-side +# masquerade installed by set_port_forwards(). echo "" -echo "Phase 7: TTL expiration and pool reclamation" +echo "Phase 7: Inbound port forward" + +# Confirm the port-forward DNAT rule is present on the gateway. The +# distinctive listen port (18080) identifies our rule regardless of how +# nft renders the l4proto/dport predicates. +if echo "$NFT_RULES" | grep -q "18080"; then + check "nftables port-forward DNAT rule (tcp 18080)" 0 +else + check "nftables port-forward DNAT rule (tcp 18080)" 1 +fi + +# Start a marker HTTP server on the LAN-side client (fd02::20:8080). +# `docker exec -d` is required; `docker exec bash -c 'cmd &'` doesn't +# keep the child alive past the exec session, even with nohup. +docker exec "$CLIENT" sh -c \ + 'mkdir -p /tmp/inbound && echo "inbound-forward-ok" > /tmp/inbound/index.html && pkill -f "http.server 8080" 2>/dev/null || true' \ + >/dev/null 2>&1 || true +docker exec -d "$CLIENT" python3 -m http.server 8080 --bind :: --directory /tmp/inbound \ + >/dev/null 2>&1 || true +# Give the server a moment to bind. +for _ in 1 2 3 4 5; do + if docker exec "$CLIENT" ss -6lnt 2>/dev/null | grep -q ':8080'; then + break + fi + sleep 1 +done + +# Derive the gateway's mesh IPv6 (fd00::/8 address assigned to fips0). +GW_MESH_IP=$(docker exec "$GATEWAY" bash -c \ + "ip -6 -o addr show fips0 | awk '/inet6 fd/ {print \$4}' | cut -d/ -f1 | head -1" \ + 2>/dev/null || echo "") + +if [ -z "$GW_MESH_IP" ]; then + check "Gateway fips0 IPv6 address" 1 +else + echo " Gateway mesh IPv6: $GW_MESH_IP" + + # From the mesh side (gw-server), fetch through the forward rule. + FWD_RESPONSE=$(docker exec "$SERVER" curl -6 -s --max-time 10 \ + "http://[${GW_MESH_IP}]:18080/" 2>&1) || true + if echo "$FWD_RESPONSE" | grep -q "inbound-forward-ok"; then + check "Inbound HTTP via port forward 18080 → [fd02::20]:8080" 0 + else + check "Inbound HTTP via port forward (response: '${FWD_RESPONSE:0:80}')" 1 + fi +fi + +# Phase 8: TTL expiration and pool reclamation +echo "" +echo "Phase 8: TTL expiration and pool reclamation" # Flush conntrack so stale sessions from Phase 5 don't keep the mapping alive. docker exec "$GATEWAY" conntrack -F 2>/dev/null || true # Config uses ttl=5, pool_grace_period=5. Pool tick interval is 10s, so: @@ -185,9 +247,9 @@ else check "Mapping reclaimed (count: $MAPPING_COUNT)" 1 fi -# Phase 8: SERVFAIL when daemon DNS is down +# Phase 9: SERVFAIL when daemon DNS is down echo "" -echo "Phase 8: SERVFAIL when daemon DNS is down" +echo "Phase 9: SERVFAIL when daemon DNS is down" # Kill the fips daemon inside the gateway container (gateway stays running) docker exec "$GATEWAY" pkill -f "^fips --config" 2>/dev/null || true sleep 2 @@ -201,9 +263,9 @@ else check "SERVFAIL when daemon DNS down (got: '${SERVFAIL_RESULT:0:80}')" 1 fi -# Phase 9: Cleanup verification (nftables removed on shutdown) +# Phase 10: Cleanup verification (nftables removed on shutdown) echo "" -echo "Phase 9: Cleanup on shutdown" +echo "Phase 10: Cleanup on shutdown" # fips-gateway is PID 1 (exec in entrypoint), so SIGTERM stops the container. # Verify cleanup by checking container logs for the shutdown sequence. docker stop --time=10 "$GATEWAY" >/dev/null 2>&1 || true