Files
fips/testing/mdns/test.sh
Johnathan Corgan 5119b0f5f3 Add a workspace with an empty no_std fips-core crate
Start the no_std core as a second workspace member. The crate is empty:
it holds the rule that code in it builds for any target with an
allocator (no std, no async runtime, no OS calls, no target cfg), and
the root crate does not depend on it yet, so nothing that builds or
ships changes. The root package stays first in Cargo.toml with its
version line untouched, since the packaging scripts read the release
version from it, and the core is fixed at 0.0.0 with publish = false so
releases keep a single bump site.

default-members names both crates, so the bare cargo clippy and test
commands in local CI and GitHub CI cover the core as well as the root
package.

Add a fence to both runners: clippy and the tests of the core on its
own, so a dependency feature that only the root crate enables cannot
hide one the core needs.

Pin the privileged interface-binding step to the fips package's lib
test binary. With two lib test executables, `tail -1` picked whichever
cargo listed last, and the core's binary passes the ethernet filter
having run nothing. The step now also runs under bash with pipefail and
fails unless at least one test passed.

Widen the comment-reference, portable-atomics and log-string guards to
core/src/, and add core/ to the stale-binary checks of the mdns,
native-api and deb-install harnesses, so code that later moves into the
core stays checked. Copy core/ into the two nostr relay example images,
whose build would otherwise fail to load the workspace manifest, and
give the standalone BLE spike under testing/ble an empty workspace table
of its own, so cargo keeps loading it as its own project rather than
refusing it as an unlisted member of the root workspace.


Add core/README.md for anyone reading or adding code under core/: what the crate is for, the rules its code follows, how to add a dependency without letting workspace feature unification hide a missing feature, how tests work under no_std, a checklist for moving a module in from the root crate, and the checks to run before a change.
2026-10-11 17:08:56 +00:00

256 lines
9.8 KiB
Bash

#!/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=<scope> 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/core" "$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 <file> <nsec> <scope>: LAN rendezvous on, Nostr off, no peers.
write_config() {
cat > "$1" <<YAML
node:
identity:
nsec: "$2"
rendezvous:
nostr:
enabled: false
lan:
enabled: true
scope: "$3"
tun:
enabled: false
dns:
enabled: false
transports:
udp:
bind_addr: "0.0.0.0:$UDP_PORT"
mtu: 1472
YAML
}
# start_node <container> <network> <config file>
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 <container> <fixed string> <timeout>: 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 <container> <npub>: the transport address at which the node lists
# <npub> 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> <network>
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