diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4e09b1..324515f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,6 +178,9 @@ jobs: - suite: mixed-technology type: chaos scenario: mixed-technology + # ── Sidecar deployment ────────────────────────────────────────── + - suite: sidecar + type: sidecar steps: - uses: actions/checkout@v4 @@ -255,4 +258,25 @@ jobs: with: name: sim-results-${{ matrix.scenario }} path: testing/chaos/sim-results/ - retention-days: 7 \ No newline at end of file + retention-days: 7 + + # ── Sidecar deployment ────────────────────────────────────────────── + - name: Install binary (sidecar) + if: matrix.type == 'sidecar' + run: | + chmod +x _bin/fips _bin/fipsctl + cp _bin/fips testing/sidecar/fips + cp _bin/fipsctl testing/sidecar/fipsctl + + - name: Run sidecar integration test + if: matrix.type == 'sidecar' + run: bash testing/sidecar/scripts/test-sidecar.sh + + - name: Collect logs on failure (sidecar) + if: matrix.type == 'sidecar' && failure() + run: | + for node in a b c; do + echo "--- sidecar-${node} logs ---" + docker logs "sidecar-${node}-fips-1" 2>&1 || true + echo "" + done \ No newline at end of file diff --git a/testing/sidecar/.env b/testing/sidecar/.env new file mode 100644 index 0000000..dc8e0f0 --- /dev/null +++ b/testing/sidecar/.env @@ -0,0 +1,12 @@ +# FIPS sidecar default configuration. +# Override these values or create a .env.local file. + +# Node identity (hex-encoded secret key). +# WARNING: This is a development-only default. In production, use Docker +# secrets or a vault — never commit real keys to version control. +FIPS_NSEC=e752b92aed3ac1595807f5d0eb5125589fbec0a2cfd3a2948d87ea076557deeb + +# Peer configuration (leave empty for standalone operation) +FIPS_PEER_NPUB= +FIPS_PEER_ADDR= +FIPS_PEER_ALIAS=peer diff --git a/testing/sidecar/.gitignore b/testing/sidecar/.gitignore new file mode 100644 index 0000000..fd9c1ee --- /dev/null +++ b/testing/sidecar/.gitignore @@ -0,0 +1,2 @@ +fips +fipsctl diff --git a/testing/sidecar/Dockerfile b/testing/sidecar/Dockerfile new file mode 100644 index 0000000..dda07d6 --- /dev/null +++ b/testing/sidecar/Dockerfile @@ -0,0 +1,33 @@ +FROM debian:bookworm-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + iproute2 iputils-ping dnsutils dnsmasq iptables \ + openssh-client openssh-server python3 \ + tcpdump netcat-openbsd curl iperf3 && \ + rm -rf /var/lib/apt/lists/* + +# Setup SSH server with no authentication (test only!) +RUN mkdir -p /var/run/sshd && \ + ssh-keygen -A && \ + sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \ + sed -i 's/#PermitEmptyPasswords no/PermitEmptyPasswords yes/' /etc/ssh/sshd_config && \ + sed -i 's/UsePAM yes/UsePAM no/' /etc/ssh/sshd_config && \ + passwd -d root + +# dnsmasq: forward .fips to FIPS daemon, everything else to Docker DNS +RUN printf '%s\n' \ + 'port=53' \ + 'listen-address=127.0.0.1' \ + 'bind-interfaces' \ + 'server=/fips/127.0.0.1#5354' \ + 'server=127.0.0.11' \ + 'no-resolv' \ + >> /etc/dnsmasq.conf + +COPY fips fipsctl /usr/local/bin/ +RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl + +COPY entrypoint.sh /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/testing/sidecar/Dockerfile.app b/testing/sidecar/Dockerfile.app new file mode 100644 index 0000000..302cdb0 --- /dev/null +++ b/testing/sidecar/Dockerfile.app @@ -0,0 +1,18 @@ +FROM debian:bookworm-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + iproute2 iputils-ping dnsutils iptables \ + openssh-client openssh-server python3 \ + tcpdump netcat-openbsd curl iperf3 && \ + rm -rf /var/lib/apt/lists/* + +# Setup SSH server with no authentication (test only!) +RUN mkdir -p /var/run/sshd && \ + ssh-keygen -A && \ + sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \ + sed -i 's/#PermitEmptyPasswords no/PermitEmptyPasswords yes/' /etc/ssh/sshd_config && \ + sed -i 's/UsePAM yes/UsePAM no/' /etc/ssh/sshd_config && \ + passwd -d root + +CMD ["sleep", "infinity"] diff --git a/testing/sidecar/README.md b/testing/sidecar/README.md new file mode 100644 index 0000000..d6534f4 --- /dev/null +++ b/testing/sidecar/README.md @@ -0,0 +1,236 @@ +# FIPS Sidecar + +Run FIPS as a network sidecar container, providing mesh-only network +access to a companion application. The app container shares the FIPS +container's network namespace and is isolated from the host network by +iptables rules — it can only communicate over the FIPS mesh via `fips0`. + +This is the recommended deployment pattern when connecting to an untrusted +public FIPS mesh: the application never touches the underlying transport +network, so it cannot leak traffic outside the mesh or be reached by +non-mesh peers. + +## Quick Start + +```bash +cd testing/sidecar +./scripts/build.sh +docker compose up -d + +# Verify the sidecar is running: +docker exec fips-sidecar fipsctl show status + +# Verify the app container can see the FIPS interface: +docker exec fips-app ip addr show fips0 +``` + +With the default `.env`, FIPS starts with no peers. See +[Run with peers](#run-with-peers) to connect to an existing mesh. + +## Security Model + +The sidecar pattern enforces strict network isolation on the app container: + +- **No IPv4 access**: iptables blocks all eth0 traffic except FIPS UDP + transport (port 2121). The app container cannot reach the Docker bridge, + the host network, or any IPv4 address. +- **No IPv6 on eth0**: ip6tables blocks all IPv6 traffic on eth0. The app + container cannot use link-local or any Docker-assigned IPv6 addresses. +- **FIPS mesh only**: The only routable network path is through `fips0` + (`fd::/8`). All application traffic traverses the FIPS mesh with + end-to-end encryption. +- **Loopback allowed**: `lo` is unrestricted for inter-process communication + within the shared namespace. + +This means the app container treats the FIPS mesh as its sole network. Even +if the application is compromised, it cannot bypass the mesh or communicate +with the transport layer directly. + +## Architecture + +```text +┌───────────────────────────────────────────────────┐ +│ Shared network namespace │ +│ │ +│ ┌───────────────┐ ┌──────────────────────────┐ │ +│ │ fips-sidecar │ │ fips-app │ │ +│ │ │ │ │ │ +│ │ fips daemon │ │ your workload │ │ +│ │ fipsctl │ │ │ │ +│ │ dnsmasq │ │ │ │ +│ └───────────────┘ └──────────────────────────┘ │ +│ │ +│ Interfaces: │ +│ lo — loopback (unrestricted) │ +│ eth0 — Docker bridge (iptables: FIPS only) │ +│ fips0 — FIPS TUN (fd::/8, unrestricted) │ +└───────────────────────────────────────────────────┘ +``` + +The FIPS sidecar owns the network namespace and creates the `fips0` TUN +interface. The app container joins via `network_mode: service:fips` and +sees the same interfaces. The entrypoint script applies iptables rules +before launching the FIPS daemon: + +**IPv4 rules** (iptables): + +- ACCEPT on `lo` (both directions) +- ACCEPT UDP sport/dport 2121 on `eth0` (FIPS transport) +- DROP everything else on `eth0` + +**IPv6 rules** (ip6tables): + +- ACCEPT on `lo` (both directions) +- ACCEPT on `fips0` (both directions) +- DROP everything on `eth0` + +### DNS Resolution + +DNS inside the container is handled by dnsmasq (127.0.0.1:53): + +- `.fips` queries are forwarded to the FIPS daemon's built-in DNS resolver + (127.0.0.1:5354), which resolves npub-based names to `fd::/8` addresses +- All other queries are forwarded to Docker's embedded DNS (127.0.0.11) + +The `resolv.conf` mount points the container's resolver at 127.0.0.1, +where dnsmasq handles the routing. + +## Build + +```bash +cd testing/sidecar +./scripts/build.sh +``` + +This compiles FIPS for Linux, copies the binaries into the Docker context, +and builds the sidecar and app images. Cross-compilation from macOS is +supported via `cargo-zigbuild`. + +## Run with Peers + +To connect the sidecar to an existing mesh, provide the peer's npub and +transport address: + +```bash +FIPS_PEER_NPUB=npub1... \ +FIPS_PEER_ADDR=203.0.113.10:2121 \ +FIPS_PEER_ALIAS=gateway \ +docker compose up -d +``` + +Verify the peer link: + +```bash +docker exec fips-sidecar fipsctl show peers +docker exec fips-sidecar fipsctl show links +``` + +## Verify Connectivity and Isolation + +From the app container: + +```bash +# Ping a mesh node by npub (resolves via .fips DNS): +docker exec fips-app ping6 -c3 npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m.fips + +# Fetch a web page from a mesh node over FIPS: +docker exec fips-app curl -6 "http://[fd69:e08d:65cc:3a6b:9c2c:2ac4:bd40:5e4b]:8000/" + +# Docker bridge is blocked — this should fail: +docker exec fips-app ping -c1 -W2 172.20.0.13 + +# Loopback is allowed: +docker exec fips-app ping -c1 127.0.0.1 +``` + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `FIPS_NSEC` | *(required)* | Node secret key (hex or nsec1 bech32) | +| `FIPS_PEER_NPUB` | *(empty)* | Peer's npub to connect to | +| `FIPS_PEER_ADDR` | *(empty)* | Peer's transport address (e.g. `203.0.113.10:2121`) | +| `FIPS_PEER_ALIAS` | `peer` | Human-readable peer name | +| `FIPS_UDP_BIND` | `0.0.0.0:2121` | UDP transport bind address | +| `FIPS_TUN_MTU` | `1280` | TUN interface MTU | +| `FIPS_NETWORK` | `fips-sidecar-net` | Docker network name (set to join external network) | +| `FIPS_SUBNET` | `172.20.1.0/24` | Docker network subnet | +| `FIPS_IPV4` | `172.20.1.20` | Sidecar's IPv4 address on the Docker network | +| `RUST_LOG` | `info` | FIPS log level | + +## Troubleshooting + +**`FIPS_NSEC is required`** — The `FIPS_NSEC` environment variable is not +set. Either add it to `.env` or pass it on the command line. Generate a +random key with: `openssl rand -hex 32` + +**`fips0` interface not appearing** — The FIPS daemon needs `/dev/net/tun` +and `NET_ADMIN` capability. Check that the compose file includes both: + +```yaml +cap_add: + - NET_ADMIN +devices: + - /dev/net/tun:/dev/net/tun +``` + +**No peer connection established** — Verify the peer address is reachable +from the sidecar container (`docker exec fips-sidecar ping -c1 `). +If joining an external Docker network, ensure `FIPS_NETWORK`, `FIPS_SUBNET`, +and `FIPS_IPV4` match the target network. Check logs with +`docker logs fips-sidecar`. + +**DNS not resolving `.fips` names** — Verify dnsmasq is running: +`docker exec fips-sidecar pgrep dnsmasq`. Check that `resolv.conf` is +mounted (should contain `nameserver 127.0.0.1`). Verify the FIPS DNS +resolver is listening: `docker exec fips-sidecar dig @127.0.0.1 -p 5354 .fips AAAA`. + +**iptables errors in entrypoint** — The sidecar container requires +`NET_ADMIN` capability for iptables. Without it, the isolation rules +cannot be applied and the entrypoint will fail. + +## Production Considerations + +**Secrets management**: The default `.env` contains a hardcoded nsec for +development. In production, use Docker secrets, a vault, or inject the key +via a secure CI/CD pipeline. Never commit production keys to version control. + +**Logging**: Set `RUST_LOG` to control log verbosity (`debug`, `info`, +`warn`, `error`). For production, configure the Docker logging driver with +size limits: + +```yaml +logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" +``` + +**Resource limits**: Add memory and CPU constraints in the compose file: + +```yaml +deploy: + resources: + limits: + memory: 256M + cpus: "0.5" +``` + +**Multiple peers**: The entrypoint supports a single peer via environment +variables. For multiple peers, mount a custom `fips.yaml` directly: + +```yaml +volumes: + - ./my-fips.yaml:/etc/fips/fips.yaml:ro +``` + +**Health checks**: Add a Docker health check using `fipsctl`: + +```yaml +healthcheck: + test: ["CMD", "fipsctl", "show", "status"] + interval: 30s + timeout: 5s + retries: 3 +``` diff --git a/testing/sidecar/docker-compose.external-net.yml b/testing/sidecar/docker-compose.external-net.yml new file mode 100644 index 0000000..c88c522 --- /dev/null +++ b/testing/sidecar/docker-compose.external-net.yml @@ -0,0 +1,6 @@ +# Override: use a pre-existing external network instead of creating one. +# Used by test-sidecar.sh for multi-instance testing on a shared network. +networks: + fips-net: + external: true + name: ${FIPS_NETWORK:-fips-sidecar-net} diff --git a/testing/sidecar/docker-compose.yml b/testing/sidecar/docker-compose.yml new file mode 100644 index 0000000..7a95790 --- /dev/null +++ b/testing/sidecar/docker-compose.yml @@ -0,0 +1,44 @@ +networks: + fips-net: + name: ${FIPS_NETWORK:-fips-sidecar-net} + driver: bridge + ipam: + config: + - subnet: ${FIPS_SUBNET:-172.20.1.0/24} + +services: + fips: + build: + context: . + hostname: fips-sidecar + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + restart: "no" + environment: + - RUST_LOG=${RUST_LOG:-info} + - FIPS_NSEC=${FIPS_NSEC} + - FIPS_PEER_NPUB=${FIPS_PEER_NPUB:-} + - FIPS_PEER_ADDR=${FIPS_PEER_ADDR:-} + - FIPS_PEER_ALIAS=${FIPS_PEER_ALIAS:-peer} + - FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121} + - FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280} + volumes: + - ./resolv.conf:/etc/resolv.conf:ro + networks: + fips-net: + ipv4_address: ${FIPS_IPV4:-172.20.1.20} + + app: + build: + context: . + dockerfile: Dockerfile.app + network_mode: "service:fips" + depends_on: + - fips + volumes: + - ./resolv.conf:/etc/resolv.conf:ro + command: ["sleep", "infinity"] diff --git a/testing/sidecar/entrypoint.sh b/testing/sidecar/entrypoint.sh new file mode 100755 index 0000000..1cac02f --- /dev/null +++ b/testing/sidecar/entrypoint.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# FIPS sidecar entrypoint: generate config, apply iptables isolation, launch FIPS. +set -e + +# --- Generate FIPS config from environment variables --- + +FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}" +FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}" +FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}" + +mkdir -p /etc/fips + +# Build peers section +PEERS_SECTION="" +if [ -n "$FIPS_PEER_NPUB" ] && [ -n "$FIPS_PEER_ADDR" ]; then + FIPS_PEER_ALIAS="${FIPS_PEER_ALIAS:-peer}" + PEERS_SECTION=" - npub: \"${FIPS_PEER_NPUB}\" + alias: \"${FIPS_PEER_ALIAS}\" + addresses: + - transport: udp + addr: \"${FIPS_PEER_ADDR}\" + connect_policy: auto_connect" +fi + +cat > /etc/fips/fips.yaml <&2 + echo "Expected layout: /testing/sidecar/scripts/build.sh" >&2 + exit 1 +fi + +# Detect host OS +UNAME_S=$(uname -s) +CARGO_TARGET="x86_64-unknown-linux-musl" + +if [ "$UNAME_S" = "Darwin" ]; then + echo "Detected macOS host — using cross-compilation for Linux..." + + if ! command -v cargo-zigbuild &> /dev/null; then + echo "Error: cargo-zigbuild not found." >&2 + echo "Please install it: cargo install cargo-zigbuild" >&2 + exit 1 + fi + + if ! rustup target list --installed | grep -q "$CARGO_TARGET"; then + echo "Installing Rust target $CARGO_TARGET..." + rustup target add "$CARGO_TARGET" + fi + + echo "Building FIPS for Linux (release) using cargo-zigbuild..." + cargo zigbuild --release --target "$CARGO_TARGET" --manifest-path="$PROJECT_ROOT/Cargo.toml" + + echo "Copying binaries to docker context..." + cp "$PROJECT_ROOT/target/$CARGO_TARGET/release/fips" "$DOCKER_DIR/fips" + cp "$PROJECT_ROOT/target/$CARGO_TARGET/release/fipsctl" "$DOCKER_DIR/fipsctl" +else + echo "Building FIPS (release)..." + cargo build --release --manifest-path="$PROJECT_ROOT/Cargo.toml" + + echo "Copying binaries to docker context..." + cp "$PROJECT_ROOT/target/release/fips" "$DOCKER_DIR/fips" + cp "$PROJECT_ROOT/target/release/fipsctl" "$DOCKER_DIR/fipsctl" +fi + +echo "Done. Binaries at $DOCKER_DIR/{fips,fipsctl}" +echo "" +echo "Building Docker image..." +docker compose -f "$DOCKER_DIR/docker-compose.yml" build +echo "" +echo "Ready: cd testing/sidecar && docker compose up -d" diff --git a/testing/sidecar/scripts/test-sidecar.sh b/testing/sidecar/scripts/test-sidecar.sh new file mode 100755 index 0000000..c0d398c --- /dev/null +++ b/testing/sidecar/scripts/test-sidecar.sh @@ -0,0 +1,230 @@ +#!/bin/bash +# Integration test for the FIPS sidecar deployment. +# +# Starts a 3-node chain (A—B—C) using standalone sidecar instances, +# verifies link establishment, multi-hop connectivity, and network +# isolation on each app container. +# +# Usage: ./test-sidecar.sh [--skip-build] +# +# Exit codes: +# 0 — all tests passed +# 1 — test failure +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SIDECAR_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Deterministic keys derived from: derive-keys.py sidecar-test node-{a,b,c} +NODE_A_NSEC="9e688d0879fa9cd025fea0487ac23495080e3de626070fdb9b78dc1f619dd453" +NODE_A_NPUB="npub1jvren5hnege54lu3p2nzqacctmulqvkgp68yvfuj5jme5dtgnhxsdh6788" +NODE_B_NSEC="3e4e10614c0490575fa5e994524ff3f4deaac2f20db189cc9c9a79da0d90f17a" +NODE_B_NPUB="npub15h7z0ljzudqe9pgwx99cjsz2c0ennuyvkcc8zvtk3lg97xwzex9ska6g4y" +NODE_C_NSEC="15148ed0131f7da43fd13e369dfedede14fb64698f3756636b569c3a3e87438f" +NODE_C_NPUB="npub1zhezcykd0e34z4fxtranl45jaasgnlxv0kjqwlq2v56ggssn0w4qelcrvr" + +NETWORK_NAME="fips-sidecar-test" +SUBNET="172.20.2.0/24" +NODE_A_IP="172.20.2.10" +NODE_B_IP="172.20.2.11" +NODE_C_IP="172.20.2.12" + +CONVERGE_TIMEOUT=30 +PASSED=0 +FAILED=0 + +# Compose file paths +COMPOSE_BASE="-f $SIDECAR_DIR/docker-compose.yml" +COMPOSE_EXT="$COMPOSE_BASE -f $SIDECAR_DIR/docker-compose.external-net.yml" + +# ── Helpers ──────────────────────────────────────────────────────────────── + +log() { echo "=== $*"; } +pass() { echo " PASS: $*"; PASSED=$((PASSED + 1)); } +fail() { echo " FAIL: $*"; FAILED=$((FAILED + 1)); } + +cleanup() { + log "Cleaning up..." + # Tear down B and C first (they reference A's network as external) + docker compose $COMPOSE_EXT -p sidecar-c down --volumes --remove-orphans 2>/dev/null || true + docker compose $COMPOSE_EXT -p sidecar-b down --volumes --remove-orphans 2>/dev/null || true + # Tear down A last (it owns the network) + docker compose $COMPOSE_BASE -p sidecar-a down --volumes --remove-orphans 2>/dev/null || true +} + +# Always clean up on exit +trap cleanup EXIT + +# ── Build ────────────────────────────────────────────────────────────────── + +if [[ "${1:-}" != "--skip-build" ]]; then + log "Building sidecar images..." + docker compose $COMPOSE_BASE build +fi + +# ── Start nodes ──────────────────────────────────────────────────────────── +# +# Chain topology: A — B — C +# node-a: no outbound peers (accepts inbound from B) +# node-b: peers with A (middle node, transit router) +# node-c: peers with B (end node) + +log "Starting node-a (no peers, creates network)..." +FIPS_NSEC="$NODE_A_NSEC" \ +FIPS_NETWORK="$NETWORK_NAME" \ +FIPS_SUBNET="$SUBNET" \ +FIPS_IPV4="$NODE_A_IP" \ +docker compose $COMPOSE_BASE -p sidecar-a up -d + +log "Starting node-b (peers with node-a, joins external network)..." +FIPS_NSEC="$NODE_B_NSEC" \ +FIPS_PEER_NPUB="$NODE_A_NPUB" \ +FIPS_PEER_ADDR="${NODE_A_IP}:2121" \ +FIPS_PEER_ALIAS="node-a" \ +FIPS_NETWORK="$NETWORK_NAME" \ +FIPS_SUBNET="$SUBNET" \ +FIPS_IPV4="$NODE_B_IP" \ +docker compose $COMPOSE_EXT -p sidecar-b up -d + +log "Starting node-c (peers with node-b, joins external network)..." +FIPS_NSEC="$NODE_C_NSEC" \ +FIPS_PEER_NPUB="$NODE_B_NPUB" \ +FIPS_PEER_ADDR="${NODE_B_IP}:2121" \ +FIPS_PEER_ALIAS="node-b" \ +FIPS_NETWORK="$NETWORK_NAME" \ +FIPS_SUBNET="$SUBNET" \ +FIPS_IPV4="$NODE_C_IP" \ +docker compose $COMPOSE_EXT -p sidecar-c up -d + +# ── Wait for convergence ────────────────────────────────────────────────── + +log "Waiting for link establishment (up to ${CONVERGE_TIMEOUT}s)..." + +converged=false +for i in $(seq 1 "$CONVERGE_TIMEOUT"); do + # node-b should have 2 links (A and C) + link_count=$(docker exec sidecar-b-fips-1 fipsctl show links 2>/dev/null \ + | grep -c '"state": "connected"' || true) + if [ "$link_count" -ge 2 ]; then + converged=true + break + fi + sleep 1 +done + +if [ "$converged" = true ]; then + log "Links established after ${i}s" +else + log "TIMEOUT: links did not converge in ${CONVERGE_TIMEOUT}s" + log "node-a links:" + docker exec sidecar-a-fips-1 fipsctl show links 2>&1 || true + log "node-b links:" + docker exec sidecar-b-fips-1 fipsctl show links 2>&1 || true + log "node-c links:" + docker exec sidecar-c-fips-1 fipsctl show links 2>&1 || true + exit 1 +fi + +# Allow a few more seconds for tree convergence and coordinate propagation +sleep 3 + +# ── Link verification ───────────────────────────────────────────────────── + +log "Verifying link counts..." + +a_links=$(docker exec sidecar-a-fips-1 fipsctl show links 2>/dev/null \ + | grep -c '"state": "connected"' || true) +b_links=$(docker exec sidecar-b-fips-1 fipsctl show links 2>/dev/null \ + | grep -c '"state": "connected"' || true) +c_links=$(docker exec sidecar-c-fips-1 fipsctl show links 2>/dev/null \ + | grep -c '"state": "connected"' || true) + +[ "$a_links" -ge 1 ] && pass "node-a has $a_links link(s)" || fail "node-a has $a_links links (expected >= 1)" +[ "$b_links" -ge 2 ] && pass "node-b has $b_links link(s)" || fail "node-b has $b_links links (expected >= 2)" +[ "$c_links" -ge 1 ] && pass "node-c has $c_links link(s)" || fail "node-c has $c_links links (expected >= 1)" + +# ── Direct connectivity (adjacent nodes) ────────────────────────────────── + +log "Testing direct connectivity (B app → A via fips0)..." + +if docker exec sidecar-b-app-1 ping6 -c2 -W5 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then + pass "node-b app can ping node-a via fips0" +else + fail "node-b app cannot ping node-a via fips0" +fi + +# ── Multi-hop connectivity (C → A through B) ───────────────────────────── + +log "Testing multi-hop connectivity (C app → A via fips0, through B)..." + +if docker exec sidecar-c-app-1 ping6 -c2 -W10 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then + pass "node-c app can ping node-a via fips0 (multi-hop through B)" +else + fail "node-c app cannot ping node-a via fips0 (multi-hop through B)" +fi + +# ── Reverse direction (A → C through B) ────────────────────────────────── + +log "Testing reverse multi-hop (A app → C via fips0, through B)..." + +if docker exec sidecar-a-app-1 ping6 -c2 -W10 "${NODE_C_NPUB}.fips" >/dev/null 2>&1; then + pass "node-a app can ping node-c via fips0 (multi-hop through B)" +else + fail "node-a app cannot ping node-c via fips0 (multi-hop through B)" +fi + +# ── Network isolation verification ──────────────────────────────────────── +# +# This is the critical security assertion: app containers must NOT be able +# to reach anything outside the FIPS mesh. + +log "Verifying network isolation on app containers..." + +for node in a b c; do + container="sidecar-${node}-app-1" + # Pick a peer IP that isn't this node's own address + case $node in + a) peer_ip="$NODE_B_IP" ;; + b) peer_ip="$NODE_C_IP" ;; + c) peer_ip="$NODE_A_IP" ;; + esac + log " Checking $container..." + + # IPv4 gateway should be unreachable (iptables DROP on eth0) + if docker exec "$container" ping -c1 -W2 172.20.2.1 >/dev/null 2>&1; then + fail "$container can reach IPv4 gateway (isolation broken!)" + else + pass "$container cannot reach IPv4 gateway (IPv4 blocked)" + fi + + # IPv4 peer should be unreachable (iptables DROP on eth0) + if docker exec "$container" ping -c1 -W2 "$peer_ip" >/dev/null 2>&1; then + fail "$container can reach peer IPv4 (isolation broken!)" + else + pass "$container cannot reach peer IPv4 (IPv4 blocked)" + fi + + # Loopback should work + if docker exec "$container" ping -c1 -W2 127.0.0.1 >/dev/null 2>&1; then + pass "$container can reach loopback (expected)" + else + fail "$container cannot reach loopback" + fi +done + +# ── Summary ─────────────────────────────────────────────────────────────── + +echo "" +log "Results: $PASSED passed, $FAILED failed" + +if [ "$FAILED" -gt 0 ]; then + log "Dumping logs for failed run..." + for node in a b c; do + echo "--- sidecar-${node} logs ---" + docker logs "sidecar-${node}-fips-1" 2>&1 | tail -30 + echo "" + done + exit 1 +fi + +log "All tests passed."