Companion to the ethernet `accept_connections: false` rekey-deadlock
fix from earlier this release: the same dual-init failure mode shows
up over UDP when peers register by hostname, and the existing
addr_to_link-only carve-out in `should_admit_msg1` doesn't cover it.
The carve-out's first predicate keys `addr_to_link` by the literal
`TransportAddr` that `initiate_connection` inserted, which is the
hostname-form when a peer config carries a hostname (e.g.,
`core-vm.tail65015.ts.net:2121`). Inbound packets always arrive with
numeric source addrs because `udp_receive_loop` builds the
`TransportAddr` from the `SocketAddr` the kernel reports via
`recvfrom`. `TransportAddr` equality is byte-exact, so the two forms
don't match and the lookup misses. With `udp.accept_connections:
false` (or `udp.outbound_only: true`, which forces it false) the
gate then rejects the rekey msg1 from an established peer. The
dual-init tie-breaker stalls because the loser side never produces
msg2; both sides retry indefinitely and the winner side keeps
logging "Dual rekey initiation: we win, dropping their msg1" at 1Hz.
The earlier ethernet fix didn't generalize to this variant because
ethernet TransportAddrs are always numeric MAC bytes — both the
config-time form and the inbound-arrival form match identically.
Add a second predicate to `should_admit_msg1`: an active peer's
`current_addr()` matching `(transport_id, remote_addr)`.
`current_addr` is updated and refreshed from inbound encrypted-frame
source addrs (`handlers/encrypted.rs`), which are always numeric
`SocketAddr`-form, so this catches the established peer regardless
of how its `addr_to_link` key was originally inserted. The fast
`addr_to_link` check stays first; the iteration over peers is
bounded by peer count and only runs when the first predicate misses.
Regression coverage in this commit:
- Unit test `test_should_admit_msg1_admits_rekey_when_addr_form_differs`
in `src/node/tests/handshake.rs`. Constructs the failing scenario
in-process: `addr_to_link` populated with hostname-form key, peer's
`current_addr` at the resolved numeric form, query with numeric form.
Without the new predicate this fails immediately.
- New integration topology `rekey-outbound-only` plus matching
docker-compose profile. Same 5-node mesh shape as `rekey-accept-off`
but `inject-config` sets `udp.outbound_only: true` on node-b and
rewrites node-b's peer-c address from the numeric docker IP to the
docker hostname (`node-c:2121`), reproducing the production
hostname-vs-numeric mismatch. The test asserts no sustained
"Dual rekey initiation: we win" log lines on any node (>10 = bug)
and the existing rekey health checks catch the connectivity loss
the loop produces.
- `testing/ci-local.sh` and `.github/workflows/ci.yml` extended to
run the new variant in the local sweep and the GitHub CI integration
matrix alongside `rekey` and `rekey-accept-off`.
Verified locally: full `bash testing/ci-local.sh` sweep passes 29/29
suites (23m 12s) with the new variant green; 1084 unit tests pass.
Single combined commit covering five interlocking pieces of test and
CI work that landed during the v0.3.0-prep cycle.
## fips-gateway robustness
- src/bin/fips-gateway.rs DNS upstream probe converted from a 3-second
hard-fail to a bounded retry loop (5 attempts × 1s timeout, 1s sleep
between attempts; ~10s worst case). Covers the cold-boot race where
the daemon's TUN is up but the DNS responder at [::1]:5354 is still
binding. Each failed attempt logs at INFO. In production the binary's
retry is the live recovery mechanism; with retry it recovers silently
instead of relying on Restart=on-failure (~5s blip + spurious ERROR
per cycle).
- packaging/debian/fips-gateway.service `ExecStartPre` now waits up to
30 seconds for the daemon's `fips0` TUN to appear before exec'ing
the gateway binary. Eliminates the cold-boot race where the gateway
exits with `fips0 interface not found` and recovers via
`Restart=on-failure`, producing a 5-second blip and a spurious error
log per restart cycle.
- testing/docker/entrypoint.sh gateway-mode waits up to 30s for the
daemon's DNS responder to bind [::1]:5354 (probes once per second
with `dig @::1 -p 5354 ... test.fips`) before exec'ing fips-gateway.
Belt-and-suspenders with the binary's own retry: in CI we want
deterministic startup ordering. On timeout, fall through so the
binary's probe reports the definitive error.
## Test infrastructure DNS bind migration to ::1
After session 359's daemon DNS-bind default flipped from `127.0.0.1`
to `::1` (the production fix for ISSUE-2026-0002), the static-test
infrastructure was carrying a stale workaround that overrode the
default back to IPv4 loopback. The fips-gateway integration test
exposed the divergence: the gateway probes its DNS upstream at
`[::1]:5354` (production default) while the daemon was binding
`127.0.0.1:5354` from the template override — IPv6-explicit sockets
do not accept v4-mapped traffic, so the upstream probe exhausted
retries and the gateway exited.
- Drop the explicit `bind_addr: "127.0.0.1"` line from every test
config that emits it: testing/static/configs/node.template.yaml,
testing/chaos/configs/node.template.yaml, the sidecar heredoc in
testing/docker/entrypoint.sh, testing/acl-allowlist/generate-configs.sh
(six per-node blocks), testing/nat/scripts/generate-configs.sh, and
the four tor templates under testing/tor/. Daemon picks up its
production `::1` default.
- Flip the dnsmasq forwarder for `.fips` in testing/docker/Dockerfile
from `127.0.0.1#5354` to `::1#5354` so dnsmasq on the shared test
image continues to reach the daemon. Template and Dockerfile must
move together since most static suites resolve `<npub>.fips` via
the test-image dnsmasq.
## rekey-accept-off integration variant + UDP unit test
- New `rekey-accept-off` topology and docker-compose profile under
testing/static/. 2-node variant where node-b runs with
`udp.accept_connections: false`. Pins the regression class that
ISSUE-2026-0004 fixed (cross-connection winner's rekey msg1 was
being filtered by the accept_connections gate, breaking rekey).
- testing/static/scripts/rekey-test.sh accepts REKEY_TOPOLOGY and
REKEY_ACCEPT_OFF_NODES env vars; its inject-config subcommand
applies the per-node `udp.accept_connections: false` edit, and
the test asserts no sustained "Dual rekey initiation" log lines.
- New UDP variant of `should_admit_msg1` admit-rekey unit test in
src/node/tests/handshake.rs.
## ci-local.sh full integration coverage
- New runner functions and dispatcher entries for `acl-allowlist`,
`nat-cone` / `nat-symmetric` / `nat-lan`, `rekey-accept-off`,
`dns-resolver`, `deb-install`. Each integrates with the existing
summary tracking via `record`.
- New `--with-tor` flag (off by default) gates `tor-socks5-outbound`
and `tor-directory-mode` runners. Tor stays opt-in because both
harnesses depend on the live Tor network and would introduce a
flake source unrelated to the FIPS code.
- New suite arrays (`ACL_SUITES`, `NAT_SUITES`, `DNS_RESOLVER_SUITES`,
`DEB_INSTALL_SUITES`, `TOR_SUITES`) drive both the default sweep
and `--list` output.
- `run_suite` extended to accept the new suite names for `--only`
invocations.
## GitHub CI matrix expansions
- `gateway` matrix entry runs testing/static/scripts/gateway-test.sh
against the existing docker-compose `gateway` profile.
- `rekey-accept-off` matrix entry exercises the new topology with
REKEY_ACCEPT_OFF_NODES=b.
- `deb-install` matrix (debian12 + ubuntu24 + ubuntu26) runs
testing/deb-install/test.sh with privileged systemd containers.
~5-7 min cold cache, ~2 min warm per distro. Self-contained: builds
its own .deb in a Debian 12 cargo-deb builder image; does not
depend on the build job's pre-built artifact.
- `dns-resolver` matrix entry runs the full 13-scenario harness
(per-distro systemd resolver-backend tests + real-fips end-to-end
scenarios) in a single job. Pins the production DNS bind path that
ISSUE-2026-0002 lived in. ~7-12 min warm, ~12-15 min cold.
Verified locally: full `bash testing/ci-local.sh` sweep passes,
including 5/5 deb-install distros and all 13 dns-resolver scenarios.
Tor-inclusive sweep (`--with-tor`) verified in a follow-up run.
- testing/static/scripts/rekey-test.sh: bump REKEY_SETTLE from 5s to
12s so post-rekey ping_all samples are taken after the 10s old-
session drain window has closed (DRAIN_WINDOW_SECS in
handlers/rekey.rs). The 5s sample window straddled the drain,
occasionally catching old-session decrypts mid-cutover and producing
spurious ping failures.
- testing/lib/log_analysis.py: discovery-succeeded match string is
"proof verified, route cached" (the wording in handlers/discovery.rs);
the regex looked for the older "caching route" wording, so the chaos
analysis summary always reported `Succeeded: 0` regardless of how
many successes the raw node logs actually showed.
- testing/scripts/build.sh: drop the second cargo invocation that
passed `--features gateway --bin fips-gateway`. PR #79 removed the
`gateway` cargo feature in favor of platform cfg gates, so this
invocation now fails with `the package 'fips' does not contain this
feature: gateway`. The first cargo build already produces
fips-gateway as a workspace binary on every supported target.
Phase 1 pre-rekey baseline was failing intermittently on CI runners
because 5s wasn't enough for multi-hop discovery (B→D requires
B→C→D). The mesh always converged by Phase 3, confirming this was
purely a timing issue.
The FSP XK rekey handshake has a race condition where the initiator
can cut over (K-bit flip) and send data encrypted with the new session
before msg3 reaches the responder. The responder has no pending session
yet, so K-bit detection fails and packets are dropped.
Defer FSP initiator cutover by 2 seconds after handshake completion
(FSP_CUTOVER_DELAY_MS) to give msg3 time to traverse the mesh. FMP
(IK, 2 messages) is unaffected since the responder completes during
msg1 processing.
Also fix MMP metric corruption after rekey cutover: the new session
starts with counter 0 but MMP state carries highest_counter and
GapTracker.expected_next from the old session, producing false
reorder counts, jitter spikes, and invalid OWD trends. Add
reset_for_rekey() methods that clear counter-dependent state while
preserving RTT estimates.
Additional fixes:
- Remove stale peers_by_index entry on abandon_rekey error path
- Replace redundant peers_by_index inserts with debug assertions
verifying the pre-registration invariant
- Tighten rekey integration test to zero tolerance (was 4 failures)
Phase 3 (post-first-rekey connectivity) can see brief disruptions while
links are mid-cutover. Add a 5s settle time after the rekey wait and
allow up to 4 transient pair failures in Phase 3. Phase 5 remains
strict, ensuring full recovery after the second rekey cycle.
Implement periodic full rekey at both protocol layers using fresh DH
key exchanges. Uses the existing K-bit flag (FLAG_KEY_EPOCH /
FSP_FLAG_K) to coordinate cutover between peers.
FMP layer (IK pattern):
- ActivePeer gains rekey state: pending/previous sessions, K-bit epoch
tracking, drain window, dampening timer
- Handshake state stored on ActivePeer with msg1 sent on existing link
- Encrypted frame handler detects K-bit flips, promotes pending
sessions, falls back to previous session during drain
- Handshake handlers distinguish rekey from new connections using
addr_to_link lookup with identity-based fallback
- Free all session indices (current, rekey, pending, previous) on
peer removal
FSP layer (XK pattern):
- SessionEntry gains parallel rekey fields with XK-specific state
for the 3-message handshake
- Route availability check before FSP rekey initiation
- Encrypted session handler adds K-bit flip detection and dual-session
decrypt fallback
- SessionSetup/Ack/Msg3 handlers extended for rekey paths
Defense-in-depth:
- Consecutive decryption failure detector (threshold=20) triggers
forced peer removal instead of waiting for link-dead timeout
- Identity-based rekey detection as fallback when addr_to_link
doesn't match (e.g., TCP ephemeral ports)
Configuration: RekeyConfig with enabled flag, after_secs (default 120),
and after_messages (default 65536) thresholds.
Logging: info for successful K-bit cutover completions, warn for
failures, debug for intermediate handshake steps, trace for routine
operations (resends, drain cleanup).
Rekey lifecycle:
1. Timer/counter fires -> initiator starts new handshake
2. Old session continues handling traffic during handshake
3. Handshake completes -> initiator cuts over, flips K-bit
4. Responder sees flipped K-bit -> promotes new session
5. Both keep old session for 10s drain window
6. After drain, old session discarded
Integration test: Docker-based multi-phase test exercising both FMP
and FSP rekey with aggressive timers (35s). Verifies connectivity
across all 20 directed pairs survives two consecutive rekey cycles.
Includes rekey topology, docker-compose profile, and CI matrix entry.
Increase ping test convergence wait from 3s to 5s for CI reliability.