diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a88b64a..7e06b2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -321,6 +321,9 @@ jobs: topology: rekey-outbound-only - suite: acl-allowlist type: acl-allowlist + # ── Firewall baseline (fips0 nftables default-deny) ──────────── + - suite: firewall + type: firewall # ── Outbound LAN gateway integration test ────────────────────── - suite: gateway type: gateway @@ -575,6 +578,22 @@ jobs: run: | docker compose -f testing/acl-allowlist/docker-compose.yml down --volumes --remove-orphans + # ── Firewall baseline integration test ───────────────────────────────── + - name: Run firewall baseline integration test + if: matrix.type == 'firewall' + run: bash testing/firewall/test.sh --skip-build --keep-up + + - name: Collect logs on failure (firewall) + if: matrix.type == 'firewall' && failure() + run: | + docker compose -f testing/firewall/docker-compose.yml logs --no-color + docker exec fips-fw-container-b nft list table inet fips || true + + - name: Stop containers (firewall) + if: matrix.type == 'firewall' && always() + run: | + docker compose -f testing/firewall/docker-compose.yml down --volumes --remove-orphans + # ── Chaos simulation ─────────────────────────────────────────────────── - name: Install Python deps (chaos) if: matrix.type == 'chaos' diff --git a/testing/ci-local.sh b/testing/ci-local.sh index 0bbc31f..bcda5b9 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -17,7 +17,7 @@ # Integration suites (default coverage): # static-mesh, static-chain, rekey, rekey-accept-off, # rekey-outbound-only, gateway, -# acl-allowlist, nat-cone, nat-symmetric, nat-lan, +# acl-allowlist, firewall, nat-cone, nat-symmetric, nat-lan, # nostr-publish-consume, # chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh, # chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent, @@ -74,6 +74,7 @@ CHAOS_SUITES=( GATEWAY_SUITES=(gateway) SIDECAR_SUITES=(sidecar) ACL_SUITES=(acl-allowlist) +FIREWALL_SUITES=(firewall) NAT_SUITES=(cone symmetric lan) NOSTR_RELAY_SUITES=(nostr-publish-consume) DNS_RESOLVER_SUITES=(dns-resolver) @@ -113,6 +114,9 @@ list_suites() { echo " ACL allowlist:" for s in "${ACL_SUITES[@]}"; do echo " $s"; done echo "" + echo " Firewall baseline:" + for s in "${FIREWALL_SUITES[@]}"; do echo " $s"; done + echo "" echo " NAT scenarios:" for s in "${NAT_SUITES[@]}"; do echo " nat-$s"; done echo "" @@ -440,6 +444,16 @@ run_acl_allowlist() { fi } +# Run firewall baseline integration test +run_firewall() { + info "[firewall] Running integration test" + if bash testing/firewall/test.sh --skip-build 2>&1; then + record "firewall" 0 + else + record "firewall" 1 + fi +} + # Run a NAT scenario (cone, symmetric, lan) run_nat() { local scenario="$1" @@ -540,6 +554,9 @@ run_integration() { # ACL allowlist run_acl_allowlist + # Firewall baseline + run_firewall + # NAT scenarios (sequential — each owns its compose project) for scenario in "${NAT_SUITES[@]}"; do run_nat "$scenario" @@ -632,6 +649,8 @@ run_suite() { run_gateway ;; acl-allowlist) run_acl_allowlist ;; + firewall) + run_firewall ;; nat-cone|nat-symmetric|nat-lan) run_nat "${suite#nat-}" ;; nostr-publish-consume) diff --git a/testing/firewall/.gitignore b/testing/firewall/.gitignore new file mode 100644 index 0000000..31315f6 --- /dev/null +++ b/testing/firewall/.gitignore @@ -0,0 +1 @@ +generated-configs diff --git a/testing/firewall/README.md b/testing/firewall/README.md new file mode 100644 index 0000000..1da268d --- /dev/null +++ b/testing/firewall/README.md @@ -0,0 +1,111 @@ +# Firewall Baseline Test + +End-to-end exercise of the production fips0 nftables baseline at +`packaging/common/fips.nft`. Closes the v0.3.0 audit gap that the +default-deny + conntrack + drop-in semantics had no integration coverage. + +## What this exercises + +The `fips.nft` baseline polices ONLY the fips0 mesh interface and +implements default-deny inbound. This suite asserts the four behaviors +documented in the file's header are actually true on a live mesh: + +- **(a)** Unallowed inbound on fips0 is **dropped** +- **(b)** Outbound-initiated flows get their reply via the + `ct state established,related accept` rule +- **(c)** ICMPv6 echo-request is **accepted** (ping6 reachability) +- **(d)** A drop-in `.nft` file under `/etc/fips/fips.d/` adds an + allowlisted port and that port is **accepted** + +A drop-counter check after case (a) confirms the connection was +actively DROP'd by the fips chain (not silently unrouted). + +## Topology + +Two FIPS nodes peered over UDP on a Docker bridge network: + +| Container | Hostname | docker IPv4 | Firewall | +|-------------------------|----------|---------------|----------| +| `fips-fw-container-a` | `host-a` | 172.32.0.10 | none (probe) | +| `fips-fw-container-b` | `host-b` | 172.32.0.11 | `fips.nft` + drop-in | + +`node-b` mounts the production `packaging/common/fips.nft` read-only at +`/etc/fips/fips.nft`, plus a drop-in at `/etc/fips/fips.d/services.nft` +containing `tcp dport 22 accept`. `node-a` is unfirewalled and serves +as the probe origin. + +Both containers run the unified test image's `default` mode, which +starts dnsmasq + sshd (port 22) + iperf3 + python http.server on +port 8000 + the FIPS daemon. + +## fips-firewall.service activation + +The production unit's ExecStart is: + +```text +ExecStart=/usr/sbin/nft -f /etc/fips/fips.nft +``` + +The unified test image does not run systemd, so `test.sh` invokes the +same `nft -f` command directly inside `node-b` after fips0 is up and +peering has converged. The deb-install harness covers the systemd +unit-enablement path under real systemd separately. + +## Run + +Build the Linux binaries and test image: + +```bash +./testing/scripts/build.sh --no-docker +``` + +Run the suite: + +```bash +./testing/firewall/test.sh +``` + +`test.sh` regenerates fixtures automatically before starting Docker. +Use `--skip-build` to reuse the existing release binaries. Use +`--keep-up` to leave the containers running for inspection. + +## Expected output shape + +```text +=== Generating firewall fixtures +=== Starting firewall harness +=== Waiting for fips0 on both nodes +=== Waiting for peer convergence +=== Resolving fips0 addresses + node-a: fd97:... + node-b: fd97:... +=== Activating fips-firewall on fips-fw-container-b +PASS: fips-fw-container-b: fips.nft baseline + drop-in loaded +=== Case (c): ICMPv6 echo-request to firewalled node +PASS: (c) ICMPv6 ping node-a → node-b accepted +=== Case (a): unallowed inbound TCP/8000 from node-a → node-b +PASS: (a) inbound TCP/8000 blocked (curl rc=28) +=== Case (b): node-b initiates outbound TCP, expects reply via conntrack +PASS: (b) outbound from node-b got HTTP 200 via conntrack reply path +=== Case (d): drop-in allowlisted TCP/22 from node-a → node-b +PASS: (d) drop-in allowlisted TCP/22 reachable +=== Drop counter incremented (case a should have ticked it) +PASS: drop counter = N (case a was actually dropped, not just unrouted) +=== Firewall integration test passed +``` + +## Inspect the loaded ruleset + +```bash +docker exec fips-fw-container-b nft list table inet fips +``` + +## Stop and clean up + +```bash +docker compose -f testing/firewall/docker-compose.yml down +``` + +## Generated fixture location + +`testing/firewall/generated-configs/` (gitignored). diff --git a/testing/firewall/docker-compose.yml b/testing/firewall/docker-compose.yml new file mode 100644 index 0000000..2b1b1c1 --- /dev/null +++ b/testing/firewall/docker-compose.yml @@ -0,0 +1,52 @@ +networks: + fw-net: + driver: bridge + ipam: + config: + - subnet: 172.32.0.0/24 + +x-fips-common: &fips-common + build: + context: ../docker + image: fips-test:latest + entrypoint: ["/usr/local/bin/entrypoint.sh"] + cap_add: + - NET_ADMIN + - NET_RAW + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + restart: "no" + environment: + - FIPS_TEST_MODE=default + - RUST_LOG=info,fips::node=debug + +services: + service-a: + <<: *fips-common + container_name: fips-fw-container-a + hostname: host-a + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/node-a/hosts:/etc/fips/hosts:ro + - ./generated-configs/node-a/fips.yaml:/etc/fips/fips.yaml:ro + - ./generated-configs/node-a/fips.key:/etc/fips/fips.key:ro + networks: + fw-net: + ipv4_address: 172.32.0.10 + + service-b: + <<: *fips-common + container_name: fips-fw-container-b + hostname: host-b + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/node-b/hosts:/etc/fips/hosts:ro + - ./generated-configs/node-b/fips.yaml:/etc/fips/fips.yaml:ro + - ./generated-configs/node-b/fips.key:/etc/fips/fips.key:ro + - ../../packaging/common/fips.nft:/etc/fips/fips.nft:ro + - ./generated-configs/node-b/fips.d:/etc/fips/fips.d:ro + networks: + fw-net: + ipv4_address: 172.32.0.11 diff --git a/testing/firewall/generate-configs.sh b/testing/firewall/generate-configs.sh new file mode 100755 index 0000000..1fca1de --- /dev/null +++ b/testing/firewall/generate-configs.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Generate fixtures for the firewall integration test. +# +# Two FIPS nodes (a, b). node-b mounts the production fips.nft baseline +# plus a single drop-in (.nft) under /etc/fips/fips.d/ that allows TCP +# port 22 inbound — the test asserts this is honored. node-a is a +# probe-only node with no firewall. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GENERATED_DIR="$SCRIPT_DIR/generated-configs" + +# Deterministic test identities (mirrors the acl-allowlist style). +NPUB_A="npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" +KEY_A="0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + +NPUB_B="npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le" +KEY_B="b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0" + +write_file() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" +} + +write_hosts_file() { + local node="$1" + write_file "$GENERATED_DIR/$node/hosts" <&2; exit 1 ;; + esac +done + +cleanup() { + if [ "$KEEP_UP" = false ]; then + docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT + +log() { + echo "=== $*" +} + +pass() { + echo "PASS: $*" +} + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# Wait for fips0 to exist and have a global IPv6 address inside container. +wait_for_fips0() { + local container="$1" + local timeout="${2:-30}" + for _ in $(seq 1 "$timeout"); do + if docker exec "$container" ip -6 addr show fips0 2>/dev/null \ + | grep -qE 'inet6 fd[0-9a-f]+:'; then + return 0 + fi + sleep 1 + done + fail "$container fips0 did not come up within ${timeout}s" +} + +# Wait for the peer count on a container to reach the expected value. +wait_for_peers_exact() { + local container="$1" + local expected_count="$2" + local timeout="${3:-30}" + for _ in $(seq 1 "$timeout"); do + local count + count=$(docker exec "$container" fipsctl show peers 2>/dev/null \ + | python3 -c 'import json,sys; data=json.load(sys.stdin); print(sum(1 for p in data.get("peers", []) if p.get("connectivity") == "connected"))' 2>/dev/null || echo 0) + if [ "$count" -eq "$expected_count" ]; then + return 0 + fi + sleep 1 + done + fail "$container did not reach $expected_count connected peers in ${timeout}s" +} + +# Resolve `.fips` inside a container and print the AAAA answer. +resolve_fips_addr() { + local container="$1" + local npub="$2" + docker exec "$container" getent ahostsv6 "${npub}.fips" \ + | awk '{print $1; exit}' +} + +# Activate the fips firewall baseline inside a container. Mirrors the +# fips-firewall.service ExecStart. +activate_firewall() { + local container="$1" + docker exec "$container" nft -f /etc/fips/fips.nft + # Sanity: the table must now exist. + if ! docker exec "$container" nft list table inet fips >/dev/null 2>&1; then + fail "$container: inet fips table not present after nft -f" + fi +} + +# Verify default-policy and key chain rules look right. +assert_baseline_loaded() { + local container="$1" + local listing + listing="$(docker exec "$container" nft list table inet fips)" + # Default-deny is achieved via the trailing `counter drop` (chain + # policy is `accept` for return-on-non-fips0 to work safely). + if ! printf '%s' "$listing" | grep -q 'counter packets'; then + fail "$container: counter drop rule missing from inet fips" + fi + if ! printf '%s' "$listing" | grep -q 'iifname != "fips0" return'; then + fail "$container: non-fips0 early return rule missing" + fi + if ! printf '%s' "$listing" | grep -q 'ct state established,related accept'; then + fail "$container: conntrack established,related rule missing" + fi + if ! printf '%s' "$listing" | grep -q 'icmpv6 type echo-request accept'; then + fail "$container: ICMPv6 echo-request rule missing" + fi + if ! printf '%s' "$listing" | grep -q 'tcp dport 22 accept'; then + fail "$container: drop-in tcp dport 22 rule missing (fips.d not included?)" + fi + pass "$container: fips.nft baseline + drop-in loaded" +} + +# ──────────────────────────────────────────────────────────────────────── + +if [ "$SKIP_BUILD" = false ]; then + log "Building Linux test binaries" + "$TESTING_DIR/scripts/build.sh" --no-docker +fi + +log "Generating firewall fixtures" +"$GENERATE_CONFIGS" + +log "Starting firewall harness" +docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true +docker compose -f "$COMPOSE_FILE" up -d --build + +log "Waiting for fips0 on both nodes" +wait_for_fips0 "$CONTAINER_A" 40 +wait_for_fips0 "$CONTAINER_B" 40 + +log "Waiting for peer convergence" +wait_for_peers_exact "$CONTAINER_A" 1 40 +wait_for_peers_exact "$CONTAINER_B" 1 40 + +log "Resolving fips0 addresses" +ADDR_A="$(resolve_fips_addr "$CONTAINER_A" "$NPUB_A")" +ADDR_B="$(resolve_fips_addr "$CONTAINER_B" "$NPUB_B")" +[ -z "$ADDR_A" ] && fail "could not resolve node-a fips0 address" +[ -z "$ADDR_B" ] && fail "could not resolve node-b fips0 address" +echo " node-a: $ADDR_A" +echo " node-b: $ADDR_B" + +log "Activating fips-firewall on $CONTAINER_B" +activate_firewall "$CONTAINER_B" +assert_baseline_loaded "$CONTAINER_B" + +# ── (c) Pre-firewall sanity: confirm both ports are reachable BEFORE ─ +# the firewall is up would be ideal, but we activated already to +# keep the test deterministic. Instead we run case (c) ICMPv6 +# first, since it's the most basic reachability check. + +log "Case (c): ICMPv6 echo-request to firewalled node" +if docker exec "$CONTAINER_A" ping6 -c 3 -W 5 "$ADDR_B" >/dev/null 2>&1; then + pass "(c) ICMPv6 ping node-a → node-b accepted" +else + fail "(c) ICMPv6 ping node-a → node-b should succeed but was dropped" +fi + +# ── (a) Unallowed inbound is dropped ─────────────────────────────────── +log "Case (a): unallowed inbound TCP/${UNALLOWED_PORT} from node-a → node-b" +# python3 http.server is already listening on :: per entrypoint default mode. +# Use curl --max-time 5 — must time out (exit 28) or otherwise fail. +set +e +docker exec "$CONTAINER_A" curl -6 --silent --output /dev/null \ + --max-time 5 "http://[${ADDR_B}]:${UNALLOWED_PORT}/" +RC=$? +set -e +if [ "$RC" -eq 0 ]; then + fail "(a) connection to ${UNALLOWED_PORT} succeeded but should have been DROP'd (rc=0)" +fi +pass "(a) inbound TCP/${UNALLOWED_PORT} blocked (curl rc=$RC)" + +# ── (b) Outbound-initiated flow + conntrack reply ────────────────────── +log "Case (b): node-b initiates outbound TCP, expects reply via conntrack" +# node-b → node-a:8000 on the fips overlay. node-a has http.server on +# [::]:8000 and is NOT firewalled, so this is purely a test of node-b's +# outbound + ct state established,related path on the way back. +set +e +docker exec "$CONTAINER_B" curl -6 --silent --max-time 5 \ + --output /dev/null --write-out '%{http_code}' \ + "http://[${ADDR_A}]:${OUTBOUND_TARGET_PORT}/" >/tmp/fw_b_rc 2>/dev/null +RC=$? +set -e +HTTP_CODE="$(cat /tmp/fw_b_rc 2>/dev/null || true)" +rm -f /tmp/fw_b_rc +if [ "$RC" -ne 0 ]; then + fail "(b) outbound from node-b failed (curl rc=$RC, http=$HTTP_CODE) — conntrack reply path broken" +fi +if [ "$HTTP_CODE" != "200" ]; then + fail "(b) outbound returned http=$HTTP_CODE (expected 200) — reply blocked?" +fi +pass "(b) outbound from node-b got HTTP $HTTP_CODE via conntrack reply path" + +# ── (d) Drop-in allowlisted port accepted ────────────────────────────── +log "Case (d): drop-in allowlisted TCP/${ALLOWED_PORT} from node-a → node-b" +# nc -zv -w3: zero-I/O scan, verbose, 3-second timeout. Exit 0 = port +# open and reachable. The container's sshd is listening on [::]:22 by +# default per the test entrypoint. +if docker exec "$CONTAINER_A" nc -6 -z -v -w 3 "$ADDR_B" "$ALLOWED_PORT" 2>&1 \ + | grep -qE 'succeeded|open'; then + pass "(d) drop-in allowlisted TCP/${ALLOWED_PORT} reachable" +else + fail "(d) drop-in allowlisted TCP/${ALLOWED_PORT} should be reachable but was blocked" +fi + +# ── Drop-counter sanity ──────────────────────────────────────────────── +log "Drop counter incremented (case a should have ticked it)" +DROP_PKTS="$(docker exec "$CONTAINER_B" nft list table inet fips \ + | awk '/counter packets/ {print $3; exit}')" +if [ -z "${DROP_PKTS:-}" ] || [ "$DROP_PKTS" -lt 1 ]; then + fail "drop counter is $DROP_PKTS — case (a) should have produced drops" +fi +pass "drop counter = $DROP_PKTS (case a was actually dropped, not just unrouted)" + +log "Firewall integration test passed"