mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-11 17:17:54 +00:00
Merge branch 'master' into next
# Conflicts: # testing/ci-local.sh
This commit is contained in:
+19
-2
@@ -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());
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()))?;
|
||||
|
||||
Reference in New Issue
Block a user