Merge branch 'master' into next

# Conflicts:
#	testing/ci-local.sh
This commit is contained in:
Johnathan Corgan
2026-04-15 06:35:32 +00:00
13 changed files with 374 additions and 50 deletions
+19 -2
View File
@@ -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());
+152
View File
@@ -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<PortForward>,
}
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::<SocketAddrV6>().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<GatewayConfig, _> = 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();
}
}
+1 -1
View File
@@ -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,
+82 -2
View File
@@ -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<Ipv6Addr, NatMapping>,
/// Inbound port-forward rules (TASK-2026-0061).
port_forwards: Vec<PortForward>,
}
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<Self, NatError> {
///
/// `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<Self, NatError> {
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()))?;