mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
1f149bda32b0ab92a9f140d9fff6e7b7a1267637
566
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9b1016ffaf |
ci: correct OpenWrt MIPS-disabled comment to locate the actual blocker
Investigated the mipsel-unknown-linux-musl build of this branch on a
Linux/x86_64 host. ring 0.17, portable-atomic, and the fips codebase
itself all compile cleanly for that target — the AtomicU64 portability
work is already done. The actual blocker is in the nostr-relay-pool 0.44
transitive dep, which uses std::sync::atomic::AtomicU64 directly in
src/relay/{stats,ping,flags}.rs. Verified fixable with a 6-line
portable-atomic patch via a [patch.crates-io] shim during local testing.
Updating the comment so the next person looking at this matrix has the
right starting point.
No functional change.
|
||
|
|
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
|
||
|
|
0cc3de3daa |
changelog: update [Unreleased]
Three Changed entries for the rx-path performance work (Linux UDP recvmmsg batched receive, run_rx_loop drain batching, and eager pubkey_full precompute on PeerIdentity construction) and five Fixed entries: adopted NAT-traversed UDP transports inheriting the primary listener's MTU and buffer config, TreeAnnounce ancestry on self-root transitions, unconditional overlay-advert refetch before each retry, stale overlay-advert eviction on NoTransportForType, and scheduled retry on startup peer-init failure. Pure CHANGELOG addition (+117 lines, no edits to existing entries). Bullets are wrapped at 80 columns and attribute external contributions to the originating PR and author. |
||
|
|
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. |
||
|
|
e471807239 |
testing/firewall: avoid SIGPIPE in drop-counter probe
The drop-counter sanity check piped `nft list table inet fips`
through `awk '/counter packets/ {print $3; exit}'`. Awk's `exit`
on first match closes the pipe, the upstream `nft list` SIGPIPEs
on its next write, `set -o pipefail` makes the pipeline return
141, and the surrounding command-substitution aborts the script
before it can assign DROP_PKTS or print the section header.
Replaces the early-exit pattern with `/counter packets/ && !seen
{ print $3; seen=1 }` — same first-match output, but awk reads
the full input so nft never SIGPIPEs.
The original form had been latent for as long as the test has
existed; recent CI runs at master tip
|
||
|
|
c412646498 |
testing: pre-create /run/fips/ in the unified test image
Mirrors systemd's RuntimeDirectory=fips so the daemon's resolve_default_socket() picks /run/fips/control.sock inside containers, matching production layout and the path that the chaos sim harness (testing/chaos/sim/control.py) probes. Without this, the resolver falls through to /tmp/fips-control.sock (no /run/fips, no XDG_RUNTIME_DIR), the daemon binds there, and the harness's hardcoded /run/fips/control.sock probe returns FileNotFoundError on every node. chaos-bloom-storm then fails its bloom_send_rate assertion with start=0 nodes, end=0 nodes; other chaos scenarios pass only because their tolerances absorb the empty samples. Production hosts always have /run/fips materialized by the fips.service unit's RuntimeDirectory directive before the daemon starts, which is why the regression hit only the containerized test path. Verified locally with chaos-bloom-storm: max per-node delta 27 <= ceiling 30 over the trailing 30s window. |
||
|
|
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.
|
||
|
|
b3a1fb464f |
testing: add bloom-storm chaos scenario
Six-node depth-4 mesh with an induced upstream parent flap. Asserts a
trailing-window ceiling on per-node `stats.bloom.sent` and a sanity
floor on parent-switch count over a ~3-4 min observation window.
Guards against the regression class where a spanning-tree update that
changes only an internal path edge (no root or depth delta) fails to
be properly contained and instead propagates to leaves as a sustained
bloom-traffic oscillation, visible only at fleet scale and only after
several minutes of uptime.
Adds a new chaos primitive (`link_swap`) for deterministic asymmetric
link-cost flapping and a post-run assertion framework with two
checks:
- `bloom_send_rate.max_per_node`: trailing-window ceiling on the
`show_bloom` stats counter delta. Calibrated against the
post-mortem reproduction harness data (per-variant counter table
against pre-fix vs post-fix binaries).
- `min_parent_switches.min_total`: sanity guard against a
misconfigured harness where the flap inducer fires but the
topology never produces a real parent-switch event (e.g., wrong
root election from a different seed). Without this, the
bloom-rate assertion would trivially pass on any binary
including a regressed one.
The runner exits 3 on assertion failure (alongside 0 success and 2
panic-detected). Threshold derivation is documented in the scenario
README; the seed pin is also documented there since smallest-NodeAddr
root election is sensitive to the pubkey hash ordering.
Wired into ci-local.sh's chaos pool and the GitHub CI chaos matrix.
|
||
|
|
6807a3213b |
deps: bump windows-service 0.7 to 0.8.1
Routine refresh; raises MSRV to 1.71 (non-issue for our 2024-edition toolchain) and updates windows-sys to 0.61. FIPS uses define_windows_service!, service_main, the Error type, and Error::Winapi - all stable across 0.7 -> 0.8. Windows CI matrix is the verification gate; no live Windows nodes. |
||
|
|
b547dd70f5 |
deps: bump rtnetlink 0.20.0 to 0.21.0
Pulls netlink-packet-route 0.30.0, which adds DEVCONF_FORCE_FORWARDING to Inet6DevConf for kernel 6.17+. Closes the IFLA_INET6_CONF WARN observed on kernel-6.17 hosts during fips startup. Zero source edits: FIPS does not use the deprecated link_local_address API or the renamed StablePrivacy display path. Live-host WARN-absence verification on a kernel-6.17 host is scheduled for a separate deploy. |
||
|
|
1d7d0d2522 |
deps: refresh bump-safe batch
Manifest pin widening: - clap 4.5 -> 4.6 (env-var rebuild correctness, derive hygiene) - tun 0.8.5 -> 0.8.7 (Linux ioctl-type fix) Lockfile-only refreshes within existing pins: - tokio -> 1.52.3 - tracing-subscriber -> 0.3.23 - socket2 -> 0.6.3 - futures -> 0.3.32 - libc -> 0.2.186 - tempfile -> 3.27.0 - bytes (transitive) -> 1.11.1+ (clears RUSTSEC-2026-0007 BytesMut::reserve overflow) All additive; no source-level migration required. |
||
|
|
67e660d813 |
deps: bump rand 0.10.0 to 0.10.1
Closes RUSTSEC-2026-0097 (unsoundness with custom logger calling rand::rng() from the log handler). Fix is the upstream deprecation of the `log` feature; no API change for our pin. |
||
|
|
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. |
||
|
|
e4f37082c2 |
docs: gateway tutorial review feedback
Two small improvements to the OpenWrt gateway deploy tutorial: - Add a router-side ping step at the top of Step 4 (post-gateway-start client test). Confirms the router itself reaches the mesh before bringing the LAN segment into the diagnosis: if this fails the troubleshooting target is the daemon / mesh side; if it succeeds and the LAN-client test below fails, the target is the LAN segment (proxy_ndp, RA pool route, or DNS forwarding through dnsmasq). - Mark the inbound port-forward section heading as Optional. The outbound half is the steady-state use of a gateway and applies to every deployment; the inbound port-forward half is a per-service opt-in that many operators won't need. |
||
|
|
7daca6bcf1 |
packaging: refresh OpenWrt ipk README
Bring the packaging README into agreement with what the ipk actually installs and the CLI surface fipsctl exposes today: - Package contents table now lists /usr/bin/fips-gateway, /etc/init.d/fips-gateway, and /etc/sysctl.d/fips-gateway.conf alongside the daemon. These have been part of the install block but were missing from the README. - fipsctl examples updated to the current command form (fipsctl show peers / show links / show sessions in place of the removed shorthands), with a pointer to the canonical CLI reference. - Service management section gains a short subsection covering the optional gateway service, including the enable/start incantation and a link to the deploy-fips-gateway tutorial. |
||
|
|
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. |
||
|
|
9112c8f7f0 |
changelog: prep [Unreleased] for v0.3.0
- Add a Documentation entry covering the docs/ reorganisation, top-level getting-started.md, per-section landing pages, source-accuracy pass, and gateway feature-set rewrite. - Add a Fixed entry covering propagation of spanning-tree updates whose changes are confined to internal path edges (no root or depth delta). - Add a single rolled-up entry covering expanded test coverage across the new-feature surface plus CI hardening. - Drop a tree-ancestry test-determinism bullet that did not change user-visible behaviour. |
||
|
|
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. |
||
|
|
18019bb1b5 |
docs: rewrite top-level README for v0.3.0-dev
- Status badge v0.2.0 → v0.3.0-dev. - Lede rewritten around the two equally-supported deployment modes (overlay on existing IP networks; ground-up over raw Ethernet, WiFi, Bluetooth) matching docs/README.md and docs/getting-started.md. - Features list refreshed: Nostr-mediated discovery and UDP NAT traversal called out, LAN gateway described as both halves (outbound + inbound port forwarding), peer ACL and control-socket-per-binary noted. - Quick start trimmed to the Debian inline path + pointer at docs/getting-started.md for the multi-platform walkthrough; transport-by-platform matrix retained. - Documentation section reorganised around the four-section docs/ tree (tutorials, how-to, reference, design) with one entry-point pointer per section. - Stale doc links fixed (docs/design/fips-intro.md → docs/design/fips-concepts.md; docs/design/fips-configuration.md no longer linked). - Status & roadmap rewritten for the v0.3.0-dev release-line scope (no new wire-format changes; FMP swap deferred to the next-branch post-v0.3.0 line). 422 → 235 lines. |
||
|
|
5abf9a9325 |
docs: four-section /docs/ restructure with new-user content, accuracy pass, and gateway feature-set rewrite
Restructures /docs/ by reader purpose (tutorials, how-to, reference, design), adds the new-user-progression and operator-recipe content the prior layout lacked, runs an accuracy pass against current source across the pre-existing design docs, and rewrites the gateway feature-set documentation end-to-end around its actual operational profile (a niche feature designed for systems already serving DHCP/DNS to a LAN, with two independent halves — outbound LAN→mesh, inbound mesh→LAN — sharing one nftables table, one binary, and one control socket). Top-level README and getting-started rewritten around two equally-weighted deployment modes (overlay on existing IP networks; ground-up over non-IP transports). ## Additions - 11 new tutorials in docs/tutorials/: an 8-step new-user progression from single-daemon test-mesh peering through to a ground-up two-device mesh, an IPv6-adapter side-trip walkthrough, an Advanced Tutorials index, and a hand-held OpenWrt walk-through for fips-gateway deployment that exercises both halves of the feature. - 12 new how-tos in docs/how-to/: firewall activation, Nostr discovery (resolve / advertise / open across five scenarios), Tor onion (directory + control_port modes), UDP buffer tuning, unprivileged-user setup, persistent identity, host aliases, Bluetooth LE peering, MTU diagnostics, manual Linux-host gateway deployment (covers both halves), gateway troubleshooting (organised by half), and a section index. - 9 new reference docs in docs/reference/: configuration, wire formats, control-socket protocol, four CLI references (fips, fipsctl, fipstop, fips-gateway), security posture matrix, and Nostr events catalog. Configuration and wire-formats are renamed-and-extended from prior design/ versions; the other seven are net-new. - 6 new design docs: fips-concepts, fips-architecture, and fips-prior-work split out of the deleted fips-intro.md; consolidated fips-mmp and fips-mtu aggregations; and a new generic port-advertisement-and-nat-traversal doc (Nostr-signaled port advertisement plus UDP NAT-traversal protocol, FIPS as an example implementation, suitable for eventual NIP submission). - Top-level docs/getting-started.md walking through the binary-installer-only Install story. - packaging/common/hosts pre-populated with the eight public test-mesh nodes so shortnames resolve out of the box on every fresh install. ## Changes - 23 wire-format diagrams relocated to reference/diagrams/ alongside the wire-formats move. - 4 design diagrams corrected against source code (fips-protocol-stack, fips-identity-derivation, fips-coordinate-discovery, fips-routing-decision). - 10 pre-existing design docs reconciled with current source. Numeric corrections: stale link-MMP report bounds (now [1s, 5s] with 200 ms cold-start floor); UDP default MTU (now 1280, IPv6 minimum); node_addr formula (SHA-256(pubkey)[..16]); Noise patterns (IK at link, XK at session); peer-ACL semantics (strict allowlist requires ALL in peers.deny); daemon DNS upstream ([::1]:5354); on-the-wire bloom-filter size (1,071 bytes); obsolete Cargo-feature references (PR #79 dropped them) removed. - Transport framing tightened across the docs: TCP is for UDP-filtered networks (not NAT traversal); Tor is a deployment mode (not failover); WebSocket dropped (not a shipped FIPS transport); WiFi promoted to Implemented via Ethernet in infrastructure mode; classic-Bluetooth row removed (BLE is the only Bluetooth-mode transport). - docs/design/fips-gateway.md rewritten end-to-end to lead with the niche-feature framing and the two-halves structure. Title moved from "FIPS Outbound LAN Gateway" to "FIPS Gateway"; architecture section describes the common machinery (the fips-gateway service, the nftables table, the control socket) before splitting into separate "Outbound Half" and "Inbound Half" sections of equal weight; security considerations split per-half; no Future Work section (speculative directions live in the project tracker, not in protocol design docs). Inbound port forwarding is a first-class half rather than a buried "Implemented Extensions" subsection. - Gateway terminology unified across all gateway docs as a separate Linux service running alongside the fips daemon (its own systemd unit / OpenWrt init script). Container- pattern terms (sidecar) are reserved for the Docker/Kubernetes sidecar deployment examples — the testing/sidecar/ tree, examples/k8s-sidecar/, examples/sidecar-nostr-relay/, examples/wireguard-sidecar-macos/, and the related CHANGELOG / top-level README entries — where the term carries its standard container meaning. - Net-new design body content: rekey section in fips-mesh-layer (Noise IK msg1/msg2 over the established link, K-bit cutover, drain window, smaller-NodeAddr-wins tie-breaker on dual-init); Mesh Size Estimation and Antipoison FPR Cap sections in fips-bloom-filters; Mesh-Interface Query Filter subsection in fips-ipv6-adapter; failure-suppression knobs and clock- skew tolerance in fips-nostr-discovery; loop-rejection and mid-chain ancestor swap added to spanning-tree propagation / stability rules; Priority Chain in fips-mesh-operation renumbered to match the routing-decision diagram. - Top-level README: dropped the stale nostr-discovery cargo-feature parenthetical. docs/README.md and the four section READMEs (tutorials, how-to, reference, design) refreshed for the new structure; index rows reflect both halves of the gateway feature and the new fips-gateway CLI reference. - Cargo.toml [package.metadata.deb] assets path updated for the fips-security.md move; .gitignore /reference/ rule anchored to repo root so docs/reference/ is trackable. - packaging/openwrt-ipk/files/etc/fips/fips.yaml configuration-doc URL updated to the new docs/reference/configuration.md location. ## Deletions - docs/design/fips-intro.md (split into the three new intro design docs). - docs/design/document-relationships.svg (orphan, no longer referenced). - docs/proposals/ tree removed; the only proposal it contained (the Nostr UDP hole-punch protocol) was rewritten as the new generic design/port-advertisement-and-nat-traversal.md. |
||
|
|
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. |
||
|
|
a78f670a6a |
CHANGELOG: restructure and complete [Unreleased] for v0.3.0
- Reorganize Added into 11 subsections ordered by importance and protocol layer: Mesh Layer (FMP), Platform Support, Mesh Peer Transports, Security, LAN Gateway, IPv6 Adapter, Operator Tooling, Packaging and Deployment, Examples, Documentation. - Add entries for peer ACL enforcement (#50), MIPS portable_atomic (#62), inbound mesh port forwarding, and historical node and per-peer statistics with btop-style graphs (#64). - Add a Fixed entry covering the TCP-over-FIPS reliability work (transport_mtu determinism, per-destination TCP MSS clamp at the TUN boundary, reactive MtuExceeded mirror, Windows TUN reader plumbing). - Recast the multi-backend DNS configuration entry as a systemd-host overhaul under IPv6 Adapter (default ::1 bind, global drop-in backend, five-distro test harness); fold the Ubuntu 22 Fixed entry into it. - Expand the cargo feature flag rationalization Changed entry to cover PR #79's full scope: tui, ble, gateway, and nostr-discovery all dropped. - Drop the rekey msg1 Fixed entry; all cases require new-in-release functionality. - Collapse the BLE transport bullets into one consolidated bullet under Mesh Peer Transports and scrub stale cargo-feature wording from the overlay-discovery and BLE bullets. |
||
|
|
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. |
||
|
|
5c92fffa2b |
Extend shellcheck excludes for OpenWrt rc.common and ash conventions
The package-openwrt.yml shellcheck step was failing on warnings that
are false positives for OpenWrt scripts:
SC2034 — USE_PROCD, START, STOP in /etc/init.d scripts are read by
rc.common, not by the script itself; standard shellcheck
cannot see the indirection.
SC3043 — `local` is undefined in strict POSIX sh, but OpenWrt uses
ash which supports it. The init scripts use `local`
extensively for parameter scoping.
SC2086, SC2089, SC2090 — firewall.sh constructs nft match clauses
with literal quotes that need to survive variable expansion
(`match='iifname "$TUN"'` then `nft ... $match accept`). The
lack of double-quoting around `$match` is intentional so
word-splitting yields separate nft arguments.
Adding these to the exclude list. SC2317 (unreachable code) and
SC1008 (rc.common shebang form) were already excluded.
|
||
|
|
43639fecb9 |
Add stun-faults integration suite covering STUN failure/recovery
Cover the previously untested STUN client behavior under server
unreachable, response timeout, and packet loss. The 3 NAT scenarios
test happy paths only; if the STUN client mishandled a fault (panic,
hang, missing log signal), it would silently degrade NAT traversal
without surfacing in CI.
testing/nat/scripts/stun-faults-test.sh (new, 244 lines):
Phase 1 (drop, ~12s): tc prio + netem loss 100% band + u32 filter on
dst 172.31.10.40 udp 3478. Falls back to iptables -j DROP if netem
isn't available. Asserts daemon process alive, no panic, log line
matching stun.*(timed?out|fail|fallback|unreachable|no address)
within the phase window.
Phase 2 (delay then clear, ~17s): tc qdisc add dev eth0 root netem
delay 5000ms for 7s, then deleted. 10s settle. Asserts process alive,
no panic, AND "STUN observation succeeded" log line after clear
(recovery proof).
Phase 3 (kill, ~12s): docker stop fips-nat-stun. Asserts process
alive, no panic, fault evidence in logs.
testing/nat/docker-compose.yml: stun-faults profile adds two
services. stun-fault-node is fips-test:latest on shared-lan at
172.31.10.50. stun-fault-shim is fips-test:latest sharing the
daemon's network namespace via network_mode: service:stun-fault-
node, with cap_add NET_ADMIN, NET_RAW; entrypoint sleep infinity so
the script can docker exec into it. Reuses existing stun
(172.31.10.40:3478) and relay (172.31.10.30:7777) services.
testing/nat/scripts/generate-configs.sh: 3-hunk update so the
generator accepts the new scenario and points its peer config at the
existing relay/STUN. The peer is configured for connect_peer() so
the daemon retries traversal on a loop, repeatedly invoking
observe_traversal_addresses() — which is the fault-injection target.
testing/ci-local.sh: STUN_FAULTS_SUITES=(stun-faults) array,
run_stun_faults runner, list/integration-loop/--only-dispatch hooks.
.github/workflows/ci.yml: matrix row {suite: stun-faults, type:
stun-faults} + 3 steps gated on matrix.type == 'stun-faults' between
nostr-publish-consume and any chaos suite. Reuses fips-linux
artifact + fips-test:latest image.
Approach: script-driven via docker exec stun-fault-shim. Sharing
network namespace means tc rules on the shim's eth0 affect daemon
egress. No timing logic in the shim itself.
|
||
|
|
33a2063672 |
Add firewall integration suite covering fips0 default-deny baseline
End-to-end exercise the v0.3.0 nftables firewall baseline so the
security claim — "services on fips0 are not exposed by default" — is
validated in CI rather than by operator opt-in. The packaging postinst
state is pinned by the deb-install matrix; this suite pins the actual
ruleset behavior.
testing/firewall/ — new directory mirroring the acl-allowlist
precedent (kept in its own directory, not extending testing/static):
docker-compose.yml (52 lines): two FIPS containers on bridge
172.32.0.0/24, peered over UDP/2121. node-b mounts
packaging/common/fips.nft RO at /etc/fips/fips.nft and a generated
drop-in services.nft at /etc/fips/fips.d/.
test.sh (247 lines): four-case asserter
(a) curl from node-a to node-b:8000 → DROP (port not allowlisted;
terminal counter drop fires)
(b) curl from node-b to node-a:8000 → 200 OK (reply traverses
node-b's ct state established,related accept)
(c) ping6 a→b → success (icmpv6 echo-request accept)
(d) nc -z a→b:22 → success (drop-in tcp dport 22 accept honored
via include "/etc/fips/fips.d/*.nft")
Plus drop-counter check after case (a) confirms the dropped
connection actually hit the chain's terminal counter drop.
generate-configs.sh (111 lines): mirrors acl-allowlist generator,
produces the drop-in services.nft + fips configs.
README.md (111 lines): how to run, expected output, design notes.
.gitignore: ignores generated-configs/.
testing/ci-local.sh: FIREWALL_SUITES=(firewall) array + run_firewall
runner; dispatch in run_integration and run_suite mirroring
run_acl_allowlist.
.github/workflows/ci.yml: matrix row {suite: firewall, type:
firewall} + 3 steps gated on matrix.type == 'firewall' between
acl-allowlist and gateway. Reuses fips-linux artifact + fips-test:
latest image.
Activation note: the unified test image does not run systemd, so
test.sh invokes the fips-firewall.service ExecStart
(/usr/sbin/nft -f /etc/fips/fips.nft) directly. The systemd-unit
enablement path is covered by the deb-install matrix; this suite
exercises what the unit configures, not how it gets started.
|
||
|
|
5611e976ad |
Add nostr-publish-consume integration suite
Cover the previously untested overlay advert publish/relay/consume
round-trip. The bilateral publish/subscribe path was a v0.3.0 release
gap: malformed adverts could panic consumers, broken signatures could
go undetected, and reverse-direction subscription was unverified.
Adds testing/nat/scripts/nostr-relay-test.sh (290 lines):
Phase 1+2 (combined): wait_for_peers on both nodes; pass on
bidirectional advert publish/subscribe round-trip + dial completed;
ping6 both directions confirms TUN-level reachability.
Phase 3 (malformed advert resilience): stdlib-only Python WebSocket
client publishes a syntactically valid Schnorr-signed Kind-37195
event whose `content` is gibberish (cannot deserialize as
OverlayAdvert). The relay enforces BIP-340 signature validity, so the
event reaches the consumers (rather than being dropped at the relay)
— a trivially-junk content payload is the right adversarial input.
Required ~80 lines of stdlib-only secp256k1 + BIP-340 in the script
(no new container deps). Asserts pidof fips on both nodes after the
publish, scans logs for panic markers, re-pings to prove the existing
peer link survives.
testing/nat/docker-compose.yml: new profile nostr-publish-consume
with two daemon services (nostr-pub-a 172.31.10.20, nostr-pub-b
172.31.10.21) on shared-lan, reusing the existing strfry relay
(172.31.10.30:7777) and STUN service (172.31.10.40:3478).
testing/nat/scripts/generate-configs.sh: 2-line allowlist update so
the new scenario flows through the existing config generator (rather
than forking a parallel one). Generated node-{a,b}.yaml + npubs.env
smoke-tested cleanly.
testing/ci-local.sh: NOSTR_RELAY_SUITES=(nostr-publish-consume)
array, run_nostr_publish_consume runner, dispatch in run_integration
and run_suite. Mirrors existing run_nat shape.
.github/workflows/ci.yml: one matrix row + 3 steps in the integration
job, gated on matrix.type == 'nostr-publish-consume'. Consumes the
same fips-linux artifact and fips-test:latest image as the existing
NAT suites.
Tor/TCP transport variants kept out of v0.3.0 scope; the structure
leaves room for nostr-publish-consume-tcp/-tor siblings later without
disturbing this baseline.
|
||
|
|
d822ee8b3c |
Validate packaging/common/fips.nft syntax in CI build phase
Add nft -c -f packaging/common/fips.nft syntax-check to both testing/ci-local.sh and .github/workflows/ci.yml so a regression in the 128-line firewall ruleset surfaces in the build gate rather than when an operator activates fips-firewall.service. testing/ci-local.sh: first step inside run_build(), before cargo build --release. Uses command -v nft for prereq detection mirroring the existing cargo-nextest pattern; records as nft-syntax in RESULTS. Operator-facing message points at apt install nftables when nft is absent. .github/workflows/ci.yml: nftables added to the build job's existing Linux apt-install step; new Validate fips.nft syntax (Linux only) step gated on runner.os == 'Linux' (skips macOS/Windows matrix slots, runs on ubuntu-latest and ubuntu-24.04-arm). Note: nft -c -f requires netlink cache initialization on modern nftables even in check mode, so both invocations use sudo (safe in CI's passwordless sudo, and operator's typical local sudo). Without sudo, nft fails with "cache initialization failed: Operation not permitted" before reaching ruleset parse. |
||
|
|
ff40966832 |
Add PowerShell lint job for packaging/windows scripts
Add windows-lint job to .github/workflows/ci.yml running
PSScriptAnalyzer against the three operator-facing PowerShell scripts
(build-zip.ps1, install-service.ps1, uninstall-service.ps1) on the
windows-latest runner. Job runs in parallel with build/test, no
needs:, no tag gating.
Also add packaging/windows/PSScriptAnalyzerSettings.psd1 with two
project-appropriate suppressions:
PSAvoidUsingWriteHost — operator-facing progress in all three
scripts is intentional; Write-Output would conflate progress
with return value.
PSAvoidUsingPositionalParameters — Copy-Item src dst / New-Item
-Path style positional usage is widespread for cp/mv-like
readability.
Severity = @('Error', 'Warning') gates CI on Warnings+ only,
skipping Information-only findings.
The lint job lives in ci.yml rather than package-windows.yml: keeps
the packaging workflow focused, avoids interleaving with the
structural-verification steps in package-windows.yml, and the lint
runs on every push (not just tags).
|
||
|
|
b8b1bb03a0 |
Cover UDP forward, multi-forward, and multi-client paths in gateway-test
Extend the gateway integration suite with three previously unexercised runtime paths. All three share testing/static/scripts/gateway-test.sh and testing/static/docker-compose.yml so they land as one commit. 6A — UDP port forwarding runtime path. Add udp 18081 -> [fd02::20]:8081 to inject_gateway_config() and a phase-7 case where gw-client runs an inline Python UDP echo server bound [::]:8081 and gw-server sends a UDP probe to [GW_MESH_IP]:18081 via inline python3, asserting the echoed payload prefix. The config layer already accepted proto: udp (test_port_forwards_same_port_different_proto_ok) but the UDP NAT rule shape and conntrack handling differ from TCP and were unverified. Uses Python rather than socat because fips-test:latest does not ship socat; nc -u IPv6 round-trip semantics are messier than a Python one-liner. 6B — Second simultaneous TCP forward. Add tcp 18082 -> [fd02::20]:8081 alongside the existing 18080 forward. Phase 7 now greps the daemon's nft DNAT table for all three rules (18080, 18082, 18081) and runs HTTP fetches through both TCP forwards with distinct backend payloads (inbound-forward-ok vs inbound-forward-ok-2) so a misrouted response fails the assertion. 11A — Concurrent multi-client flows. Add gw-client-2 service to docker-compose mirroring gw-client (IPv6 fd02::21, IPv4 172.20.1.21). Phase 3 sets the fd01::/112 route on both. Phase 4 issues DNS lookups from both, asserts they receive distinct virtual IPs, and queries the gateway control socket (show_mappings) to confirm exactly 2 active mappings (5-attempt retry loop tolerates snapshot-publish lag). Phase 5 launches both curl requests concurrently as background processes, asserts each response. Validates concurrent NAT mappings, pool contention, proxy NDP under simultaneous LAN-client traffic — all real-world deployment shape that was not pinned. Phase 8 reclamation timing unchanged (TTL=5s, grace=5s, 25s wait covers both mapping ticks generously even with a slight stagger). |
||
|
|
616010f8c8 |
Verify package structural correctness across macOS, Windows, and OpenWrt builds
Add post-build structural verification steps to the three platform packaging workflows so structural defects abort before checksum/upload. The .pkg/.zip/.ipk builds run on tag (or every push for OpenWrt) but their output layouts were unverified — silent drift could ship missing binaries, broken plists, or malformed package containers that would only surface at install time. Field operators rely on manual validation; CI now catches packaging regressions before release. macOS (.github/workflows/package-macos.yml): The pkg is a pkgbuild component flat package; pkgutil --expand yields the structure but actual files live inside Payload (cpio.gz). The verification step extracts that with gzip -dc | cpio -i, then asserts: - usr/local/bin/fips, fipsctl, fipstop all present - Library/LaunchDaemons/com.fips.daemon.plist present - plutil -lint on the plist returns zero Each check prints PASS/FAIL; verifier dumps the full extracted tree on failure for diagnosis. Codesigning verification deferred (pkgbuild invocation has no --sign — nothing to verify yet). Windows (.github/workflows/package-windows.yml): build-zip.ps1 produces a flat archive (no wrapper directory) via Compress-Archive -Path "$StagingDir\*". The verifier extracts and asserts each expected file at the extract root: fips.exe, fipsctl.exe, fipstop.exe (binaries) fips.yaml, hosts (config from packaging/common/) install-service.ps1, uninstall-service.ps1 (from packaging/windows/) README.txt (generated inline by build script) Each check prints PASS/FAIL; verifier dumps the full extracted listing on failure for diagnosis. LICENSE intentionally not asserted: the build script does not currently ship one, and asserting on it would false-fail every build. OpenWrt (.github/workflows/package-openwrt.yml): Add four post-build verification steps between Build .ipk and SHA-256 hashes. The ipk build runs every push but its contents, shell-script lint state, and sysctl drop-in syntax were unverified. Steps: 1. Install shellcheck (conditional — pre-installed on ubuntu-latest; apt-get install fallback only if missing). 2. Lint init/uci/hotplug/firewall scripts: run shellcheck --shell=sh --exclude=SC1008,SC2317 against the 5 shipped #!/bin/sh scripts (fips, fips-gateway, firewall.sh, 99-fips hotplug, 90-fips-setup uci-default). SC1008 silences the rc.common shebang form; SC2317 silences "unreachable" warnings for externally-invoked rc.common hooks. 3. Sysctl drop-in syntax: per-line regex check against both fips-gateway.conf and fips-bridge.conf. 4. Verify ipk structural integrity: extract via tar -xzf (OpenWrt's actual format despite the misleading "ar archive" comment in build-ipk.sh), assert all three top-level entries (control.tar.gz, data.tar.gz, debian-binary), assert 14 file paths in data.tar.gz (binaries, init scripts, configs, sysctl drop-ins, hotplug + uci-defaults + sysupgrade keep), and assert the 4 control-tarball entries plus debian-binary content "2.0". Full ipk install/runtime test deferred past v0.3.0 scope. Operator-side note: testing/ci-local.sh NOT modified for the OpenWrt verifier (operators don't typically have the cross-compile toolchain locally). |
||
|
|
1d8e698b57 |
Assert nftables firewall baseline postinst state in deb-install matrix
Extend testing/deb-install/test.sh per-distro container test loop with 5 new PASS assertions covering the v0.3.0 nftables firewall baseline that ships installed-but-disabled (operator opt-in): 1. fips-firewall.service unit present at /lib/systemd/system/ 2. fips-firewall.service disabled by default (is-enabled = 'disabled') 3. /etc/fips/fips.nft exists AND is registered as a dpkg conffile 4. /etc/fips/fips.d/ drop-in directory present with mode 755 root:root 5. fips.nft includes the drop-in glob /etc/fips/fips.d/*.nft Assertions slot in between the existing fips-dns service-state check and the simulated-boot service-start block. Each runs against every distro in the existing matrix (debian12 / ubuntu24 / ubuntu26). The security claim — services on fips0 are not exposed by default — remains end-to-end-validated separately by the testing/firewall/ suite. This commit specifically pins the static install state so packaging regressions surface in the per-push deb-install gate rather than at operator opt-in time. |
||
|
|
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
|