mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 08:37:02 +00:00
cc7f967128236fcdb954e0fd8b67b7b2fc153c57
236
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
de78c94d58 |
node: register decrypt worker on cross-connection-won promotion
The cross-connection-won branch of `promote_connection` builds a fresh ActivePeer with a new Noise session and our_index, inserts it into peers, and registers identity, but did not hand the new session to the decrypt shard worker pool. The normal-promotion tail in the same function does make that call. A session established via the cross-connection race path therefore missed the worker fast-path for its lifetime, falling back to inline decryption on the rx loop. Correctness was unaffected, but the throughput/latency benefit of the worker pool was lost for peerspromoted through that path. Mirror the normal-promotion tail and call `register_decrypt_worker_session` after the fresh ActivePeer is inserted into `self.peers` in the `this_wins` arm. |
||
|
|
050483f3bf |
forwarding: log no-route SessionDatagram drops at debug level
Logs source, destination, and payload size at the existing no-route drop site so investigations can attribute transit drops without enabling trace-level instrumentation. Diagnostic-only; no behavior change on the success path. |
||
|
|
3fc0178192 | Merge maint into master (FSP rekey overlapping-epoch, drain-erase fix) | ||
|
|
6e5cb8965f |
Make FSP session rekey hitless under packet loss and reordering
An FSP session rekey could leave the two endpoints holding different key sets for a brief window: if a handshake message was lost in transit, one side rotated to the new keys while the other did not. Traffic sealed in one key epoch then reached a peer still on the other epoch and failed to decrypt, producing bursts of AEAD decryption failures and dropped connectivity until a later rekey cycle reconverged the pair. Choreographing the cutover order cannot close this window: any fixed ordering still leaves a skew that packet reordering widens. Make rekey correctness independent of cutover timing by overlapping the key epochs on the receive path. During a rekey transition the receiver trial-decrypts each frame against every live session it holds: current, the not-yet-promoted pending session, and the draining previous session. The K-bit becomes a hint that orders the trial-decrypt cascade rather than a hard gate, and a frame that authenticates against the pending session is itself the cutover signal. No rotation ordering and no packet reordering can then cause a decryption failure. The pre-rekey Noise session is held in the `previous` slot until the peer has demonstrably moved off it. Its drain deadline is anchored on the most recent frame the peer authenticated against that slot, refreshed each time the trial-decrypt cascade lands there, rather than on a fixed wall-clock timer started unilaterally at the local cutover. A peer that never received the new keys keeps authenticating against `previous` and the slot stays live; without this, a fixed timer would erase the only key set that could decrypt the peer's frames, producing a permanent silent decrypt failure on a live data path. A peer that never catches up is handled by the existing FSP session liveness path rather than by silent decrypt failure. The lost-handshake liveness gap is closed separately by retransmitting the third rekey handshake message until the peer is confirmed on the new keys, with a bounded retry budget after which the rekey cycle is cleanly abandoned and retried on the next timer. Adds unit tests covering the trial-decrypt cascade (epoch selection, promotion on pending decrypt, reordered old-epoch stragglers after cutover, per-slot replay-window integrity), the msg3 retransmission lifecycle, and the peer-progress-aware drain retirement. |
||
|
|
0a5c367edc |
data-plane perf overhaul: off-task encrypt + decrypt, GSO, connected UDP
Moves both AEAD layers (ChaCha20-Poly1305, one round per layer per packet) plus the sendmsg syscall off the rx_loop task onto a per-shard worker pool, adds per-peer connect(2)-ed UDP with SO_REUSEPORT, and uses Linux UDP GSO (sendmsg+UDP_SEGMENT — kernel splits one super-skb into N on-the-wire datagrams in a single TX-stack walk) when packets in a batch are uniform-size. Same kernel primitive WireGuard's in-kernel module and BoringTun use to hit 2.5–3.2 Gbps single-stream. Single TCP stream on a 5-node docker-bridge mesh, 5 x 15 s x P=1: A→D: 1379 → 2708 Mbps (1.96x, RTT +0.12 ms) A→E: 1394 → 2663 Mbps (1.91x, RTT +0.11 ms) E→A: 1406 → 2624 Mbps (1.87x, RTT +0.19 ms) Static-peer pairs only — every CoV under 3%, 0 outliers, 0% ICMP loss. The ~+100 µs RTT is the worker queue handoff cost; AEAD + sendmmsg now run on a separate core in exchange. What lands: - src/node/encrypt_worker.rs: std::thread + crossbeam_channel workers; hash-by-destination dispatch pins a TCP flow to one worker so wire ordering is preserved; per-worker sendmmsg(2) batching up to 32; Linux uses sendmsg(2)+UDP_SEGMENT when packets in a group are uniform-size. - src/node/decrypt_worker.rs: receive-side mirror. Each shard owns its session's recv cipher + replay window in a thread-local HashMap (no shared RwLock/Mutex). Sessions are handed off at promote_connection and re-registered on K-bit flip / rekey cutover. - src/node/handlers/session.rs try_send_session_data_pipelined: FSP+FMP both seal in-place in the worker on one wire-buffer alloc; no intermediate inner_plaintext / fsp_payload Vecs. - src/transport/udp/connected_peer.rs + peer_drain.rs: per-peer connect(2)-ed UDP socket with SO_REUSEPORT (set on the listen socket too — without that, EADDRINUSE on activation and every packet falls back to the wildcard path); the worker sends with msg_name=NULL and the kernel uses its cached 5-tuple. Tick- driven activation in handlers/connected_udp.rs, idempotent. - src/transport/udp/mod.rs: mem::replace the recvmmsg backing buffer instead of buf.to_vec() per packet — single pointer swap, no MTU-sized memcpy. - src/protocol/link.rs SessionDatagramRef: zero-copy borrowed view used by handle_session_datagram for the bulk local-delivery path; handle_session_payload takes the borrowed payload directly (no payload[35..].to_vec()). - src/transport/mod.rs TransportAddr::from_socket_addr: collapses the two-alloc from_string(addr.to_string()) pattern to one. - src/node/handlers/rx_loop.rs: decrypt-fallback drain promoted ahead of packet_rx in the select! (TCP ACK starvation fix); interleaved fallback drain every 32 packets inside the rx burst loop. - noise::Session: send_cipher_clone / recv_cipher_clone / recv_replay_snapshot_owned / take_send_counter / accept_replay so off-task workers can hold a cloned cipher + reserved counter while the dispatcher keeps replay/counter sequencing serial. CipherState::cipher_clone returns a refcount-bumped LessSafeKey. AsyncUdpSocket: AsRawFd so workers issue raw sendmmsg / sendmsg without going through the tokio reactor. - Worker pool sizing: both default to num_cpus, overridable via FIPS_ENCRYPT_WORKERS=N / FIPS_DECRYPT_WORKERS=N. Per-peer connected UDP can be disabled via FIPS_CONNECTED_UDP=0. - src/perf_profile.rs: optional per-stage timing reporter under FIPS_PERF=1 (or FIPS_PIPELINE_TRACE=1). Off by default; zero overhead when disabled. - All cfg(unix)-gated. Windows continues on the existing tokio- based send/recv. Decrypt worker session lifecycle: - Node::unregister_decrypt_worker_session mirrors the existing register helper. Wired at the two natural sites that already iterate peers_by_index: the rekey drain-completion block in handlers/rekey.rs (drops the worker entry for the old our_index once the drain window has expired and the cache_key is unreachable to any in-flight OLD-K packet), and remove_active_peer in handlers/dispatch.rs (drops the worker entry for each of the four index slots: current, rekey, pending, previous). Only our_index is normally registered; unregister_session is fire- and-forget for missing entries, so calling unconditionally on all four slots is correct and bounds the cleanup without per- slot accounting. Without these callers the per-worker sessions HashMap and the Node's decrypt_registered_sessions set would grow monotonically per rekey on long-lived peers. Testing: - testing/static/scripts/bench-multirun.sh: multi-run iperf3 + ping bench. N reruns (default 5), median / min / max / CoV % / per-run outlier flag, avg ping RTT, ICMP loss %, TCP retransmit total. Plain client→dest labels + topology header. Pre-bench peer-convergence check (FIPS_BENCH_CONVERGE_SECS, default 15); per-path route verification via stats.bytes_sent deltas — fails fast if traffic exits via a non-static-peer link. - testing/static/docker-compose.yml: passes FIPS_ENCRYPT_WORKERS / FIPS_DECRYPT_WORKERS / FIPS_PERF through to containers for A/B benchmarking without rebuilds. - testing/static/scripts/iperf-test.sh: same plain client→dest labels + topology header (was multihop/direct/N hop, which conflated topology distance with on-wire path). - .config/nextest.toml: synthetic UDP node tests serialized through a max-threads=1 test group. Localhost handshakes drop on shared CI runners under parallel load; one-at-a-time keeps assertions reliable. - src/node/tests/spanning_tree.rs: repair_missing_edge_handshakes — retries up to 5 times for synthetic edges whose msg1 was dropped, with a drain after each edge retry instead of after each attempt's full burst. - src/node/decrypt_worker.rs::tests: two unit tests asserting WorkerMsg::UnregisterSession removes the worker-thread session HashMap entry (handle_msg_unregister_session_removes_entry) and is a no-op for never-seen cache_keys (handle_msg_unregister_session_idempotent_on_unknown_key), which is the safety invariant the unconditional unregister calls at the four index slots in remove_active_peer rely on. - src/node/encrypt_worker.rs::unix_tests pipelined_send_wire_layout_roundtrips_canonical_decoders: mirrors the encoder geometry of try_send_session_data_pipelined (no coords, the common established-session path), runs the worker's real seal + send via flush_direct_batch_sync, and decodes the resulting wire packet using only canonical receive-side decoders (EncryptedHeader::parse, SessionDatagramRef::decode, FSP header parse, noise::open). Any divergence between the hand-rolled encoder offsets (fsp_aad_offset, fsp_plaintext_offset) and the decoders fails at one of the parse / open / decode steps before the inner-plaintext assertion fires. Complements the existing fsp_preseal_runs_before_outer_fmp_seal test which covers the seal-ordering invariant with synthetic headers but does not exercise the wire-layout invariant. CHANGELOG.md [Unreleased] # Changed entry added describing the worker-pool threading model, hash-by-destination dispatch, sendmmsg/UDP_GSO, per-peer connected UDP, the operator-facing env vars, and the bench numbers above. Cherry-picks from mmalmi/master (paths translated from crates/fips-core/src/ to src/): 9b7c723, 0deb5cb, 13f7339, e036c0e, 3740a68, 3792f83, 8510193, 4910b07, e53f545, e4e2896, 5fe4af5, 1d01ada, 8c37008, e12469e, 6eb2860. Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com> |
||
|
|
6e7e44c8ff | Merge maint into master (advert filter, PR-REVIEW) | ||
|
|
d418106034 | nostr: filter unroutable direct advert endpoints | ||
|
|
c0ccedb491 | nostr: start discovery without blocking node startup | ||
|
|
6bd40640bf |
chore: quiet platform-specific warnings
The non-Linux test build was emitting warnings from code that is intentionally platform-specific: the nftables firewall parser is Linux-only, the utun address-family helper is only used in macOS TUN paths, and one macOS Ethernet test module trips a clippy layout lint. These warnings made focused test runs noisy and encouraged bundling unrelated warning fixes into behavioral PRs. - Gate the firewall parser dead-code allowance to non-Linux targets, where the parser is compiled but not used. - Mark the macOS utun helper and long TUN reader entry point with narrow allowances. - Rewrite the small MAC-copy loop to satisfy clippy and mark the macOS Ethernet test module layout explicitly. No runtime behavior change. |
||
|
|
f51dde647f | Merge maint into master (coord cache surgical invalidation) | ||
|
|
49bd210480 |
cache: scope coord cache invalidation to entries actually affected by topology change
Replaces the unconditional `CoordCache::clear()` calls at parent-switch, become-root, and loop-detection sites with two targeted invalidation methods scoped to what actually makes an entry stale: - `invalidate_via_node(node_addr)`: drop entries whose cached destination ancestry contains `node_addr`. Used at parent-position- change sites — our prefix changed, so destinations downstream of us have stale-prefix coords. - `invalidate_other_roots(current_root)`: drop entries rooted under a different root than the current one. Used at root-change sites. Under the previous global flush, parent switches blanked the cache across the board, leaving `find_next_hop` returning `None` for every non-direct-peer destination until the cache passively re-warmed via incoming TreeAnnounces / SessionSetup. Surgical invalidation preserves entries that remain correct after the topology change. The cached coord describes a destination's tree position; that position only goes stale relative to our own routing decisions when our own prefix changes (entries we are downstream of) or the root changes (entries in a different tree). Peer removal does not invalidate cached coords: `Node::find_next_hop` recomputes the next-hop decision on every call against the current peer set, bloom filters, and tree state, and Discovery already triggers on `no route to destination` errors when a destination becomes unroutable through us. The peer-removal site retains the original "no cache invalidation" behavior. Each method returns the count of entries removed for observability. Unit tests cover each method against the cases enumerated in the acceptance criterion. |
||
|
|
b1af151aef |
rx: avoid copies in receive hot paths
- Borrowed SessionDatagramRef decoder is used in the forwarding
handler so local delivery and coordinate-cache warming no longer
allocate or copy the session payload. The owned SessionDatagram is
materialized only when re-encoding for the next hop.
- Owned SessionDatagram::decode is reimplemented as Ref::decode +
into_owned, so the two decoders cannot drift.
- recvmmsg / recvmsg_x (Linux + macOS) receive loop moves each filled
slot buffer into ReceivedPacket via mem::replace instead of cloning
it; a fresh empty buffer is installed for the next syscall.
- TransportAddr is formatted directly from the SocketAddr without
going through an intermediate String.
Focused decode bench: ref 1.6 ns/op vs owned 34.7 ns/op (21.4x).
End-to-end iperf is neutral as expected for a ~30 ns saving per
packet.
Unit tests added:
- test_session_datagram_ref_decode_borrows_payload (verifies the
payload slice pointer equals the input slice's offset 35, a real
zero-copy invariant guard against accidental future to_vec)
- bench_session_datagram_decode_owned_vs_ref (ignored, run with
--ignored --nocapture)
- test_transport_addr_from_socket_addr
|
||
|
|
59225ccfe1 |
udp: batch macOS receive with recvmsg_x
The Linux recv path drains up to 32 datagrams per kernel wakeup via recvmmsg(2), amortising the per-syscall + per-task-wakeup cost across the burst. macOS still fell through to single-packet recv_from, so the same overhead capped inbound rate on Apple builds. Add an equivalent batch path for Darwin using recvmsg_x(2). It is a xnu-private syscall (not in the public SDK) but is the canonical amortisation primitive on macOS — same shape used by quinn-udp for the same reason. ABI is the public msghdr layout plus a trailing msg_datalen (per-datagram bytes-received output), declared via `unsafe extern "C"` against a local repr(C) `msghdr_x`. Same `(count, kernel_drops)` contract as the Linux `recv_batch`. macOS has no SO_RXQ_OVFL equivalent, so `kernel_drops` is always 0 — the 1Hz `sample_transport_congestion()` detector simply sees no kernel drop signal on Apple hosts (it already tolerates that, since the field has been 0 there pre-batching too). cmsg buffer is intentionally null: we never consume ancillary data on this path, and quinn-udp documents that `recvmsg_x` does not overwrite `msg_controllen` on macOS 10.15+ (zeroed init is the only safe state). The udp_receive_loop dispatch widens from cfg(linux) to cfg(any(linux, macos)); the per-packet recv_from path is now used only on the remaining unix targets (BSDs etc.) and Windows. Add test_burst_recv_batch exercising 10 in-flight datagrams to verify per-datagram boundaries and arrival order across the batch. Add an ignored bench_udp_recv_amortization measuring recv-side syscall amortization across 1/2/4/8 sender threads on dedicated blocking std threads (kernel rx queue stays saturated regardless of tokio scheduling). Sample numbers on aarch64-apple-darwin (100B payloads, 3s windows): senders=1: recv_from 398k pps recv_batch 432k pps 1.09x senders=2: recv_from 353k pps recv_batch 608k pps 1.72x senders=4: recv_from 322k pps recv_batch 503k pps 1.56x senders=8: recv_from 353k pps recv_batch 515k pps 1.46x Gate the Linux-only IpAddr import in control::listening behind a cfg(target_os = "linux") so the macOS test build is warning-clean now that test code paths there compile. |
||
|
|
87d1af0269 |
nostr: ignore stale traversal for active peers
Skip BootstrapEvent::Established and BootstrapEvent::Failed dispatch in poll_nostr_discovery for peers that are already connected or actively handshaking. Without these guards, stale traversal events arriving after a peer connected through a different path would either attempt to adopt a redundant socket against the live connection (Established) or poison the per-peer failure-state cooldown and trigger redundant retraversal via schedule_retry / try_peer_addresses (Failed). The four guard sites use a new is_connecting_to_peer helper extracted from the existing closure inside initiate_peer_connection; the helper checks for an in-flight outbound handshake state. adopt_established_traversal gains a defense-in-depth check returning PeerAlreadyExists when called against an already-promoted peer, so the invariant holds if a future caller bypasses the outer dispatch guard. Side benefit: narrows a cooldown-poisoning vector previously available to an attacker injecting stale failure events for an active peer. Test coverage for the new behavior: - test_try_peer_addresses_skips_connected_peer - test_try_peer_addresses_skips_connecting_peer - test_nostr_traversal_failure_skips_connected_peer (Failed-arm event injection) - test_nostr_traversal_established_skips_connected_peer (Established-arm event injection, mirror of the Failed test) - test_adopted_traversal_skips_already_connected_peer (adopt_established_traversal defense-in-depth) CHANGELOG entry under [Unreleased] / Fixed. Closes #87 |
||
|
|
4f3d2f8471 |
rekey: apply symmetric jitter to desynchronize dual-initiation
Add a per-session signed jitter offset (uniform [-15, +15] seconds) to the rekey timer triggers in check_rekey (FMP) and check_session_rekey (FSP). The configured `node.rekey.after_secs` becomes the nominal interval rather than a floor; mean is preserved. Desynchronizes both endpoints in symmetric-start meshes so the dual-initiation race stops occurring rather than being resolved after the fact by the smaller-NodeAddr tie-breaker. Per-session storage means each rekey cutover reconstructs the session and redraws the jitter naturally — successive cycles get independent offsets, preventing drift back into sync. |
||
|
|
733ee512d3 |
chore: replace real IPs in docs and configs with placeholders
Operator-facing IPs in user-visible configs/docs (examples, tutorials, packaging, sidecar templates) are now the resolvable hostnames of the public test fleet (test-us01.fips.network, etc.) so they keep working without baking specific addresses into examples. Doc-comment and test fixtures in src/config/transport.rs use RFC 5737 TEST-NET-2 (198.51.100.1) so they cannot accidentally point at a real host. Also resyncs the openwrt-ipk fips.yaml with the common reference (merge from master) and applies the same DNS-name swap there. |
||
|
|
5cda4a9a55 |
noise: switch ChaCha20-Poly1305 backend to ring (BoringSSL asm)
The chacha20 crate (RustCrypto) ships SSE2 + soft backends only — on
aarch64 (Apple Silicon, ARM Linux servers, Docker on M-series Macs) it
falls through to a portable software impl at ~600–800 MB/s/core. ring
0.17 wraps BoringSSL's hand-tuned ChaCha20-Poly1305, which dispatches
to NEON on aarch64 and AVX2/AVX-512 on x86_64 — typically 3-5 GB/s/core
on the same hardware.
Same wire format. ChaCha20-Poly1305 is byte-deterministic for a given
(key, nonce, plaintext, aad), so any correct AEAD implementation
produces identical ciphertext. The full noise test suite covers this
implicitly: IK and XK roundtrip handshakes, replay window correctness,
multi-message nonce sequencing, and 100-message stress all pass at
1129/1129 (the lib's full `cargo test` count) — these only succeed if
ring's output matches what the receiver's existing replay-window
decrypt path expects.
Implementation notes:
* `LessSafeKey` (and `UnboundKey`) deliberately do not implement
Clone for safety. `CipherState`'s manual Clone impl rebuilds it
from the retained 32-byte key — cheap for ChaCha20-Poly1305 since
construction is essentially a key copy + a constant-time check.
* The keyed AEAD is now cached in `CipherState.cipher` instead of
being re-derived per packet. This was already a perf win for the
chacha20poly1305 backend (`new_from_slice` per packet was hot in
profiles); for ring it's a bigger win because `LessSafeKey`
construction also derives the Poly1305 key.
* Public `Vec<u8>`-returning API preserved. New module-private
`seal`/`open` helpers wrap ring's `seal_in_place_append_tag` /
`open_in_place` so the per-packet allocation pattern is local to
one place.
* `EndToEndState::Established` triggers `clippy::large_enum_variant`
after the swap (`NoiseSession` grew from ~600 to ~1.5 KB because
ring precomputes the Poly1305 key state at construction). That
precomputation is the win — boxing the variant would re-add an
indirection per packet and work against it. `#[allow]`'d at the
enum decl with a justifying comment.
ring is widely deployed (rustls, hyper-rustls, AWS SDK, …) and a
pure-Rust crate (uses BoringSSL's asm via a vendored build). It
introduces no new C toolchain requirements that aren't already there
for any rustls user.
Bench data from a downstream consumer of this crate (Docker e2e,
DURATION=10, identical hardware before/after, aarch64 Linux on
Apple Silicon):
2-node direct (A↔B):
TCP 1-stream 437 → 1097 Mbps (2.51×)
TCP 4-stream 439 → 1109 Mbps (2.53×)
TCP 8-stream 445 → 1069 Mbps (2.40×)
UDP @1000 Mbit 599/40% loss → 1000 Mbps lossless
ping under load ~0.6 ms (unchanged)
3-node forced transit (A → C → B):
TCP 1-stream 438 → 1019 Mbps (2.33×)
TCP 4-stream 421 → 982 Mbps (2.33×)
TCP 8-stream 443 → 1031 Mbps (2.33×)
UDP @1000 Mbit 475/52% loss → 1000 Mbps lossless
ping under load 7.68 ms / 215 ms max → 0.72 ms / 3.6 ms max
The relay-path lift is the cleanest tell on the bottleneck: the
transit node was crypto-bound (single-threaded soft chacha couldn't
keep up with offered rate), so the queue accumulated under load. With
NEON the relay isn't crypto-bound and the queue stops accumulating —
the 215ms ping-tail collapses to 3.6ms.
|
||
|
|
8094a51a82 |
nostr: fix subscription startup race losing relay REQ replays
Freshly-restarted nodes with policy: open silently lost the historical
event replay that relays send in response to subscribe(). The
broadcast::Receiver was created INSIDE spawn_notify_loop, which the
tokio runtime starts at some indeterminate point after subscribe()
returns. tokio's broadcast channel only delivers messages sent after
the receiver is created; messages dispatched in the gap between
subscribe() issuing the REQ and the spawned task calling
client.notifications() were dropped by external_notification_sender.send
returning Err(SendError) with no subscribers attached.
Symptom on a node with policy: open: non-configured peers were not
discovered until they next re-published their advert (default
advert_refresh_secs = 1800s = 30 min). Configured peers were unaffected
because fetch_advert (relay-fetch path) caches them at startup-sweep
time. The bug has been latent since
|
||
|
|
2d18d019d6 |
Evict stale overlay advert when retry hits NoTransportForType
The advert cache inside fetch_advert is read-only on hit — once a peer's overlay advert is cached, every subsequent lookup returns the same endpoints regardless of whether they still work. So when a peer rebinds its NAT (or its STUN-discovered port flaps), connection retries to that peer dial the same dead address forever, even with exponential backoff firing at the right cadence. Observed in deployment: macOS daemon's view of a Linux peer would "regress" — peer marked rch=False after a brief link-dead window, then hours of "Retry connection initiation failed: no operational transport for any of <npub>'s addresses" with no recovery. Manual pause+resume of the daemon (which restarts the FIPS endpoint and forces fresh advert fetches) was the only way out. When initiate_peer_connection / a retry tick returns NodeError::NoTransportForType, fire-and-forget refetch_advert_for_stale_check on the peer's npub. This re-fetches kind 37195 from advert_relays; if the relay has a newer advert it replaces the cached entry, if it has nothing it evicts the cached entry. Either way the next retry tick goes to fresh data instead of looping on the same dead endpoint. Mirrors the existing stale-advert sweep that runs from the BootstrapEvent::Failed (NAT-traversal-streak) path, but covers the direct-UDP-retry path which never crosses that streak threshold. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
64cc30df12 |
Schedule retry on startup peer-init failure
When initiate_peer_connections() runs at boot, address resolution can fail for an entire peer (no operational transport for the configured transport types, all addresses unreachable, NAT rebind invalidated cached endpoints, etc.). Before this change the failure was logged and silently forgotten — the peer entry stayed in a dead state forever, accepting incoming pings but unable to answer them, until the daemon was manually restarted. The retry plumbing (schedule_retry / process_pending_retries with exponential backoff) already exists and is wired into the post-handshake failure paths (BootstrapEvent::Failed, MMP dead-link timeout, handshake timeout). The startup loop just wasn't calling it. Mirror the BootstrapEvent::Failed path: on a startup peer-init error, parse the peer's npub and call schedule_retry so the peer recovers without operator intervention. Includes a regression test that asserts retry_pending is populated when initiate_peer_connections() fails for a peer with no operational transport. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6ce1406664 |
Refetch overlay advert before every retry, not only on NoTransportForType
The previous fix (6ebca3e) only refetched the advert when retry returned NodeError::NoTransportForType (cache returned no addresses at all). But the much more common stale-cache failure mode is: cache returns an endpoint that LOOKS valid (the address it had last week, before the peer's NAT rebound), the dial succeeds at the IP layer, the handshake times out, MMP fires, schedule_reconnect adds the entry back to retry_pending, next retry hits the same cached endpoint, dials it again, times out again. Loop forever — no NoTransportForType ever fires because the cache has data, just dead data. Move refetch_advert_for_stale_check to before each retry attempt unconditionally. Cheap (one Filter query against advert_relays with a 2s timeout, bounded by the retry backoff cadence), and replaces the cache only if the relay has a newer advert or evicts if the relay has nothing. Keeps the retry loop pinned to relay ground truth instead of whatever the cache happened to learn at startup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e81fd4b477 |
tree: fix TreeAnnounce ancestry when self is smallest visible NodeAddr
When a node was the smallest-NodeAddr peer it could see (no smaller neighbor available as a parent), the spanning-tree state was promoting it to root. But the ancestry it advertised on the next TreeAnnounce still referenced its previous parent's path, so receiving peers rejected the announce with `invalid ancestry: advertised root X is not the minimum path entry Y`, blocking mesh transit on any path that needed to traverse this node. Detect the self-root transition explicitly in `TreeState::become_root` and rebuild the advertised ancestry to start from self. Also surface the same path through the MMP receive handler so a stale ancestry inherited across reconnect is corrected eagerly rather than waiting for the next observation tick. Adds 80 unit tests in `tree::tests` covering self-root transitions, mid-chain ancestor disappearance, and ancestry validation against the new root, plus a regression in `node::tests::spanning_tree` for a 3-node chain where the middle node's only parent (the smallest-addr peer) goes away — previously it would advertise an ancestry rejected by both endpoints; now it self-roots cleanly. |
||
|
|
fac4450694 |
node: drain packet_rx / tun_outbound_rx in batches in run_rx_loop
The run_rx_loop's `tokio::select!` was costing one full scheduler hop + futex per inbound packet and per outbound TUN packet. Under sustained load that capped throughput at one event per scheduler quantum — independent of CPU (which sat near-idle) because every iteration parked the worker, woke it via futex, processed one event, then parked again. After the await on `packet_rx.recv()` / `tun_outbound_rx.recv()` fires, drain up to 256 additional ready items via `try_recv()` in a tight inner loop before yielding back to `select!`. `biased` ordering gives the data-plane branches priority over tick / control / DNS under sustained load. The 256 cap is empirically tuned to keep the worker on a busy stream between yield points (a contiguous burst of ~256 MTU-sized packets ≈ 400 KB of contiguous traffic) while still bounding the inner loop so a flood on one branch can't starve the periodic tick or control socket. Lower caps (64) left perf on the table; higher caps (1024+) delayed tick handling visibly under stress. Pairs with the recvmmsg(2) change in the previous commit: the kernel UDP queue now hands packets to `packet_rx` in 32-batches, and the rx_loop drains them without a per-packet scheduler hop. |
||
|
|
253dddabe3 |
udp: batched recvmmsg receive on Linux (32-pkt bursts)
The UDP recv loop drained the kernel queue one packet per recvmsg(2). Each call paid full per-syscall + per-task-wakeup overhead (~50us avg including a futex-based scheduler hop), so under sustained load the loop ran at one rx event per scheduler quantum — the dominant cap on inbound packet rate. On Linux, switch the steady-state path to recvmmsg(2) with a 32-packet batch. A single readable() wakeup drains up to 32 datagrams in one syscall before yielding back to the reactor. Stack-allocated mmsghdr arrays sized to a module-level `BATCH_SIZE` constant. `SO_RXQ_OVFL` is sampled once per batch off the cmsg chain of `msgs[0]` and plumbed through `AsyncUdpSocket::recv_batch` as `(count, drops)`. The counter is socket-wide and monotonic, so a single sample per batch gives the 1Hz `sample_transport_congestion()` detector ample fresh values under load (one batch = up to 32 datagrams). Cost is one stack-allocated CMSG_SPACE(4) buffer + one CMSG_FIRSTHDR walk per batch syscall. macOS / Windows fall through to the per-packet recv_from loop — recvmmsg is Linux-specific and the per-packet API is fast enough on those platforms for now (recvmsg_x for Darwin can be added later). The slice-array build also drops the `MaybeUninit::uninit().assume_init()` + `transmute` pair for `std::array::from_fn` over a single shared `backing.iter_mut()` — same disjoint mutable borrows, no `unsafe`. |
||
|
|
cd56fee7cf |
identity: eagerly precompute pubkey_full in PeerIdentity::from_pubkey
`PeerIdentity::pubkey_full()` falls through to `self.pubkey.public_key(Parity::Even)` whenever the parity-aware full key wasn't passed at construction (i.e. for every peer constructed from an npub or x-only key). Underneath, that runs a secp256k1 EC point parse — `fe_sqrt` + `fe_mul` + `ge_set_xo_var` — which is ~6% of per-packet CPU on the bulk-data send path for a value that never changes after construction. Compute it eagerly. The same EC point parse already runs at construction inside `NodeAddr::from_pubkey`, so the cost is paid once where it would be paid anyway. |
||
|
|
f0bb29ff6e |
node: inherit primary UDP config when adopting NAT-traversal sockets
`Node::adopt_established_traversal` was constructing the adopted UDP transport with `UdpConfig::default()` — MTU 1280, default recv/send buffer sizes, default accept/advertise flags. If the operator had configured a higher MTU on the primary `[transports.udp]` listener (e.g. 1500 on a path where larger frames are known viable), full-sized tunnel datagrams sent over the NAT-traversed link would exceed the adopted socket's MTU and get dropped at the socket layer with no visibility into why throughput collapsed. Inherit the primary UDP config (MTU + recv/send buffer sizes + accept / advertise flags) and clear the bind / external-address fields since the adopted socket is already bound. Lookup tries `transport_name` first so operators with multiple named `[transports.udp.<name>]` listeners pick up inheritance from the matching listener, and falls back to the unnamed `Single` listener so single-instance configs work unchanged. The previous default of MTU 1280 was deliberately the IPv6 minimum, the only value guaranteed to survive arbitrary middlebox paths. With this change, operators who set their primary listener higher (based on known-clean LAN topology) will have NAT-traversed flows initially attempting that higher MTU and possibly black-holing on tighter paths until reactive `MtuExceeded` recovery kicks in. Documented in the adoption call-site comment so future readers understand why the conservative default went away. Discovered in a downstream consumer where a `MESH_TUNNEL_MTU=1320` / encrypted wire ~1426B produced silent packet drop on every session that had been promoted onto a NAT-traversed link. Adds two sibling tests in `src/node/tests/bootstrap.rs` pinning the new behaviour for the `Single` and `Named` config variants. |
||
|
|
53ad528f7d |
fipstop: add "Listening on fips0" panel to Node tab
Surfaces local services reachable from the mesh, paired with their
current `inet fips` baseline filter classification. Lands to the
right of the existing TUN section in the Traffic block.
A new daemon control query `show_listening_sockets` returns IPv6
listeners bound to either `::` (wildcard) or the node's fd00::/8
address, each classified as Accept / Drop / Unknown / NoFirewall
against the running inbound chain. fipstop renders the result as a
table beside the Traffic counters: Accept rows in default White,
Drop / Unknown in DarkGray, a yellow banner above the table when
`fips-firewall.service` is inactive, and a trailing `*` on
wildcard binds to remind the operator the bind is not
fips0-specific.
Daemon side:
- `src/control/listening.rs` walks `/proc/net/tcp6` and
`/proc/net/udp6` via the procfs crate (LISTEN state for TCP,
wildcard remote for UDP), filters to fips0-reachable binds, and
resolves inodes to PID / comm via `/proc/<pid>/fd`.
- `src/control/firewall_state.rs` shells out to
`nft -j list table inet fips` and walks the inbound chain.
Recognises canonical accepts (`tcp/udp dport N accept`,
`dport { ... } accept`, `dport A-B accept`), the iifname-scoping
line, conntrack and icmpv6 lines (skipped). Any rule with
unrecognised matchers (saddr filters, jumps, daddr filters) or
non-terminal verdicts forces Unknown classification for the
ports it references. Eleven unit tests cover the classification
logic; the listening enumerator carries a /proc-parsing test of
its own.
- `show_listening_sockets` emits
`{fips0_addr, firewall_active, sockets[]}` with per-row
`{proto, local_addr, port, pid, process, filter, wildcard_bind}`.
fipstop side:
- `src/bin/fipstop/ui/dashboard.rs` splits the Traffic block into
a 50/50 horizontal layout; the existing TUN + Forwarded panel
occupies the left half.
- `src/bin/fipstop/ui/listening.rs` renders the right half.
- `main.rs` fetches the new query each tick when the Node tab is
active. Errors are non-fatal: an old daemon without the query
leaves the payload at None and the panel renders "loading...".
`Cargo.toml` gains `procfs = "0.18"` on the Linux target. IPv4
listeners are not enumerated — fips0 is IPv6-only.
Folded in: revert the default-socket lookup from writability-probe
back to existence-based selection. The previous tempfile-probe on
`/run/fips` silently steered fipstop / fipsctl onto an XDG path
the daemon never bound for any user in the `fips` group whose
shell session had not yet picked up the supplementary group (no
re-login after `usermod -aG`). `XDG_RUNTIME_DIR` is set on every
modern systemd-managed user session, so this hit the common case.
The kernel checks actual group membership at `connect(2)`, so a
user who genuinely cannot connect now gets a clear `EACCES`
rather than a silent path mismatch. Drops the now-unused
`is_writable_dir` helper. `XDG_RUNTIME_DIR` existence validation
is preserved.
Documentation:
- `docs/reference/cli-fipstop.md` — Node-tab row updated, new
"Listening on fips0 panel" section.
- `docs/reference/control-socket.md` — `show_listening_sockets`
added to the read-only queries table.
- `docs/how-to/enable-mesh-firewall.md` — new "Verify with
fipstop" section.
- `docs/tutorials/host-a-service.md` — fipstop callouts at
Steps 3, 5, 6 + Troubleshooting bullet + wildcard-bind reminder
under "What you've learned".
- `CHANGELOG.md` — new bullet under `Added / Operator Tooling`,
resolver `Fixed` entry rewritten to describe the
existence-based final shape.
|
||
|
|
c255e3f4a2 |
session: drop dead SessionSetup/SessionAck variants
Both variants of SessionMessageType were never emitted anywhere in src/, and the production from_byte dispatch sites lacked Some-arms for them — any 0x00/0x01 byte that reached either dispatcher would log "Unknown..." and drop. The matching rustdoc tables described an Offset 0 msg_type byte that the encode() path has never written; the actual wire format is the FSP common prefix [ver_phase][flags][payload_len:2 LE] with body keyed by phase nibble, as documented in docs/reference/wire-formats.md. Drop the variants, drop their from_byte/to_byte/Display arms, fix the two stale rustdoc tables to describe the real wire shape, and trim the variant-iteration unit test that enumerated them. Zero on-wire behaviour change. |
||
|
|
f32bc83034 |
docs: correct Ethernet MTU framing rustdoc
The Ethernet data frame format is `[type:1][length:2 LE][payload]`, so the per-link payload MTU is the interface MTU minus 3 bytes, not minus 1. The 2-byte length field is required to trim NIC minimum-frame padding before AEAD verification. The implementation in src/transport/ethernet/mod.rs already uses saturating_sub(3) correctly; only the rustdoc on the effective_mtu field and the EthernetConfig.mtu field's documentation lagged behind. No behaviour change. |
||
|
|
0fcf0f6f8f |
gateway: change dns.listen default to [::1]:5353
The gateway is designed for systems already serving DHCP and DNS to a LAN segment (canonically an OpenWrt AP). On those systems port 53 is already taken by the existing resolver, so the prior `[::]:53` default conflicted with the gateway's intended deployment target out of the box. The OpenWrt ipk previously overrode this in its packaged config as a workaround; matching the source default to what the canonical deployment actually wants makes the override redundant and removes a foot-gun for fresh manual Linux-host installs. The redundant `dns.listen` line in `packaging/openwrt-ipk/files/etc/fips/fips.yaml` is dropped along with this change. Operators on a host without a pre-existing resolver on port 53 can opt back into the wildcard bind by setting `dns.listen: "[::]:53"` explicitly. The new default binds IPv6 loopback only — Linux IPv6 sockets bound to explicit `::1` do not accept v4-mapped traffic, so forwarders that reach the gateway over IPv4 loopback need to be pointed at an explicit IPv4 listen address instead. Touches the gateway config struct and its default-value test, the commented-out gateway example in the Debian common fips.yaml, the OpenWrt ipk config (override removed), the gateway reference / how-to / design / tutorial / troubleshoot docs, and a CHANGELOG entry under [Unreleased] -> Changed. |
||
|
|
db5b6b10bd |
config: unify default control-socket path resolution
Daemon and client tools previously evaluated the same three locations (`/run/fips`, `XDG_RUNTIME_DIR`, `/tmp`) in different orders, allowing fipsctl/fipstop to connect to a socket the daemon never bound when neither side set `node.control.socket_path` explicitly. Collapse the three call sites (`default_control_path`, `default_gateway_path`, `ControlConfig::default_socket_path`) into a shared `resolve_default_socket` helper. Canonical order is `/run/fips` -> `$XDG_RUNTIME_DIR/fips/` -> `/tmp/fips-<name>`. Two hardening fixes folded in: writability is probed via tempfile create rather than mode bits (ACL- and group-aware), and `XDG_RUNTIME_DIR` is validated as an existing directory before being used (avoids stale post-logout values). The deployed fleet is unaffected -- packaged configs set `node.control.socket_path` explicitly. The fix surfaces for dev runs and the binary-install getting-started path. |
||
|
|
4cdf382038 |
tree: propagate mid-chain ancestor swaps to leaves
A leaf node's my_coords could go stale after an upstream mid-chain ancestor swap, leaving non-parent destinations with 100% loss until either the parent or the depth also changed. The broadcast gate in handle_tree_announce's `else if !is_root && parent_id == from` branch compared only (root, depth). A swap that altered an interior ancestor without changing root or depth (e.g. A->B->C reorganizing to A->D->C while keeping (A, depth=2)) was silently dropped one hop below the swap node. Downstream nodes' coords paths then drifted from the real tree topology, defeating greedy distance routing for any destination whose path crossed the unrepresented section. Widen the gate to compare the full my_coords.node_addrs() so mid-chain swaps propagate to leaves the same way root/depth changes already did. The gate body's bloom-marking is adjusted in step so the wider gate doesn't generate empty/redundant FilterAnnounces to every peer on every mid-chain swap propagation: mark_changed_peers replaces mark_all_updates_needed in the gate body (parent_id is unchanged in this branch, so outgoing filter content is typically unchanged, and mark_changed_peers correctly marks zero peers in that case), and the unconditional mark_update_needed(*from) at the top of handle_tree_announce is removed (bloom exchange initiation is already handled at handshake completion, and ongoing content changes are picked up naturally by mark_changed_peers in handle_filter_announce when peers send their next filter). Required surface change: peer_inbound_filters in src/node/bloom.rs upgraded from private to pub(super) so the gate body can call it. Verified in a 6-node depth-4 docker reproduction under tc/netem-induced parent flapping: a depth-4 leaf's ancestry_changed counter advances with upstream parent switches while bloom_sent stays at zero matching the pre-change steady-state baseline. |
||
|
|
a62a0a6cf4 |
nostr: suppress retraversal of cross-FMP-version peers
Open-discovery NAT traversal succeeds at the UDP layer regardless of what FMP-protocol version the peer speaks. When the daemon discovers a peer running a different FMP version (e.g. a v0/v1 mix during a mid-rollout window, or a misconfigured peer in the same advert namespace), the punch sequence completes, the socket is adopted via `Node::adopt_established_traversal`, and we initiate an FMP handshake. The peer drops our msg1 at its own version-gate and we drop their msg1/msg2 at `Unknown FMP version, dropping`. Neither side advances the handshake. Today the bootstrap transport sits idle until the 31s stale- handshake timeout, drops, and the open-discovery sweep ~30s later fires the full STUN+offer+answer+punch sequence again — every minute, indefinitely, against peers the handshake literally cannot complete with. Add a `Node::bootstrap_transport_npubs` map populated alongside `bootstrap_transports` at adopt time. The rx loop reverse-maps the transport_id → npub on version-mismatch and bumps the discovery layer's `failure_state` to a long structural cooldown via the new `NostrDiscovery::record_protocol_mismatch` API. The next sweep skips the npub for `protocol_mismatch_cooldown_secs` (default 86400 = 24h, separate from the 30-min transient-failure `extended_cooldown_secs`). One-shot WARN per fresh observation. Repeat mismatches inside the cooldown window are silent (the failure_state method returns false when an existing comparable cooldown is already in place). The handshake/transport teardown chain is unchanged — the fix is specifically about preventing the *next* sweep cycle from re-traversing. Cleared on `cleanup_bootstrap_transport_if_unused` and on the adopt-failure rollback path so completed handshakes don't leave stale entries behind. Four new unit tests in `failure_state.rs` cover fresh-entry signaling, repeat-suppression inside the window, streak-pin behavior for `show_peers` rendering, and post-cooldown re-arming. |
||
|
|
7fc890b7a2 |
session: mirror proactive PathMtuNotification into path_mtu_lookup
The TUN-side TCP MSS clamp consults `path_mtu_lookup` (FipsAddress- keyed) when sizing outbound TCP flows. Until now, only the reactive `MtuExceeded` handler mirrored the bottleneck MTU into that store; the proactive end-to-end `PathMtuNotification` echoed by the destination updated only `MmpSessionState.path_mtu`, leaving the TUN mirror stale. On stable long-lived paths, the proactive echo can tighten the session-canonical MTU well before any transit router fires a `MtuExceeded` for those flows (since all current traffic is already sized by the tighter session value). New TCP flows opened during that window get clamped by the discovery-time value rather than the session-canonical one, leading to PMTU-D loss until the reactive path eventually fires. Mirror the post-apply MTU into `path_mtu_lookup` whenever `apply_notification` returns true, with the same tighter-only semantics as the reactive mirror — never loosen the clamp. Gated on the bool return so spurious writes don't happen on rejected increases or no-op same-value notifications. Four new unit tests exercise the empty-lookup write, tighten- existing, keep-tighter-existing, and no-session-no-op paths, parallel to the existing reactive-mirror test trio. |
||
|
|
2f95929862 |
nostr: public-IP discovery for UDP/TCP advert publication
When a UDP transport had `advertise_on_nostr: true` + `public: true` + `bind_addr: 0.0.0.0:NNNN`, the advert builder previously read the kernel's `local_addr()`, found `0.0.0.0`, filtered it out (correctly — wildcard isn't a valid advertised endpoint), and silently emitted no UDP endpoint in the published advert. Operators on AWS EIP / GCP / Azure setups (where binding to the public IP directly is impossible because 1:1 NAT does the address translation off-host) had no way to advertise UDP without binding to a specific local IP — and no log explaining what was happening. TCP had the same shape, with no `public: true` precondition. Three pieces, layered. UDP gets zero-config autodiscovery via STUN; both UDP and TCP get an explicit operator-supplied override; the fall-through path now logs loudly instead of silently skipping. UDP public-IP autodiscovery (STUN) ---------------------------------- In the UDP `is_public()` + wildcard-bind branch, run a one-shot STUN observation against an ephemeral UDP socket on the daemon's configured `stun_servers`. Take the reflexive IPv4 (the STUN-reported port is the ephemeral source port and is discarded), combine with the configured listener port for the advert (`udp:<reflexive-ip>:<port>`). Works on AWS EIP / GCP / Azure 1:1-NAT setups because STUN sees the public-Internet egress IP and the bind port is preserved through 1:1 NAT. Result is cached per-transport on a new `public_udp_addr_cache` field on `NostrDiscovery` (keyed by `TransportId.as_u32()`). Asymmetric cache TTL: a successful observation is cached for `advert_refresh_secs` (default 30 min) so we don't STUN every refresh tick. A failed observation is cached for only 60s (`PUBLIC_UDP_ADDR_FAILURE_TTL`) so a transient STUN flake at startup retries within ~a minute and the advert grows its UDP endpoint as soon as STUN starts working — rather than waiting the full 30-min cycle. The shared `observe_traversal_addresses` STUN helper had a hard-coded 2s per-server response wait, right for the per-traversal flow (latency-sensitive — 3 STUN servers worst-case = 6s) but too short for the one-shot advert-publish startup discovery. Parameterized `per_server_timeout` on the helper, with two named constants in `stun.rs`: `TRAVERSAL_STUN_TIMEOUT = 2s` (existing call sites) and `ADVERT_STUN_TIMEOUT = 5s` (new public-UDP discovery path). Both use `tokio::time::timeout_at` under the hood, so success returns immediately — the timeout is only the worst case. `external_addr` override (UDP + TCP) ------------------------------------ New `external_addr: Option<String>` field on `transports.udp.*` and `transports.tcp.*` for explicit advertise-as override. Takes precedence over both the bound `local_addr` and (for UDP) the STUN-derived autodiscovery. Required for TCP on cloud-NAT setups (AWS EIP, GCP/Azure external IPs) where binding to the public IP directly fails with `EADDRNOTAVAIL` because the public IP isn't on a host interface — the network fabric does 1:1 NAT off-host. Without this field the operator's only TCP path was "leave advert off" or "find a way to make the public IP locally bindable." For UDP, `external_addr` is optional but useful as a deterministic alternative to STUN. Operators who want to skip STUN egress, whose STUN servers are blocked, or who want the daemon to not depend on external services for advert content can specify it explicitly. The accessor parses two shapes: - Bare IP (`"54.183.70.180"` or `"2001:db8::1"`): combines with the configured `bind_addr` port. - Full host:port (`"54.183.70.180:8443"` or `"[2001:db8::1]:443"`): used verbatim — useful for port-forward setups where the externally-visible port differs from the bind port. Final precedence in `Node::build_overlay_advert` (now async, only caller `refresh_overlay_advert` was already async): - UDP: `external_addr` → non-wildcard `local_addr` → STUN → loud warn - TCP: `external_addr` → non-wildcard `local_addr` → loud warn Loud warns instead of silent skips ---------------------------------- The wildcard-bind fall-through paths now log a `warn!` pointing at the operator-side fixes: - UDP: "set transports.udp.external_addr, bind to a specific public IP, or ensure node.discovery.nostr.stun_servers is reachable" - TCP: "Either set external_addr to the public IP (recommended for cloud 1:1-NAT setups) or bind explicitly to the public IP" Replaces the silent skip that previously cost operators a debugging session when the advert mysteriously contained only the Tor onion endpoint. Tests ----- 11 new unit tests in `src/config/transport.rs` covering the parser (IPv4/IPv6, bare/full, malformed) and the accessor (UDP with default bind, UDP with explicit port override, UDP unset, TCP without bind_addr, TCP with bind_addr, TCP with full socket-addr override, parse_bind_port for IPv4/IPv6/malformed). The 38-test nostr suite still passes. CHANGELOG entries under `[Unreleased]` Fixed. |
||
|
|
bcc9c525d3 |
nostr: per-peer NAT-traversal failure suppression and clock-skew handling
Public-test daemons with populous open-discovery caches generate
sustained NAT-traversal-failure WARN volume (~140/hour, ~3500/day)
against cache-learned peers that have gone offline — their adverts
are absent from major Nostr relays but cached entries persist until
their advertised `valid_until` expires. The daemon kept publishing
offers indefinitely under exponential backoff with no per-peer
suppression, drowning operator signal and hammering relays. A
parallel concern: the strict freshness check at signal.rs silently
rejected offers under modest clock skew (now_ms() anchors to
SystemTime once at startup, so post-startup NTP step adjustments
don't propagate on long-uptime daemons), indistinguishable from
"peer is offline."
Six independent improvements layered on the existing retry logic.
Per-npub WARN log rate-limit
----------------------------
New `FailureState` struct on `NostrDiscovery` records per-npub
`last_warn_at_ms`. Subsequent failures inside `warn_log_interval_secs`
(default 5 min) emit DEBUG instead of WARN. Each WARN now also
carries `consecutive_failures` and remaining `cooldown_secs` so
operators can read the trajectory without grepping multiple lines.
Per-npub consecutive-failure counter + extended cooldown
--------------------------------------------------------
After `failure_streak_threshold` (default 5) consecutive failures
against a peer, the next `extended_cooldown_secs` (default 1800)
of attempts are suppressed by pushing
`retry_pending[npub].retry_after_ms` past the cooldown wall. The
open-discovery sweep also consults `cooldown_until` and increments
a new `skipped_cooldown` counter so a peer whose `retry_pending`
was cleared by max_retries doesn't get re-enqueued during the
cooldown window. Caps offer-publish rate per dead peer regardless
of how often the sweep tries to re-enqueue.
Stale-advert eviction on streak-threshold transition
----------------------------------------------------
On the threshold-crossing transition (one-shot, not every
subsequent failure), `tokio::spawn` an active re-fetch of the
peer's Kind 37195 advert from `advert_relays`. Three outcomes:
- absent on relays → cache evicted; sweep won't re-enqueue
(peer is genuinely gone).
- newer `created_at` → cache refreshed + streak reset
(peer republished; allowed to retry immediately).
- same → cache untouched; cooldown stands.
Cost: ~one fetch per dead peer per 30-min cooldown cycle, vs
hundreds of offer publishes/hour today.
Clock-skew tolerance on freshness check
---------------------------------------
`signal.rs` `validate_offer_freshness` and
`validate_traversal_answer_for_offer` now allow ±60s grace beyond
strict TTL. Both return a new `FreshnessOutcome` enum so callers
can DEBUG-log when an offer/answer was only accepted via the grace
window. `FRESHNESS_SKEW_TOLERANCE_MS` is hard-coded — loosening
this past minutes erodes the freshness/replay security boundary
and operators tend to tune in the wrong direction.
NTP-style skew estimate (offer_received_at echo)
------------------------------------------------
Added optional `offerReceivedAt: Option<u64>` field to
`TraversalAnswer` payload. Responder fills it with `now_ms()` at
offer-receipt time. Initiator computes the standard NTP offset
formula `((T2-T1) + (T3-T4)) / 2` against the round-trip and
DEBUG-logs when `|skew| ≥ 30s`. Skew is also stashed in
`FailureState` and surfaced in `show_peers`. Non-breaking — older
responders that don't fill the field still produce valid answers,
and `estimate_clock_skew` returns `None`.
Per-peer state in `show_peers` JSON
-----------------------------------
Each peer entry in `show_peers` now carries:
"nostr_traversal": {
"consecutive_failures": <u32>,
"in_cooldown": <bool>,
"cooldown_until_ms": <u64 | null>,
"last_observed_skew_ms": <i64 | null>
}
Always emitted (schema-stable); values populated when discovery is
enabled and the npub has a recorded entry. Required a new public
`Node::nostr_discovery_handle()` accessor and refactored
`FailureState`'s internal Mutex from `tokio::sync` to `std::sync`
(operations never hold across await), which lets the synchronous
`show_peers` handler call `snapshot()` directly without the
dispatcher becoming async.
New config knobs (under `node.discovery.nostr`)
-----------------------------------------------
failure_streak_threshold: 5
extended_cooldown_secs: 1800
warn_log_interval_secs: 300
failure_state_max_entries: 4096
Tests
-----
12 new unit tests:
- 5 in `tests.rs` covering freshness strict / tolerated / rejected
outcomes, NTP skew estimation, and the backward-compat None case
when the responder didn't fill `offer_received_at`.
- 7 in `failure_state.rs` covering streak/warn-rate-limit state
transitions, cooldown active vs expired semantics,
success-resets-streak, observed-skew records, and size-cap
eviction by oldest `last_failure_at`.
CHANGELOG entries added under `[Unreleased]` Fixed.
|
||
|
|
f66be793b8 |
Fix snapshot-test CRLF mismatch on Windows runners
The control-query snapshot tests panicked on the Windows GitHub runner because git's default core.autocrlf=true converted fixture files (src/control/snapshots/*.json) to CRLF on checkout, while the in-memory JSON output is LF. trim_end() only strips trailing newlines, not interior \r, so every snapshot comparison mismatched. Two defenses: 1. .gitattributes: pin src/control/snapshots/*.json to text eol=lf so future Windows checkouts keep the fixtures LF-only regardless of local git config. 2. src/control/queries.rs (assert_snapshot): replace \r\n with \n in the expected text before comparison, so any future re-introduction of CRLF (a contributor with non-LF editor settings, a different runner, etc.) doesn't surface as a snapshot mismatch. |
||
|
|
e08f42e3cc |
Pin discovery state machine: open-discovery sweep + per-attempt lookup timeout
Cover two adjacent runtime behaviors in the discovery state machine
that were previously unpinned at the test level.
1. Open-discovery startup sweep iterate-filter-queue contract.
Cover the runtime sweep behavior: iterate advert cache, apply
skip-filters (own-pubkey, already-connected peers), queue eligible
entries to retry_pending. The config layer was tested but the sweep's
own filtering logic was unpinned.
src/discovery/nostr/runtime.rs: add #[cfg(test)] impl block with
three pub(crate) helpers — new_for_test() builds a minimal
NostrDiscovery with empty cache and no relays/background tasks (uses
fresh nostr::Keys signer + Client::builder().autoconnect(false));
cached_advert_for_test() wraps an OverlayEndpointAdvert into a
CachedOverlayAdvert valid for 1h; insert_advert_for_test() writes
direct to the advert_cache RwLock. All three vanish from release
builds via cfg-gating.
src/node/lifecycle.rs: visibility-only widen on
run_open_discovery_sweep from private async fn to
pub(in crate::node) async fn so the in-tree test can drive it
directly. Same pattern as already-pub(in crate::node) handlers in
src/node/handlers/.
src/node/tests/discovery.rs: add #[tokio::test]
test_open_discovery_sweep_queues_eligible_skips_filtered. Builds
Node + Arc<NostrDiscovery>, injects 3 adverts (eligible, already-
connected peer, own-pubkey), invokes the sweep, asserts retry_pending
contains exactly the eligible entry with matching peer_config npub
and the two filtered entries do NOT appear.
2. Per-attempt timeout state machine in check_pending_lookups.
Cover the central new behavior of
|
||
|
|
81c0547bdf |
Pin consecutive decrypt-failure counter and threshold-20 force removal
Cover the security-relevant defensive signal: sustained decrypt failures indicate key drift or active probing; threshold-trip force- removes the peer. src/peer/active.rs (+43): two unit tests on the counter struct itself - test_increment_decrypt_failures_monotonic asserts each increment_decrypt_failures() call returns count+1 for at least 25 iterations - test_reset_decrypt_failures_zeroes_counter asserts the reset helper zeroes a non-zero counter and is idempotent src/node/tests/decrypt_failure.rs (new, 93 lines): end-to-end test - Builds a Node + connected peer via existing make_completed_connection / add_connection / promote_connection harness so peers_by_index is exercised, not just peers - Drives the peer to threshold-20 by calling handle_decrypt_failure 20 times; asserts iterations 1..20 leave the peer registered with monotonically increasing counter, then iteration 20 evicts from both peers and peers_by_index src/node/handlers/encrypted.rs: visibility-only widen on handle_decrypt_failure from private to pub(in crate::node) so the in-tree test can drive the threshold logic without re-implementing it. Same pattern as the already-pub(in crate::node) handle_encrypted_frame in the same file. Threshold pinned: DECRYPT_FAILURE_THRESHOLD = 20 at src/node/handlers/encrypted.rs:11. |
||
|
|
5ed2d36464 |
Snapshot-pin all 18 control query handlers as v0.3.0 schema baseline
Add hand-rolled JSON snapshot harness in src/control/queries.rs to detect silent schema drift in operator-facing control-socket responses. Builds a Node with deterministic identity (Identity::from_secret_bytes(&[0xAB; 32])), invokes each of the 18 show_* handlers, redacts 17 volatile fields (version, pid, exe_path, control_socket, tun_name, allow_file, deny_file, *_ms / *_secs_ago / uptime_secs), sorts object keys recursively, and compares against a fixture in src/control/snapshots/. First run writes snapshots and passes; subsequent runs enforce. Future schema changes show as a snapshot diff that operators update intentionally — not a stability contract, just a tripwire so drift is never silent. A 19th meta-test dispatch_covers_all_snapshotted_handlers walks every name through dispatch() to confirm each returns status: ok and trips if a 19th handler is added without a matching snapshot. No new dependencies (insta deliberately not added; Cargo.toml [dev-dependencies] keeps tempfile + criterion only). 18 fixture files added, ~544 lines combined; harness is 367 added lines, all inside #[cfg(test)] mod tests. |
||
|
|
9204888a54 |
Pin macOS utun AF prefix and BPF frame parsing at unit level
Add #[cfg(target_os = "macos")] unit tests catching macOS-specific regressions before they reach the macos-latest GitHub runner. src/upper/tun.rs: surgical refactor extracts the inline AF_INET6_HEADER constant into module-scope helpers utun_af_inet6_header() (encode) and parse_utun_af_prefix() (decode inverse for round-trip testability). TunWriter::run now calls the helper instead of the inline const; behavior unchanged. Six new tests pin the AF=30 constant matching Darwin, big-endian byte order, encode/parse round-trip, short-buffer rejection, minimum header acceptance with trailing payload, and no-panic on garbage bytes. src/transport/ethernet/socket_macos.rs: existing test mod already covered bpf_wordalign and 5 parse_next_frame cases. Three new tests fill genuine gaps: struct layout pin against kernel ABI, caplen- overrun rejection, full Ethernet header round-trip via parse. Existing tests pre-exist; only adding to the same #[cfg(test)] block. |
||
|
|
00f4a4c7af |
Pin bloom-not-closer-than-tree-parent fall-through to greedy tree
Add test_routing_bloom_hit_not_closer_falls_through_to_tree to
src/node/tests/routing.rs covering the regression class fixed in
|
||
|
|
9c96c9193d |
Pin node.log_level parser string-to-tracing::Level mapping
Add table-driven unit test test_log_level_parser to src/config/node.rs covering all 5 explicit match arms (trace, debug, warn|warning, error), the implicit None-and-unknown → INFO default, case-insensitivity via to_lowercase (TRACE / Debug / Warning / WARN / ERROR / INFO), and edge cases (empty string, "verbose"). Pins observed behavior: there is no explicit "info" arm — it falls through the wildcard to INFO, identical to unknown strings. |
||
|
|
c86dc32197 |
Pin STUN binding-success parser malformed-response behavior
Add 6 negative-input unit tests to src/discovery/nostr/stun.rs covering truncated header (all lengths 0..20), bad magic cookie, unknown attribute type (skip-not-error), truncated XOR-MAPPED-ADDRESS, length-overflow attribute, and transaction-ID mismatch. The happy path was exercised by the 3 NAT scenarios but the parser had no negative-input coverage. Tests pin observed behavior: parse_stun_binding_success returns Option<SocketAddr>, so all malformed-response cases assert None rather than an error variant. Unknown TLVs are silently skipped via the loop's default arm; length-overflow triggers the value_end > packet.len() guard and breaks out of the loop without panicking. |
||
|
|
037a965a93 |
Mirror reactive MtuExceeded into path_mtu_lookup
When a transit forwarder drops an oversized data packet and reports the bottleneck back via MtuExceeded, the receive-side handler already updates per-session MmpSessionState::path_mtu (used by PTB synthesis to feed kernel TCP). It did not, however, update path_mtu_lookup — the per-destination map the TUN reader/writer consult at TCP MSS clamp time. So forward-path-asymmetry flows kept clamping at the discovery reverse-path value (too generous for the actual forward-path budget) on every subsequent SYN. Add the missing write at the same point apply_notification runs. Keep the tighter of existing-or-new — the clamp must never loosen. Same write-shape as seed_path_mtu_for_link_peer. Tests: - Three focused unit tests on handle_mtu_exceeded for the empty, tighten, and keep-tighter cases. - Extended test_multihop_pmtud_heterogeneous_mtu to assert the lookup tightens after the wire-level MtuExceeded propagation, alongside its existing PathMtuState assertion. Adds two #[cfg(test)] accessors on Node (path_mtu_lookup_get / path_mtu_lookup_insert) and bumps handle_mtu_exceeded to pub(in crate::node) for direct test invocation. |
||
|
|
953137ede7 |
Plumb path_mtu_lookup into Windows run_tun_reader
The B3 path_mtu_lookup plumbing landed without updating the windows_tun::run_tun_reader signature or its inner handle_tun_packet call, breaking the Windows build. Linux/macOS variants and TunWriter (Windows) were already plumbed; this brings the Windows reader into line. No behavioral change on any platform. |
||
|
|
ae607431eb |
Per-destination TCP MSS clamping at the TUN boundary
Adds source-side TCP MSS clamping informed by per-destination path MTU learned via discovery, with a conservative IPv6-minimum-derived ceiling for cold flows where discovery has not yet completed. Closes the multi-hop default-config TCP wedges observed in production where a sender's local-floor MSS exceeds what some intermediate forwarder hop is willing to carry: silent drops, no PTB feedback through the userspace TUN to the kernel TCP stack, retransmits at the same too- large MSS, application connection times out. ## Architecture A new `Arc<RwLock<HashMap<FipsAddress, u16>>>` field `path_mtu_lookup` on Node mirrors the per-destination path MTU in a form accessible from sync TUN reader/writer threads. A new `per_flow_max_mss` helper in `src/upper/tun.rs` reads the lookup at SYN-clamp time and returns the appropriate ceiling for the flow. Three write sites populate `path_mtu_lookup`: 1. **Discovery originator branch** of `handle_lookup_response`: the path MTU bottleneck accumulated through the reverse path lands here when a LookupResponse arrives at the originator. Same value also lands in `coord_cache` per the existing `insert_with_path_mtu` API. 2. **FMP peer-promotion seed** (`seed_path_mtu_for_link_peer`): when an FMP link-layer peer is promoted to active, the local outgoing-link MTU on the peer's transport seeds the lookup. Tighter existing values (learned via discovery) are preserved; the seed only writes when no entry exists or the existing value is looser than the link MTU. Without this seed, directly-configured peers (auto_connect / static peer config) would leave `path_mtu_lookup` empty for their FipsAddress because the FSP session establishes without ever issuing a LookupRequest. 3. **Target-edge fold at `send_lookup_response`**: when a node is the discovery target, it folds its own outgoing-link MTU to the response's next-hop into `path_mtu` before sending. Without this fold, the response leaves the target with `path_mtu = u16::MAX` and only intermediate transits min-fold; the target's first reverse-path hop is never represented in the bottleneck calculation. Refactored the existing transit- side min-fold into a shared `apply_outgoing_link_mtu_to_response` helper called from both sites. ## Read-side: per_flow_max_mss Two TUN call sites consume the lookup: - Outbound `handle_tun_packet` clamps SYN MSS using packet[24..40] (IPv6 destination) as the lookup key. - Inbound `TunWriter::run` clamps SYN-ACK MSS using packet[8..24] (IPv6 source). When the lookup contains a learned value, the helper computes `min(global_max_mss, effective_ipv6_mtu(path_mtu) - 60)` where 60 is IPv6 (40) + TCP (20) headers and `effective_ipv6_mtu` accounts for the FIPS encapsulation overhead. When the lookup is empty for a destination — the cold-flow case — the helper returns `min(global_max_mss, IPv6-minimum-derived ceiling)`. RFC 8200 mandates every IPv6 path accept ≥1280-byte packets, so the IPv6-minimum-derived MSS (1280 - 77 - 60 = 1143) fits any compliant path. Without this conservative ceiling, the first SYN to a destination with no learned path MTU exits the TUN at the kernel-natural MSS (TUN MTU - 60), and the application connection wedges silently before discovery completes for a corrected second SYN to fire. The fix is provably safe: the ceiling is taken with `min` against the local global so operators with even tighter local floors are never loosened upward. Subsequent flows pick up the actual learned per-destination value once discovery (or the FMP-promotion seed for direct peers) populates the lookup. ## Diagnostic logging All write and read sites emit instrumentation suitable for operators bisecting a wedged path: - `debug!` log on every `path_mtu_lookup` write (discovery originator path and FMP-promotion seed path), showing the FipsAddress, written value, prior value, and post-write map size. `warn!` on poisoned-lock failure path. - `trace!` log per `per_flow_max_mss` call covering every fall-through branch (wrong addr_bytes length, non-fd::/8 prefix, lookup poisoned, no entry for destination, empty-lookup conservative ceiling) and the success path. trace level filters out under normal log settings; capture with `RUST_LOG=info,fips::node::handlers::discovery=debug,fips::upper::tun=trace`. ## Tests 15 new unit tests across 3 files: - `per_flow_max_mss` (8 tests in `src/upper/tun.rs::tests`): empty-lookup conservative ceiling, empty-lookup global-smaller floor, learned-value-overrides-conservative, per-destination smaller, per-destination larger capped by global, non-fips addr, short addr slice, per-destination independence. - `seed_path_mtu_for_link_peer` (4 tests in `src/node/tests/unit.rs`): seed when empty, keep tighter existing, tighten looser existing, no-op for unknown transport. - Discovery integration (3 tests in `src/node/tests/discovery.rs`): apply_outgoing_link_mtu_to_response on unknown peer no-op, two-node target-edge fold (path_mtu reflects target-edge link), three-node chain transit min-fold (existing test, updated for target-edge inclusion). Two pre-existing discovery tests had assertions updated to account for the target-edge fold: - `test_response_path_mtu_two_node`: previously asserted `u16::MAX` (no transit to min-fold); now asserts 1280 (the test transport MTU, folded in by send_lookup_response). - `test_response_path_mtu_four_node_chain`: previously asserted 1350 (transit MTUs only); now asserts 1280 (target-edge MTU is the bottleneck). - `test_transit_forwards_when_mtu_sufficient`: previously asserted 1400 (transit MTU only); now asserts 1280 (target- edge MTU is the bottleneck). ## Verification Local CI on this commit: 29/29 suites pass, 1105 lib tests pass, clippy --all-targets --all-features -D warnings clean, cargo fmt clean. Production deploy verified via trace capture across the managed fleet: cold-flow conservative ceiling branch fires on first SYN, learned-lookup branch takes over once discovery completes, both behaviors observable end-to-end at the SYN MSS on the wire. No wire-format change. No config-format change. |
||
|
|
da5d23ccb7 |
Document nostr-nat ephemeral UDP transport MTU choice
Adopted ephemeral UDP transports created by adopt_established_traversal() default to UdpConfig::default() (MTU=1280, IPv6 minimum) when the bootstrap runtime hands a socket without an explicit transport_config override. This is by design: NAT-traversal middlebox MTU is unpredictable and the IPv6 minimum is the only value guaranteed by spec to survive arbitrary paths. Add an explanatory comment at the call site so future readers find the rationale without spelunking through ISSUE-2026-0013, and so any future change to the inheritance behavior is a deliberate decision rather than an accidental refactor. No behavior change. |
||
|
|
8448e38510 |
Make Node::transport_mtu() deterministic across restarts (TCP black hole fix)
Default-config TCP flows between fips peers were stalling completely (cwnd-pinned, 0 bps for 10s+) on a non-trivial fraction of restarts. Reproducible with iperf3 between any two peers. Root cause: `Node::transport_mtu()` iterated `self.transports.values()` (HashMap with default RandomState hasher) and returned `handle.mtu()` of the first one whose `is_operational()` returned true. Two stacked sources of non-determinism stacked on each other: HashMap iteration order is randomized per-process via RandomState, and async transport `.start()` completion order races each daemon restart. The returned value drives the TCP MSS clamp ceiling computed once at TUN init (src/upper/tun.rs:501-524) and stored as immutable max_mss in the reader/writer thread state. When the picker landed on a transport with MTU > 1357 (any non-UDP-1280 in the standard fleet defaults), `max_mss > 1220` (kernel's natural fips0-MTU-derived MSS), the daemon's clamp was silently a no-op, and the kernel emitted 1220-byte segments. Those wrap into 1280-byte IPv6 → 1357-byte fips datagrams that exceed UDP-1280 transports at any forwarding hop, causing silent drops with no PTB feedback to the kernel TCP stack. Fix: return min across operational transports instead of first-iterated. With UDP-1280 in the configured set (the common case), `transport_mtu = 1280`, `max_mss = 1143 < 1220`, daemon's clamp engages, MSS=1143 reaches the wire, packets fit, throughput recovers. Empirical green light from a single-UDP-config end-to-end test: iperf3-without-`-M` recovered to ~21 Mbps with no operator-side nft TCPMSS rules. Adds three unit tests: - transport_mtu_returns_min_across_operational: pin selection to smallest MTU when multiple operational transports differ. - transport_mtu_fallback_when_no_operational_transports: 1280 fallback. - transport_mtu_min_with_single_operational: trivial single-transport case. The `effective_ipv6_mtu` field reported by `fipsctl show status` was also racy (consequence of the same bug); fixed by this change as a side effect. |
||
|
|
a41f80a776 |
Tighten clippy gate to --all-targets --all-features and clean up
The local ci-local.sh and the GitHub CI clippy invocations both used `cargo clippy --all -- -D warnings`, which only checks lib + bin targets. Test code, integration tests, and benches were not lint-gated. Three pre-existing clippy errors lurked in test modules as a result (two field_reassign_with_default in config tests, one items_after_test_module in stun.rs). Tighten both invocations to `cargo clippy --all-targets --all-features -- -D warnings` so the gate covers everything cargo can build, and fix the three exposed errors: - src/config/mod.rs: rewrite two test-only `Config::default()` + field-reassign sites to struct-update syntax. - src/discovery/nostr/stun.rs: move helper `random_txn_id` above the `#[cfg(test)] mod tests` block. Also adds a dedicated Clippy job to the GitHub CI workflow so the strict gate runs on every PR (the workflow had no clippy job before; clippy ran only via testing/ci-local.sh on operator machines). No behavior changes; lint hygiene + CI hardening only. |