From e74c6b91da3863005a17553fdc3a54d63055486b Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Thu, 1 Oct 2026 03:30:03 +0000 Subject: [PATCH] Test mDNS LAN discovery end to end between two containers Nothing exercised LAN rendezvous end to end. The one positive unit test runs two mDNS daemons in one process, which multicast loopback does not support reliably, so it is ignored; the two that run assert only that something was not seen and would pass with the receive path removed. testing/mdns/test.sh starts two daemons in separate containers on one user-defined bridge, with LAN rendezvous on, matching scopes, no configured peers and Nostr off, and requires each to list the other as an authenticated peer at its bridge address within 60 seconds. Mismatched scopes or separate bridges leave it red. It runs in local CI and as a leg of the GitHub integration matrix. --- .github/workflows/ci.yml | 16 +++ testing/ci-local.sh | 23 +++- testing/mdns/test.sh | 255 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 testing/mdns/test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7021746..1026b6c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -594,6 +594,11 @@ jobs: # per-distro images and no TUN. ~2-3 min. - suite: native-api type: native-api + # mDNS LAN discovery: two daemons on a user-defined bridge with LAN + # rendezvous on and no configured peers, which must find and peer + # with each other by mDNS alone. Seconds when healthy. + - suite: mdns + type: mdns steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -814,6 +819,17 @@ jobs: docker rm -f "$c" >/dev/null 2>&1 || true done + # ── mDNS LAN discovery ────────────────────────────────────────────── + # Reads FIPS_TEST_IMAGE, so it runs against the image this workflow + # built. Creates and removes its own docker network; the harness prints + # both nodes' discovery log lines itself when a check fails. + - name: Run mDNS LAN discovery test + if: matrix.type == 'mdns' + timeout-minutes: 10 + env: + FIPS_TEST_IMAGE: fips-test:latest + run: bash testing/mdns/test.sh + # ───────────────────────────────────────────────────────────────────────────── # Job 4 – The .deb the install suite installs # diff --git a/testing/ci-local.sh b/testing/ci-local.sh index 96071e87..6479f362 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -31,7 +31,7 @@ # nat-lan, nostr-publish-consume, stun-faults, # chaos-churn-mixed-10, chaos-ethernet-mesh, # chaos-ethernet-only, chaos-tcp-mesh, chaos-congestion-stress, -# sidecar, dns-resolver, deb-install +# sidecar, native-api, mdns, dns-resolver, deb-install # # Opt-in (require --with-tor; depend on live Tor network): # tor-socks5, tor-directory @@ -216,6 +216,7 @@ NOSTR_RELAY_SUITES=(nostr-publish-consume) STUN_FAULTS_SUITES=(stun-faults) DNS_RESOLVER_SUITES=(dns-resolver) NATIVE_API_SUITES=(native-api) +MDNS_SUITES=(mdns) DEB_INSTALL_SUITES=(deb-install) TOR_SUITES=(tor-socks5 tor-directory) @@ -276,6 +277,9 @@ list_suites() { echo " Native API:" for s in "${NATIVE_API_SUITES[@]}"; do echo " $s"; done echo "" + echo " mDNS LAN discovery:" + for s in "${MDNS_SUITES[@]}"; do echo " $s"; done + echo "" echo " DNS resolver:" for s in "${DNS_RESOLVER_SUITES[@]}"; do echo " $s"; done echo "" @@ -1069,6 +1073,18 @@ run_native_api() { fi } +# Run the mDNS LAN discovery harness: two nodes on a user-defined bridge that +# must find and peer with each other by mDNS alone. Reads FIPS_TEST_IMAGE, and +# creates and removes its own network. +run_mdns() { + info "[mdns] Running mDNS LAN discovery test" + if FIPS_TEST_IMAGE="$CI_IMAGE_TEST" bash testing/mdns/test.sh 2>&1; then + record "mdns" 0 + else + record "mdns" 1 + fi +} + # Run dns-resolver harness (multi-distro + e2e scenarios) # # Its e2e scenarios run the fips binaries from the package build_ci_deb @@ -1360,6 +1376,9 @@ run_integration() { # Native datagram API (light — one single-node run plus a two-node pair) run_native_api + # mDNS LAN discovery (light — one two-node pair, seconds when healthy) + run_mdns + # DNS resolver multi-distro suite (heavy — per-distro systemd images) run_dns_resolver @@ -1420,6 +1439,8 @@ run_suite() { run_dns_resolver ;; native-api) run_native_api ;; + mdns) + run_mdns ;; deb-install) run_deb_install ;; tor-socks5) diff --git a/testing/mdns/test.sh b/testing/mdns/test.sh new file mode 100644 index 00000000..dbfb8de7 --- /dev/null +++ b/testing/mdns/test.sh @@ -0,0 +1,255 @@ +#!/bin/bash +# ── mDNS LAN discovery, end to end ────────────────────────────────────────── +# Two fips daemons in separate containers on one user-defined Docker bridge, +# with LAN rendezvous on, matching scopes, no configured peers and Nostr off. +# The only way they can find each other is the mDNS advert one multicasts and +# the other browses for, so each node listing the other as an authenticated +# peer, at the other's address on this bridge, is the receive path working end +# to end. +# +# This is the coverage the unit tests in src/mdns/tests.rs cannot give. Their +# positive test needs two mDNS daemons in one process, which multicast +# loopback does not support reliably, so it is #[ignore]d; the two that run +# assert only that something was NOT seen, and would pass with the receive +# path removed. Separate containers are separate processes in separate +# network namespaces, which is the real cross-daemon path, and a user-defined +# bridge forwards link-local multicast between its containers the way a +# switch does (tested for 224.0.0.251:5353 before this harness was written). +# +# The network takes a docker-assigned subnet rather than a fixed one, so two +# concurrent runs cannot collide on address space; nothing here depends on +# the addresses beyond reading them back. +# +# Two settings exist for break-checking the harness, and leave it red when +# used; a normal run sets neither: +# MDNS_SCOPE_B= give node B a scope other than node A's +# MDNS_SPLIT_BRIDGES=1 put node B on a bridge of its own +# +# Image: FIPS_TEST_IMAGE if set (ci-local.sh passes its per-run image; the +# GitHub job passes the image it built). Otherwise a minimal image is built +# from target/release, refusing binaries older than the source. +# +# Usage: ./test.sh +# Exit 0 = both nodes discovered and peered with each other. Exit 1 = they did +# not. Exit 2 = the harness could not run; never treated as a pass. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +LABEL="com.corganlabs.fips-ci=1" +NET_A="fips-mdns-net-$$" +NET_B="fips-mdns-net-b-$$" +NODE_A="fips-mdns-a-$$" +NODE_B="fips-mdns-b-$$" +UDP_PORT=2121 +SCOPE_A="fips-mdns-test" +SCOPE_B="${MDNS_SCOPE_B:-$SCOPE_A}" +SPLIT_BRIDGES="${MDNS_SPLIT_BRIDGES:-0}" +# Seconds from both daemons running until each must list the other. mDNS +# resolution and the handshake take well under a second on a quiet host; the +# rest is headroom for a loaded CI runner. +DISCOVERY_TIMEOUT="${MDNS_DISCOVERY_TIMEOUT:-60}" +IMAGE="" +BUILT_IMAGE="" +WORK="$(mktemp -d)" + +PASS=0 +FAIL=0 +log() { echo "=== $*"; } +pass() { echo " PASS: $*"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $*"; FAIL=$((FAIL + 1)); } + +cleanup() { + docker rm -f "$NODE_A" "$NODE_B" >/dev/null 2>&1 + docker network rm "$NET_A" "$NET_B" >/dev/null 2>&1 + [[ -n "$BUILT_IMAGE" ]] && docker rmi -f "$BUILT_IMAGE" >/dev/null 2>&1 + rm -rf "$WORK" + return 0 +} +trap cleanup EXIT + +# Set IMAGE to FIPS_TEST_IMAGE, or build a minimal one from target/release. +resolve_image() { + if [[ -n "${FIPS_TEST_IMAGE:-}" ]]; then + IMAGE="$FIPS_TEST_IMAGE" + log "Using FIPS_TEST_IMAGE=$IMAGE" + return 0 + fi + local bin="$REPO_ROOT/target/release" + if [[ ! -x "$bin/fips" || ! -x "$bin/fipsctl" ]]; then + echo "No FIPS_TEST_IMAGE and no release fips/fipsctl; build them: cargo build --release" >&2 + return 1 + fi + # A stale daemon would give a verdict about code that is not the tree's. + local newer + newer="$(find "$REPO_ROOT/src" "$REPO_ROOT/Cargo.toml" -newer "$bin/fips" -print -quit 2>/dev/null)" + if [[ -n "$newer" ]]; then + echo "target/release/fips is older than $newer; rebuild: cargo build --release" >&2 + return 1 + fi + BUILT_IMAGE="fips-mdns-test:$$" + log "Building $BUILT_IMAGE from target/release" + local context="$WORK/context" + mkdir -p "$context" + cp "$bin/fips" "$bin/fipsctl" "$context/" + cat > "$context/Dockerfile" <<'DOCKERFILE' +FROM debian:trixie-slim +# libdbus-1-3 and libsystemd0 are the daemon's dynamic dependencies. +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates libdbus-1-3 libsystemd0 && \ + rm -rf /var/lib/apt/lists/* +COPY fips fipsctl /usr/local/bin/ +RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl && mkdir -p /run/fips +DOCKERFILE + docker build -t "$BUILT_IMAGE" --label "$LABEL" "$context" --quiet >/dev/null || return 1 + IMAGE="$BUILT_IMAGE" + return 0 +} + +# write_config : LAN rendezvous on, Nostr off, no peers. +write_config() { + cat > "$1" < +start_node() { + # fips::mdns at debug so a failed run shows what the browser saw. + docker create --name "$1" --hostname "$1" --label "$LABEL" --network "$2" \ + -e RUST_LOG=info,fips::mdns=debug \ + --entrypoint /usr/local/bin/fips "$IMAGE" --config /fips-mdns.yaml >/dev/null \ + && docker cp "$3" "$1:/fips-mdns.yaml" >/dev/null \ + && docker start "$1" >/dev/null +} + +# wait_for_log : 0 once the line appears. +wait_for_log() { + local waited=0 + while [[ $waited -lt $3 ]]; do + docker logs "$1" 2>&1 | grep -qF "$2" && return 0 + sleep 1 + waited=$((waited + 1)) + done + return 1 +} + +# peer_addr : the transport address at which the node lists +# as a peer. Empty when it does not list it; status 1 when the node +# could not be asked, so a dead reader is never taken for "not yet". +peer_addr() { + local out + out="$(docker exec "$1" fipsctl show peers 2>/dev/null)" || return 1 + python3 -c ' +import json, sys +peers = json.loads(sys.argv[1])["peers"] +print(next((p.get("transport_addr", "?") for p in peers if p["npub"] == sys.argv[2]), "")) +' "$out" "$2" 2>/dev/null +} + +# container_ip +container_ip() { + docker inspect -f "{{(index .NetworkSettings.Networks \"$2\").IPAddress}}" "$1" 2>/dev/null +} + +# ───────────────────────────────────────────────────────────────────── + +resolve_image || exit 2 + +keys_a="$(python3 "$REPO_ROOT/testing/lib/derive_keys.py" fips-mdns node-a)" +keys_b="$(python3 "$REPO_ROOT/testing/lib/derive_keys.py" fips-mdns node-b)" +nsec_a="$(sed -n 's/^nsec=//p' <<<"$keys_a")"; npub_a="$(sed -n 's/^npub=//p' <<<"$keys_a")" +nsec_b="$(sed -n 's/^nsec=//p' <<<"$keys_b")"; npub_b="$(sed -n 's/^npub=//p' <<<"$keys_b")" +if [[ -z "$nsec_a" || -z "$npub_a" || -z "$nsec_b" || -z "$npub_b" ]]; then + echo "could not derive node keys" >&2 + exit 2 +fi +write_config "$WORK/a.yaml" "$nsec_a" "$SCOPE_A" +write_config "$WORK/b.yaml" "$nsec_b" "$SCOPE_B" + +net_b="$NET_A" +where="one user-defined bridge" +if [[ "$SPLIT_BRIDGES" == "1" ]]; then + net_b="$NET_B" + where="two separate bridges" +fi +log "Two nodes on $where, scopes '$SCOPE_A' and '$SCOPE_B'" +for net in $(printf '%s\n' "$NET_A" "$net_b" | sort -u); do + if ! docker network create --driver bridge --label "$LABEL" "$net" >/dev/null; then + echo "could not create network $net" >&2 + exit 2 + fi +done +if ! start_node "$NODE_A" "$NET_A" "$WORK/a.yaml" || ! start_node "$NODE_B" "$net_b" "$WORK/b.yaml"; then + echo "could not start the two nodes" >&2 + exit 2 +fi + +# Both daemons must be up with LAN discovery running before an absence of +# peers means anything: a node that never started mDNS cannot find a peer, and +# that is a setup failure, not a discovery verdict. +for node in "$NODE_A" "$NODE_B"; do + if ! wait_for_log "$node" "lan: mDNS discovery started" 30; then + echo "$node did not start LAN discovery; last log lines:" >&2 + docker logs "$node" 2>&1 | tail -20 >&2 + exit 2 + fi +done +pass "both daemons running with LAN discovery started" + +ip_a="$(container_ip "$NODE_A" "$NET_A")" +ip_b="$(container_ip "$NODE_B" "$net_b")" +log "Waiting up to ${DISCOVERY_TIMEOUT}s for each to list the other ($NODE_A $ip_a, $NODE_B $ip_b)" +addr_ab=""; addr_ba=""; unreadable=0 +for ((waited = 0; waited < DISCOVERY_TIMEOUT; waited++)); do + addr_ab="$(peer_addr "$NODE_A" "$npub_b")" || { unreadable=$((unreadable + 1)); addr_ab=""; } + addr_ba="$(peer_addr "$NODE_B" "$npub_a")" || { unreadable=$((unreadable + 1)); addr_ba=""; } + [[ -n "$addr_ab" && -n "$addr_ba" ]] && break + sleep 1 +done +[[ "$unreadable" -gt 0 ]] && echo " note: show peers could not be read $unreadable time(s) while waiting" + +for row in "$NODE_A|$addr_ab|$ip_b|B" "$NODE_B|$addr_ba|$ip_a|A"; do + IFS='|' read -r node addr want other <<<"$row" + # transport_addr is host:port; the host must be the other node's address on + # this bridge, which is where its advert came from. + if [[ -z "$addr" ]]; then + fail "$node does not list node $other as a peer after ${DISCOVERY_TIMEOUT}s" + elif [[ "${addr%:*}" == "$want" ]]; then + pass "$node lists node $other as an authenticated peer at $addr" + else + fail "$node lists node $other at $addr, not at its bridge address $want" + fi +done + +if [[ "$FAIL" -ne 0 ]]; then + for node in "$NODE_A" "$NODE_B"; do + echo "--- $node: lan and handshake lines" + docker logs "$node" 2>&1 | grep -iE "lan:|mdns|handshake|peer" | tail -20 + done +fi + +echo "" +echo "=== mdns: $PASS passed, $FAIL failed" +[[ "$FAIL" -eq 0 ]] || exit 1 +exit 0