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