Files
fips/testing/interop/interop-test.sh
T
Johnathan Corgan 87bf17dd4d testing/interop: add multi-hop mixed-version topology with forwarding, continuity, and mesh-size checks
The interop harness only tested a full mesh, where every pair is a direct
one-hop FMP link. That left multi-hop forwarding, routing, coordinate
handling, and mesh-size estimation untested across versions.

Add an opt-in multi-hop topology and three new checks:

- generate-configs.sh: FIPS_INTEROP_EDGES selects an explicit undirected
  edge list instead of the full mesh, with symmetric peering, connectivity
  validation (no isolated node, graph connected), and per-node degree plus
  edge metadata in nodes.env. Unset means full mesh, unchanged.

- interop-test.sh: a --topology flag with a built-in multihop-3v-cycle
  (six nodes, two of each version, one cycle, two leaves). The per-node
  peer check now expects each node's adjacency degree rather than N-1, and
  the all-pairs ping now also exercises cross-version forwarding over
  non-adjacent pairs. Adds a control-differential data-plane continuity
  stream across the rekey window (Phase 5b) and a strict plus/minus 25
  percent mesh-size convergence check (Phase 7).

- interop-stress.sh: forward --topology through to the per-rep driver
  (mutually exclusive with a positional node-spec).

Phase 1 makes the strict retrying ping authoritative. The convergence
detector (one fully-clean, no-retry sweep of every directed pair) is now an
advisory settle-wait whose timeout is non-fatal; the strict retrying ping
decides pass/fail, matching how the post-rekey phases already work. Under
packet loss a clean no-retry sweep of a large mesh is statistically unlikely
even when the mesh is healthy (30 pairs at 2 percent loss leaves only about
55 percent of sweeps clean), so the detector otherwise falsely failed runs
whose strict ping reported full reachability. CONVERGENCE_TIMEOUT is now
env-overridable and defaults to 90s for larger meshes under loss.

Full-mesh runs are unaffected. Validated end-to-end with the
multihop-3v-cycle topology across three versions: all 30 directed pairs
reachable including multi-hop routed pairs, zero data-plane loss through
both rekey cutovers, and mesh-size converging to the true node count on
every node.
2026-06-07 13:59:55 +00:00

1008 lines
41 KiB
Bash
Executable File

#!/bin/bash
# Mixed-version interop test driver.
#
# Brings up an N-node full mesh from a NODE-SPEC (a multiset of image
# slots), verifies every pair interoperates — FMP link, FSP session,
# connectivity, rekey survival — and runs a per-node, per-pair log
# analysis tuned to surface INTEROP problems: handshake failures,
# FSP/FMP decrypt failures, `unknown FMP version`, replay storms,
# link/session teardowns.
#
# The harness's job is NOT to test a single version. It is to find any
# place where two DIFFERENT versions fail to interoperate in a way a
# same-version pair would not. Every failure is attributed to a specific
# (version-X <-> version-Y) pair, classified same-version vs MIXED.
#
# A node-spec like `a a b c` produces a same-version pair (a1<->a2) — the
# CONTROL ARM — alongside the mixed pairs. The control pair is what makes
# a netem run interpretable: a failure on a mixed pair that the control
# pair does not share is an interop regression; a failure both share is
# loss noise, not version-specific.
#
# Prerequisites:
# 1. bash build-images.sh <ref-a> <ref-b> <ref-c> (builds fips-interop:a/b/c)
# 2. configs are (re)generated automatically below for the given spec.
#
# Usage:
# ./interop-test.sh [--topology <name>] [node-spec...]
#
# --topology built-in multi-hop topology selecting a node-spec + edge
# set + default data-plane streams. Known: multihop-3v-cycle
# (6 nodes, 2 of each version, one cycle, two leaves —
# exercises cross-version forwarding + mesh-size). Without
# it the mesh is a full mesh from the positional node-spec.
# node-spec space-separated slot letters (a/b/c), default `a b c`.
# e.g. `a a b c` (4-node, one same-version control pair),
# `a a a` (3-node same-version flake rig).
#
# Beyond FMP/FSP rekey survival, a --topology run also verifies multi-hop
# FORWARDING (all-pairs ping over non-adjacent pairs), DATA-PLANE
# CONTINUITY across rekey (control-differential ping-loss stream,
# Phase 5b), and MESH-SIZE estimate convergence across versions
# (Phase 7).
#
# Environment:
# FIPS_INTEROP_NETEM tc-netem arg string applied to every container's
# eth0, e.g. "delay 10ms 5ms 25% loss 1%". Unset =
# no impairment.
# FIPS_INTEROP_EDGES explicit undirected edge list (overrides the
# full mesh) — see generate-configs.sh. Usually
# set for you by --topology.
# FIPS_INTEROP_STREAMS data-plane stream pairs (`nid-nid` tokens, both
# directions streamed). Enables Phase 1b/5b. Set
# by --topology; empty = streams off.
# STREAM_LOSS_MARGIN_PCT rekey-vs-control loss margin (default 1).
# CONTROL_STREAM_SECS quiet control-window length (default 12).
# MESH_SIZE_TIMEOUT Phase 7 convergence poll budget (default 180).
# FIPS_INTEROP_KEEP_UP 1 = leave containers running after the test (debug).
# REKEY_AFTER_SECS rekey interval to generate configs with (default
# 35; multihop-3v-cycle defaults it to 50).
# FIPS_INTEROP_RUNS_DIR Root for harness scratch dirs (.build/,
# .stress-runs/, generated-configs/). When
# unset, falls back to in-tree paths under
# testing/interop/ and prints a warning to
# stderr; set it to a path outside the source
# tree to keep generated artefacts out of the
# checkout.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
source "$SCRIPT_DIR/../lib/wait-converge.sh"
# ── Scratch-dir root ─────────────────────────────────────────────────
#
# FIPS_INTEROP_RUNS_DIR controls where the harness writes its scratch
# directories (.build/, .stress-runs/, generated-configs/). When unset
# we fall back to in-tree paths under testing/interop/ and warn the
# operator, so the warning fires exactly once per invocation. When a
# parent script has already warned it exports _FIPS_INTEROP_WARNED=1
# to suppress duplicate warnings in child scripts.
if [[ -n "${FIPS_INTEROP_RUNS_DIR:-}" ]]; then
RUNS_BASE="$FIPS_INTEROP_RUNS_DIR"
mkdir -p "$RUNS_BASE"
else
RUNS_BASE="$SCRIPT_DIR"
if [[ -z "${_FIPS_INTEROP_WARNED:-}" ]]; then
echo >&2 "WARNING: FIPS_INTEROP_RUNS_DIR not set; harness output will be written under the source tree at $RUNS_BASE. Set FIPS_INTEROP_RUNS_DIR to a path outside the source tree to avoid this."
export _FIPS_INTEROP_WARNED=1
fi
fi
GEN_DIR="$RUNS_BASE/generated-configs"
COMPOSE_FILE="$GEN_DIR/docker-compose.generated.yml"
NODES_ENV="$GEN_DIR/nodes.env"
REFS_ENV="$RUNS_BASE/.build/refs.env"
# ── Built-in topology selection ──────────────────────────────────────
#
# --topology <name> selects a node-spec + an explicit edge set (a
# multi-hop, mixed-version, leaf-bearing graph) + a default data-plane
# stream set, exercising forwarding / routing / mesh-size convergence
# across versions. Without it, positional args are the node-spec and the
# mesh is a full mesh (historical behavior).
TOPOLOGY_NAME=""
_ARGS=()
while [ "$#" -gt 0 ]; do
case "$1" in
--topology) TOPOLOGY_NAME="${2:-}"; shift 2 ;;
--topology=*) TOPOLOGY_NAME="${1#--topology=}"; shift ;;
*) _ARGS+=("$1"); shift ;;
esac
done
set -- ${_ARGS[@]+"${_ARGS[@]}"}
if [ -n "$TOPOLOGY_NAME" ]; then
case "$TOPOLOGY_NAME" in
multihop-3v-cycle)
# 6 nodes (2 of each version), one cycle, two leaves.
# a1
# / \ leaves: a2, c2
# b1 c1 cycle: b1-a1-c1-b2-b1
# / \ \
# a2 b2------c2-edge(b2-c1)
set -- a a b b c c
export FIPS_INTEROP_EDGES="a1-b1 a1-c1 b1-a2 b1-b2 c1-c2 b2-c1"
: "${FIPS_INTEROP_STREAMS:=a2-c2 a1-b1}"
export FIPS_INTEROP_STREAMS
# Multi-hop convergence + a clean pre-rekey control window
# want more headroom than the 35s full-mesh default.
: "${REKEY_AFTER_SECS:=50}"
;;
*)
echo "ERROR: unknown --topology '$TOPOLOGY_NAME' (known: multihop-3v-cycle)" >&2
exit 2
;;
esac
fi
REKEY_AFTER_SECS="${REKEY_AFTER_SECS:-35}"
# ── Node-spec ────────────────────────────────────────────────────────
SPEC=("$@")
if [ "${#SPEC[@]}" -eq 0 ]; then
SPEC=(a b c)
fi
SPEC_STR="${SPEC[*]}"
# ── Timing ───────────────────────────────────────────────────────────
CONVERGENCE_TIMEOUT="${CONVERGENCE_TIMEOUT:-90}" # detector settle budget (env-overridable; larger meshes under loss converge slower)
PING_TIMEOUT=5
MAX_PING_ATTEMPTS=4 # strict-ping retry (mirrors rekey-test.sh)
PING_RETRY_DELAY=1
# First rekey should follow shortly after the interval once converged.
FIRST_REKEY_TIMEOUT=$((REKEY_AFTER_SECS + 20))
SECOND_REKEY_WAIT=$((REKEY_AFTER_SECS + 5))
REKEY_SETTLE=12 # FSP-cutover settle budget (Phase 6)
# Post-rekey reconvergence is polled, not fixed-slept: the mesh is given
# up to this long to restore full connectivity after a rekey before the
# strict assertion sweep runs. A genuinely stuck pair still fails — the
# poll times out and the recording sweep captures it.
POST_REKEY_TIMEOUT=45
LOG_POLL_INTERVAL=2
# Data-plane continuity stream (control-differential). Streams run a
# sustained ping6 over the overlay across the rekey window vs a quiet
# control window; loss is compared to prove rekey is hitless.
STREAM_RATE_HZ=5 # ping6 -i 0.2
CONTROL_STREAM_SECS="${CONTROL_STREAM_SECS:-12}" # quiet pre-rekey window
STREAM_LOSS_MARGIN_PCT="${STREAM_LOSS_MARGIN_PCT:-1}"
# The rekey-window stream must span Phases 2-5 (both cutovers + reconverge).
REKEY_STREAM_SECS=$(( FIRST_REKEY_TIMEOUT + SECOND_REKEY_WAIT + POST_REKEY_TIMEOUT + 15 ))
# Mesh-size estimate convergence (strict ±25% of true N). Generous poll
# budget — the bloom-union estimate converges over minutes.
MESH_SIZE_TIMEOUT="${MESH_SIZE_TIMEOUT:-180}"
# ── Counters ─────────────────────────────────────────────────────────
PASSED=0
FAILED=0
TOTAL_PASSED=0
TOTAL_FAILED=0
# INTEROP_FAILURES collects human-readable, pair-attributed failure lines.
INTEROP_FAILURES=()
# ── Preflight: docker + images ───────────────────────────────────────
if ! docker info >/dev/null 2>&1; then
echo "ERROR: Docker daemon is not reachable" >&2
exit 2
fi
# A node-spec only ever references the three image slots, regardless of
# how many nodes it has. Check the slots actually present in the spec.
for slot in $(printf '%s\n' "${SPEC[@]}" | sort -u); do
case "$slot" in
a|b|c) ;;
*) echo "ERROR: invalid slot '$slot' in node-spec" >&2; exit 2 ;;
esac
if ! docker image inspect "fips-interop:$slot" >/dev/null 2>&1; then
echo "ERROR: image fips-interop:$slot not present." >&2
echo "Build the three images first:" >&2
echo " bash $SCRIPT_DIR/build-images.sh <ref-a> <ref-b> <ref-c>" >&2
exit 2
fi
done
# ── (Re)generate configs for this node-spec ──────────────────────────
#
# Always regenerate when the spec on disk does not match the requested
# spec (or no manifest exists). Generation is deterministic and cheap.
need_regen=1
if [ -f "$NODES_ENV" ]; then
existing_spec="$(sed -n 's/^INTEROP_SPEC="\(.*\)"$/\1/p' "$NODES_ENV")"
existing_edges="$(sed -n 's/^INTEROP_TOPOLOGY_EDGES="\(.*\)"$/\1/p' "$NODES_ENV")"
if [ "$existing_spec" = "$SPEC_STR" ] \
&& [ "$existing_edges" = "${FIPS_INTEROP_EDGES:-}" ]; then
need_regen=0
fi
fi
if [ "$need_regen" -eq 1 ]; then
echo "Generating mesh configs for spec '$SPEC_STR' (rekey.after_secs=$REKEY_AFTER_SECS)..."
REKEY_AFTER_SECS="$REKEY_AFTER_SECS" bash "$SCRIPT_DIR/generate-configs.sh" \
"${SPEC[@]}"
echo ""
fi
# ── Load the manifest ────────────────────────────────────────────────
#
# nodes.env gives the ordered node list and the per-node slot/container/
# ip/npub maps. npubs.env gives NPUB_<NODEID> vars used by ping_one.
# shellcheck disable=SC1090
source "$NODES_ENV"
# shellcheck disable=SC1091
source "$GEN_DIR/npubs.env"
read -r -a NODES <<< "$INTEROP_NODE_IDS"
declare -A SLOT_OF CONTAINER NODE_IP_OF NPUB_OF
parse_map() {
# parse_map <assoc-array-name> <space-separated nodeid:value tokens>
local -n _dst="$1"
local tok nid val
for tok in $2; do
nid="${tok%%:*}"
val="${tok#*:}"
_dst["$nid"]="$val"
done
}
parse_map SLOT_OF "$INTEROP_NODE_SLOTS"
parse_map CONTAINER "$INTEROP_NODE_CONTAINERS"
parse_map NODE_IP_OF "$INTEROP_NODE_IPS"
parse_map NPUB_OF "$INTEROP_NODE_NPUBS"
# Topology metadata: per-node expected direct-peer degree, and the
# undirected direct-edge set (for direct-vs-routed pair labeling).
TOPOLOGY_KIND="${INTEROP_TOPOLOGY:-full-mesh}"
declare -A DEGREE_OF
parse_map DEGREE_OF "${INTEROP_NODE_DEGREE:-}"
declare -A IS_DIRECT_EDGE
if [ -n "${INTEROP_TOPOLOGY_EDGES:-}" ]; then
for _e in $INTEROP_TOPOLOGY_EDGES; do
_x="${_e%%-*}"; _y="${_e#*-}"
IS_DIRECT_EDGE["$_x|$_y"]=1
IS_DIRECT_EDGE["$_y|$_x"]=1
done
else
# Full mesh: every ordered pair is a direct edge.
for _a in "${NODES[@]}"; do
for _b in "${NODES[@]}"; do
[ "$_a" = "$_b" ] && continue
IS_DIRECT_EDGE["$_a|$_b"]=1
done
done
fi
pair_is_direct() { [ -n "${IS_DIRECT_EDGE[$1|$2]:-}" ]; }
hop_label() { if pair_is_direct "$1" "$2"; then echo "direct"; else echo "routed"; fi; }
# Data-plane stream directed pairs (both directions of each token).
STREAM_PAIRS=()
if [ -n "${FIPS_INTEROP_STREAMS:-}" ]; then
for _tok in ${FIPS_INTEROP_STREAMS//,/ }; do
_x="${_tok%%-*}"; _y="${_tok#*-}"
STREAM_PAIRS+=("$_x $_y" "$_y $_x")
done
fi
# Unordered pairs of the mesh.
PAIRS=()
for ((i = 0; i < ${#NODES[@]}; i++)); do
for ((j = i + 1; j < ${#NODES[@]}; j++)); do
PAIRS+=("${NODES[$i]} ${NODES[$j]}")
done
done
NUM_NODES="${#NODES[@]}"
NUM_PAIRS="${#PAIRS[@]}"
NUM_DIRECTED=$((NUM_PAIRS * 2))
PEERS_EXPECTED=$((NUM_NODES - 1))
# ── Ref / version metadata ───────────────────────────────────────────
#
# refs.env (written by build-images.sh) records what each SLOT was built
# from. If absent, fall back to the image labels, then to "unknown".
# These maps are keyed by SLOT (a/b/c), not by node id.
declare -A SLOT_REF SLOT_SHA
load_slot_metadata() {
local slot upper
for slot in a b c; do
SLOT_REF[$slot]="unknown"
SLOT_SHA[$slot]="unknown"
done
if [ -f "$REFS_ENV" ]; then
# shellcheck disable=SC1090
source "$REFS_ENV"
fi
for slot in a b c; do
upper="$(echo "$slot" | tr '[:lower:]' '[:upper:]')"
local ref_var="INTEROP_REF_${upper}"
local sha_var="INTEROP_SHA_${upper}"
if [ -n "${!ref_var:-}" ]; then
SLOT_REF[$slot]="${!ref_var}"
SLOT_SHA[$slot]="${!sha_var:-unknown}"
continue
fi
local lbl_ref lbl_sha
lbl_ref="$(docker image inspect "fips-interop:$slot" \
--format '{{ index .Config.Labels "fips.interop.ref" }}' 2>/dev/null || true)"
lbl_sha="$(docker image inspect "fips-interop:$slot" \
--format '{{ index .Config.Labels "fips.interop.sha" }}' 2>/dev/null || true)"
[ -n "$lbl_ref" ] && SLOT_REF[$slot]="$lbl_ref"
[ -n "$lbl_sha" ] && SLOT_SHA[$slot]="$lbl_sha"
done
}
# A pair is "mixed-version" iff the two nodes' image slots resolve to
# different built SHAs. SHA is the precise discriminator; ref names can
# differ yet point at the same commit.
pair_is_mixed() {
local n1="$1" n2="$2"
local s1="${SLOT_OF[$n1]}" s2="${SLOT_OF[$n2]}"
if [ "${SLOT_SHA[$s1]}" = "${SLOT_SHA[$s2]}" ] \
&& [ "${SLOT_SHA[$s1]}" != "unknown" ]; then
return 1 # same version
fi
return 0 # mixed (or unknown — treat as mixed for scrutiny)
}
pair_kind() {
if pair_is_mixed "$1" "$2"; then
echo "MIXED"
else
echo "same"
fi
}
pair_label() {
# e.g. "a1[A fix/...@3045212] <-> c1[C v0.3.0@b11b639]"
local n1="$1" n2="$2"
local s1="${SLOT_OF[$n1]}" s2="${SLOT_OF[$n2]}"
local u1 u2
u1="$(echo "$s1" | tr '[:lower:]' '[:upper:]')"
u2="$(echo "$s2" | tr '[:lower:]' '[:upper:]')"
echo "${n1}[$u1 ${SLOT_REF[$s1]}@${SLOT_SHA[$s1]}] <-> ${n2}[$u2 ${SLOT_REF[$s2]}@${SLOT_SHA[$s2]}]"
}
# ── Helpers ──────────────────────────────────────────────────────────
# ping6 from one container to another node's mesh address (npub.fips).
ping_one() {
local from_node="$1" to_node="$2" quiet="${3:-}"
local max_attempts="${4:-1}"
local from_ctr="${CONTAINER[$from_node]}"
local to_npub="${NPUB_OF[$to_node]}"
local label="$from_node -> $to_node"
local attempt=1 output rtt
while (( attempt <= max_attempts )); do
(( attempt > 1 )) && sleep "$PING_RETRY_DELAY"
if output=$(docker exec "$from_ctr" ping6 -c 1 -W "$PING_TIMEOUT" \
"${to_npub}.fips" 2>&1); then
rtt=$(echo "$output" | grep -oE 'time=[0-9.]+' | cut -d= -f2)
if [ -z "$quiet" ]; then
echo " $label ... OK (${rtt:-?}ms${attempt:+, attempt $attempt})"
fi
PASSED=$((PASSED + 1))
return 0
fi
attempt=$((attempt + 1))
done
if [ -z "$quiet" ]; then
echo " $label ... FAIL (after $max_attempts attempt(s))"
fi
FAILED=$((FAILED + 1))
return 1
}
# All directed pairs of the mesh. Records pair-attributed failures into
# INTEROP_FAILURES, with the mixed/same classification.
ping_all_pairs() {
local quiet="${1:-}" max_attempts="${2:-1}" context="${3:-connectivity}"
PASSED=0
FAILED=0
local i j
for i in "${NODES[@]}"; do
for j in "${NODES[@]}"; do
[ "$i" = "$j" ] && continue
if ! ping_one "$i" "$j" "$quiet" "$max_attempts"; then
# The `convergence` context is the wait_for_full_baseline
# detector poll, NOT an assertion: a miss there only means
# "not converged yet, keep polling". Recording detector
# misses as interop failures is wrong — it false-fails
# every rep that takes a moment to converge. Only the
# strict assertion sweeps (baseline / post-rekey-*) record.
if [ "$context" != "convergence" ]; then
local kind hop
kind="$(pair_kind "$i" "$j")"
hop="$(hop_label "$i" "$j")"
# NOTE: keep "$kind pair" contiguous — interop-stress.sh
# greps "MIXED pair"/"same pair". The [hop] tag is additive.
INTEROP_FAILURES+=("[$context] $kind pair $(pair_label "$i" "$j") [$hop]: ping $i->$j FAILED")
fi
fi
done
done
}
# Poll until every directed pair pings clean, or until timeout. This is a
# convergence DETECTOR — used at establishment (Phase 1) and after each
# rekey (Phases 3/5). It pings with the "convergence" context, which
# ping_all_pairs deliberately does not record as a failure; the caller
# runs a separate strict assertion sweep afterwards.
wait_for_full_baseline() {
local timeout="$1"
local start=$SECONDS
local best_passed=0 best_failed="$NUM_DIRECTED"
while (( SECONDS - start < timeout )); do
ping_all_pairs quiet 1 "convergence"
if [ "$PASSED" -gt "$best_passed" ]; then
best_passed="$PASSED"; best_failed="$FAILED"
fi
[ "$FAILED" -eq 0 ] && return 0
sleep 1
done
PASSED="$best_passed"; FAILED="$best_failed"
return 1
}
phase_result() {
local phase="$1"
TOTAL_PASSED=$((TOTAL_PASSED + PASSED))
TOTAL_FAILED=$((TOTAL_FAILED + FAILED))
if [ "$FAILED" -eq 0 ]; then
echo " PASS $phase: $PASSED/$((PASSED + FAILED))"
else
echo " FAIL $phase: $PASSED passed, $FAILED FAILED"
fi
}
# Count a pattern across all node logs.
count_log_pattern() {
local pattern="$1" total=0 n count
for n in "${NODES[@]}"; do
count=$(docker logs "${CONTAINER[$n]}" 2>&1 | grep -cE "$pattern" || true)
total=$((total + count))
done
echo "$total"
}
# Per-node count of a pattern.
count_node_pattern() {
local node="$1" pattern="$2"
docker logs "${CONTAINER[$node]}" 2>&1 | grep -cE "$pattern" || true
}
wait_for_log_pattern_count() {
local pattern="$1" min_count="$2" timeout="$3"
local start=$SECONDS
while (( SECONDS - start < timeout )); do
[ "$(count_log_pattern "$pattern")" -ge "$min_count" ] && return 0
sleep "$LOG_POLL_INTERVAL"
done
[ "$(count_log_pattern "$pattern")" -ge "$min_count" ]
}
# ── Data-plane continuity streams ────────────────────────────────────
#
# A sustained ping6 stream over the overlay (<npub>.fips) is data-plane
# traffic: it traverses TUN → FSP session → FMP link → forwarding, the
# same path application packets take. We run streams across the rekey
# window and across a quiet control window, then compare loss — a rekey
# that drops data shows up as rekey-window loss above the control.
declare -A STREAM_TX STREAM_RX
_STREAM_PIDS=()
_STREAM_FILES=() # "label:from->to:resultfile"
_STREAM_TMP=""
# One directed ping6 stream of <dur>s at STREAM_RATE_HZ; writes "tx rx".
_stream_one() {
local from="$1" to="$2" dur="$3" outfile="$4"
local from_ctr="${CONTAINER[$from]}" to_npub="${NPUB_OF[$to]}"
local count=$(( dur * STREAM_RATE_HZ ))
local out tx rx
out=$(docker exec "$from_ctr" ping6 -i 0.2 -c "$count" -W "$PING_TIMEOUT" \
"${to_npub}.fips" 2>&1)
tx=$(echo "$out" | grep -oE '[0-9]+ packets transmitted' | grep -oE '^[0-9]+')
rx=$(echo "$out" | grep -oE '[0-9]+ received' | grep -oE '^[0-9]+')
echo "${tx:-0} ${rx:-0}" > "$outfile"
}
# Launch all stream pairs in the background for <label> over <dur>s.
launch_streams() {
local label="$1" dur="$2"
_STREAM_TMP="$(mktemp -d)"
_STREAM_PIDS=(); _STREAM_FILES=()
local sp from to
for sp in "${STREAM_PAIRS[@]}"; do
read -r from to <<< "$sp"
_stream_one "$from" "$to" "$dur" "$_STREAM_TMP/$label-$from-$to" &
_STREAM_PIDS+=("$!")
_STREAM_FILES+=("$label:$from->$to:$_STREAM_TMP/$label-$from-$to")
done
}
# Wait for the launched streams and read tx/rx into STREAM_TX/STREAM_RX.
collect_streams() {
[ "${#_STREAM_PIDS[@]}" -gt 0 ] && wait "${_STREAM_PIDS[@]}" 2>/dev/null || true
local entry label rest key f tx rx
for entry in "${_STREAM_FILES[@]}"; do
label="${entry%%:*}"; rest="${entry#*:}"; key="${rest%%:*}"; f="${rest#*:}"
if read -r tx rx < "$f" 2>/dev/null; then :; else tx=0; rx=0; fi
STREAM_TX["$label:$key"]="${tx:-0}"
STREAM_RX["$label:$key"]="${rx:-0}"
done
[ -n "$_STREAM_TMP" ] && rm -rf "$_STREAM_TMP"
_STREAM_TMP=""
}
# loss% from tx/rx (one decimal), or "NA" when tx==0.
_loss_pct() {
awk -v t="${1:-0}" -v r="${2:-0}" \
'BEGIN{ if (t>0) printf "%.1f", (t-r)*100/t; else print "NA" }'
}
dump_diagnostics() {
echo ""
echo "=== Peer / link snapshot ==="
local n s
for n in "${NODES[@]}"; do
s="${SLOT_OF[$n]}"
echo "--- $n (slot $s, ${SLOT_REF[$s]}@${SLOT_SHA[$s]}) ---"
docker exec "${CONTAINER[$n]}" fipsctl show peers 2>/dev/null || true
docker exec "${CONTAINER[$n]}" fipsctl show links 2>/dev/null || true
echo ""
done
echo "=== Recent node logs (interop-relevant lines) ==="
for n in "${NODES[@]}"; do
echo "--- $n ---"
docker logs "${CONTAINER[$n]}" 2>&1 \
| grep -E "(ERROR|PANIC|panicked|FMP version|handshake|Handshake|decrypt|Decrypt|teardown|rekey|Rekey|K-bit|established|Established)" \
| tail -40
echo ""
done
}
compose() {
docker compose -f "$COMPOSE_FILE" "$@"
}
cleanup() {
# Reap any in-flight stream tmpdir (e.g. on interrupt mid-window).
[ -n "${_STREAM_TMP:-}" ] && rm -rf "$_STREAM_TMP" 2>/dev/null
if [ "${FIPS_INTEROP_KEEP_UP:-}" = "1" ]; then
echo ""
echo "FIPS_INTEROP_KEEP_UP=1 — leaving mesh running. Tear down with:"
echo " docker compose -f $COMPOSE_FILE down --volumes --remove-orphans"
return
fi
compose down --volumes --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
trap 'echo ""; echo "Interrupted"; exit 130' INT
load_slot_metadata
# ── Banner ───────────────────────────────────────────────────────────
echo "=============================================================="
echo " FIPS Mixed-Version Interop Test"
echo "=============================================================="
echo ""
echo "Node-spec: $SPEC_STR ($NUM_NODES nodes, $NUM_PAIRS pairs, $NUM_DIRECTED directed)"
echo "Topology : $TOPOLOGY_KIND${TOPOLOGY_NAME:+ ($TOPOLOGY_NAME)}"
if [ "$TOPOLOGY_KIND" != "full-mesh" ]; then
echo " edges: ${INTEROP_TOPOLOGY_EDGES:-}"
fi
echo ""
echo "Mesh nodes:"
for n in "${NODES[@]}"; do
s="${SLOT_OF[$n]}"
u="$(echo "$s" | tr '[:lower:]' '[:upper:]')"
echo " $n slot $u ${SLOT_REF[$s]} @ ${SLOT_SHA[$s]} deg=${DEGREE_OF[$n]:-?} (${NODE_IP_OF[$n]})"
done
echo ""
echo "Mesh pairs:"
for p in "${PAIRS[@]}"; do
read -r n1 n2 <<< "$p"
echo " $(pair_kind "$n1" "$n2") $(pair_label "$n1" "$n2") [$(hop_label "$n1" "$n2")]"
done
if [ "${#STREAM_PAIRS[@]}" -gt 0 ]; then
echo ""
echo "Data-plane streams (continuity across rekey):"
for sp in "${STREAM_PAIRS[@]}"; do
read -r sf st <<< "$sp"
echo " $sf -> $st [$(hop_label "$sf" "$st")]"
done
fi
echo ""
if [ -n "${FIPS_INTEROP_NETEM:-}" ]; then
echo "Netem impairment: $FIPS_INTEROP_NETEM"
echo ""
fi
echo "rekey.after_secs=$REKEY_AFTER_SECS"
echo ""
# ── Phase 0: bring up the mesh ───────────────────────────────────────
echo "Phase 0: Starting mesh"
compose down --volumes --remove-orphans >/dev/null 2>&1 || true
if ! compose up -d; then
echo " FAIL compose up failed"
exit 1
fi
# Optional netem: applied via `docker exec ... tc qdisc` on each
# container's eth0 — host bridge qdisc does NOT shape inter-container
# port-to-port traffic, so the impairment must live inside each
# container. Same mechanism as testing/flake-lab/run-loop.sh.
if [ -n "${FIPS_INTEROP_NETEM:-}" ]; then
echo " Applying netem to each container eth0: $FIPS_INTEROP_NETEM"
for n in "${NODES[@]}"; do
if docker exec "${CONTAINER[$n]}" tc qdisc add dev eth0 root \
netem ${FIPS_INTEROP_NETEM} >/dev/null 2>&1; then
echo " $n: netem applied"
else
echo " $n: WARN netem apply failed"
fi
done
fi
echo ""
# ── Phase 1: FMP link + FSP session establishment ────────────────────
echo "Phase 1: Link/session establishment + connectivity baseline"
PASSED=0; FAILED=0
# Every node must reach its DIRECT-peer degree of authenticated peers (a
# peer in `show peers` is an authenticated FMP-link peer). In a full mesh
# that degree is N-1; in a multi-hop topology it is the node's adjacency.
for n in "${NODES[@]}"; do
exp="${DEGREE_OF[$n]:-$PEERS_EXPECTED}"
if ! wait_for_peers "${CONTAINER[$n]}" "$exp" "$CONVERGENCE_TIMEOUT"; then
echo " $n did not reach $exp authenticated direct peer(s)"
fi
done
# The all-pairs ping over fips0 is the definitive reachability check.
# Direct-neighbor pairs prove their FSP session; NON-adjacent pairs in a
# multi-hop topology can only succeed via forwarding, so all-pairs ping
# is also the cross-version FORWARDING test (we keep pinging every pair).
# The convergence detector is an ADVISORY settle-wait, not the assertion.
# It needs one fully-clean, no-retry sweep of every directed pair; under
# packet loss that is statistically unlikely on a large mesh even when the
# mesh is perfectly healthy (e.g. 30 pairs at 2% loss => ~55% of sweeps
# are clean), so its timeout must NOT be fatal. The strict, retrying ping
# below is the real baseline assertion — it decides pass/fail.
if ! wait_for_full_baseline "$CONVERGENCE_TIMEOUT"; then
echo " detector saw no fully-clean sweep within ${CONVERGENCE_TIMEOUT}s (best $PASSED/$NUM_DIRECTED); strict re-ping decides"
fi
ping_all_pairs "" "$MAX_PING_ATTEMPTS" "baseline"
phase_result "Establishment baseline (all $NUM_DIRECTED directed pairs)"
if [ "$FAILED" -ne 0 ]; then
dump_diagnostics
echo ""
echo "=== Results: $TOTAL_PASSED passed, $TOTAL_FAILED failed ==="
echo "FAIL: mesh did not converge — see pair attribution above."
exit 1
fi
echo ""
# ── Phase 1b: data-plane control window (quiet, pre-rekey) ───────────
#
# Measure stream loss over a window with NO rekey cutover, as the control
# baseline for the differential. Validated cutover-free by confirming the
# FMP cutover count did not advance during the window. Then launch the
# rekey-window streams, which run in the background across Phases 2-5 and
# are collected/asserted in Phase 5b.
control_contaminated=0
if [ "${#STREAM_PAIRS[@]}" -gt 0 ]; then
echo "Phase 1b: Data-plane control stream (${CONTROL_STREAM_SECS}s quiet window)"
pre_cut="$(count_log_pattern 'Rekey cutover complete \(initiator\), K-bit flipped')"
launch_streams CONTROL "$CONTROL_STREAM_SECS"
collect_streams
post_cut="$(count_log_pattern 'Rekey cutover complete \(initiator\), K-bit flipped')"
if [ "$post_cut" -ne "$pre_cut" ]; then
control_contaminated=1
echo " WARN a rekey cutover occurred during the control window — control loss may be contaminated"
fi
for sp in "${STREAM_PAIRS[@]}"; do
read -r sf st <<< "$sp"
key="$sf->$st"
echo " control $key: tx=${STREAM_TX[CONTROL:$key]:-0} rx=${STREAM_RX[CONTROL:$key]:-0} loss=$(_loss_pct "${STREAM_TX[CONTROL:$key]:-0}" "${STREAM_RX[CONTROL:$key]:-0}")%"
done
echo " Launching rekey-window streams (${REKEY_STREAM_SECS}s, spanning Phases 2-5)"
launch_streams REKEY "$REKEY_STREAM_SECS"
echo ""
fi
# ── Phase 2: first rekey cycle ───────────────────────────────────────
echo "Phase 2: First rekey cycle (waiting up to ${FIRST_REKEY_TIMEOUT}s)"
PASSED=0; FAILED=0
wait_for_log_pattern_count \
"Rekey cutover complete \(initiator\), K-bit flipped" 1 \
"$FIRST_REKEY_TIMEOUT" || true
fmp_cutovers="$(count_log_pattern 'Rekey cutover complete \(initiator\), K-bit flipped')"
if [ "$fmp_cutovers" -ge 1 ]; then
echo " PASS FMP rekey initiator cutovers: $fmp_cutovers"
PASSED=$((PASSED + 1))
else
echo " FAIL no FMP rekey cutover observed within ${FIRST_REKEY_TIMEOUT}s"
FAILED=$((FAILED + 1))
INTEROP_FAILURES+=("[rekey] no FMP rekey cutover completed across the mesh")
fi
phase_result "First rekey cycle"
echo ""
# ── Phase 3: post-first-rekey connectivity ───────────────────────────
echo "Phase 3: Post-first-rekey connectivity (reconverge within ${POST_REKEY_TIMEOUT}s)"
PASSED=0; FAILED=0
# Poll until the mesh has reconverged after the rekey, then assert once.
# The detector poll records nothing (see ping_all_pairs); only the strict
# sweep below records, so a brief rekey-induced disruption is not a fail.
wait_for_full_baseline "$POST_REKEY_TIMEOUT" || true
ping_all_pairs "" "$MAX_PING_ATTEMPTS" "post-rekey-1"
phase_result "Post-first-rekey (all $NUM_DIRECTED directed pairs)"
echo ""
# ── Phase 4: second rekey cycle ──────────────────────────────────────
echo "Phase 4: Second rekey cycle (waiting ${SECOND_REKEY_WAIT}s)"
sleep "$SECOND_REKEY_WAIT"
echo ""
echo "Phase 5: Post-second-rekey connectivity (reconverge within ${POST_REKEY_TIMEOUT}s)"
PASSED=0; FAILED=0
wait_for_full_baseline "$POST_REKEY_TIMEOUT" || true
ping_all_pairs "" "$MAX_PING_ATTEMPTS" "post-rekey-2"
phase_result "Post-second-rekey (all $NUM_DIRECTED directed pairs)"
echo ""
# ── Phase 5b: data-plane continuity across rekey (control-differential)
#
# Collect the rekey-window streams launched in Phase 1b and compare their
# loss to the control window. A hitless rekey keeps rekey-window loss at
# or below the control (plus a small margin); excess loss is data the
# rekey dropped — the failure the cutover fixes (4af3730/6e5cb89) and the
# pipelined wire layout are supposed to prevent.
if [ "${#STREAM_PAIRS[@]}" -gt 0 ]; then
echo "Phase 5b: Data-plane continuity across rekey (control-differential, margin ${STREAM_LOSS_MARGIN_PCT}%)"
PASSED=0; FAILED=0
collect_streams
printf ' %-16s %11s %11s %8s %s\n' "stream" "ctrl-loss%" "rekey-loss%" "delta" "verdict"
for sp in "${STREAM_PAIRS[@]}"; do
read -r sf st <<< "$sp"
key="$sf->$st"
cpct="$(_loss_pct "${STREAM_TX[CONTROL:$key]:-0}" "${STREAM_RX[CONTROL:$key]:-0}")"
rpct="$(_loss_pct "${STREAM_TX[REKEY:$key]:-0}" "${STREAM_RX[REKEY:$key]:-0}")"
verdict="$(awk -v c="$cpct" -v k="$rpct" -v m="$STREAM_LOSS_MARGIN_PCT" 'BEGIN{
if (c=="NA" || k=="NA") { print "NODATA"; exit }
if (k <= c + m) print "PASS"; else print "FAIL"
}')"
delta="$(awk -v c="$cpct" -v k="$rpct" 'BEGIN{ if(c=="NA"||k=="NA") print "NA"; else printf "%+.1f", k-c }')"
printf ' %-16s %11s %11s %8s %s\n' "$key" "$cpct" "$rpct" "$delta" "$verdict"
if [ "$verdict" = "PASS" ]; then
PASSED=$((PASSED + 1))
else
FAILED=$((FAILED + 1))
INTEROP_FAILURES+=("[stream] $key ($(hop_label "$sf" "$st")): rekey-window loss ${rpct}% vs control ${cpct}% (+${STREAM_LOSS_MARGIN_PCT}% margin) -> $verdict")
fi
done
[ "${control_contaminated:-0}" -eq 1 ] && echo " NOTE: control window saw a cutover; differential may understate rekey loss."
phase_result "Data-plane continuity across rekey"
echo ""
fi
# ── Phase 6: per-node, per-pair interop log analysis ─────────────────
#
# This is the interop-specific analysis. For each unordered pair it
# reports same vs mixed-version, then scans BOTH endpoints' logs for
# interop failure signatures. A signature firing on a mixed pair that
# does not fire on a same-version pair is the harness's primary signal.
echo "Phase 6: Interop log analysis"
PASSED=0; FAILED=0
# Give FSP rekey (which trails FMP rekey) a bounded chance to complete
# at least one cutover before the final assertions.
wait_for_log_pattern_count "FSP rekey cutover complete" 1 "$REKEY_SETTLE" || true
# Global negative checks — these must be zero on EVERY node regardless
# of version pairing. A non-zero count is attributed to the node and,
# where the count is asymmetric across versions, flagged as interop.
echo ""
echo " -- Global health (all $NUM_NODES nodes) --"
declare -A GLOBAL_PATTERNS=(
["PANIC|panicked"]="panics"
["ERROR"]="error-level log lines"
["unknown FMP version|Unknown FMP version"]="unknown-FMP-version drops"
["MMP link teardown"]="MMP link teardowns"
["Excessive decrypt failures"]="excessive-decrypt-failure removals"
["Session AEAD decryption failed"]="FSP AEAD decrypt failures"
["Rekey msg2 processing failed"]="rekey msg2 failures"
["Handshake failed|handshake failed|Handshake error"]="handshake failures"
)
for pat in "${!GLOBAL_PATTERNS[@]}"; do
desc="${GLOBAL_PATTERNS[$pat]}"
total="$(count_log_pattern "$pat")"
if [ "$total" -eq 0 ]; then
echo " PASS $desc: 0"
PASSED=$((PASSED + 1))
else
echo " FAIL $desc: $total (expected 0)"
FAILED=$((FAILED + 1))
# Per-node breakdown so the count can be attributed to a build.
for n in "${NODES[@]}"; do
c="$(count_node_pattern "$n" "$pat")"
if [ "$c" -gt 0 ]; then
s="${SLOT_OF[$n]}"
u="$(echo "$s" | tr '[:lower:]' '[:upper:]')"
echo " $n [$u ${SLOT_REF[$s]}@${SLOT_SHA[$s]}]: $c"
INTEROP_FAILURES+=("[log] node $n ($u ${SLOT_REF[$s]}@${SLOT_SHA[$s]}): $c x '$desc'")
fi
done
fi
done
# Positive checks — the rekey machinery actually exercised both layers.
echo ""
echo " -- Rekey machinery exercised --"
fmp_total="$(count_log_pattern 'Rekey cutover complete \(initiator\), K-bit flipped')"
fsp_total="$(count_log_pattern 'FSP rekey cutover complete')"
if [ "$fmp_total" -ge 1 ]; then
echo " PASS FMP rekey cutovers across mesh: $fmp_total"
PASSED=$((PASSED + 1))
else
echo " FAIL FMP rekey cutovers: $fmp_total (expected >= 1)"
FAILED=$((FAILED + 1))
fi
if [ "$fsp_total" -ge 1 ]; then
echo " PASS FSP rekey cutovers across mesh: $fsp_total"
PASSED=$((PASSED + 1))
else
# FSP rekey not completing is a soft signal: report it but only
# hard-fail if a pair also lost connectivity. The connectivity
# phases already cover the user-visible impact.
echo " WARN FSP rekey cutovers: $fsp_total (expected >= 1)"
fi
# Per-pair summary: classify each unordered pair and report whether it
# stayed healthy through the run. "Healthy" = no connectivity failure
# recorded for either direction of the pair.
echo ""
echo " -- Per-pair interop summary --"
for p in "${PAIRS[@]}"; do
read -r n1 n2 <<< "$p"
kind="$(pair_kind "$n1" "$n2")"
label="$(pair_label "$n1" "$n2") [$(hop_label "$n1" "$n2")]"
pair_failed=0
if [ "${#INTEROP_FAILURES[@]}" -gt 0 ]; then
for f in "${INTEROP_FAILURES[@]}"; do
case "$f" in
*"ping $n1->$n2 FAILED"*|*"ping $n2->$n1 FAILED"*)
pair_failed=1 ;;
esac
done
fi
if [ "$pair_failed" -eq 0 ]; then
echo " PASS $kind pair $label: stayed healthy"
PASSED=$((PASSED + 1))
else
echo " FAIL $kind pair $label: connectivity failed during the run"
FAILED=$((FAILED + 1))
fi
done
phase_result "Interop log analysis"
echo ""
# ── Phase 7: mesh-size estimate convergence (strict ±25%) ────────────
#
# Each node's bloom-union mesh-size estimate (fipsctl show status
# .estimated_mesh_size) should converge to the true node count across
# versions. A mixed-version bloom/tree-encoding divergence shows up as a
# node that never produces an in-band estimate (or returns null). Strict
# band = [0.75N, 1.25N]; polled up to MESH_SIZE_TIMEOUT (the estimate
# converges over minutes — see ISSUE-2026-0046 on its transient jitter).
echo "Phase 7: Mesh-size estimate convergence (strict ±25% of true N=$NUM_NODES)"
PASSED=0; FAILED=0
ms_lo="$(awk -v n="$NUM_NODES" 'BEGIN{printf "%.2f", 0.75*n}')"
ms_hi="$(awk -v n="$NUM_NODES" 'BEGIN{printf "%.2f", 1.25*n}')"
echo " band [$ms_lo, $ms_hi], poll up to ${MESH_SIZE_TIMEOUT}s"
declare -A MS_EST MS_OK
ms_deadline=$(( SECONDS + MESH_SIZE_TIMEOUT ))
while :; do
all_ok=1
for n in "${NODES[@]}"; do
[ "${MS_OK[$n]:-0}" = "1" ] && continue
est="$(docker exec "${CONTAINER[$n]}" fipsctl show status 2>/dev/null \
| python3 -c "import sys,json; v=json.load(sys.stdin).get('estimated_mesh_size'); print(v if v is not None else 'null')" 2>/dev/null || echo null)"
MS_EST[$n]="$est"
if [ "$est" != "null" ] && awk "BEGIN{exit !($est>=$ms_lo && $est<=$ms_hi)}"; then
MS_OK[$n]=1
else
all_ok=0
fi
done
[ "$all_ok" = "1" ] && break
[ "$SECONDS" -ge "$ms_deadline" ] && break
sleep 3
done
for n in "${NODES[@]}"; do
s="${SLOT_OF[$n]}"; u="$(echo "$s" | tr '[:lower:]' '[:upper:]')"
if [ "${MS_OK[$n]:-0}" = "1" ]; then
echo " PASS $n [$u]: estimated_mesh_size=${MS_EST[$n]}"
PASSED=$((PASSED + 1))
else
echo " FAIL $n [$u]: estimated_mesh_size=${MS_EST[$n]:-null} (outside [$ms_lo,$ms_hi] after ${MESH_SIZE_TIMEOUT}s)"
FAILED=$((FAILED + 1))
INTEROP_FAILURES+=("[mesh-size] node $n ($u ${SLOT_REF[$s]}@${SLOT_SHA[$s]}): estimate=${MS_EST[$n]:-null} outside [$ms_lo,$ms_hi]")
fi
done
phase_result "Mesh-size estimate convergence"
echo ""
# ── Summary ──────────────────────────────────────────────────────────
echo "=============================================================="
echo " Results: $TOTAL_PASSED checks passed, $TOTAL_FAILED failed"
echo "=============================================================="
# `${#arr[@]}` cannot be combined with `:-`; count into a plain var.
INTEROP_FAILURE_COUNT="${#INTEROP_FAILURES[@]}"
if [ "$TOTAL_FAILED" -eq 0 ] && [ "$INTEROP_FAILURE_COUNT" -eq 0 ]; then
echo ""
echo "PASS: all versions interoperate cleanly across the mesh (spec '$SPEC_STR')."
exit 0
fi
echo ""
echo "INTEROP FAILURES (attributed to specific version pairs / builds):"
if [ "$INTEROP_FAILURE_COUNT" -gt 0 ]; then
printf '%s\n' "${INTEROP_FAILURES[@]}" | sort -u | sed 's/^/ - /'
else
echo " (no per-pair failure recorded; see phase output above)"
fi
echo ""
# Highlight whether failures concentrate on mixed-version pairs — the
# defining interop signal.
mixed_hits=0
same_hits=0
if [ "$INTEROP_FAILURE_COUNT" -gt 0 ]; then
for f in "${INTEROP_FAILURES[@]}"; do
case "$f" in
*"MIXED pair"*) mixed_hits=$((mixed_hits + 1)) ;;
*"same pair"*) same_hits=$((same_hits + 1)) ;;
esac
done
fi
echo "Connectivity-failure attribution: mixed-version=$mixed_hits same-version=$same_hits"
if [ "$mixed_hits" -gt 0 ] && [ "$same_hits" -eq 0 ]; then
echo "=> Failures are MIXED-VERSION ONLY: a genuine interop regression."
elif [ "$mixed_hits" -gt 0 ] && [ "$same_hits" -gt 0 ]; then
echo "=> Failures hit both mixed and same-version pairs: likely a general"
echo " instability, not version-specific — investigate the common cause."
elif [ "$same_hits" -gt 0 ]; then
echo "=> Failures are SAME-VERSION ONLY: not an interop issue per se;"
echo " a build is unstable even against itself."
fi
dump_diagnostics
exit 1