#!/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)" # shellcheck source=../../lib/wait-converge.sh source "$SCRIPT_DIR/../../lib/wait-converge.sh" # 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${FIPS_CI_NAME_SUFFIX:-}" # Compose project names. These are explicit (they override COMPOSE_PROJECT_NAME), # so they carry the run suffix themselves — otherwise concurrent runs would share # one project and tear down each other's containers. PROJ_A="sidecar-a${FIPS_CI_NAME_SUFFIX:-}" PROJ_B="sidecar-b${FIPS_CI_NAME_SUFFIX:-}" PROJ_C="sidecar-c${FIPS_CI_NAME_SUFFIX:-}" 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 "$PROJ_C" down --volumes --remove-orphans 2>/dev/null || true docker compose $COMPOSE_EXT -p "$PROJ_B" down --volumes --remove-orphans 2>/dev/null || true # Tear down A last (it owns the network) docker compose $COMPOSE_BASE -p "$PROJ_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 test images..." DOCKER_DIR="$(cd "$SIDECAR_DIR/../docker" && pwd)" docker build -t fips-test:latest "$DOCKER_DIR" docker build -t fips-test-app:latest -f "$DOCKER_DIR/Dockerfile.app" "$DOCKER_DIR" 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)..." # node-a is the root: explicitly clear FIPS_PEER_* so it does not inherit the # external peer default from .env (which points at a real public mesh node). # Without this, node-a auto-connects to the live mesh and the chain attaches # under an external root, inflating tree depth and breaking isolation. FIPS_NSEC="$NODE_A_NSEC" \ FIPS_PEER_NPUB="" \ FIPS_PEER_ADDR="" \ FIPS_NETWORK="$NETWORK_NAME" \ FIPS_SUBNET="$SUBNET" \ FIPS_IPV4="$NODE_A_IP" \ docker compose $COMPOSE_BASE -p "$PROJ_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 "$PROJ_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 "$PROJ_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 "${PROJ_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 "${PROJ_A}-fips-1" fipsctl show links 2>&1 || true log "node-b links:" docker exec "${PROJ_B}-fips-1" fipsctl show links 2>&1 || true log "node-c links:" docker exec "${PROJ_C}-fips-1" fipsctl show links 2>&1 || true exit 1 fi # Wait for end-to-end multi-hop connectivity (the same app-to-app pings # the test asserts on) with a progress-aware deadline, instead of a # blind fixed sleep that can fire before coordinates propagate across the # chain. The directed pings below remain the actual assertions. _sidecar_converged() { PASSED=0; FAILED=0 if docker exec "${PROJ_B}-app-1" ping6 -c1 -W2 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); fi if docker exec "${PROJ_C}-app-1" ping6 -c1 -W2 "${NODE_A_NPUB}.fips" >/dev/null 2>&1; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); fi if docker exec "${PROJ_A}-app-1" ping6 -c1 -W2 "${NODE_C_NPUB}.fips" >/dev/null 2>&1; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); fi } wait_until_connected _sidecar_converged "$CONVERGE_TIMEOUT" 10 || true # ── Link verification ───────────────────────────────────────────────────── log "Verifying link counts..." a_links=$(docker exec "${PROJ_A}-fips-1" fipsctl show links 2>/dev/null \ | grep -c '"state": "connected"' || true) b_links=$(docker exec "${PROJ_B}-fips-1" fipsctl show links 2>/dev/null \ | grep -c '"state": "connected"' || true) c_links=$(docker exec "${PROJ_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 "${PROJ_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 "${PROJ_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 "${PROJ_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."