Finalize v0.4.0 release content: CHANGELOG, release notes, README

Prepare the source tree for the v0.4.0 release cut, leaving the version
at 0.4.0-dev (the version bump rides a separate release-candidate
commit).

- CHANGELOG: backfill the missing entry for the route-class transit
  counters and fipstop routing-tab reorg, then reorganize the Unreleased
  block from a flat per-commit list into topic-grouped subsections
  mirroring the 0.3.0 entry (coalescing interim fixes into net-effect
  descriptions without dropping technical detail), and stamp it as
  [0.4.0] - 2026-06-21 with a fresh empty Unreleased block. The date is
  provisional and reconfirmed at the final tag.
- Release notes: refresh both RELEASE-NOTES.md and the versioned archive
  (kept byte-identical) to cover the OpenWrt .apk packaging, the Nix
  flake, the macOS self-traffic checksum fix (#117), and the route-class
  transit counters.
- README: bump the status badge to v0.4.0 so it matches the prose.

Claude-Session: https://claude.ai/code/session_01A2pYfSypNmmG4HyHwZuLex
This commit is contained in:
Johnathan Corgan
2026-06-21 13:45:19 +00:00
parent 274b09d4ff
commit 262d98a8eb
4 changed files with 485 additions and 344 deletions
+402 -339
View File
@@ -9,16 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- OpenWrt `.apk` packaging (`packaging/openwrt-apk/`, `make apk`) for ### Changed
OpenWrt 25+, where apk-tools is the mandatory package manager (the
existing `.ipk` continues to cover OpenWrt 24.x and earlier). Built ### Fixed
SDK-free: it reuses the `.ipk` cross-compile (`cargo-zigbuild`) and the
shared installed-filesystem payload, and assembles the package with ## [0.4.0] - 2026-06-21
`apk mkpkg` from apk-tools 3.0.5 built from source — no OpenWrt SDK
image. A `build-apk` CI job (aarch64, x86_64) builds and structurally ### Added
verifies the package; releases now publish `.apk` artifacts and
checksums alongside `.ipk`. Packages are unsigned, installed with #### Transports (Nym, mDNS LAN discovery)
`apk add --allow-untrusted`, matching the `.ipk` posture.
- Nym mixnet transport (`transports.nym`) for outbound peer links - Nym mixnet transport (`transports.nym`) for outbound peer links
tunneled through a local `nym-socks5-client` SOCKS5 proxy into the tunneled through a local `nym-socks5-client` SOCKS5 proxy into the
Nym mixnet, as a privacy transport alongside Tor. Outbound-only and Nym mixnet, as a privacy transport alongside Tor. Outbound-only and
@@ -26,6 +26,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
crate dependencies. A single-container example crate dependencies. A single-container example
(`examples/sidecar-nostr-mixnet-relay/`) demonstrates FIPS peering (`examples/sidecar-nostr-mixnet-relay/`) demonstrates FIPS peering
across the mixnet end to end. across the mixnet end to end.
- Opt-in mDNS / DNS-SD LAN discovery for sub-second pairing of peers on
the same local link, without a relay or NAT-traversal roundtrip.
Disabled by default; operators enable it with
`node.discovery.lan.enabled: true`. Configurable service type and an
optional `node.discovery.lan.scope` that isolates discovery to peers
sharing the same private-network scope. The advertised UDP port is
chosen from a non-bootstrap operational UDP transport using a stable
selector, so it is deterministic across restarts.
#### Admission / peer-list management
- `Node::update_peers` for runtime peer-list refresh, returning an
`UpdatePeersOutcome` summarizing added, removed, and retained peers.
Re-derives active peer connections from a new peer configuration
without dropping links to peers that remain in the set.
`PeerAddress` gains a `seen_at_ms` recency field (with
`with_seen_at_ms`) used to prefer more recently observed addresses.
#### Data-plane / metrics / observability
- Typed `RejectReason` classification for receive-path silent-rejection - Typed `RejectReason` classification for receive-path silent-rejection
sites across the node. Each rejection-and-return path now passes a sites across the node. Each rejection-and-return path now passes a
typed reason to `NodeStats::record_reject`, which routes it to a typed reason to `NodeStats::record_reject`, which routes it to a
@@ -38,47 +58,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
site that was previously silent. Several handshake, MMP, tree, and site that was previously silent. Several handshake, MMP, tree, and
discovery rejection paths that had no counter at all are now counted, discovery rejection paths that had no counter at all are now counted,
including the `send_lookup_response` no-route drop including the `send_lookup_response` no-route drop
(`DiscoveryStats::resp_no_route`). Existing (`DiscoveryStats::resp_no_route`).
direct counters at the bloom / discovery / forwarding sites are
retained alongside the new dispatch while the rollout is in progress;
a later change collapses the duplicate increment.
- Internal atomic metric registry (`Arc<MetricsRegistry>`) that shadows - Internal atomic metric registry (`Arc<MetricsRegistry>`) that shadows
the plain-`u64` `NodeStats` counters, written alongside them and the plain-`u64` `NodeStats` counters, written alongside them and
validated by a whole-struct debug-build parity check. Covers the validated by a whole-struct debug-build parity check. Covers the
forwarding receive counters, the full discovery counter family, and the forwarding receive counters, the full discovery counter family, and the
tree, bloom, congestion, and error-signal counter families so far, with tree, bloom, congestion, and error-signal counter families, with
the hottest counters cache-line padded. Behavior-neutral: the hottest counters cache-line padded. Behavior-neutral:
`NodeStats` remains the serving path. Groundwork for sampling metrics `NodeStats` remains the serving path. Groundwork for sampling metrics
without contending the receive loop. without contending the receive loop.
- `Node::update_peers` for runtime peer-list refresh, returning an - `fipsctl stats metrics`, backed by a new counter-only `show_metrics`
`UpdatePeersOutcome` summarizing added, removed, and retained peers. control query that dumps the atomic metric registry as flat counter
Re-derives active peer connections from a new peer configuration name/value pairs. Serves a Prometheus-style scraper that samples node
without dropping links to peers that remain in the set. counters without contending the receive loop.
`PeerAddress` gains a `seen_at_ms` recency field (with
`with_seen_at_ms`) used to prefer more recently observed addresses.
- Opt-in mDNS / DNS-SD LAN discovery for sub-second pairing of peers on
the same local link, without a relay or NAT-traversal roundtrip.
Disabled by default; operators enable it with
`node.discovery.lan.enabled: true`. Configurable service type and an
optional `node.discovery.lan.scope` that isolates discovery to peers
sharing the same private-network scope. The advertised UDP port is
chosen from a non-bootstrap operational UDP transport using a stable
selector, so it is deterministic across restarts.
- `pool_inbound` and `pool_outbound` counters on the TCP and Tor - `pool_inbound` and `pool_outbound` counters on the TCP and Tor
transport stats (`TcpStats`, `TorStats`). Per-direction accounting transport stats (`TcpStats`, `TorStats`). Per-direction accounting
is updated at every pool-insert and receive-loop-exit site, plus on is updated at every pool-insert and receive-loop-exit site, plus on
transport stop and on send-failure-driven removal. Surfaces through transport stop and on send-failure-driven removal. Surfaces through
`TcpStatsSnapshot` and `TorStatsSnapshot` for `show_transports`. `TcpStatsSnapshot` and `TorStatsSnapshot` for `show_transports`.
- `fipsctl stats metrics`, backed by a new counter-only `show_metrics`
control query that dumps the atomic metric registry as flat counter #### Spanning-tree / mesh-size / routing
name/value pairs. Serves a Prometheus-style scraper that samples node
counters without contending the receive loop. - Six route-class transit counters that partition transit-forwarded
packets by their tree relationship to the chosen next hop: tree-up
(peer is our ancestor), tree-down (peer is our descendant and the
destination is within its subtree), tree-down-cross (peer is our
descendant but the destination is outside its subtree), cross-link
descend (lateral peer, destination within its subtree), cross-link
ascend (lateral peer, destination outside its subtree), and
direct-peer. The six classes sum to `forwarded_packets` (asserted by a
unit test) and are computed from tree coordinates at the transit
chokepoint, so error-signal routing callers are excluded. They surface
through the forwarding stats snapshot via `show_routing` and
`show_status`.
- Discovery now counts `LookupRequest`s dropped when the dedup cache is - Discovery now counts `LookupRequest`s dropped when the dedup cache is
full. A saturated `recent_requests` cache full. A saturated `recent_requests` cache
(`MAX_RECENT_DISCOVERY_REQUESTS`) previously dropped requests (`MAX_RECENT_DISCOVERY_REQUESTS`) previously dropped requests
silently; a new `DiscoveryStats::req_dedup_cache_full` counter (typed silently; a new `DiscoveryStats::req_dedup_cache_full` counter (typed
reject reason `DiscoveryReject::ReqDedupCacheFull`) makes the drop reject reason `DiscoveryReject::ReqDedupCacheFull`) makes the drop
visible through `show_routing`. visible through `show_routing`.
#### Packaging & deployment
- OpenWrt `.apk` packaging (`packaging/openwrt-apk/`, `make apk`) for
OpenWrt 25+, where apk-tools is the mandatory package manager (the
existing `.ipk` continues to cover OpenWrt 24.x and earlier). Built
SDK-free: it reuses the `.ipk` cross-compile (`cargo-zigbuild`) and the
shared installed-filesystem payload, and assembles the package with
`apk mkpkg` from apk-tools 3.0.5 built from source — no OpenWrt SDK
image. A `build-apk` CI job (aarch64, x86_64) builds and structurally
verifies the package; releases now publish `.apk` artifacts and
checksums alongside `.ipk`. Packages are unsigned, installed with
`apk add --allow-untrusted`, matching the `.ipk` posture.
- Nix flake (`flake.nix` at the project root) for reproducible
from-source builds on Nix/NixOS. Builds all four binaries (`fips`,
`fipsctl`, `fips-gateway`, `fipstop`), pins the exact toolchain from
`rust-toolchain.toml` via fenix, and wires the build-time native
dependencies (`libclang` for `bindgen`, plus `dbus` and `pkg-config`),
so it needs no host setup beyond Nix with flakes enabled. Flake inputs
are lock-pinned (`flake.lock` committed) for reproducibility, and the
flake exposes `nix build`, `nix run`, a `nix develop` dev shell with the
pinned toolchain, and `nix flake check`. The flake produces binaries
(and a NixOS `packages.<system>.fips` output); the systemd/service
integration that the `.deb`/tarball installers provide is handled
through the NixOS configuration instead.
#### Docs & contributor tooling
- [`PR-REVIEW.md`](PR-REVIEW.md) — the 13-criteria PR review checklist - [`PR-REVIEW.md`](PR-REVIEW.md) — the 13-criteria PR review checklist
the maintainer runs against every incoming PR, published at the the maintainer runs against every incoming PR, published at the
repo root so contributors can run the same pass on their own change repo root so contributors can run the same pass on their own change
@@ -97,32 +143,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`LimitNOFILE` drop-in) and OpenWrt (procd `nofile`) procedures to (`LimitNOFILE` drop-in) and OpenWrt (procd `nofile`) procedures to
raise the limit, and how to verify the per-peer ratio stays bounded. raise the limit, and how to verify the per-peer ratio stays bounded.
Linked from the how-to index. Linked from the how-to index.
- Nix flake (`flake.nix` at the project root) for reproducible
from-source builds on Nix/NixOS. Builds all four binaries (`fips`,
`fipsctl`, `fips-gateway`, `fipstop`), pins the exact toolchain from
`rust-toolchain.toml` via fenix, and wires the build-time native
dependencies (`libclang` for `bindgen`, plus `dbus` and `pkg-config`),
so it needs no host setup beyond Nix with flakes enabled. Flake inputs
are lock-pinned (`flake.lock` committed) for reproducibility, and the
flake exposes `nix build`, `nix run`, a `nix develop` dev shell with the
pinned toolchain, and `nix flake check`. The flake produces binaries
(and a NixOS `packages.<system>.fips` output); the systemd/service
integration that the `.deb`/tarball installers provide is handled
through the NixOS configuration instead.
### Changed ### Changed
#### FMP/FSP rekey reliability
- `complete_rekey_msg2` now returns the remote peer's startup epoch - `complete_rekey_msg2` now returns the remote peer's startup epoch
alongside the new Noise session, so the rekey path can detect a peer alongside the new Noise session, so the rekey path can detect a peer
restart and clear stale session state. restart and clear stale session state.
- Active-peer path selection now sorts address candidates by recency
(`seen_at_ms`), preferring the most recently observed address when #### NAT traversal / Nostr discovery
racing concurrent path probes.
- Per-tick work budgets bound the connection churn done in a single
node tick: `MAX_DISCOVERY_CONNECTS_PER_TICK`,
`MAX_RETRY_CONNECTIONS_PER_TICK`, and
`MAX_PARALLEL_PATH_CANDIDATES_PER_PEER`. Work beyond a tick's budget
is deferred to the next tick rather than discarded.
- Nostr discovery startup is now non-blocking. `Node::start` no - Nostr discovery startup is now non-blocking. `Node::start` no
longer waits for relay connect, subscribe, or initial advert longer waits for relay connect, subscribe, or initial advert
publish before returning. A slow or unreachable relay no longer publish before returning. A slow or unreachable relay no longer
@@ -131,6 +162,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Subscribe retries with exponential backoff (2 s base, 60 s cap), Subscribe retries with exponential backoff (2 s base, 60 s cap),
publish attempts time out at 10 s, and the new tasks are aborted publish attempts time out at 10 s, and the new tasks are aborted
cleanly on `Node::stop`. cleanly on `Node::stop`.
#### Spanning-tree / mesh-size / routing
- Active-peer path selection now sorts address candidates by recency
(`seen_at_ms`), preferring the most recently observed address when
racing concurrent path probes.
- Per-tick work budgets bound the connection churn done in a single
node tick: `MAX_DISCOVERY_CONNECTS_PER_TICK`,
`MAX_RETRY_CONNECTIONS_PER_TICK`, and
`MAX_PARALLEL_PATH_CANDIDATES_PER_PEER`. Work beyond a tick's budget
is deferred to the next tick rather than discarded.
#### Admission / peer caps
- `node.bloom.max_inbound_fpr` default raised from `0.05` to `0.10`. The - `node.bloom.max_inbound_fpr` default raised from `0.05` to `0.10`. The
cap rejects inbound `FilterAnnounce` whose FPR (`fill^k`) exceeds it. On cap rejects inbound `FilterAnnounce` whose FPR (`fill^k`) exceeds it. On
the fixed 1 KB / k=5 filter, `0.05` corresponds to fill 0.549 (~1,300 the fixed 1 KB / k=5 filter, `0.05` corresponds to fill 0.549 (~1,300
@@ -146,6 +191,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
per-transport `max_inbound_connections`, then node-wide per-transport `max_inbound_connections`, then node-wide
`max_connections`, then the built-in default of 256. Established peers `max_connections`, then the built-in default of 256. Established peers
remain bounded node-wide by `add_connection`. remain bounded node-wide by `add_connection`.
#### Data-plane / worker-pool / metrics / observability
- The control-socket read surface is now served off the `rx_loop`. - The control-socket read surface is now served off the `rx_loop`.
Every pure-read `show_*` query — `show_status`, the `show_stats_*` Every pure-read `show_*` query — `show_status`, the `show_stats_*`
family, `show_listening_sockets`, the new `show_metrics`, family, `show_listening_sockets`, the new `show_metrics`,
@@ -170,11 +218,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
snapshot fields above. Built on a ratatui `TestBackend` snapshot fields above. Built on a ratatui `TestBackend`
render-snapshot harness that asserts the text grid and per-cell render-snapshot harness that asserts the text grid and per-cell
style of every `ui::draw_*` against canned `show_*` JSON. style of every `ui::draw_*` against canned `show_*` JSON.
- Static host aliases in `/etc/fips/hosts` now hot-reload on mtime
change instead of only at daemon startup, so `fipsctl`/`fipstop`
display names reflect edits without a restart. The peer ACL and host
map both reload once per node tick through a new lock-free
`Reloadable` snapshot.
- Steady-state log noise reduced on saturated public-mesh nodes. - Steady-state log noise reduced on saturated public-mesh nodes.
Routine per-peer connection-lifecycle and capacity-cap events are Routine per-peer connection-lifecycle and capacity-cap events are
demoted from info/warn to debug — FMP K-bit cutover promotion, demoted from info/warn to debug — FMP K-bit cutover promotion,
@@ -185,43 +228,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
are no longer drowned out. An exhausted FMP-msg1 / FSP-msg3 rekey are no longer drowned out. An exhausted FMP-msg1 / FSP-msg3 rekey
retransmission-budget abort (an expected, self-limiting outcome on retransmission-budget abort (an expected, self-limiting outcome on
lossy or high-latency links) is likewise demoted from warn to debug. lossy or high-latency links) is likewise demoted from warn to debug.
- Sidecar example (`examples/sidecar-nostr-relay`): `udp.mtu` is now
overridable via the `FIPS_UDP_MTU` environment variable, defaulting to
1472 (preserving prior behavior). Plumbed through `docker-compose.yml`
and documented in the README env-var table. Annotated the static-CI
node template `mtu: 1472` literal with the same Docker-bridge
rationale and a pointer at the daemon's 1280 default.
- Overhauled `CONTRIBUTING.md`: replaced generic Rust-template framing
with a FIPS-specific entry point covering the four-layer
architecture, branch model and PR-target selection, structured bug
reporting, scope discipline and local-CI requirements, an AI coding
assistant policy, and project communication channels. Added
`docs/branching.md` as the long-form companion covering the release
workflow, version conventions, and merge-direction rationale.
- CI and release-publish workflows hardened:
- `ci.yml` declares a top-level `concurrency` block keyed on
`(workflow, ref)` with `cancel-in-progress: true`. Force-pushes
and rapid successive pushes to the same ref now retire any
in-flight run rather than letting superseded and current-tip runs
both burn runner minutes.
- `aur-publish.yml` rewritten to fetch the upstream source tarball
and compute its `b2sum` in CI, then patch `pkgver` and the
`b2sums` SKIP placeholder in `PKGBUILD` in-place. Previously
`updpkgsums: true` downloaded the tarball into the AUR working
tree, where it was rejected by AUR's 488 KiB max-blob hook —
silently no-op'ing the v0.3.0 stable AUR push. `fips.sysusers` /
`fips.tmpfiles` asset b2sums are recomputed in the same step to
stay in sync with the local files. `workflow_dispatch` gains a
tag input so historical release tags can be re-published
manually, and `continue-on-error: true` is dropped so future
regressions surface in CI.
- New `aur-publish-git.yml` workflow for the `fips-git` VCS
PKGBUILD, triggered on master pushes touching `PKGBUILD-git` or
companion files plus `workflow_dispatch`. `pkgver` is computed at
build time by the PKGBUILD's `pkgver()` function, so this workflow
is not tied to release tags.
- Tag-triggered `package-*` release-build workflows remain
untouched.
- macOS UDP receive path now batches up to 32 datagrams per kernel - macOS UDP receive path now batches up to 32 datagrams per kernel
wakeup via `recvmsg_x(2)`, matching the Linux `recvmmsg(2)` wakeup via `recvmsg_x(2)`, matching the Linux `recvmmsg(2)`
amortization shape introduced in v0.3.0. Previously macOS fell amortization shape introduced in v0.3.0. Previously macOS fell
@@ -294,6 +300,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and the connected-UDP drain no longer busy-spins on a poll error and the connected-UDP drain no longer busy-spins on a poll error
(#106). (#106).
#### Transports & config
- Static host aliases in `/etc/fips/hosts` now hot-reload on mtime
change instead of only at daemon startup, so `fipsctl`/`fipstop`
display names reflect edits without a restart. The peer ACL and host
map both reload once per node tick through a new lock-free
`Reloadable` snapshot.
- Sidecar example (`examples/sidecar-nostr-relay`): `udp.mtu` is now
overridable via the `FIPS_UDP_MTU` environment variable, defaulting to
1472 (preserving prior behavior). Plumbed through `docker-compose.yml`
and documented in the README env-var table. Annotated the static-CI
node template `mtu: 1472` literal with the same Docker-bridge
rationale and a pointer at the daemon's 1280 default.
#### Packaging & deployment
- The Debian package no longer ships `/etc/fips/fips.yaml` as a dpkg - The Debian package no longer ships `/etc/fips/fips.yaml` as a dpkg
conf-file. The default configuration is installed as an example at conf-file. The default configuration is installed as an example at
`/usr/share/fips/fips.yaml.example`, and `postinst` seeds `/usr/share/fips/fips.yaml.example`, and `postinst` seeds
@@ -305,6 +327,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
example is placed under `/usr/share/fips`, deliberately outside example is placed under `/usr/share/fips`, deliberately outside
`/usr/share/doc`, which minimal and container installs path-exclude `/usr/share/doc`, which minimal and container installs path-exclude
(so the install-time seed source is never dropped). (so the install-time seed source is never dropped).
#### CI & test-harness reliability
- CI and release-publish workflows hardened:
- `ci.yml` declares a top-level `concurrency` block keyed on
`(workflow, ref)` with `cancel-in-progress: true`. Force-pushes
and rapid successive pushes to the same ref now retire any
in-flight run rather than letting superseded and current-tip runs
both burn runner minutes.
- `aur-publish.yml` rewritten to fetch the upstream source tarball
and compute its `b2sum` in CI, then patch `pkgver` and the
`b2sums` SKIP placeholder in `PKGBUILD` in-place. Previously
`updpkgsums: true` downloaded the tarball into the AUR working
tree, where it was rejected by AUR's 488 KiB max-blob hook —
silently no-op'ing the v0.3.0 stable AUR push. `fips.sysusers` /
`fips.tmpfiles` asset b2sums are recomputed in the same step to
stay in sync with the local files. `workflow_dispatch` gains a
tag input so historical release tags can be re-published
manually, and `continue-on-error: true` is dropped so future
regressions surface in CI.
- New `aur-publish-git.yml` workflow for the `fips-git` VCS
PKGBUILD, triggered on master pushes touching `PKGBUILD-git` or
companion files plus `workflow_dispatch`. `pkgver` is computed at
build time by the PKGBUILD's `pkgver()` function, so this workflow
is not tied to release tags.
- Tag-triggered `package-*` release-build workflows remain
untouched.
- Local and GitHub CI integration coverage brought into parity, and - Local and GitHub CI integration coverage brought into parity, and
the Rust toolchain selection given a single source of truth: the Rust toolchain selection given a single source of truth:
- The `admission-cap` integration suite, previously run only by - The `admission-cap` integration suite, previously run only by
@@ -327,61 +376,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`-D warnings` is newly imposed. The OpenWrt nightly Tier-3 leg `-D warnings` is newly imposed. The OpenWrt nightly Tier-3 leg
keeps `@nightly`. keeps `@nightly`.
#### Docs & contributor tooling
- Overhauled `CONTRIBUTING.md`: replaced generic Rust-template framing
with a FIPS-specific entry point covering the four-layer
architecture, branch model and PR-target selection, structured bug
reporting, scope discipline and local-CI requirements, an AI coding
assistant policy, and project communication channels. Added
`docs/branching.md` as the long-form companion covering the release
workflow, version conventions, and merge-direction rationale.
### Fixed ### Fixed
- Packets addressed to a node's own mesh address are now delivered #### FMP/FSP rekey reliability
locally instead of being dropped. On macOS the point-to-point `utun`
interface egresses self-addressed traffic into the daemon, which - FMP link-layer rekey is now reliable under packet loss, bringing it up
previously pushed it onto the mesh outbound path where it was dropped to the FSP session layer's rekey discipline. The rekey msg1
for lack of a route to self; such packets are now hairpinned back to retransmission driver was previously uncapped and never abandoned, so a
the TUN for inbound delivery, so `ping6` to a node's own rekey that never completed resent msg1 forever; it now uses a bounded
`<npub>.fips` address works. Linux was unaffected (the kernel retransmission budget (`handshake_max_resends` with exponential
already loops self-traffic via `lo`). (Full TCP/UDP self-delivery is backoff) and abandons the rekey cycle cleanly once the budget is
completed by the checksum fix below.) exhausted, mirroring the FSP rekey msg3 driver. With the cap in place
- Self-delivered TCP and UDP traffic on macOS now carries a completed the link-dead heartbeat is rekey-aware: `check_link_heartbeats` no
checksum, finishing the self-delivery the hairpin above only partly longer reaps a link that is still actively carrying rekey-handshake
provided. macOS first routes self-addressed packets as loopback (a traffic, while a genuinely dead link is still reaped once the budget
`LOCAL` route via `lo0`), which leaves their transport TX checksum abandons. At the K-bit cutover the receiver now authenticates an
offloaded and unfinished, but the point-to-point `utun` then egresses inbound frame against the pending session before promoting it, instead
them into the daemon with only the pseudo-header partial present. of promoting on the bare header K-bit; under jitter a node could
Re-injecting them verbatim made the local stack drop every segment otherwise promote a stale pending session, leaving the two endpoints on
whose checksum MSS clamping did not happen to rewrite: the SYN and different keys and silently dropping traffic until the link died — the
SYN-ACK got through, but the bare ACK, data, and FIN were dropped for same failure class already closed on FSP, now closed on FMP.
a bad checksum, so connections to a node's own `<npub>.fips` service - FSP session rekey is now hitless under packet loss and reordering.
half-opened and hung. The hairpin path now recomputes the TCP/UDP Previously, a rekey could leave the two endpoints holding different
checksum before re-injection, so full self-connections — not just key sets for a brief window — if a handshake message was lost in
`ping6` — work. Linux remains unaffected. transit one side rotated keys while the other did not, and traffic
- The Tor transport now increments its `connect_refused` statistic (the sealed in one key epoch reached a peer still on the other epoch and
"Refused" line in fipstop) when a SOCKS5 connection is actively failed to decrypt, producing bursts of AEAD decryption failures and
refused, instead of recording every connect failure as a generic dropped connectivity until a later rekey reconverged the pair. The
SOCKS5 error. The counter previously stayed at zero. receive path now trial-decrypts each frame against every live key
- MMP sender metrics now ignore duplicate or regressed receiver reports epoch (current, pending, and the draining previous session) for the
before updating RTT, loss, goodput, or ETX. Receiver reports also duration of the rekey transition, so no rotation ordering and no
suppress timestamp echo when dwell time overflows, so stale reports packet reordering can cause a decryption failure. The previous-epoch
cannot inflate SRTT. slot is retained as long as the peer keeps using it, with its drain
- Six discovery counters (`req_decode_error`, `req_duplicate`, deadline anchored on the last frame the peer authenticates against
`req_ttl_exhausted`, `resp_decode_error`, `resp_identity_miss`, it rather than a fixed wall-clock timer, so a peer that did not
`resp_proof_failed`) no longer double-count. Each was incremented both receive the new keys is not stranded by a silent permanent decrypt
by a direct bump and again through the typed reject dispatch; the failure. The lost-handshake case is closed by retransmitting the
redundant direct increment is removed, so each counts once per event. third rekey handshake message until the peer is confirmed on the
- Six bloom counters (`decode_error`, `invalid`, `non_v1`, new keys, with a bounded retry budget after which the rekey cycle
`unknown_peer`, `stale`, `fill_exceeded`) no longer double-count. Each is cleanly abandoned and retried. There are no FSP decryption
was incremented both by a direct bump and again through the typed failures across a rekey under lossy, jittery links.
reject dispatch; the redundant direct increment is removed, so each - ±15s symmetric jitter is applied per session to the FMP and FSP rekey
counts once per event. timer trigger, eliminating the steady-state dual-initiation race in
- Five forwarding reject packet counters (`decode_error_packets`, symmetric-start meshes (previously the smaller-NodeAddr tie-breaker
`ttl_exhausted_packets`, `drop_no_route_packets`, resolved correctness only after every cycle's collision).
`drop_mtu_exceeded_packets`, `drop_send_error_packets`) no longer `node.rekey.after_secs` becomes the nominal interval rather than a
double-count. Each was incremented both by the byte-aware outcome floor; the mean is preserved.
recorder and again through the typed reject dispatch; the two calls are
collapsed into a single byte-aware reject entry point, so packets and
bytes each count once per event.
- A stale FSP (session-layer) session is now cleared when a peer - A stale FSP (session-layer) session is now cleared when a peer
restart is detected during FMP rekey or cross-connection promotion. restart is detected during FMP rekey or cross-connection promotion.
Previously the old session could linger after the peer came back Previously the old session could linger after the peer came back
with a new startup epoch, leaving the session-layer map out of sync with a new startup epoch, leaving the session-layer map out of sync
with the freshly promoted peer. with the freshly promoted peer.
#### NAT traversal / Nostr discovery
- Two nodes that each `auto_connect` to the other no longer stall their - Two nodes that each `auto_connect` to the other no longer stall their
Nostr-mediated NAT-traversal handshake. Each side ran both an Nostr-mediated NAT-traversal handshake. Each side ran both an
initiator and a responder traversal session, binding a separate UDP initiator and a responder traversal session, binding a separate UDP
@@ -394,43 +452,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
its own NodeAddr is smaller — so one matching socket pair survives on its own NodeAddr is smaller — so one matching socket pair survives on
both ends and the peer's redundant initiator times out harmlessly. both ends and the peer's redundant initiator times out harmlessly.
One-sided (asymmetric) `auto_connect` has no co-active initiator and is One-sided (asymmetric) `auto_connect` has no co-active initiator and is
never suppressed, so connectivity is preserved. (Distinct from the never suppressed, so connectivity is preserved.
cross-init adoption tie-breaker below, which dedups two simultaneous - NAT-traversal cross-init adoption is now deterministic under
punches but does not stop each node from running redundant simultaneous dual-initiation. Previously, when two peers'
initiator + responder sessions.) Nostr-mediated UDP punches completed within the same scheduling
- FMP link-layer rekey is now reliable under packet loss, bringing it up window, each side's bootstrap-completion event arrived with an
to the FSP session layer's rekey discipline: in-flight handshake already recorded against the other peer (each
- The rekey msg1 retransmission driver was uncapped and never side had received an inbound msg1 from the other's pre-punch
abandoned, so a rekey that never completed resent msg1 forever. It outbound attempt). The deduplication skip then fired on both
now uses a bounded retransmission budget (`handshake_max_resends` sides, neither installed the fresh traversal socket as canonical,
with exponential backoff) and abandons the rekey cycle cleanly once and the 45-second peer-adoption budget expired with both nodes
the budget is exhausted, mirroring the FSP rekey msg3 driver. With stuck waiting for an adoption that never happened. The handler now
the cap in place the link-dead heartbeat is rekey-aware: applies the same deterministic NodeAddr tie-breaker the codebase
`check_link_heartbeats` no longer reaps a link that is still already uses for rekey dual-initiation and cross-connection
actively carrying rekey-handshake traffic, while a genuinely dead resolution: the smaller NodeAddr wins as adopter, tears down its
link is still reaped once the budget abandons. in-flight handshake state, and proceeds with adoption; the larger
- At the K-bit cutover the receiver now authenticates an inbound frame NodeAddr keeps the skip semantics, and its in-flight outbound is
against the pending session before promoting it, instead of reconciled by the cross-connection logic when the winner's fresh
promoting on the bare header K-bit. Under jitter a node could msg1 arrives over the adopted socket. The dual cross-init stall is
otherwise promote a stale pending session, leaving the two endpoints eliminated; cross-init NAT-traversal completes in well under a
on different keys and silently dropping traffic until the link died second even under host CPU contention.
— the same failure class already closed on FSP, now closed on FMP. - Nostr-discovered NAT-traversal events (`BootstrapEvent::Established`
- Node-level multi-node tests no longer flake under parallel CPU load. and `BootstrapEvent::Failed`) for peers that are already connected
They previously delivered handshake packets over real localhost UDP, or actively handshaking are now short-circuited at the
whose kernel receive buffer could overflow and drop a packet when many `poll_nostr_discovery` dispatch sites before any cooldown
tests ran concurrently, panicking the large-network convergence tests. bookkeeping or fallback retry scheduling runs. Stale `Failed` events
A `cfg(test)`-only loopback `TransportHandle` variant now delivers previously poisoned the per-peer failure-state cooldown of healthy
packets directly between nodes over an unbounded in-process channel, so peers and could trigger redundant retraversal attempts via
there is no socket buffer to overflow, and the previously-quarantined `schedule_retry` / `try_peer_addresses`; stale `Established`
large-network tests run in the default suite again. The shipping daemon handoffs could attempt to adopt a second socket against a live
build is unaffected (the variant is test-gated). connection. A defense-in-depth guard was added to
- Integration suites that wait for the mesh to converge no longer `adopt_established_traversal` so the same invariant holds if a
false-fail under concurrent CI load. The rekey, static-mesh, and future caller bypasses the outer dispatch check. As a side benefit,
sidecar suites replace a fixed wall-clock baseline timeout (and a blind narrows a cooldown-poisoning vector previously available to an
sleep) with a progress-aware wait that polls the suite's own pairwise attacker injecting stale failure events for an active peer.
pings, returns as soon as every pair is reachable, extends its deadline - Nostr discovery now filters unroutable direct UDP/TCP advert
while the reachable-pair count is still climbing, and gives up only endpoints. Publisher and validator retain only endpoints that parse as
when progress stalls. concrete socket addresses with routable IPs and nonzero ports;
`udp:nat` rendezvous endpoints and Tor endpoints pass through
unchanged. Adverts that collapse to zero usable endpoints after
filtering are rejected with a clear "missing publicly routable
endpoints" error. Before this change, misconfigured nodes could
publish RFC1918, loopback, link-local, CGNAT 100.64/10, IPv6 ULA,
or IPv6 link-local endpoints into Nostr discovery, and consumers
would cache and dial them; in mixed LAN/VPN/NAT environments, that
could prefer a misleading one-way private path over the intended
`udp:nat` bootstrap.
#### Admission / peer caps
- TCP and Tor `max_inbound_connections` admission cap is now compared - TCP and Tor `max_inbound_connections` admission cap is now compared
against the per-direction inbound count (`pool_inbound`) rather than against the per-direction inbound count (`pool_inbound`) rather than
the combined pool size. Outbound connect-on-send connections share the combined pool size. Outbound connect-on-send connections share
@@ -459,10 +529,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
reconnect / cross-connection bypass for known peers still fires) and reconnect / cross-connection bypass for known peers still fires) and
before index allocation + Msg2 wire send. The late cap check inside before index allocation + Msg2 wire send. The late cap check inside
`promote_connection` is intentionally retained as `promote_connection` is intentionally retained as
defense-in-depth. Wire savings observed in a 45 s ops tcpdump at defense-in-depth. Wire savings observed in a 45 s tcpdump at
saturation: ~3.6 cap-denials/s × Msg2 (~104 B + AEAD compute) each. saturation: ~3.6 cap-denials/s × Msg2 (~104 B + AEAD compute) each.
Bigger win is cleaner peer-side semantics — no fake-completed Bigger win is cleaner peer-side semantics — no fake-completed
handshake whose subsequent data frames fail decryption on this side. handshake whose subsequent data frames fail decryption on this side.
#### Spanning-tree / mesh-size / routing
- The mesh-size estimator (`compute_mesh_size`) no longer over-counts - The mesh-size estimator (`compute_mesh_size`) no longer over-counts
under filter overlap. It previously summed the per-filter cardinality under filter overlap. It previously summed the per-filter cardinality
of the parent and each child filter, which assumes the filters are of the parent and each child filter, which assumes the filters are
@@ -473,12 +546,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
nearly-but-not-exactly doubling during rebalancing). The estimator now nearly-but-not-exactly doubling during rebalancing). The estimator now
computes the cardinality of the OR-union over self plus every computes the cardinality of the OR-union over self plus every
connected peer's inbound filter, dropping the parent/child tree gating connected peer's inbound filter, dropping the parent/child tree gating
entirely. entirely. OR is idempotent, so any overlap is deduplicated — the
OR is idempotent, so any overlap is deduplicated — the result equals result equals the old sum in the disjoint case, stays correct under
the old sum in the disjoint case, stays correct under overlap, damps overlap, damps the parent-switch flap, and removes the estimate's
the parent-switch flap, and removes the estimate's dependence on dependence on tree-declaration cache freshness. The per-peer 500 ms
tree-declaration cache freshness. The per-peer 500 ms rate-limiter and rate-limiter and overall recompute cadence are unchanged.
overall recompute cadence are unchanged.
- Spanning-tree state distribution is now eventually-consistent. - Spanning-tree state distribution is now eventually-consistent.
Previously every `send_tree_announce_to_all` call site fired only Previously every `send_tree_announce_to_all` call site fired only
on a local state-change event (parent switch, self-root promotion, on a local state-change event (parent switch, self-root promotion,
@@ -516,7 +588,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
that peer, provoking the better-rooted peer to re-push its real that peer, provoking the better-rooted peer to re-push its real
position immediately. The echo fires only in that one direction and is position immediately. The echo fires only in that one direction and is
bounded by the existing per-peer rate limiter. bounded by the existing per-peer rate limiter.
- Coord cache invalidation made surgical at parent-position-change
and root-change sites. Replaces the previous unconditional
`CoordCache::clear()` calls with two targeted methods:
`invalidate_via_node(node_addr)` (drops entries whose cached
ancestry contains the changed node, used at parent-switch /
become-root / loop-detection sites) and `invalidate_other_roots`
(drops entries from a different tree, used at root-change sites).
The previous global flush left `find_next_hop` returning `None`
for every non-direct-peer destination after every parent switch
until the cache passively re-warmed; surgical invalidation
preserves entries that remain correct across the topology change.
Peer-removal retains the original "no invalidation" behavior
(`find_next_hop` already recomputes against the current peer set
every call, and Discovery handles "no route" on demand).
- `rx_loop` tick-arm stall under convergence-phase mesh pressure - `rx_loop` tick-arm stall under convergence-phase mesh pressure
is eliminated. Previously, the tick body's per-peer `check_*` is eliminated. Previously, the tick body's per-peer `check_*`
loops (heartbeats, bloom announces, MMP reports, tree announces) loops (heartbeats, bloom announces, MMP reports, tree announces)
@@ -548,45 +633,133 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
synchronous connect (e.g., explicit operator-driven synchronous connect (e.g., explicit operator-driven
`fipsctl connect`); the tick path just no longer trips it. `fipsctl connect`); the tick path just no longer trips it.
- NAT-traversal cross-init adoption is now deterministic under #### Data-plane / metrics / observability
simultaneous dual-initiation. Previously, when two peers'
Nostr-mediated UDP punches completed within the same scheduling - The Tor transport now increments its `connect_refused` statistic (the
window, each side's bootstrap-completion event arrived with an "Refused" line in fipstop) when a SOCKS5 connection is actively
in-flight handshake already recorded against the other peer (each refused, instead of recording every connect failure as a generic
side had received an inbound msg1 from the other's pre-punch SOCKS5 error. The counter previously stayed at zero.
outbound attempt). The deduplication skip then fired on both - MMP sender metrics now ignore duplicate or regressed receiver reports
sides, neither installed the fresh traversal socket as canonical, before updating RTT, loss, goodput, or ETX. Receiver reports also
and the 45-second peer-adoption budget expired with both nodes suppress timestamp echo when dwell time overflows, so stale reports
stuck waiting for an adoption that never happened. The handler now cannot inflate SRTT.
applies the same deterministic NodeAddr tie-breaker the codebase - Reject-reason counters no longer double-count now that the rollout's
already uses for rekey dual-initiation and cross-connection interim direct increments are removed. Six discovery counters
resolution: the smaller NodeAddr wins as adopter, tears down its (`req_decode_error`, `req_duplicate`, `req_ttl_exhausted`,
in-flight handshake state, and proceeds with adoption; the larger `resp_decode_error`, `resp_identity_miss`, `resp_proof_failed`), six
NodeAddr keeps the skip semantics, and its in-flight outbound is bloom counters (`decode_error`, `invalid`, `non_v1`, `unknown_peer`,
reconciled by the cross-connection logic when the winner's fresh `stale`, `fill_exceeded`), and five forwarding reject packet counters
msg1 arrives over the adopted socket. The dual cross-init stall is (`decode_error_packets`, `ttl_exhausted_packets`,
eliminated; cross-init NAT-traversal completes in well under a `drop_no_route_packets`, `drop_mtu_exceeded_packets`,
second even under host CPU contention. `drop_send_error_packets`) were each incremented both by a direct bump
- FSP session rekey is now hitless under packet loss and reordering. and again through the typed reject dispatch. The redundant direct
Previously, a rekey could leave the two endpoints holding different increments are removed — for the forwarding family the two calls are
key sets for a brief window — if a handshake message was lost in collapsed into a single byte-aware reject entry point — so each counter
transit one side rotated keys while the other did not, and traffic (and, for forwarding, its byte tally) counts once per event.
sealed in one key epoch reached a peer still on the other epoch and - Transport-layer mutex poisoning no longer cascades. Ten
failed to decrypt, producing bursts of AEAD decryption failures and `Mutex::lock().unwrap()` sites across the UDP, BLE, and Ethernet
dropped connectivity until a later rekey reconverged the pair. The transports would turn a single panic (poisoning the mutex) into a
receive path now trial-decrypts each frame against every live key cascade of panics on every subsequent lock. Each is replaced with
epoch (current, pending, and the draining previous session) for the `lock().unwrap_or_else(|e| e.into_inner())`, recovering the guarded
duration of the rekey transition, so no rotation ordering and no data with no new dependency and no call-graph change; four
packet reordering can cause a decryption failure. The previous-epoch `local_addr.unwrap()` calls on the UDP start/adopt paths get a
slot is retained as long as the peer keeps using it, with its drain provably-safe sentinel fallback. The critical sections are short,
deadline anchored on the last frame the peer authenticates against locally-scoped, and not reachable from peer input, so this is
it rather than a fixed wall-clock timer, so a peer that did not robustness hardening, not a remotely-triggerable fix.
receive the new keys is not stranded by a silent permanent decrypt
failure. The lost-handshake case is closed by retransmitting the #### Peer lifecycle / gateway
third rekey handshake message until the peer is confirmed on the
new keys, with a bounded retry budget after which the rekey cycle - A manual `fipsctl disconnect` now notifies the peer so teardown is
is cleanly abandoned and retried. There are no FSP decryption symmetric. Previously a manual disconnect tore down only the local
failures across a rekey under lossy, jittery links. side and sent the peer nothing, so the peer kept its session and never
re-emitted its tree and filter announcements; on reconnect it was
never re-adopted as a child and its bloom filter was never recorded.
The local side now sends the disconnected peer a scoped `Disconnect`
(the same message graceful shutdown sends), so both ends tear down and
re-handshake cleanly on the next connection.
- `fips-gateway` no longer drops long-lived or DNS-cached client
mappings while traffic is still flowing. The virtual-IP pool's TTL
clock advanced only on DNS re-query, never on traffic, and the mapping
TTL is wired equal to the DNS TTL, so an in-use mapping was forced to
drain at TTL and reclaimed at the first zero-conntrack tick — breaking
long-lived, bursty, or DNS-cached clients. The tick now refreshes the
mapping's last-referenced time whenever conntrack reports active
sessions, and recovers a draining mapping to active (with a fresh
grace window) when traffic resumes; only genuinely idle mappings
drain.
#### macOS self-traffic / resolver
- Self-addressed mesh traffic is now delivered locally on macOS instead
of being dropped, for both `ping6` and full TCP/UDP. The point-to-point
`utun` interface egresses self-addressed traffic into the daemon, which
previously pushed it onto the mesh outbound path where it was dropped
for lack of a route to self; such packets are now hairpinned back to
the TUN for inbound delivery. macOS first routes self-addressed packets
as loopback (a `LOCAL` route via `lo0`), which leaves their transport
TX checksum offloaded and unfinished, so re-injecting them verbatim
made the local stack drop every segment whose checksum MSS clamping did
not happen to rewrite (the SYN and SYN-ACK got through, but the bare
ACK, data, and FIN were dropped, so connections to a node's own
`<npub>.fips` service half-opened and hung). The hairpin path now
recomputes the TCP/UDP checksum before re-injection, so full
self-connections — not just `ping6` — to a node's own `<npub>.fips`
address work. Linux was unaffected (the kernel already loops
self-traffic via `lo`). (#117)
- macOS `.fips` name resolution now works on a fresh install: the
shipped resolver shim points at `::1`, matching the daemon's default
IPv6 DNS listener, instead of `127.0.0.1`. The mismatched shim
(`nameserver 127.0.0.1` while the daemon listens on `::1`) broke
`getaddrinfo` for `.fips` on every macOS install since the resolver
was introduced.
#### CI & test-harness reliability
- Node-level multi-node tests no longer flake under parallel CPU load.
They previously delivered handshake packets over real localhost UDP,
whose kernel receive buffer could overflow and drop a packet when many
tests ran concurrently, panicking the large-network convergence tests.
A `cfg(test)`-only loopback `TransportHandle` variant now delivers
packets directly between nodes over an unbounded in-process channel, so
there is no socket buffer to overflow, and the previously-quarantined
large-network tests run in the default suite again. The shipping daemon
build is unaffected (the variant is test-gated).
- Integration suites that wait for the mesh to converge no longer
false-fail under concurrent CI load. The rekey, static-mesh, and
sidecar suites replace a fixed wall-clock baseline timeout (and a blind
sleep) with a progress-aware wait that polls the suite's own pairwise
pings, returns as soon as every pair is reachable, extends its deadline
while the reachable-pair count is still climbing, and gives up only
when progress stalls.
- Rekey integration test (`testing/static/scripts/rekey-test.sh`) no
longer false-fails on GitHub runners under packet loss and CPU
contention. Phase 1, Phase 3, and Phase 5 strict per-pair pings retry
up to 4 attempts (configurable via `MAX_PING_ATTEMPTS` /
`PING_RETRY_DELAY`) — under 1% per-direction loss, single-shot 20-pair
ping_all misses ~33% per phase from ICMP noise alone, and the
4-attempt retry brings that floor to ~3.2e-6 per phase; the
`wait_for_full_baseline` convergence loop stays single-shot so retries
there cannot conflate transient ping loss with still-converging routing
state. Phase 1 baseline-convergence headroom is bumped from 36s to 60s
to eliminate the intermittent Phase 1 timeout that previously required
a `gh run rerun --failed`, and a post-second-rekey settle window is
added in Phase 5 (mirroring Phase 3's 12-second pattern) to close the
post-rekey per-pair-ping flake from convergence exceeding the per-ping
5-second timeout. Test scaffold only; no daemon code changes, and the
success path is unchanged because the wait loops return as soon as all
20 pairs converge.
- ACL-allowlist integration test (`testing/acl-allowlist/test.sh`):
converted `assert_log_contains` from a one-shot `docker logs | grep`
snapshot into a bounded poll with the same wait-with-timeout shape
as `wait_for_peers_exact`. Absorbs the millisecond-to-second
variance in the XX-handshake cross-connection tie-breaker: the
inbound-handshake-context rejection can land tens of milliseconds
after the test's previous one-shot grep gave up, producing a
pre-existing flake on CI. Success-path cost is unchanged — the helper
returns as soon as the pattern appears.
#### Packaging & deployment
- AUR packaging: the `fips` and `fips-git` PKGBUILDs now install the - AUR packaging: the `fips` and `fips-git` PKGBUILDs now install the
`fips-dns-setup` and `fips-dns-teardown` helpers into `fips-dns-setup` and `fips-dns-teardown` helpers into
`/usr/lib/fips/`, matching the Debian package. The AUR `package()` `/usr/lib/fips/`, matching the Debian package. The AUR `package()`
@@ -608,113 +781,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the arch-named file is present and carries a SHA-256 integrity the arch-named file is present and carries a SHA-256 integrity
chain from the build runner through to `gh release upload`, so a chain from the build runner through to `gh release upload`, so a
recurrence fails CI instead of publishing. recurrence fails CI instead of publishing.
- Nostr discovery: filter unroutable direct UDP/TCP advert endpoints.
Publisher and validator now retain only endpoints that parse as #### fipstop
concrete socket addresses with routable IPs and nonzero ports.
`udp:nat` rendezvous endpoints and Tor endpoints pass through
unchanged. Adverts that collapse to zero usable endpoints after
filtering are rejected with a clear "missing publicly routable
endpoints" error. Before this change, misconfigured nodes could
publish RFC1918, loopback, link-local, CGNAT 100.64/10, IPv6 ULA,
or IPv6 link-local endpoints into Nostr discovery, and consumers
would cache and dial them; in mixed LAN/VPN/NAT environments, that
could prefer a misleading one-way private path over the intended
`udp:nat` bootstrap.
- Coord cache invalidation made surgical at parent-position-change
and root-change sites. Replaces the previous unconditional
`CoordCache::clear()` calls with two targeted methods:
`invalidate_via_node(node_addr)` (drops entries whose cached
ancestry contains the changed node, used at parent-switch /
become-root / loop-detection sites) and `invalidate_other_roots`
(drops entries from a different tree, used at root-change sites).
The previous global flush left `find_next_hop` returning `None`
for every non-direct-peer destination after every parent switch
until the cache passively re-warmed; surgical invalidation
preserves entries that remain correct across the topology change.
Peer-removal retains the original "no invalidation" behavior
(`find_next_hop` already recomputes against the current peer set
every call, and Discovery handles "no route" on demand).
- Rekey integration test (`testing/static/scripts/rekey-test.sh`):
Phase 1, Phase 3, and Phase 5 strict per-pair pings now retry up
to 4 attempts (configurable via `MAX_PING_ATTEMPTS` /
`PING_RETRY_DELAY`). Under low-level packet loss (1% per
direction), single-shot 20-pair ping_all misses with probability
~33% per phase from ICMP noise alone, masking the routing-state
signal the asserts target. The 4-attempt retry brings that floor
to ~3.2e-6 per phase. The `wait_for_full_baseline` convergence
loop itself stays single-shot — retries there would conflate
transient ping loss with still-converging routing state. Test
scaffold only; no daemon code changes.
- Apply ±15s symmetric jitter per session to the FMP and FSP rekey
timer trigger. Eliminates the steady-state dual-initiation race
in symmetric-start meshes; previously the smaller-NodeAddr
tie-breaker resolved correctness after every cycle's collision.
`node.rekey.after_secs` becomes the nominal interval rather than
a floor; mean is preserved.
- Rekey integration test (`testing/static/scripts/rekey-test.sh`):
bumped Phase 1 baseline-convergence headroom from 36s to 60s.
Eliminates the intermittent GitHub-runner Phase 1 timeout that
previously required `gh run rerun --failed`. Cost on the success
path is unchanged because the wait loop returns as soon as all 20
pairs converge.
- Rekey integration test (`testing/static/scripts/rekey-test.sh`):
added a post-second-rekey settle window in Phase 5, mirroring
Phase 3's existing 12-second pattern. Closes the intermittent
GitHub-runner Phase 5 per-pair-ping flake caused by post-rekey
routing convergence exceeding the per-ping 5-second timeout under
runner CPU contention. Cost on the success path is a fixed 12s per
suite run.
- ACL-allowlist integration test (`testing/acl-allowlist/test.sh`):
converted `assert_log_contains` from a one-shot `docker logs | grep`
snapshot into a bounded poll with the same wait-with-timeout shape
as `wait_for_peers_exact`. Absorbs the millisecond-to-second
variance in the XX-handshake cross-connection tie-breaker: the
inbound-handshake-context rejection can land tens of milliseconds
after the test's previous one-shot grep gave up, producing a
pre-existing flake on next-branch CI. Success-path cost is
unchanged — the helper returns as soon as the pattern appears.
- Nostr-discovered NAT-traversal events (`BootstrapEvent::Established`
and `BootstrapEvent::Failed`) for peers that are already connected
or actively handshaking are now short-circuited at the
`poll_nostr_discovery` dispatch sites before any cooldown
bookkeeping or fallback retry scheduling runs. Stale `Failed` events
previously poisoned the per-peer failure-state cooldown of healthy
peers and could trigger redundant retraversal attempts via
`schedule_retry` / `try_peer_addresses`; stale `Established`
handoffs could attempt to adopt a second socket against a live
connection. A defense-in-depth guard was added to
`adopt_established_traversal` so the same invariant holds if a
future caller bypasses the outer dispatch check. As a side benefit,
narrows a cooldown-poisoning vector previously available to an
attacker injecting stale failure events for an active peer.
- A manual `fipsctl disconnect` now notifies the peer so teardown is
symmetric. Previously a manual disconnect tore down only the local
side and sent the peer nothing, so the peer kept its session and never
re-emitted its tree and filter announcements; on reconnect it was
never re-adopted as a child and its bloom filter was never recorded.
The local side now sends the disconnected peer a scoped `Disconnect`
(the same message graceful shutdown sends), so both ends tear down and
re-handshake cleanly on the next connection.
- `fips-gateway` no longer drops long-lived or DNS-cached client
mappings while traffic is still flowing. The virtual-IP pool's TTL
clock advanced only on DNS re-query, never on traffic, and the mapping
TTL is wired equal to the DNS TTL, so an in-use mapping was forced to
drain at TTL and reclaimed at the first zero-conntrack tick — breaking
long-lived, bursty, or DNS-cached clients. The tick now refreshes the
mapping's last-referenced time whenever conntrack reports active
sessions, and recovers a draining mapping to active (with a fresh
grace window) when traffic resumes; only genuinely idle mappings
drain.
- Transport-layer mutex poisoning no longer cascades. Ten
`Mutex::lock().unwrap()` sites across the UDP, BLE, and Ethernet
transports would turn a single panic (poisoning the mutex) into a
cascade of panics on every subsequent lock. Each is replaced with
`lock().unwrap_or_else(|e| e.into_inner())`, recovering the guarded
data with no new dependency and no call-graph change; four
`local_addr.unwrap()` calls on the UDP start/adopt paths get a
provably-safe sentinel fallback. The critical sections are short,
locally-scoped, and not reachable from peer input, so this is
robustness hardening, not a remotely-triggerable fix.
- `fipstop` no longer renders a garbled screen on startup or leaves - `fipstop` no longer renders a garbled screen on startup or leaves
stray bytes on quit, most visible over SSH and inside tmux. Startup stray bytes on quit, most visible over SSH and inside tmux. Startup
forces a full repaint (`terminal.clear()`) before the first draw so forces a full repaint (`terminal.clear()`) before the first draw so
@@ -722,12 +791,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
stdin-poll thread a stop flag and joins it before restoring the stdin-poll thread a stop flag and joins it before restoring the
terminal, so post-raw-mode keystrokes or terminal query responses no terminal, so post-raw-mode keystrokes or terminal query responses no
longer echo onto the restored screen. longer echo onto the restored screen.
- macOS `.fips` name resolution now works on a fresh install: the
shipped resolver shim points at `::1`, matching the daemon's default
IPv6 DNS listener, instead of `127.0.0.1`. The mismatched shim
(`nameserver 127.0.0.1` while the daemon listens on `::1`) broke
`getaddrinfo` for `.fips` on every macOS install since the resolver
was introduced.
## [0.3.0] - 2026-05-11 ## [0.3.0] - 2026-05-11
+1 -1
View File
@@ -3,7 +3,7 @@
![banner](docs/logos/fips_banner.png) ![banner](docs/logos/fips_banner.png)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org/) [![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org/)
[![Status](https://img.shields.io/badge/status-v0.4.0--dev-green.svg)](#status--roadmap) [![Status](https://img.shields.io/badge/status-v0.4.0-green.svg)](#status--roadmap)
A self-organizing encrypted mesh network built on Nostr identities, A self-organizing encrypted mesh network built on Nostr identities,
capable of operating over arbitrary transports without central capable of operating over arbitrary transports without central
+41 -2
View File
@@ -1,6 +1,6 @@
# FIPS v0.4.0 # FIPS v0.4.0
**Released**: 2026-06-DD (provisional) **Released**: 2026-06-21 (provisional)
v0.4.0 is the throughput-and-observability release on the v0.3.x wire v0.4.0 is the throughput-and-observability release on the v0.3.x wire
format. It adds two new ways for nodes to find and reach each other (the format. It adds two new ways for nodes to find and reach each other (the
@@ -31,6 +31,11 @@ across a mesh in any order.
hot-path cost. hot-path cost.
- Reworked `fipstop` TUI on a machine-verified render-snapshot base. - Reworked `fipstop` TUI on a machine-verified render-snapshot base.
- Rekey is now hitless under loss and reordering in both directions. - Rekey is now hitless under loss and reordering in both directions.
- New packaging targets: an OpenWrt `.apk` for OpenWrt 25+ and a Nix
flake for reproducible from-source builds on Nix/NixOS.
- Six route-class transit counters partition forwarded traffic by its
tree relationship to the next hop, visible via `show_routing` and
`show_status`.
## What's new ## What's new
@@ -115,6 +120,14 @@ returns a counter-only snapshot of every metric family. It is the
enabler for a Prometheus scraper that pulls node counters at no hot-path enabler for a Prometheus scraper that pulls node counters at no hot-path
cost. cost.
Six **route-class transit counters** partition transit-forwarded packets
by their tree relationship to the chosen next hop — tree-up, tree-down,
tree-down-cross, cross-link descend, cross-link ascend, and direct-peer
— and the six classes sum to `forwarded_packets`. They surface through
`show_routing` and `show_status`, and the `fipstop` routing tab is
reorganized so its two columns separate own/endpoint traffic from
forwarded/transit traffic with the tree-down-cross line visually flagged.
### Reworked fipstop TUI ### Reworked fipstop TUI
`fipstop` gets a rendering, navigation, and read-surface overhaul on a `fipstop` gets a rendering, navigation, and read-surface overhaul on a
@@ -155,6 +168,22 @@ The net operator takeaway: rekey completes cleanly without dropping
traffic, even on lossy or high-latency links, and the log no longer traffic, even on lossy or high-latency links, and the log no longer
cries wolf when a rekey gives up and retries. cries wolf when a rekey gives up and retries.
### New packaging targets
- **OpenWrt `.apk`.** A new `.apk` package targets OpenWrt 25+, where
apk-tools is the mandatory package manager; the existing `.ipk`
continues to cover OpenWrt 24.x and earlier. It is built SDK-free,
reusing the `.ipk` cross-compile and installed-filesystem payload, and
releases publish `.apk` artifacts and checksums alongside `.ipk`. Like
the `.ipk`, the package is unsigned and installed with
`apk add --allow-untrusted`.
- **Nix flake.** A `flake.nix` at the project root builds all four
binaries (`fips`, `fipsctl`, `fips-gateway`, `fipstop`) from source on
Nix/NixOS, pinning the exact toolchain and wiring the native build
dependencies so no host setup is needed beyond Nix with flakes
enabled. It exposes `nix build`, `nix run`, a `nix develop` dev shell,
and `nix flake check`, with `flake.lock` committed for reproducibility.
## Behavior changes worth flagging ## Behavior changes worth flagging
These affect operators on upgrade. These affect operators on upgrade.
@@ -223,6 +252,15 @@ subset of fixes for behavior that shipped in v0.3.0.
advertising a strictly worse root echoes its own declaration back, advertising a strictly worse root echoes its own declaration back,
provoking the better-rooted peer to re-push its real position provoking the better-rooted peer to re-push its real position
immediately. immediately.
- **macOS self-connections work end to end (#117).** Traffic a macOS
node sends to its own `<npub>.fips` address is now delivered locally
for full TCP/UDP, not just `ping6`. The point-to-point `utun` egresses
self-addressed packets into the daemon with an unfinished transport
checksum (macOS offloads it on the `lo0` loopback route), so
re-injecting them verbatim made the local stack drop every segment the
MSS-clamp rewrite did not happen to fix and self-connections
half-opened and hung. The hairpin path now recomputes the TCP/UDP
checksum before re-injection. Linux was unaffected.
## Upgrade notes ## Upgrade notes
@@ -253,7 +291,8 @@ Operator-actionable items moving from v0.3.0 to v0.4.0:
- **Arch Linux**: `fips` from the AUR. - **Arch Linux**: `fips` from the AUR.
- **macOS**: `.pkg` at the v0.4.0 release page. - **macOS**: `.pkg` at the v0.4.0 release page.
- **Windows**: ZIP at the v0.4.0 release page. - **Windows**: ZIP at the v0.4.0 release page.
- **OpenWrt**: `.ipk` at the v0.4.0 release page. - **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
at the v0.4.0 release page.
- **From source**: `cargo build --release` from a checkout of the v0.4.0 - **From source**: `cargo build --release` from a checkout of the v0.4.0
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
required Linux build prerequisite). required Linux build prerequisite).
+41 -2
View File
@@ -1,6 +1,6 @@
# FIPS v0.4.0 # FIPS v0.4.0
**Released**: 2026-06-DD (provisional) **Released**: 2026-06-21 (provisional)
v0.4.0 is the throughput-and-observability release on the v0.3.x wire v0.4.0 is the throughput-and-observability release on the v0.3.x wire
format. It adds two new ways for nodes to find and reach each other (the format. It adds two new ways for nodes to find and reach each other (the
@@ -31,6 +31,11 @@ across a mesh in any order.
hot-path cost. hot-path cost.
- Reworked `fipstop` TUI on a machine-verified render-snapshot base. - Reworked `fipstop` TUI on a machine-verified render-snapshot base.
- Rekey is now hitless under loss and reordering in both directions. - Rekey is now hitless under loss and reordering in both directions.
- New packaging targets: an OpenWrt `.apk` for OpenWrt 25+ and a Nix
flake for reproducible from-source builds on Nix/NixOS.
- Six route-class transit counters partition forwarded traffic by its
tree relationship to the next hop, visible via `show_routing` and
`show_status`.
## What's new ## What's new
@@ -115,6 +120,14 @@ returns a counter-only snapshot of every metric family. It is the
enabler for a Prometheus scraper that pulls node counters at no hot-path enabler for a Prometheus scraper that pulls node counters at no hot-path
cost. cost.
Six **route-class transit counters** partition transit-forwarded packets
by their tree relationship to the chosen next hop — tree-up, tree-down,
tree-down-cross, cross-link descend, cross-link ascend, and direct-peer
— and the six classes sum to `forwarded_packets`. They surface through
`show_routing` and `show_status`, and the `fipstop` routing tab is
reorganized so its two columns separate own/endpoint traffic from
forwarded/transit traffic with the tree-down-cross line visually flagged.
### Reworked fipstop TUI ### Reworked fipstop TUI
`fipstop` gets a rendering, navigation, and read-surface overhaul on a `fipstop` gets a rendering, navigation, and read-surface overhaul on a
@@ -155,6 +168,22 @@ The net operator takeaway: rekey completes cleanly without dropping
traffic, even on lossy or high-latency links, and the log no longer traffic, even on lossy or high-latency links, and the log no longer
cries wolf when a rekey gives up and retries. cries wolf when a rekey gives up and retries.
### New packaging targets
- **OpenWrt `.apk`.** A new `.apk` package targets OpenWrt 25+, where
apk-tools is the mandatory package manager; the existing `.ipk`
continues to cover OpenWrt 24.x and earlier. It is built SDK-free,
reusing the `.ipk` cross-compile and installed-filesystem payload, and
releases publish `.apk` artifacts and checksums alongside `.ipk`. Like
the `.ipk`, the package is unsigned and installed with
`apk add --allow-untrusted`.
- **Nix flake.** A `flake.nix` at the project root builds all four
binaries (`fips`, `fipsctl`, `fips-gateway`, `fipstop`) from source on
Nix/NixOS, pinning the exact toolchain and wiring the native build
dependencies so no host setup is needed beyond Nix with flakes
enabled. It exposes `nix build`, `nix run`, a `nix develop` dev shell,
and `nix flake check`, with `flake.lock` committed for reproducibility.
## Behavior changes worth flagging ## Behavior changes worth flagging
These affect operators on upgrade. These affect operators on upgrade.
@@ -223,6 +252,15 @@ subset of fixes for behavior that shipped in v0.3.0.
advertising a strictly worse root echoes its own declaration back, advertising a strictly worse root echoes its own declaration back,
provoking the better-rooted peer to re-push its real position provoking the better-rooted peer to re-push its real position
immediately. immediately.
- **macOS self-connections work end to end (#117).** Traffic a macOS
node sends to its own `<npub>.fips` address is now delivered locally
for full TCP/UDP, not just `ping6`. The point-to-point `utun` egresses
self-addressed packets into the daemon with an unfinished transport
checksum (macOS offloads it on the `lo0` loopback route), so
re-injecting them verbatim made the local stack drop every segment the
MSS-clamp rewrite did not happen to fix and self-connections
half-opened and hung. The hairpin path now recomputes the TCP/UDP
checksum before re-injection. Linux was unaffected.
## Upgrade notes ## Upgrade notes
@@ -253,7 +291,8 @@ Operator-actionable items moving from v0.3.0 to v0.4.0:
- **Arch Linux**: `fips` from the AUR. - **Arch Linux**: `fips` from the AUR.
- **macOS**: `.pkg` at the v0.4.0 release page. - **macOS**: `.pkg` at the v0.4.0 release page.
- **Windows**: ZIP at the v0.4.0 release page. - **Windows**: ZIP at the v0.4.0 release page.
- **OpenWrt**: `.ipk` at the v0.4.0 release page. - **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
at the v0.4.0 release page.
- **From source**: `cargo build --release` from a checkout of the v0.4.0 - **From source**: `cargo build --release` from a checkout of the v0.4.0
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
required Linux build prerequisite). required Linux build prerequisite).