Files
fips/testing/nat
Johnathan Corgan 2236371537 fix(nostr): stop signing traversal deletion requests with the routing key
After a traversal attempt, both sides published a NIP-09 deletion request
signed by the node's routing key and naming the attempt's offer and answer
wraps. Each request put the node's public identity next to the ids of its
traversal signals on every relay it reached, which the one-time signing keys
on the wraps are meant to avoid.

What the requests deleted differed by side. A relay honouring NIP-59 deletes
a gift wrap when the request is signed by the wrap's p-tagged recipient, so
the initiator's request, sent after a successful punch, did delete the answer
wrap addressed to it; strfry in the NAT lab logged that deletion. The offer
it also named is addressed to the responder, and the responder's requests
named only the answer it had sent to the initiator, so those deleted
nothing.

Drop all three traversal calls (the initiator after a punch, the responder
on refusing an offer, and the responder after its punch attempt), and accept
that an answer wrap now stays on a relay that stores it until its NIP-40
expiration. The wraps are ephemeral kinds, so relays that do not store
ephemeral events never held them. The advertisement retraction keeps its
deletion request, since it names an event the routing key signed itself.
The signal envelope's event id had no other reader and is removed with its
import.

The NAT lab now asserts that its relay holds no kind 5 event after the cone,
symmetric and lan scenarios, scanning the relay's own store and failing if
the scan cannot run or be parsed. Before the fix each scenario failed it:
cone and lan held two requests (the initiator's naming offer and answer, the
responder's naming the answer) and symmetric held the responder's one. After
it, all three held none. The responder's refusal path is not reached by any
lab scenario and is covered by reading only.
2026-09-19 09:11:46 +00:00
..

NAT Lab Harness

Real Docker-based NAT traversal integration tests for the mainline FIPS Nostr/STUN bootstrap path.

This harness spins up:

  • two FIPS nodes
  • a local Nostr relay
  • a local STUN server
  • one or two Linux router containers performing NAT with iptables

For the NAT scenarios, the node LAN interfaces are not attached to Docker bridge networks. The harness creates explicit veth pairs and moves them into the node and router namespaces after docker compose up so every packet must traverse the router namespace.

It covers three scenarios:

  • cone: both peers behind explicit namespace/veth full-cone emulation, UDP traversal succeeds
  • symmetric: both peers behind symmetric-style NAT, UDP traversal fails, TCP fallback succeeds
  • lan: both peers share a LAN subnet, LAN targets are preferred over reflexive addresses

NAT model notes

The harness does not rely on plain Docker MASQUERADE for the cone case.

  • cone
    • uses explicit full-cone emulation in the router namespace
    • outbound UDP is SNATed to the router WAN address while preserving the source port
    • inbound UDP to the router WAN address is DNATed back to the single LAN host regardless of remote source
  • symmetric
    • uses UDP MASQUERADE --random-fully
    • outbound mappings may be port-randomized and are only reopened by matching conntrack state

This distinction matters because plain MASQUERADE is convenient source NAT, but it does not by itself model the "accept from any remote once mapped" behavior expected from a full-cone NAT.

Prerequisites

  • Docker with Compose support
  • locally built fips-test:latest

Build the test image with:

./testing/scripts/build.sh

Run

Run all scenarios:

./testing/nat/scripts/nat-test.sh

Run one scenario:

./testing/nat/scripts/nat-test.sh cone
./testing/nat/scripts/nat-test.sh symmetric
./testing/nat/scripts/nat-test.sh lan

Layout

  • docker-compose.yml
    • relay/STUN/WAN topology plus container definitions
  • node/
    • node bootstrap wrapper that waits for the injected veth interface
  • router/
    • NAT router image and iptables setup
  • stun/
    • minimal STUN binding responder
  • relay/
    • local strfry config. The relay image's strfry build is pinned by digest in docker-compose.yml (STRFRY_IMAGE); bump it there deliberately.
  • scripts/generate-configs.sh
    • derives ephemeral identities and writes per-scenario FIPS configs
  • scripts/setup-topology.sh
    • injects and configures the NAT LAN veth pairs in the container namespaces
  • scripts/nat-test.sh
    • boots the lab, waits for convergence, and asserts the resulting path
  • scripts/nostr-relay-test.sh
    • exercises the Nostr overlay advert publish/consume round-trip, including rejection of a malformed advert event
  • scripts/stun-faults-test.sh
    • cycles the daemon through STUN drop, delay and outage faults and asserts graceful behavior at each step

Assertions

  • cone

    • both nodes connect
    • connected transport is UDP
    • active link remote addresses are on the WAN NAT subnet
  • symmetric

    • NAT bootstrap does not establish a UDP link
    • fallback converges
    • connected transport is TCP via router-published WAN addresses
  • lan

    • both nodes connect
    • connected transport is UDP
    • active link remote addresses stay on the shared LAN subnet