Add nostr-publish-consume integration suite

Cover the previously untested overlay advert publish/relay/consume
round-trip. The bilateral publish/subscribe path was a v0.3.0 release
gap: malformed adverts could panic consumers, broken signatures could
go undetected, and reverse-direction subscription was unverified.

Adds testing/nat/scripts/nostr-relay-test.sh (290 lines):

Phase 1+2 (combined): wait_for_peers on both nodes; pass on
bidirectional advert publish/subscribe round-trip + dial completed;
ping6 both directions confirms TUN-level reachability.

Phase 3 (malformed advert resilience): stdlib-only Python WebSocket
client publishes a syntactically valid Schnorr-signed Kind-37195
event whose `content` is gibberish (cannot deserialize as
OverlayAdvert). The relay enforces BIP-340 signature validity, so the
event reaches the consumers (rather than being dropped at the relay)
— a trivially-junk content payload is the right adversarial input.
Required ~80 lines of stdlib-only secp256k1 + BIP-340 in the script
(no new container deps). Asserts pidof fips on both nodes after the
publish, scans logs for panic markers, re-pings to prove the existing
peer link survives.

testing/nat/docker-compose.yml: new profile nostr-publish-consume
with two daemon services (nostr-pub-a 172.31.10.20, nostr-pub-b
172.31.10.21) on shared-lan, reusing the existing strfry relay
(172.31.10.30:7777) and STUN service (172.31.10.40:3478).

testing/nat/scripts/generate-configs.sh: 2-line allowlist update so
the new scenario flows through the existing config generator (rather
than forking a parallel one). Generated node-{a,b}.yaml + npubs.env
smoke-tested cleanly.

testing/ci-local.sh: NOSTR_RELAY_SUITES=(nostr-publish-consume)
array, run_nostr_publish_consume runner, dispatch in run_integration
and run_suite. Mirrors existing run_nat shape.

.github/workflows/ci.yml: one matrix row + 3 steps in the integration
job, gated on matrix.type == 'nostr-publish-consume'. Consumes the
same fips-linux artifact and fips-test:latest image as the existing
NAT suites.

Tor/TCP transport variants kept out of v0.3.0 scope; the structure
leaves room for nostr-publish-consume-tcp/-tor siblings later without
disturbing this baseline.
This commit is contained in:
Johnathan Corgan
2026-05-03 21:06:09 +00:00
parent d822ee8b3c
commit 5611e976ad
5 changed files with 460 additions and 2 deletions
+24
View File
@@ -376,6 +376,13 @@ jobs:
- suite: nat-lan - suite: nat-lan
type: nat type: nat
scenario: lan scenario: lan
# ── Nostr overlay advert publish/consume round-trip ─────────────
# Two FIPS daemons + the existing strfry relay; covers Phase 1
# (A→B publish/consume), Phase 2 (B→A reverse), and Phase 3
# (malformed advert injected to relay; consumers must reject
# without crashing). UDP transport baseline for v0.3.0.
- suite: nostr-publish-consume
type: nostr-publish-consume
# ── Real-deb install across target distros ───────────────────── # ── Real-deb install across target distros ─────────────────────
# Boots a privileged systemd container per distro, runs # Boots a privileged systemd container per distro, runs
# `apt install ./fips_*.deb` with the locally-built package, # `apt install ./fips_*.deb` with the locally-built package,
@@ -617,6 +624,23 @@ jobs:
--profile cone --profile symmetric --profile lan \ --profile cone --profile symmetric --profile lan \
down --volumes --remove-orphans down --volumes --remove-orphans
# ── Nostr overlay advert publish/consume ───────────────────────────
- name: Run Nostr publish/consume test
if: matrix.type == 'nostr-publish-consume'
run: bash testing/nat/scripts/nostr-relay-test.sh
- name: Collect logs on failure (nostr-publish-consume)
if: matrix.type == 'nostr-publish-consume' && failure()
run: |
docker compose -f testing/nat/docker-compose.yml \
--profile nostr-publish-consume logs --no-color | tail -300
- name: Stop containers (nostr-publish-consume)
if: matrix.type == 'nostr-publish-consume' && always()
run: |
docker compose -f testing/nat/docker-compose.yml \
--profile nostr-publish-consume down --volumes --remove-orphans
# ── Outbound LAN gateway integration test ────────────────────────── # ── Outbound LAN gateway integration test ──────────────────────────
- name: Generate configs (gateway) - name: Generate configs (gateway)
if: matrix.type == 'gateway' if: matrix.type == 'gateway'
+25
View File
@@ -18,6 +18,7 @@
# static-mesh, static-chain, rekey, rekey-accept-off, # static-mesh, static-chain, rekey, rekey-accept-off,
# rekey-outbound-only, gateway, # rekey-outbound-only, gateway,
# acl-allowlist, nat-cone, nat-symmetric, nat-lan, # acl-allowlist, nat-cone, nat-symmetric, nat-lan,
# nostr-publish-consume,
# chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh, # chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh,
# chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent, # chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent,
# chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability, # chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability,
@@ -74,6 +75,7 @@ GATEWAY_SUITES=(gateway)
SIDECAR_SUITES=(sidecar) SIDECAR_SUITES=(sidecar)
ACL_SUITES=(acl-allowlist) ACL_SUITES=(acl-allowlist)
NAT_SUITES=(cone symmetric lan) NAT_SUITES=(cone symmetric lan)
NOSTR_RELAY_SUITES=(nostr-publish-consume)
DNS_RESOLVER_SUITES=(dns-resolver) DNS_RESOLVER_SUITES=(dns-resolver)
DEB_INSTALL_SUITES=(deb-install) DEB_INSTALL_SUITES=(deb-install)
TOR_SUITES=(tor-socks5 tor-directory) TOR_SUITES=(tor-socks5 tor-directory)
@@ -114,6 +116,9 @@ list_suites() {
echo " NAT scenarios:" echo " NAT scenarios:"
for s in "${NAT_SUITES[@]}"; do echo " nat-$s"; done for s in "${NAT_SUITES[@]}"; do echo " nat-$s"; done
echo "" echo ""
echo " Nostr publish/consume:"
for s in "${NOSTR_RELAY_SUITES[@]}"; do echo " $s"; done
echo ""
echo " Chaos scenarios:" echo " Chaos scenarios:"
for entry in "${CHAOS_SUITES[@]}"; do for entry in "${CHAOS_SUITES[@]}"; do
read -ra parts <<< "$entry" read -ra parts <<< "$entry"
@@ -446,6 +451,19 @@ run_nat() {
fi fi
} }
# Run the Nostr overlay advert publish/consume integration test.
# Two FIPS daemons + the existing strfry relay; exercises Phase 1
# (A→B publish/consume), Phase 2 (B→A reverse), and Phase 3 (malformed
# advert injected directly to the relay; consumer-liveness assertion).
run_nostr_publish_consume() {
info "[nostr-publish-consume] Running Nostr publish/consume test"
if bash testing/nat/scripts/nostr-relay-test.sh 2>&1; then
record "nostr-publish-consume" 0
else
record "nostr-publish-consume" 1
fi
}
# Run dns-resolver harness (multi-distro + e2e scenarios) # Run dns-resolver harness (multi-distro + e2e scenarios)
run_dns_resolver() { run_dns_resolver() {
info "[dns-resolver] Running multi-distro test (slow — builds per-distro images)" info "[dns-resolver] Running multi-distro test (slow — builds per-distro images)"
@@ -527,6 +545,11 @@ run_integration() {
run_nat "$scenario" run_nat "$scenario"
done done
# Nostr publish/consume (sequential — shares the NAT compose project)
for _suite in "${NOSTR_RELAY_SUITES[@]}"; do
run_nostr_publish_consume
done
# Chaos scenarios (parallel, throttled) # Chaos scenarios (parallel, throttled)
if [[ "$SKIP_CHAOS" != true ]]; then if [[ "$SKIP_CHAOS" != true ]]; then
info "Running ${#CHAOS_SUITES[@]} chaos scenarios (max $PARALLEL_JOBS parallel)" info "Running ${#CHAOS_SUITES[@]} chaos scenarios (max $PARALLEL_JOBS parallel)"
@@ -611,6 +634,8 @@ run_suite() {
run_acl_allowlist ;; run_acl_allowlist ;;
nat-cone|nat-symmetric|nat-lan) nat-cone|nat-symmetric|nat-lan)
run_nat "${suite#nat-}" ;; run_nat "${suite#nat-}" ;;
nostr-publish-consume)
run_nostr_publish_consume ;;
chaos-*) chaos-*)
local chaos_name="${suite#chaos-}" local chaos_name="${suite#chaos-}"
local found=false local found=false
+37
View File
@@ -232,3 +232,40 @@ services:
networks: networks:
shared-lan: shared-lan:
ipv4_address: 172.31.10.11 ipv4_address: 172.31.10.11
# ── Nostr publish/consume profile ──────────────────────────────────────
# Two FIPS daemons + the existing strfry relay, exercising the overlay
# advert publish → relay → consumer round-trip end-to-end. Both nodes
# share the same LAN bridge as the relay (no NAT in the way) so the
# focus of the test is the Nostr discovery layer rather than NAT
# traversal mechanics. Phase 3 (malformed advert) is driven by a
# one-shot publish from the test runner via the relay's WebSocket.
nostr-pub-a:
<<: *fips-common
profiles: ["nostr-publish-consume"]
container_name: fips-nat-nostr-pub-a
hostname: fips-nat-nostr-pub-a
depends_on:
- relay
- stun
volumes:
- ../docker/resolv.conf:/etc/resolv.conf:ro
- ./generated-configs/nostr-publish-consume/node-a.yaml:/etc/fips/fips.yaml:ro
networks:
shared-lan:
ipv4_address: 172.31.10.20
nostr-pub-b:
<<: *fips-common
profiles: ["nostr-publish-consume"]
container_name: fips-nat-nostr-pub-b
hostname: fips-nat-nostr-pub-b
depends_on:
- relay
- stun
volumes:
- ../docker/resolv.conf:/etc/resolv.conf:ro
- ./generated-configs/nostr-publish-consume/node-b.yaml:/etc/fips/fips.yaml:ro
networks:
shared-lan:
ipv4_address: 172.31.10.21
+2 -2
View File
@@ -11,7 +11,7 @@ SCENARIO="${1:?usage: generate-configs.sh <cone|symmetric|lan> [mesh-name]}"
MESH_NAME="${2:-nat-lab-$(date +%s)-$$}" MESH_NAME="${2:-nat-lab-$(date +%s)-$$}"
case "$SCENARIO" in case "$SCENARIO" in
cone|symmetric|lan) ;; cone|symmetric|lan|nostr-publish-consume) ;;
*) *)
echo "Unknown scenario: $SCENARIO" >&2 echo "Unknown scenario: $SCENARIO" >&2
exit 1 exit 1
@@ -30,7 +30,7 @@ npub_b="$(echo "$keys_b" | awk -F= '/^npub=/{print $2}')"
relay_addr="ws://172.31.254.30:7777" relay_addr="ws://172.31.254.30:7777"
stun_addr="stun:172.31.254.40:3478" stun_addr="stun:172.31.254.40:3478"
if [ "$SCENARIO" = "lan" ]; then if [ "$SCENARIO" = "lan" ] || [ "$SCENARIO" = "nostr-publish-consume" ]; then
relay_addr="ws://172.31.10.30:7777" relay_addr="ws://172.31.10.30:7777"
stun_addr="stun:172.31.10.40:3478" stun_addr="stun:172.31.10.40:3478"
fi fi
+372
View File
@@ -0,0 +1,372 @@
#!/bin/bash
#
# Nostr overlay advert publish/consume integration test.
#
# Exercises the round-trip:
# Phase 1: A publishes overlay advert; B subscribes; B observes A's advert;
# B dials A.
# Phase 2: B publishes; A subscribes; reverse direction. (Both directions
# are validated together via the bidirectional `peers` count.)
# Phase 3: A malformed Kind-37195 advert event is published directly to
# the relay; both consumers must reject it (parse error path)
# without crashing — asserted via process liveness.
#
# UDP transport for v0.3.0 baseline. Tor / TCP variants out of scope here.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NAT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ROOT_DIR="$(cd "$NAT_DIR/../.." && pwd)"
BUILD_SCRIPT="$ROOT_DIR/testing/scripts/build.sh"
GENERATE_SCRIPT="$SCRIPT_DIR/generate-configs.sh"
WAIT_LIB="$ROOT_DIR/testing/lib/wait-converge.sh"
PROFILE="nostr-publish-consume"
SCENARIO="$PROFILE"
COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml")
NODE_A="fips-nat-nostr-pub-a"
NODE_B="fips-nat-nostr-pub-b"
RELAY_HOST="172.31.10.30"
RELAY_PORT=7777
RELAY_CONTAINER="fips-nat-relay"
# shellcheck disable=SC1090
source "$WAIT_LIB"
cleanup() {
"${COMPOSE[@]}" --profile "$PROFILE" down -v --remove-orphans \
>/dev/null 2>&1 || true
}
trap 'echo ""; echo "nostr-relay-test interrupted"; cleanup; exit 130' INT TERM
require_docker_daemon() {
if ! docker info >/dev/null 2>&1; then
echo "Docker daemon is not reachable; cannot run nostr-relay-test" >&2
exit 1
fi
}
require_test_image() {
if ! docker image inspect fips-test:latest >/dev/null 2>&1; then
echo "fips-test:latest not found; building test image"
"$BUILD_SCRIPT"
fi
}
dump_diagnostics() {
echo ""
echo "=== nostr publish/consume diagnostics ==="
for c in "$NODE_A" "$NODE_B" "$RELAY_CONTAINER"; do
echo ""
echo "--- $c: logs (last 80) ---"
docker logs "$c" 2>&1 | tail -80 || true
done
for c in "$NODE_A" "$NODE_B"; do
echo ""
echo "--- $c: fipsctl show peers ---"
docker exec "$c" fipsctl show peers 2>&1 || true
echo "--- $c: fipsctl show links ---"
docker exec "$c" fipsctl show links 2>&1 || true
done
}
# Publish a malformed Kind-37195 (overlay-advert) event directly to the
# relay. The event is signed with a fresh ephemeral keypair (so the
# relay accepts it on the wire) but its `content` is gibberish that
# cannot deserialize as OverlayAdvert. Both consumer daemons must log a
# parse error and stay alive.
publish_malformed_advert() {
local relay_host="$1"
local relay_port="$2"
docker exec "$NODE_A" python3 - "$relay_host" "$relay_port" <<'PY'
import base64
import hashlib
import json
import os
import socket
import struct
import sys
import time
# ── Minimal secp256k1 BIP-340 (Schnorr) signer using only stdlib. ──────
# Reference: BIP-340, secp256k1 group order n / curve params.
P = 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFE_FFFFFC2F
N = 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFE_BAAEDCE6_AF48A03B_BFD25E8C_D0364141
G = (
0x79BE667E_F9DCBBAC_55A06295_CE870B07_029BFCDB_2DCE28D9_59F2815B_16F81798,
0x483ADA77_26A3C465_5DA4FBFC_0E1108A8_FD17B448_A6855419_9C47D08F_FB10D4B8,
)
def inv(a, m=P):
return pow(a, -1, m)
def point_add(a, b):
if a is None:
return b
if b is None:
return a
if a[0] == b[0] and (a[1] != b[1] or a[1] == 0):
return None
if a == b:
m = (3 * a[0] * a[0]) * inv(2 * a[1]) % P
else:
m = (b[1] - a[1]) * inv(b[0] - a[0]) % P
x = (m * m - a[0] - b[0]) % P
y = (m * (a[0] - x) - a[1]) % P
return (x, y)
def scalar_mul(k, point=G):
result = None
addend = point
while k:
if k & 1:
result = point_add(result, addend)
addend = point_add(addend, addend)
k >>= 1
return result
def lift_x(x):
if x >= P:
return None
y_sq = (pow(x, 3, P) + 7) % P
y = pow(y_sq, (P + 1) // 4, P)
if pow(y, 2, P) != y_sq:
return None
return (x, y if y % 2 == 0 else P - y)
def tagged_hash(tag, data):
th = hashlib.sha256(tag.encode()).digest()
return hashlib.sha256(th + th + data).digest()
def schnorr_sign(msg32, secret):
d0 = int.from_bytes(secret, "big")
if not (1 <= d0 < N):
raise ValueError("invalid secret key")
P_pub = scalar_mul(d0)
d = d0 if P_pub[1] % 2 == 0 else N - d0
t = (d ^ int.from_bytes(tagged_hash("BIP0340/aux", os.urandom(32)), "big"))
t_bytes = t.to_bytes(32, "big")
rand = tagged_hash(
"BIP0340/nonce",
t_bytes + P_pub[0].to_bytes(32, "big") + msg32,
)
k0 = int.from_bytes(rand, "big") % N
if k0 == 0:
raise ValueError("nonce gen failed")
R = scalar_mul(k0)
k = k0 if R[1] % 2 == 0 else N - k0
e = int.from_bytes(
tagged_hash(
"BIP0340/challenge",
R[0].to_bytes(32, "big") + P_pub[0].to_bytes(32, "big") + msg32,
),
"big",
) % N
s = (k + e * d) % N
return R[0].to_bytes(32, "big") + s.to_bytes(32, "big")
def xonly_pubkey(secret):
d0 = int.from_bytes(secret, "big")
P_pub = scalar_mul(d0)
return P_pub[0].to_bytes(32, "big")
# ── Build the malformed Kind-37195 event ───────────────────────────────
secret = os.urandom(32)
# Ensure 1 <= d < N
while int.from_bytes(secret, "big") == 0 or int.from_bytes(secret, "big") >= N:
secret = os.urandom(32)
pubkey = xonly_pubkey(secret).hex()
created_at = int(time.time())
kind = 37195
tags = [
["d", "fips-overlay-v1"],
["app", "fips.nat.lab.v1"],
]
content = "this-is-not-a-valid-overlay-advert-{garbage}"
# Nostr event id = sha256(json([0, pubkey, created_at, kind, tags, content]))
serialized = json.dumps(
[0, pubkey, created_at, kind, tags, content],
separators=(",", ":"),
ensure_ascii=False,
)
event_id = hashlib.sha256(serialized.encode("utf-8")).digest()
sig = schnorr_sign(event_id, secret).hex()
event = {
"id": event_id.hex(),
"pubkey": pubkey,
"created_at": created_at,
"kind": kind,
"tags": tags,
"content": content,
"sig": sig,
}
msg = json.dumps(["EVENT", event])
print(f"publishing malformed advert id={event['id']} pubkey={pubkey}")
# ── Minimal stdlib WebSocket client (RFC 6455) ────────────────────────
relay_host = sys.argv[1]
relay_port = int(sys.argv[2])
sock = socket.create_connection((relay_host, relay_port), timeout=10)
key_b64 = base64.b64encode(os.urandom(16)).decode()
handshake = (
f"GET / HTTP/1.1\r\n"
f"Host: {relay_host}:{relay_port}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key_b64}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n"
)
sock.sendall(handshake.encode())
resp = b""
sock.settimeout(5)
while b"\r\n\r\n" not in resp:
chunk = sock.recv(4096)
if not chunk:
break
resp += chunk
if b" 101 " not in resp.split(b"\r\n", 1)[0]:
print("websocket handshake failed:", resp[:200], file=sys.stderr)
raise SystemExit(2)
# Build a single masked text frame (FIN=1, opcode=1).
payload = msg.encode("utf-8")
mask = os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
frame = bytearray([0x81]) # FIN + text
plen = len(payload)
if plen < 126:
frame.append(0x80 | plen)
elif plen < 65536:
frame.append(0x80 | 126)
frame += struct.pack("!H", plen)
else:
frame.append(0x80 | 127)
frame += struct.pack("!Q", plen)
frame += mask + masked
sock.sendall(bytes(frame))
# Read the relay's OK/NOTICE response (best-effort).
sock.settimeout(3)
try:
reply = sock.recv(4096)
print("relay reply:", reply[:200])
except socket.timeout:
print("relay reply: <timeout — frame sent but no ack>")
# Polite close (opcode 0x88 = close), then drop.
try:
sock.sendall(bytes([0x88, 0x80]) + os.urandom(4))
except OSError:
pass
sock.close()
print("malformed advert published")
PY
}
assert_process_alive() {
local container="$1"
if ! docker exec "$container" pidof fips >/dev/null 2>&1; then
echo "fips daemon NOT running in $container after malformed advert" >&2
return 1
fi
echo " $container: fips daemon still alive after malformed advert"
}
assert_no_panic() {
local container="$1"
local logs
logs="$(docker logs "$container" 2>&1 || true)"
if grep -Eq "panicked at|RUST_BACKTRACE|fatal runtime error" <<<"$logs"; then
echo "panic detected in $container logs" >&2
return 1
fi
}
run_test() {
echo "=== nostr-relay-test: phase 1 + 2 ==="
cleanup
"$GENERATE_SCRIPT" "$SCENARIO"
"${COMPOSE[@]}" --profile "$PROFILE" up -d --build --force-recreate
# Phase 1 + Phase 2 together: each side publishes its own advert,
# subscribes for the other's, then dials. Bidirectional success
# (peer count == 1 on both nodes) proves both directions of the
# publish/consume round-trip.
echo ""
echo "--- waiting for bidirectional advert observation + dial ---"
if ! wait_for_peers "$NODE_A" 1 60; then
dump_diagnostics
return 1
fi
if ! wait_for_peers "$NODE_B" 1 60; then
dump_diagnostics
return 1
fi
# shellcheck disable=SC1090
source "$NAT_DIR/generated-configs/$SCENARIO/npubs.env"
echo " NPUB_A=$NPUB_A"
echo " NPUB_B=$NPUB_B"
# Sanity: traffic actually flows (TUN-level reachability).
if ! docker exec "$NODE_A" ping6 -c 3 -W 5 "${NPUB_B}.fips" >/dev/null; then
echo "ping6 A->B failed" >&2
dump_diagnostics
return 1
fi
if ! docker exec "$NODE_B" ping6 -c 3 -W 5 "${NPUB_A}.fips" >/dev/null; then
echo "ping6 B->A failed" >&2
dump_diagnostics
return 1
fi
echo ""
echo "=== nostr-relay-test: phase 3 (malformed advert) ==="
publish_malformed_advert "$RELAY_HOST" "$RELAY_PORT"
# Give consumers a moment to ingest and reject.
sleep 5
assert_process_alive "$NODE_A" || { dump_diagnostics; return 1; }
assert_process_alive "$NODE_B" || { dump_diagnostics; return 1; }
assert_no_panic "$NODE_A" || { dump_diagnostics; return 1; }
assert_no_panic "$NODE_B" || { dump_diagnostics; return 1; }
# Existing peer link must still be healthy (consumer didn't tear
# down on a bad advert).
if ! docker exec "$NODE_A" ping6 -c 3 -W 5 "${NPUB_B}.fips" >/dev/null; then
echo "ping6 A->B failed AFTER malformed-advert injection" >&2
dump_diagnostics
return 1
fi
cleanup
echo "nostr-relay-test passed"
}
main() {
require_docker_daemon
require_test_image
run_test
}
main "$@"