mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 16:24:45 +00:00
Merge branch 'master' into next
# Conflicts: # testing/ci-local.sh
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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
-1
@@ -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"
|
||||
@@ -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."
|
||||
|
||||
@@ -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
@@ -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()))?;
|
||||
|
||||
+14
-6
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user