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
+3 -3
View File
@@ -4,7 +4,7 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends vim wireguard-tools && \
rm -rf /var/lib/apt/lists/*
COPY entrypoint-gateway.sh /usr/local/bin/entrypoint-gateway.sh
RUN chmod +x /usr/local/bin/entrypoint-gateway.sh
COPY entrypoint-sidecar.sh /usr/local/bin/entrypoint-sidecar.sh
RUN chmod +x /usr/local/bin/entrypoint-sidecar.sh
ENTRYPOINT ["/usr/local/bin/entrypoint-gateway.sh"]
ENTRYPOINT ["/usr/local/bin/entrypoint-sidecar.sh"]
+11 -10
View File
@@ -7,8 +7,8 @@ This example lets macOS reach the FIPS mesh through a local Docker container.
- `fips-on.sh`: user-facing startup wrapper
- `fips-host.sh`: small privileged helper for macOS networking
- `fips-off.sh`: teardown wrapper
- `entrypoint-gateway.sh`: container startup logic
- `identity/fips.key` and `identity/fips.pub`: generated persistent gateway identity
- `entrypoint-sidecar.sh`: container startup logic
- `identity/fips.key` and `identity/fips.pub`: generated persistent sidecar identity
- `fips.yaml`: FIPS node config used inside the container
## Configure Peers
@@ -18,7 +18,7 @@ Before first use, replace the placeholder bootstrap peer in `fips.yaml` with a r
## Startup Flow
```text
macOS user fips-on.sh Docker / fips-gateway fips-host.sh (sudo)
macOS user fips-on.sh Docker / wireguard-sidecar fips-host.sh (sudo)
| | | |
| ./fips-on.sh | | |
|---------------> | | |
@@ -45,10 +45,10 @@ macOS user fips-on.sh Docker / fips-gateway fips-host.sh (s
## What `fips-on.sh` Does
1. Creates `identity/` and `identity/wireguard/` if needed.
2. Generates `identity/fips.key` and `identity/fips.pub` with `fipsctl keygen --dir /etc/fips` if the gateway does not already have a persistent identity.
2. Generates `identity/fips.key` and `identity/fips.pub` with `fipsctl keygen --dir /etc/fips` if the sidecar does not already have a persistent identity.
3. Generates the host WireGuard client keypair if missing.
4. Fixes ownership on the generated client key files so the Docker bind mount stays readable on macOS.
5. Starts `fips-gateway` with `docker compose up -d --build --force-recreate`.
5. Starts `wireguard-sidecar` with `docker compose up -d --build --force-recreate`.
6. Waits until the container has created and brought up `wg0`.
7. Calls the privileged helper to configure host networking:
- writes `/etc/wireguard/fips0.conf`
@@ -58,7 +58,7 @@ macOS user fips-on.sh Docker / fips-gateway fips-host.sh (s
## What Happens In The Container
`entrypoint-gateway.sh`:
`entrypoint-sidecar.sh`:
1. Generates the server WireGuard keypair on first run.
2. Waits briefly for `identity/wireguard/client.pub` from the host.
@@ -72,23 +72,24 @@ Only FIPS traffic for `fd00::/8` is forwarded through the sidecar. Regular
internet traffic still uses the macOS host network and does not route through
`wg0` or `fips0`.
## Gateway FIPS Identity
## Sidecar FIPS Identity
`fips-on.sh` generates `identity/fips.key` and `identity/fips.pub` on first run by calling:
```bash
docker run --rm \
--entrypoint fipsctl \
-v "$PWD/identity:/etc/fips" \
fips-test:latest \
fipsctl keygen --dir /etc/fips
keygen --dir /etc/fips
```
That key is then bind-mounted into `/etc/fips/fips.key`, so the gateway keeps
That key is then bind-mounted into `/etc/fips/fips.key`, so the sidecar keeps
the same FIPS identity even though `fips-on.sh` uses `docker compose up
--force-recreate`.
Delete `identity/fips.key` if you want the next `./fips-on.sh` run to create a
fresh gateway identity.
fresh sidecar identity.
## Why `sudo` Is Still Needed
@@ -1,9 +1,9 @@
services:
fips-gateway:
wireguard-sidecar:
build: .
image: fips-gateway:latest
container_name: fips-gateway
hostname: fips-gateway
image: wireguard-sidecar:latest
container_name: wireguard-sidecar
hostname: wireguard-sidecar
privileged: true
devices:
- /dev/net/tun:/dev/net/tun
@@ -1,5 +1,5 @@
#!/bin/bash
# Gateway entrypoint: WireGuard tunnel + FIPS daemon.
# Sidecar entrypoint: WireGuard tunnel + FIPS daemon.
set -e
CONFIG="/etc/fips/fips.yaml"
+3 -3
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# Stop the FIPS gateway and restore macOS network settings.
# Stop the WireGuard sidecar and restore macOS network settings.
#
# Safe to run multiple times (idempotent).
#
@@ -27,9 +27,9 @@ run_host_helper() {
run_host_helper off
echo "Stopping FIPS gateway container..."
echo "Stopping WireGuard sidecar container..."
run_as_real_user docker compose -f "$SCRIPT_DIR/docker-compose.yml" down 2>/dev/null || true
echo " Container stopped"
echo ""
echo "FIPS gateway is OFF. macOS network restored."
echo "WireGuard sidecar is OFF. macOS network restored."
+10 -9
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# Start the FIPS gateway and configure macOS to reach the mesh.
# Start the WireGuard sidecar and configure macOS to reach the mesh.
#
# Sets up:
# 1. WireGuard tunnel from macOS to the FIPS Docker container
@@ -39,9 +39,10 @@ mkdir -p "$IDENTITY_DIR" "$WG_DIR"
if [ ! -f "$IDENTITY_DIR/fips.key" ]; then
echo "Generating FIPS identity keypair..."
run_as_real_user docker run --rm \
--entrypoint fipsctl \
-v "$IDENTITY_DIR:/etc/fips" \
fips-test:latest \
fipsctl keygen --dir /etc/fips
keygen --dir /etc/fips
fi
# ── 2. Generate WireGuard client keys ─────────────────────────
@@ -59,20 +60,20 @@ fi
run_host_helper fix-key-perms "$REAL_USER" "$WG_DIR"
# ── 3. Start Docker container ─────────────────────────────────
echo "Starting FIPS gateway container..."
echo "Starting WireGuard sidecar container..."
run_as_real_user docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d --build --force-recreate
echo "Waiting for container..."
for i in $(seq 1 30); do
if run_as_real_user docker exec fips-gateway wg show wg0 >/dev/null 2>&1; then
if run_as_real_user docker exec wireguard-sidecar wg show wg0 >/dev/null 2>&1; then
break
fi
sleep 1
done
if ! run_as_real_user docker exec fips-gateway wg show wg0 >/dev/null 2>&1; then
if ! run_as_real_user docker exec wireguard-sidecar wg show wg0 >/dev/null 2>&1; then
echo "Error: WireGuard not ready in container after 30s"
echo "Check: docker logs fips-gateway"
echo "Check: docker logs wireguard-sidecar"
exit 1
fi
@@ -85,7 +86,7 @@ run_host_helper on "$SCRIPT_DIR"
# ── Done ──────────────────────────────────────────────────────
echo ""
echo "FIPS gateway is ON."
echo "WireGuard sidecar is ON."
echo ""
echo " WireGuard: fips0 tunnel active (fd00::/8 routed)"
echo " DNS: .fips names resolve via localhost:5354"
@@ -93,7 +94,7 @@ echo ""
echo "Usage:"
echo " ping6 -c3 \$(dig +short AAAA your-bootstrap-peer.fips @127.0.0.1 -p 5354)"
echo ""
echo " docker exec fips-gateway fipsctl show status"
echo " docker exec fips-gateway fipsctl show peers"
echo " docker exec wireguard-sidecar fipsctl show status"
echo " docker exec wireguard-sidecar fipsctl show peers"
echo ""
echo "To stop: ./fips-off.sh"
+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()))?;
+14 -6
View File
@@ -145,16 +145,24 @@ 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
return 1
fi
info "cargo clippy --all -- -D warnings"
if cargo clippy --all -- -D warnings 2>&1; then
info "cargo fmt --check"
if cargo fmt --check 2>&1; then
record "fmt" 0
else
record "fmt" 1
return 1
fi
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
@@ -169,7 +177,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
@@ -177,7 +185,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
+5 -2
View File
@@ -46,11 +46,13 @@ if [ "$UNAME_S" = "Darwin" ]; then
echo "Building FIPS for Linux (release) using cargo-zigbuild..."
cargo zigbuild --release --target "$CARGO_TARGET" --manifest-path="$PROJECT_ROOT/Cargo.toml"
cargo zigbuild --release --target "$CARGO_TARGET" --manifest-path="$PROJECT_ROOT/Cargo.toml" --features gateway --bin fips-gateway
TARGET_DIR="$PROJECT_ROOT/target/$CARGO_TARGET/release"
else
echo "Building FIPS (release)..."
cargo build --release --manifest-path="$PROJECT_ROOT/Cargo.toml"
cargo build --release --manifest-path="$PROJECT_ROOT/Cargo.toml" --features gateway --bin fips-gateway
TARGET_DIR="$PROJECT_ROOT/target/release"
fi
@@ -58,11 +60,12 @@ fi
echo "Copying binaries to $DOCKER_DIR/"
cp "$TARGET_DIR/fips" "$DOCKER_DIR/fips"
cp "$TARGET_DIR/fipsctl" "$DOCKER_DIR/fipsctl"
cp "$TARGET_DIR/fips-gateway" "$DOCKER_DIR/fips-gateway"
[ -f "$TARGET_DIR/fipstop" ] && cp "$TARGET_DIR/fipstop" "$DOCKER_DIR/fipstop" || true
chmod +x "$DOCKER_DIR/fips" "$DOCKER_DIR/fipsctl"
chmod +x "$DOCKER_DIR/fips" "$DOCKER_DIR/fipsctl" "$DOCKER_DIR/fips-gateway"
[ -f "$DOCKER_DIR/fipstop" ] && chmod +x "$DOCKER_DIR/fipstop" || true
echo "Done. Binaries at $DOCKER_DIR/{fips,fipsctl,fipstop}"
echo "Done. Binaries at $DOCKER_DIR/{fips,fipsctl,fipstop,fips-gateway}"
if [ "$BUILD_DOCKER" = true ]; then
echo ""
+69 -7
View File
@@ -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