Add Docker sidecar deployment for FIPS

Tailscale-style sidecar pattern: a FIPS container provides mesh
networking, and a companion app container shares its network namespace
via network_mode: service:fips.

Security model:
- iptables enforces strict isolation — the app container can only
  communicate over the FIPS mesh (fd::/8 via fips0)
- No IPv4 access: eth0 restricted to FIPS UDP transport (port 2121)
- No IPv6 on eth0: ip6tables blocks all eth0 IPv6 traffic
- Only fips0 and loopback are reachable from the app container

The sidecar accepts peer configuration via environment variables
(FIPS_NSEC, FIPS_PEER_NPUB, FIPS_PEER_ADDR), so it can be pointed
at any FIPS node without config file generation.

Files:
- testing/sidecar/: Dockerfile, Dockerfile.app, docker-compose.yml,
  entrypoint.sh, .env, resolv.conf, scripts/build.sh
- testing/sidecar/README.md: security model, quick-start, architecture,
  DNS resolution, troubleshooting, production considerations
- testing/sidecar/scripts/test-sidecar.sh: 3-node chain integration
  test verifying link establishment, multi-hop connectivity, and
  network isolation on each app container
- .github/workflows/ci.yml: sidecar integration test matrix entry
This commit is contained in:
Johnathan Corgan
2026-02-27 03:02:14 +00:00
parent e5b4f1a88a
commit 9668807ca4
12 changed files with 742 additions and 1 deletions
+25 -1
View File
@@ -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
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
+12
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
fips
fipsctl
+33
View File
@@ -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"]
+18
View File
@@ -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"]
+236
View File
@@ -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 <peer-ip>`).
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 <npub>.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
```
@@ -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}
+44
View File
@@ -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"]
+80
View File
@@ -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 <<EOF
node:
identity:
nsec: "${FIPS_NSEC}"
tun:
enabled: true
name: fips0
mtu: ${FIPS_TUN_MTU}
dns:
enabled: true
bind_addr: "127.0.0.1"
transports:
udp:
bind_addr: "${FIPS_UDP_BIND}"
mtu: 1472
peers:
${PEERS_SECTION:- []}
EOF
echo "Generated /etc/fips/fips.yaml"
# --- Apply iptables rules for strict network isolation ---
#
# Goal: only FIPS UDP transport (port 2121) may use eth0.
# All other eth0 traffic is dropped. fips0 and loopback are unrestricted.
# This ensures the app container (sharing this network namespace) can only
# communicate over the FIPS mesh.
# IPv4: allow only FIPS transport on eth0
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o eth0 -p udp --dport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -p udp --sport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --dport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --sport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -j DROP
iptables -A INPUT -i eth0 -j DROP
# IPv6: allow fips0 and loopback, block eth0
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A OUTPUT -o fips0 -j ACCEPT
ip6tables -A INPUT -i fips0 -j ACCEPT
ip6tables -A OUTPUT -o eth0 -j DROP
ip6tables -A INPUT -i eth0 -j DROP
echo "iptables isolation rules applied"
# --- Start dnsmasq and launch FIPS ---
dnsmasq
exec fips --config /etc/fips/fips.yaml
+1
View File
@@ -0,0 +1 @@
nameserver 127.0.0.1
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Build the FIPS binary and Docker sidecar image.
# Usage: ./build.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DOCKER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# Find project root (directory containing Cargo.toml)
PROJECT_ROOT="$(cd "$DOCKER_DIR/../.." && pwd)"
if [ ! -f "$PROJECT_ROOT/Cargo.toml" ]; then
echo "Error: Cannot find Cargo.toml at $PROJECT_ROOT" >&2
echo "Expected layout: <project-root>/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"
+230
View File
@@ -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."