234 Commits
Author SHA1 Message Date
Johnathan Corgan 13a98ae702 Make a half-scoped reap impossible in ci-cleanup.sh
The reap runs two selectors, one on the CI label and one on the
compose project, and each flag narrows only its own. So --run-id
alone leaves the project sweep broad and --project-prefix alone
leaves the label sweep broad, and either way the reap still destroys
every concurrent run on the host. Both single-flag forms look scoped
and are not.

The usage text asserted otherwise. It documented --run-id as leaving
other runs alone and --project-prefix as scoping the reap to a single
run, and both claims were false for the same reason. A caller passing
--run-id alone, on the strength of that line, force-removed the
containers of three concurrent runs on 2026-07-29, and its own
comment recorded the belief that it was scoped.

Rather than ask every caller to remember the pair, close the two
half-scoped states here. --run-id now derives the matching project
prefix when none is given, built from the existing base so the two
cannot drift, and an explicit --project-prefix still wins.
--project-prefix without --run-id is refused outright, because there
is nothing to derive a label scope from and the quiet failure is
someone else's run disappearing.

Passing neither flag is untouched: that is the deliberate "reap
everything" form a manual cleanup wants.

Verified against a decoy container labelled as another run: the
scoped reap leaves it up, and reproducing the old broad project sweep
removes it, so the check discriminates rather than passing because
there was nothing to reap. An earlier version of that check ran when
no CI containers existed at all and passed without proving anything.
2026-07-29 02:09:59 +00:00
Johnathan Corgan 77ed64bb88 Compute every peer's outgoing bloom filter in one sweep
Each peer must be sent the union of all other peers' inbound filters,
excluding its own contribution. Building that per recipient rebuilt
the whole peer-filter map and re-ORed it once for every peer, so a
tick that announced to R peers did R kilobyte-scale map builds and
R by T merges. At 240 peers this was 20.6 ms per tick, roughly half
the tick body, and it was steady work rather than a tail: the
per-interval maximum had a median of 34.5 ms.

Replace it with a prefix and suffix union sweep that produces every
target's filter in one pass, T merges instead of R by T. The packet
path gets the same treatment, since marking changed peers had the
identical shape once per inbound announce.

The result is exactly equal, not approximately. Merging is a bytewise
OR, so regrouping the unions cannot change the outcome, and a filter
whose size does not match is rejected before any byte is touched, at
every merge site in both the old and new arrangement. Every
accumulator here is default-sized, so an odd-sized peer filter is
skipped in the new code exactly where it was skipped in the old.

Cadence, the debounce, the sequence rule and the fill-ratio cap are
untouched. A sequence number is still drawn after the debounce
re-check and before encoding, so a suppressed peer still consumes
none and an encode failure still burns one.

Known trade-off, measured rather than assumed: the sweep does its
full O(T) work regardless of how many peers are ready, so a tick that
announces to only one or two peers now costs about twice what it did.
Break-even is around three ready peers, and the saving above that
grows without bound. The marking path is a pure win, since it always
targets every peer.
2026-07-29 01:45:12 +00:00
Johnathan Corgan bf2e7a0892 Derive each peer's npub once instead of once per tick
The per-tick stats snapshot ran a bech32 encode for every tracked
peer, and for the common mesh peer — one with no hosts-file entry and
no configured alias — it ran a second one, because the display-name
fallback chain bottoms out in the same encode. At 240 peers that was
14.1 ms per tick, a third of the tick body and its second largest
cost, all of it recomputing values that cannot change.

Cache the npub and the shortened npub on the peer at construction. An
npub is a pure function of the peer's public key, and the identity is
never mutated after construction: there is no setter, no identity_mut,
and no assignment to the field anywhere in the tree, so the cache
cannot go stale.

The display name itself is deliberately NOT cached. Two of its inputs
do mutate at runtime — the alias map and the host map, the latter
reloaded on this same tick — so a resolved name stored on the peer
would go stale on an alias change or a hosts reload. Only the
immutable component is memoized.

Tests cover both constructors, that the cached npub matches the
identity, and that the display name still tracks an alias change. The
memoization itself is asserted by pointer stability rather than by
timing, so it is deterministic under load. Three deliberate breaks
were each caught by exactly one test: re-deriving instead of
memoizing, populating one constructor's cache from the wrong source,
and reordering the display-name fallback so it stops honoring aliases.
2026-07-29 01:32:48 +00:00
Johnathan Corgan d6457fa74f Stop awaiting the advert refetch on the retry tick
process_pending_retries runs inline on the node's 1-second rx-loop
tick. For each due peer it awaited a Nostr relay fetch carrying a
2-second timeout, and discarded the result. With up to sixteen due
peers in one tick body, the timeouts stack: field profiling measured
single 2.00 s stalls as the common case and a worst tick of 12.4 s
against a 1 s period, with every other rx-loop arm delayed behind it
by as much as 4.2 s.

Spawn the refetch instead of awaiting it, matching the pattern the
failure arm of the same loop already uses thirty lines below. The dial
now uses whatever advert is cached at that moment and the refreshed
one lands for the next retry of that peer. Since retries are
backoff-paced, that defers the benefit by one backoff interval rather
than losing it, and the result was already being discarded, so nothing
downstream read it.

The test drives four due peers whose refetches all hang against a
local listener that accepts and never speaks, so the fetch burns its
full timeout with no network egress. Awaited, the call takes 8.0 s;
spawned, it returns in milliseconds. It also asserts every due peer
was still attempted and rescheduled, so a version that skipped the
dial entirely cannot pass it.
2026-07-29 01:27:49 +00:00
Johnathan Corgan 7d0a110f4e Send traversal signals only to relays the client pool actually holds
A traversal signal is addressed to a merge of the peer's NIP-17 inbox
relays, the relays its advert nominates for signaling, and our own DM
relays. The client pool is built once at startup from our configured
relays and never added to, and send_event_to rejects the entire send
with "relay not found" if any single URL in the list is outside that
pool, before contacting anything. So one relay we are not configured
with, anywhere in that merge, killed the whole attempt -- including the
sends to relays we do share and that would have carried the signal.

In an open-mode window on a public node this made discovery
non-functional: 309 traversal attempts, 290 explicit failures, zero
successes, every failure on "relay not found". Configured peers were
unaffected because they run a matching relay set.

Filter the merged list down to relays the pool holds before sending.
Our own DM relays are always in the merge and always in the pool, so
the result is empty only when no DM relay is configured at all, which
is already a total failure. Comparison is on the normalized RelayUrl
rather than the raw string, because that is how the pool is keyed --
a raw comparison would discard a configured relay spelled with a
trailing slash or a different host case, which is the same defect in
a quieter form.

Merge and filter are one synchronous function so the decision can be
exercised without a relay client. The pool is read via all_relays(),
which is the set send_event_to validates against; relays() is filtered
by service flags and would be narrower.

Two smaller fixes ride along. The responder now resolves its relays
before binding a socket and running STUN, instead of spending a STUN
round trip and holding an offer slot only to discover it has nowhere
to answer. And it gained the empty-list guard the initiator already
had, which gives BootstrapError::MissingRelays a condition it can
reach for the first time -- it was unreachable, since the merge always
appended our own DM relays.
2026-07-29 00:31:36 +00:00
Johnathan Corgan 2fdc831ce3 Claim the NAT lab's two bridges per run and derive every address from them
The NAT lab was the last suite pinning fixed IPv4 subnets, so two overlapping
runs collided on the wan and shared-lan bridges.

Each run now claims a free /24 for each bridge, scanning candidates and
advancing on an overlap while still failing fast on any other network-create
error. The claim exports NAT_WAN_PREFIX and NAT_LAN_PREFIX, and every routable
address in the compose file and the suite scripts derives from them, with
defaults that render exactly what the lab used before. The router-side LANs are
deliberately left pinned: they live inside per-container network namespaces,
never become docker networks, and cannot collide.

An external-network overlay lets the suites attach to the networks the run
already claimed instead of creating their own. Two of the three suite scripts
had no overlay hook, so they would have requested the claimed range a second
time; both now have one.

Host veth names are scoped by a short token rather than the full run id, which
overruns the fifteen-character interface-name limit at the default run-id
length, and the cleanup reaper's pattern is widened to match the new shape.

Networks are released inline on every exit path rather than in a trap, since a
trap written inside a shell function replaces the script-level handler and
would disable the whole run's teardown.
2026-07-27 14:34:46 +00:00
Johnathan Corgan fbff52d85e Give the NAT lab per-run generated configs
The NAT lab wrote its generated node configs to one shared directory, unlike
the static and firewall labs which already scope theirs by run. The directory
is bind-mounted by compose and read back by the suite scripts after the
containers are up, so two overlapping runs let the second run's generator
overwrite the npubs the first is about to ping.

Scope the directory with FIPS_CI_NAME_SUFFIX at all three places that have to
agree: the generator's output path, the ten compose bind-mounts, and the
CONFIG_DIR the suite scripts read back from. An unset suffix renders the plain
path the lab has always used, so a bare invocation and the GitHub matrix are
unaffected. The run teardown removes the per-run directory alongside the static
and firewall ones, and the gitignore is widened to cover the suffixed form.

Rendering the compose file with every profile and no suffix set reproduces
today's exact ten paths; setting a suffix moves all ten.

This lands on its own, ahead of the network and address work, so that a failure
of the two-overlapping-runs acceptance test can be bisected between the config
fix and the address conversion.
2026-07-27 14:34:33 +00:00
Johnathan Corgan 93800a503e Retire the bloom-storm chaos scenario from both CI runners
The scenario guarded a real regression: a mid-chain tree update that
changed neither root nor depth leaking downstream as a sustained bloom
announce storm. But it was never once run against the regressed binary,
and its per-node ceiling was inferred from a post-mortem harness that no
longer exists in the tree. On the only surviving regressed measurement
the tail node's rate scales to roughly 7 sends per 30s, well under the
scenario's ceiling of 40, so it was never established that the assertion
could fire on its own bug class. The ceiling is also uniform per node,
calibrated against the flap target that is legitimately busy rather than
against the tail where the storm actually shows.

This removes coverage rather than relocating it, unlike the two earlier
retirements above it, and the comment in ci-local.sh records that gap
explicitly. The scenario, its README, the link_swap sim primitive and the
mesh-lab dispatch all stay on disk, so it remains runnable by hand.

Parity holds at 24 legs a side. The parity guard was break-checked by
re-adding the GitHub leg alone, which correctly reported the asymmetry.
2026-07-26 16:51:23 +00:00
Johnathan Corgan cdda660f10 Stop local CI writing the shared mutable test image tag
The run built per-run images and then retagged them to fips-test:latest as
a compatibility bridge for the consumers that had never been migrated. The
bridge was the defect: while it existed, two concurrent runs shared one
binding, so a suite could start containers from the other run's binaries
and the verdict was recorded against a commit whose code never ran. It
fails silently by construction — the run is green either way, and nothing
compares a running container's binary against the commit under test.

Every consumer now reads the run's image, so the retag is deleted rather
than kept. That is the point of deleting it: a consumer that was missed
fails loudly instead of quietly resolving whichever run wrote the tag last.

Two consequences handled here. The cleanup script ran ip(8) inside
fips-test:latest to reap simulation interfaces, and nothing writes that tag
any more, so it now takes the caller's image, then the run's, then any
surviving test image — the last of which is what keeps an unscoped --reap
working, since that path execs before the run identity is exported. And a
guard checks statically that nothing names the shared tag, because on a
host with a hand-built copy lying around a reintroduction would run green;
it is break-checked against a reintroduced compose consumer and a
reintroduced default. Both runners gate on it, as they do the other guards.
2026-07-26 15:47:22 +00:00
Johnathan Corgan 49163befd5 Give each local CI run its own docker build context
Scoping the image tag was never sufficient on its own. Every build read one
unscoped directory in the working tree, into which each run copies the
binaries it just built, so two concurrent runs raced on the contents of the
context as well as on the name of the result — and a run could produce a
correctly-per-run-tagged image built from the other run's binaries.

The run now copies the context's tracked files into its own directory,
installs its binaries there, and builds from it, exporting the path so every
other consumer follows. Deliberately not carried over: a previous run's
binaries, since inheriting them is the failure this prevents. The path is
absolute because compose resolves a relative build context against the
compose file's own directory rather than the working directory, which was
measured rather than assumed.

The chaos entry script gated the whole simulation on a binary in the shared
directory by literal path, so it moves in this same commit: left behind, it
would have failed on a clean checkout and, worse on a host with leftovers,
passed while reading a binary that was not the one under test.

Teardown removes the directory on red runs as well as green, since it holds
only reproducible content and is never the evidence of a failure. The
worker's SIGKILL runs no trap, so the cleanup script also sweeps contexts
left by a preempted run, and the ignore rule keeps a concurrent run from
showing up as untracked working-tree noise.
2026-07-26 15:42:48 +00:00
Johnathan Corgan af847c68b5 Stop the chaos simulation rebuilding the shared test image per scenario
The runner rebuilt the shared tag from the shared build context at the
start of every scenario, unconditionally. Local CI runs chaos scenarios in
parallel, so that was several concurrent builds into one name inside a
single run, before any second run is considered, and it silently replaced
whatever image the harness had already built and handed over.

It now uses the caller's image when one is named, and asserts the image is
present rather than building a substitute, because under a harness a miss
means something upstream is broken and building would hide it. A bare run
still builds the shared tag exactly as before, reading the run's own build
context when one is set.
2026-07-26 15:40:03 +00:00
Johnathan Corgan a718cef8ce Have every suite compose file name the caller's test image
Only the static family read FIPS_TEST_IMAGE; the firewall, ACL, nat,
sidecar and both Tor compose files named the shared mutable tag directly,
which is why the local CI runner has to retag its per-run image to that
name. They now take the same defaulted form the static file already uses,
so a harness run resolves the image it built and a bare hand run still
resolves the shared tag exactly as before.

Checked by rendering each file through `docker compose config` with the
variables set: every service resolves to the supplied name and none is
left naming the shared tag.
2026-07-26 15:38:28 +00:00
Johnathan Corgan cbbdf2c13c Stop the firewall and ACL suites rebuilding an image their caller supplied
Both suites ran `docker compose up -d --build` unconditionally, and their
compose files carry both a build context and an image name, so the flag
rebuilt and retagged the shared test image from the shared build context on
every run. Their --skip-build flag did not cover it: it guarded only the
Rust build above. Under a harness that has already built the image and
handed it over, that rebuild can only replace the binaries under test with
whatever the shared context currently holds.

The nat scripts had the mirror-image problem. Their image guard built a
replacement whenever the image was missing, which is right by hand and
wrong under a harness: when the caller names an image it built, a miss
means something upstream is broken, and manufacturing a substitute hides
that. They now fail loudly in that case and keep building only on the hand
path. The mesh-lab guard reads the same variable and its build hint moves
to the script that still produces the shared tag.
2026-07-26 15:37:50 +00:00
Johnathan Corgan 24bf4d46d4 Widen the churn-mixed baseline bounds to sit outside the scenario's own spread
The root-count ceiling of 4 was set from six runs whose observed maximum
was 3. Fourteen runs that recorded a baseline verdict put the real
distribution at 1 to 5 roots, so the ceiling sat at roughly the 93rd
percentile of the scenario's own variance: one sample in fourteen
exceeded it and four sat at or above it. It therefore reddened a share of
runs whatever the daemon did, which is a calibration fault rather than a
convergence one. Every sample ran the file's fixed seed, so the spread is
container timing and not a differing chaos schedule.

The ceiling is now 6, one step beyond the observed maximum, and the
parented floor moves to its complement at 4. That still fails a mesh
where seven or more of ten nodes are islanded, which is the collapse case
the assertion exists to catch; driving evaluate_baseline directly
confirms all fourteen samples pass and 7, 8 and 10 roots still fail.

min_sessions is left at 10 against an observed minimum of 12. It has
never fired, but the comment now records the margin as thin so a future
failure there is read as calibration first.
2026-07-26 15:22:43 +00:00
Johnathan Corgan cf129cde77 Retire the three ignored Ethernet tests, which covered nothing unique
All three required root or CAP_NET_RAW, neither runner passes --ignored,
and so none had ever executed in CI. Before deleting them I checked what
each covered and whether anything else covered it:

- The two-node handshake is covered by the ethernet-only chaos scenario,
  whose baseline assertion cannot pass unless handshakes complete over
  AF_PACKET.
- Tree convergence and root election by smallest NodeAddr are covered by
  ethernet-only's single-root assertion and by the in-process
  spanning-tree tests respectively.
- The mixed-transport coexistence property is covered twice over by
  test_tcp_mixed_transport_coexistence and test_ble_mixed_transport, which
  call the same verify_tree_convergence_components helper with the same
  two-component shape and both run today.

So the only thing running them would have added is an AF_PACKET variant of
a property already proven on two other transports.

The counts confirm nothing running was lost: 1392 tests pass before and
after, and the ignored count falls from 7 to 4.

The ethernet-only comment block cited these tests as the reason its
assertion was the only Ethernet coverage in CI. It now records the sharper
fact found while reading them: that coverage is control plane only. Both
Ethernet scenarios disable traffic, so no datagram crosses an Ethernet
link anywhere, which leaves the frame length field that trims NIC minimum
frame padding ahead of AEAD verification unexercised. Deleting these tests
does not widen that gap, because the test named for data exchange did not
exchange any.
2026-07-26 14:41:47 +00:00
Johnathan Corgan 4315328f2b Stop the log analyzer double-counting outbound peer promotions
The shared analyzer matched two strings for one event: "Peer promoted to
active", emitted at info, and "Outbound handshake completed", emitted at
debug. Both come from handle_msg2 on the same call path for the same
outbound promotion, so any run at debug level counted every outbound
promotion twice.

Measured rather than argued. On the archived debug-level churn-mixed run
of 2026-07-25, the node logs carry 20 of each string; the old matcher
reported 40 promotions and the new one reports 20. The neighbouring
counters are unmoved at 13 sessions and 33 parent switches.

Info-level runs are unaffected, which was checked rather than assumed: on
the tcp-mesh run of the same date the debug string occurs zero times, and
the old and new matchers both report 7 against 7 raw occurrences. Three
scenarios run at debug, one of them gating, so this corrects the promotion
figure in their artifacts. No assertion reads the counter, so no suite's
verdict changes.

The earlier fix that introduced this added the live string alongside the
dead one where it needed to replace it.
2026-07-26 14:24:44 +00:00
Johnathan Corgan 44e04eb2e2 Retire the tcp-chain static test topology
The tcp-chain profile has never been runnable. ping-test.sh dispatches on
chain and mesh only, and the string tcp-chain has never appeared in that
script in its history, so invoking the profile has always fallen through
to the unknown-profile branch: silently asserting nothing before that
branch was made to fail, and exiting 2 since. Neither runner referenced
it either.

What it would have covered is already covered. The chaos tcp-mesh
scenario runs in local CI and gives TCP a discriminating gate: n04's only
edges are TCP, so if the transport were broken n04 could not parent and
the scenario's baseline assertion would fail. It also carries a pure-TCP
two-hop path, n01 to n04 to n05, under netem and link flaps. The only
residual tcp-chain would have added is a mesh with no UDP present
anywhere, which is not worth a fixture that has never run.

Remove the topology, its three compose services, and the documentation
rows. Every profile the static compose file still defines is now
exercised by a suite.

Also correct the chaos README, which has listed tcp-only and tcp-chain in
its transport table since those two scenarios were deleted in 9e63b42.
Neither has existed for months.
2026-07-26 14:02:12 +00:00
Johnathan Corgan fa49dc1210 Retire the mesh-public static test topology
The mesh-public profile ran in neither runner: ci-local's static suite
list carries only static-mesh and static-chain, and the GitHub matrix has
only mesh and chain. It also added no coverage over static-mesh.
ping-test.sh and iperf-test.sh branched mesh and mesh-public together and
exercised the same 20 directed pairs among node-a through node-e, and no
script referenced the external node at all. The convergence waits even
used mesh's peer counts rather than mesh-public's, so the extra link to
the public node was never counted, let alone asserted.

Running it would therefore have added a dependency on a live internet
host (test-us01.fips.network) in exchange for zero additional assertions.
Remove the topology, its five compose services, the script branches that
aliased it to mesh, and the documentation rows. An invocation using the
old profile name now fails on ping-test.sh's unknown-profile guard
instead of silently behaving as mesh.

The config generator's external-node support (external_ip,
is_external_node) stays. It has no consumer now, but it is woven into the
config path every remaining topology uses, so removing it would put the
gating suites at risk for no present gain.
2026-07-26 13:54:14 +00:00
Johnathan Corgan 0eea7dae13 Make a zero-peer floor unreachable in the shared convergence wait
wait_for_peers reads the connected-peer count through a pipeline ending in
`|| echo 0`, so a container that never answers contributes 0. That is safe
against a floor of 1 or more, where 0 reads as "not converged yet" and the wait
eventually times out, and it is unsafe against a floor of 0, where the first
read from a dead container satisfies the wait and the caller proceeds as though
convergence had been observed.

No caller passes 0 today; every one passes 1 or more, and the single variable
minimum is advisory. Rather than harden the reader, reject the input: a floor
below 1 is now an error. That makes the shape unreachable instead of repairing
instances of it, and it costs a future caller nothing except a clear message
saying that asserting "exactly zero peers" needs a reader that distinguishes no
answer from zero, which this floor is not.

wait_for_links goes with it. It had no caller anywhere in the tree on any
branch, and it carried the identical fallback, so the only thing it could do was
hand the hazard to whoever called it first. An uncalled helper cannot be wrong
today, which is exactly why it was the risk worth removing rather than the one
worth keeping for symmetry.

The hermetic gate suite gains a case covering both directions, with docker
stubbed so it stays container-free: a floor of 0 is refused with a message
naming why, and the same unreachable container with a floor of 1 still polls its
full budget and times out. Verified by removing the guard and confirming exactly
the three zero-floor assertions red while both floor-of-one controls stay green.
2026-07-25 19:15:31 +00:00
Johnathan Corgan 1077fd6a7d Stop the firewall peer reader turning a silent container into a count of zero
wait_for_peers_exact read the connected-peer count through a pipeline ending in
`|| echo 0`, so a container that never answered and a daemon that answered zero
produced the same value. That is only harmless while every caller expects a
non-zero count, which is true here today and is the reason this copy was left
alone when the acl suite's copy was fixed. It leaves the trap armed for whoever
adds the first caller expecting zero: the check would be satisfied on its first
iteration without the property it exists to verify ever being observed.

The read moves into its own function that returns the empty string when the
container does not answer, and the caller treats empty as "no answer" rather
than as a count. A run that never gets an answer now fails saying so, distinctly
from one that answered the wrong count, and the diagnostic peer dump runs only
on the latter, since dumping from a container that cannot answer prints a docker
error rather than evidence.

This is the shape the acl-allowlist suite already uses. The two copies had
diverged on whether a silent container counts as an answer, which is the kind of
disagreement that decides a security assertion in whichever file is read last.

Checked by construction rather than by a green run: driving the old form against
an absent container with an expected count of zero returns success on the first
iteration, and the new form fails; with an expected count of one, the shape both
real callers use, both forms still fail, and a genuine zero from a live daemon
is still reported as zero.
2026-07-25 19:02:15 +00:00
Johnathan Corgan 0e42c789be Let the firewall suite float its docker subnet
The compose pinned 172.32.0.0/24 with a per-container ipv4_address, so two
concurrent runs asked docker for the same address space and the second failed
with a pool-overlap error. Request no subnet and let docker assign one from the
daemon pool. Peers address each other by the docker hostname the compose already
sets (host-a, host-b) rather than by literal IP; docker's embedded DNS is
per-network, so the same hostname in two runs resolves inside each run's own
subnet. Nothing this suite asserts on moves as a result: its checks run over the
fips0 overlay, whose addresses are derived from node npubs and are independent of
docker addressing, and the packaged nftables ruleset matches on interface rather
than on any address.

The generated config directory moves under the run suffix for the same reason it
did in the acl suite, and the run teardown gains the matching removal so a run
no longer leaves its directory behind.

Case (b) also wrote curl's output to a fixed path under /tmp. Two concurrent
runs shared that one file, and either run's cleanup landing between the other's
write and read left an empty read, failing the http_code check for a reason
having nothing to do with the firewall. It uses mktemp now.

As in the acl suite, the floating subnet removes one of the two obstacles to
concurrent runs. The compose project name is still fixed; the local CI runner
scopes it externally, a bare hand run does not, and the comment at the site says
so rather than claiming the file is self-sufficient.
2026-07-25 19:01:56 +00:00
Johnathan Corgan 611f045d33 Let the acl-allowlist suite float its docker subnet
The compose pinned 172.31.0.0/24 with a per-container ipv4_address, so two
concurrent runs of this suite asked docker for the same address space and the
second failed with a pool-overlap error. Request no subnet instead and let
docker assign one from the daemon pool, which leaves nothing for two runs to
contend for. Peers now address each other by the docker hostname the compose
already sets (host-a through host-f) rather than by literal IP, and docker's
embedded DNS is per-network, so the same hostname in two runs resolves inside
each run's own subnet.

The generated config directory moves under the run suffix in the same change,
because it has to: the generator does rm -rf on it, so a shared directory means
one run deletes the fixtures another is still using, which fails silently rather
than loudly at bring-up. Scoping it requires the generator output path, all five
bind-mount sources per service, and the gitignore entry together; scoping only
the generator leaves the compose reading the unscoped path.

The floating subnet removes one of the two obstacles to running this suite
twice at once, not both: the compose project name is still fixed, and nothing
scopes it for this suite. Recorded at the site so the comment does not claim
more than the change delivers.
2026-07-25 19:01:16 +00:00
Johnathan Corgan 0cb5574077 Reject rekey settings that fire the trigger immediately and forever
Config validation said nothing about the rekey block, so two settings that
disable rekey in appearance and hammer it in practice were accepted silently.

after_messages of zero makes the message-count arm true on every poll, since
the trigger tests the counter against it with a greater-or-equal. It reads like
a way to switch the arm off and does the opposite.

after_secs at or below the per-session jitter is the same trap on the timer
arm. Each session offsets the interval by a random value drawn from plus or
minus the jitter bound, so a smaller interval saturates to zero on a negative
draw and rekeys on sight, for roughly half of sessions and not the other half.
The rule is expressed against the jitter constant rather than its current
value, so it tracks if the bound ever moves.

Both are checked whether or not rekey is enabled, so turning it on later cannot
surface a configuration error at a surprising moment. Neither has an upper
bound: a very large value is the established way to disable one arm of the
trigger and stays legal.
2026-07-25 17:07:57 +00:00
Johnathan Corgan 989ae65fc5 Align the datagram hop-limit helpers with the forwarder's semantics
The forwarding path was corrected to full IP semantics: local delivery is not
hop-limit gated, and forwarding decrements first and drops at zero. Two helpers
on SessionDatagram were left implementing the old rule. decrement_ttl still
checked before decrementing, and can_forward still answered true at a hop limit
of one, where the decrement leaves zero and the datagram is dropped. Neither is
called anywhere today, which is precisely why they are worth correcting rather
than leaving as an invitation to reintroduce the defect.

The TtlExhausted reject doc described behaviour that no longer exists. The
reject is charged for a transit arrival at one as well as at zero, and is never
charged for a datagram addressed to this node, whose delivery is decided ahead
of the test.

Both helpers are pinned by tests at the boundary, which fail against the
previous implementations.
2026-07-25 17:05:20 +00:00
Johnathan Corgan 1eabe08575 Correct what the address-to-link map claims to be for
The doc said the map "enables dispatching incoming packets to the right
connection before authentication completes." It does not: find_link_by_addr
has no callers outside its own tests on any branch, and encrypted frames
dispatch by session index. Its live readers are the msg1 admission fast
path and the duplicate-inbound-handshake check.

The comment mattered because the map has a trap that invites exactly the
misuse it was advertising. An outbound dial registers the literal
configured address string, which may be a hostname, while an inbound
packet carries the resolved form; TransportAddr compares byte-wise, so
the two never match and a lookup keyed on an inbound address returns "no
such link" for every hostname-configured peer rather than failing. The
entry is also single-valued per key, so an inbound handshake overwrites
an outbound dial's entry for the same address.

Both current readers survive this because each compares a key written in
the same form it reads. That is a property of those two call sites and
not of the map, so the comment now says so, and says not to key a
peer-identity question on it.

Documentation only; no behaviour changes.
2026-07-25 01:50:40 +00:00
Johnathan Corgan 9846e85705 Retire the admission-cap Docker suite; the inbound gate is proven in-process
The inbound max_peers early-gate in handle_msg1 (silent-drop a Msg1 from
a net-new identity at saturation, send no Msg2, admit no peer) is
unit-tested over a real UDP socket by
handle_msg1_silent_drops_at_cap_for_new_peer in src/node/tests/unit.rs:
it saturates a node, sends a Msg1 from a fresh identity, and polls the
sender socket to assert no Msg2 comes back — the same wire-observable
discriminator the Docker suite's tcpdump used — with a sibling test for
the existing-peer bypass. That is a deterministic superset of the Docker
packet-capture assertion.

Drop it from both runners in lockstep (its *_SUITES array, run function,
default-flow loop, --only dispatch arm, and the GitHub matrix leg with
its steps) so the parity guard stays green, and record the retirement in
the deliberately-not-run block with a pointer to the standalone runner.
The admission-cap-test.sh script stays on disk and runs by hand.
2026-07-24 02:31:43 +00:00
Johnathan Corgan 41ce64ba82 Retire the acl-allowlist Docker suite; the decision is proven in-process
The ACL admission decision is exhaustively unit-tested per npub over real
loaded allow/deny files (src/node/acl.rs test module: allow-match-wins,
allowlist-miss falls through, deny-only, deny-all, allow_all override,
deny-after-allowlist-miss), and the inbound and outbound handshake-
admission paths are covered in-process over the loopback transport
(src/node/tests/acl.rs: inbound msg1 denial, outbound denial, reload).
The Docker suite's only unique coverage was admission over a real UDP
transport, which every other real-transport suite already exercises, and
its operator-facing log assertion.

Drop it from both runners in lockstep (its *_SUITES array, run function,
default-flow call, --only dispatch arm, and the GitHub matrix leg with
its steps) so the parity guard stays green, and record the retirement in
the deliberately-not-run block with a pointer to the standalone runner.
The testing/acl-allowlist/ suite stays on disk and runs by hand.
2026-07-24 02:29:38 +00:00
Johnathan Corgan 7cbe1d3d4e Retire the smoke-10 chaos scenario; convergence is covered in-process
smoke-10 was a no-stressor 10-node tree-convergence sanity check (netem
off, no ping). Its subject, spanning-tree convergence and root election,
is now covered in-process, faster and deterministically, by the loopback
spanning-tree harness (src/node/tests/spanning_tree.rs: ring, star,
chain, 100-node and disconnected-component convergence) plus end-to-end
datagram delivery (src/node/tests/forwarding.rs). Real-UDP convergence
smoke still runs via static-mesh and the other scenarios' baseline
assertions, so no Docker coverage is lost.

Drop it from both runners in lockstep (the CHAOS_SUITES list and the
GitHub chaos matrix) so the parity guard stays green, delete the scenario
YAML, and update the chaos README.
2026-07-24 01:43:20 +00:00
Johnathan Corgan 08a226fb63 Test cost-based parent selection and kernel-drop detection as unit tests
The cost-selection chaos scenarios (cost-reeval, cost-avoidance,
cost-stability, depth-vs-cost, mixed-technology, bottleneck-parent) tested
TreeState::evaluate_parent's decision logic through a Docker mesh that could
not exercise it reliably: the tree roots at whichever node holds the smallest
NodeAddr, MMP link costs take several measurement windows to settle, and the
parent hold-down plus hysteresis timing all confound the outcome. A
deterministic link-cost flap still produced zero periodic parent switches in a
full run.

Replace those six scenarios with deterministic unit tests in src/tree/tests.rs
that drive evaluate_parent directly: cheaper-link selection at equal depth,
switch-on-cost-change, hysteresis suppressing a marginal change while allowing
a significant one, and the depth-versus-cost effective-depth tradeoff. Each is
constructed so that breaking the cost or hysteresis logic makes it fail.

The congestion kernel-drop signal (SO_RXQ_OVFL) cannot be provoked
deterministically in Docker: a fresh daemon reader keeps up with
container-speed traffic, so the socket receive queue never overflows (an
unshaped run with a 4 KB buffer and heavy traffic recorded zero drops on every
node). Extract the drop-detection edge -- read the cumulative counter, fire an
event only on the transition into a new drop burst -- into
TransportDropState::observe_drops and unit-test it directly. congestion-stress
keeps its ECN and MMP congestion-signal assertions, which do need the real
shaped bottleneck queue.

Remove the retired scenarios from both CI runners and update the chaos README.
2026-07-23 23:33:01 +00:00
Johnathan Corgan be5deee814 Make the gateway integration suite safe for concurrent CI runs
gateway-lan pinned 172.20.1.0/24 and fd02::/64, so two concurrent local CI
runs collided on "Pool overlaps" at network creation. The IPv4 subnet has no
consumer -- the whole gateway LAN path is IPv6 -- so drop it and let docker
auto-assign. The IPv6 side cannot float, because the LAN clients' resolv.conf
pins the gateway's address as their nameserver and that must be a literal
known before they start, so claim a free /64 per run and thread the prefix
through the compose address pins, a generated resolv.conf, and the test via a
single exported variable.

The claim and the external network ship as a harness-only compose overlay
applied by run_gateway; the base compose keeps a normal gateway-lan network,
so the GitHub matrix and any standalone bring-up are unaffected. With no claim
every address renders exactly as before.
2026-07-23 19:50:17 +00:00
Johnathan Corgan bb9bca4d83 Accept a degraded systemd as a booted one in the deb-install boot wait
The boot check piped `systemctl is-system-running --wait` into `grep -qE
'running|degraded'`, but the script runs `set -o pipefail` and is-system-running
exits non-zero for `degraded`. So the pipeline failed on a degraded system even
though grep matched, and the wait looped to its timeout. The older distros reach
`running` (exit 0) and passed; debian:trixie and ubuntu:26.04 reach `degraded`
because a unit that cannot run in a container (systemd-modules-load) fails, so
they timed out at every ceiling -- which is why the earlier timeout increase did
nothing.

Capture the state string and test it directly instead of trusting the pipeline
exit, so a degraded-but-booted system is accepted as intended. Validated: all
five distros pass, including the two that previously failed.
2026-07-23 19:50:07 +00:00
Johnathan Corgan 2bc0345174 Make the cost-based chaos assertions reliable under parallel CI load
cost-avoidance and mixed-technology decide a parent by measured link cost
(etx * (1 + srtt_ms/100)). Under the local CI's 4-way-parallel chaos the fast
fiber link's measured srtt spikes from host scheduling and can exceed the
Bluetooth link's, flipping the choice and reddening a run that proves nothing
about the daemon. The old margin was ~0.4 cost units, which a ~24ms one-sided
scheduling blip closes.

Widen the Bluetooth delay to 150-250ms so the margin dwarfs any plausible
one-sided spike. The link still establishes well within the handshake budget
(a ~500ms RTT against a 30s stale-handshake window), so the losing candidate
is still a real, established peer rather than an absent one.

Add a netem mutation exclude_edges option and use it in mixed-technology to
pin the two links n08 chooses between, so a random degradation can't flip the
asserted comparison either. An unknown excluded edge is a hard error, since a
silent no-op would reintroduce the flakiness it exists to remove.

Validated by running both scenarios repeatedly under heavier-than-CI
concurrent load: the parent choice holds every time.
2026-07-23 18:58:49 +00:00
Johnathan Corgan 9b7a27f219 Claim the chaos network range instead of deriving it, ending the collision
Two concurrent ci-local runs could not both run chaos. Each child's subnet came
from its position in the suite list, so both runs walked 10.30.0 through
10.30.12 and requested identical ranges; whichever reached the daemon first won
and the other died with a pool overlap. A run index or hashed offset would only
make that unlikely, and it is precisely the failure being removed.

The simulator now claims its range: attempt-create on a candidate, advance on
docker's own overlap error, fail loudly on anything else. Docker's address pool
becomes the arbiter, so an overlap is impossible rather than improbable. The
claim lives in the sim rather than in ci-local because only the process that
creates the network can advance on conflict, and because it fixes the bare
chaos.sh path too, which a ci-local-only fix would have left broken.

The generated compose now declares the network external over the claimed one,
and teardown releases the range from both paths, including the setup-failed
path where a run that fell over after claiming still holds one.

Ordering matters here and is not obvious: node IPs derive from the subnet
during topology generation, and traffic shaping keys its filters on those
addresses, so the claim happens before the topology exists. Claiming later
would give a network on one range and filters on another, which does not fail
at bring-up and instead leaves the shaping matching nothing.

--subnet survives as an explicit pin for when a known range is wanted, and now
fails loudly if that range is taken rather than silently overlapping. ci-local
no longer passes it.

Validated by running two instances of the same scenario deliberately
concurrently: they claimed 10.30.1.0/24 and 10.30.0.0/24, both exited 0 with
their assertions passing, and both released their networks. The negative
control holds too: with a squatter on a pinned range the run aborts with a
message naming the range rather than proceeding.
2026-07-23 17:09:07 +00:00
Johnathan Corgan f478f51afe Assert mixed-technology's sound criterion, and correct the one that never was
With the root pinned, n08's test subject is assertable for the first time: its
two candidates sit at equal depth with fiber-grade default netem on one side and
Bluetooth on the other, which is exactly the preference this scenario exists to
test, and it takes the fiber parent in four of four runs. Encoded as written
rather than calibrated from the runs, since "should pick n03" is the spec and
the runs merely confirm it is met.

n06 is deliberately absent, and its documented criterion is corrected rather
than left standing. The header called n03 a fiber parent for n06; the netem
policies in the same file say that link is WiFi and the other candidate is
Bluetooth, so n06 chooses between two impaired links. Its only fiber-grade link
reaches a node at depth 3 that is never a good parent. There was never a reason
to expect it to prefer n03, and the two-of-four split observed is correct
behaviour: in a run where it took the Bluetooth peer, the fiber-labelled
candidate measured a link cost of 3 against the other's 1, so the daemon chose
the cheaper effective depth as it should.

Validated by replaying the assertion against all four archived runs, then
breaking it three ways to confirm it can fail, then one live run for the wiring.
One of the breaks expects n06 to take n03 and reports that it chose n02, which
is the direct demonstration of why n06 is not encoded.

cost-reeval records that its subject now fires in two runs of three, against
zero of five when it was structurally impossible, and why that rate is still not
one an assertion can rest on.
2026-07-23 17:04:23 +00:00
Johnathan Corgan 9d92cfeab4 Correct three scenario headers that documented conclusions since disproved
congestion-stress said the transport drop-detection path "is exercised by
nothing". Widening the scan beyond this one scenario finds it firing in six
others, so the path is live and what is dead is this scenario's ability to
provoke it. Records the arithmetic that explains why: at the 1 Mbps cap the
receive queue needs tens of milliseconds of reader stall to overflow, and the
ingress policer discards the same traffic a layer below the socket.

mixed-technology said the implementation does not do what its criteria say.
It does. The criteria were conditioned on a tree rooted at n01 while the mesh
rooted at n09, under which n08's choice was settled by depth before link
technology could matter. The archived parent distributions are relabelled as
describing the old root so nobody calibrates against them.

cost-reeval now records why it had no assertion about its own subject: its
designated subject was the root, so the parent switch it exists to observe
could not occur. The historical logs show the switch on n04 in 13 of 14 runs
when the root still wandered, and only on n01 in the five runs before the fix.

Each header now names what remains to be done and where it is tracked, rather
than leaving a disproved conclusion in place for the next reader to inherit.
2026-07-23 15:47:28 +00:00
Johnathan Corgan 6319c7f577 Describe the trailing command accurately in the gate's finding message
For a value-returning helper the trailing echo is the return mechanism, not a
log call. The hazard is the same either way -- it fixes the exit status at zero
-- but calling it a log call misdescribes four of the sites the gate now finds.
2026-07-23 15:46:04 +00:00
Johnathan Corgan d4a2504f99 Teach the trailing-log gate to see command-substitution call sites
Found while fixing count_log_pattern: the gate reported that function clean
even though it ends in echo and both callers consume its status. Its call-site
patterns only matched direct forms -- if fn, while fn, fn ||, fn && -- so a
status consumed through command substitution was invisible, because the line
begins with the variable rather than the function name.

That is not an exotic form. It is how a shell function returns a value, and it
is precisely the shape of the swallowed-failure family this gate exists to
catch, so the gate was blind to a large part of its own stated class.

Three patterns added for the assignment, if-guarded and test-expression forms.
The extension finds four real instances, all value-returning helpers whose
trailing echo made their exit status unconditionally zero, and each now carries
an explicit return 0. Also corrects the finding message, which described the
trailing command as a log call; for these it is the return mechanism, and the
hazard is that it fixes the status either way.

Validated by breaking what it guards: a probe reintroducing the defect shape
behind a command substitution is reported and exits 1, where before the
extension it would have passed.
2026-07-23 15:45:37 +00:00
Johnathan Corgan bf173d8d98 Pin the chaos mesh root to n01 so scenario diagrams describe the real tree
Every chaos scenario draws n01 at the top of its topology, and until now that
held in three of thirteen. The mesh roots itself at the numerically smallest
NodeAddr, which is a hash of the node's public key and bears no relation to the
node numbering, so which node ended up as root was effectively arbitrary.

The consequences were not cosmetic. cost-reeval rooted at n04, its own
designated test subject, so that node had no parent and the periodic parent
switch the scenario exists to observe could not occur at all. mixed-technology
rooted at n09, which put its two documented parent criteria out of reach and
made correct cost-based selection look like a defect.

Identities are still derived from the mesh name exactly as before and are still
deterministic. What changes is which node id holds which one: they are now
assigned in NodeAddr order, so n01 holds the smallest and is the root. Verified
against a model of the daemon's own derivation that reproduces the previously
observed root for every scenario and n01's address byte for byte; all twelve
pinned scenarios now root at n01.

smoke-10 deliberately opts out via pin_root: false so that root election from an
arbitrary key distribution stays exercised somewhere. Its assertion is a
convergence floor and is root-agnostic, which is why it is the cheapest home for
that. The new key is rejected when non-boolean, and a near-miss spelling is
rejected as unknown; both checked.

Not yet established: the trees themselves change, so the parent-dependent
assertions in bottleneck-parent, cost-avoidance and cost-stability need
re-deriving against live runs. Those are held until the in-flight CI finishes,
because a chaos run rebuilds the shared fips-test image that run is using.
2026-07-23 15:43:10 +00:00
Johnathan Corgan 34a2561af7 Close the remaining "caught" and "produces red" harness holes
Four sites where a check could report a verdict it had not established, or
detect a failure and then not turn it red.

assert_no_panic in both NAT suites read `docker logs ... || true`, so a
container that could not be read produced empty output, matched no panic
pattern, and returned success. The assertion's failure mode was
indistinguishable from its success condition. It now reports that absence of
panics is not established.

deb-install's apt capture discarded the exit status, so a failed docker exec
gave an empty capture that matched neither error pattern and reached the pass
branch. The status is now kept and checked before the output is inspected, with
the output still printed on failure.

wait_for_systemd printed a warning and returned success on timeout, so every
check after it read a system that may not have started its units. It now returns
non-zero and the caller abandons that distro leg rather than testing an
unstarted system.

interop's copy of count_log_pattern carried the same defect fixed in rekey: a
node whose logs could not be read contributed zero to eight expect-zero
assertions. Fixed the same way, and its consumer now reports the unreadable case
rather than comparing a sentinel against zero.

Each validated by breaking what it guards and by confirming the healthy path is
unchanged: an absent container now fails each check where it previously passed,
a readable panic-free container still passes, a real panic is still caught, and
a genuinely successful install still passes.
2026-07-23 15:36:59 +00:00
Johnathan Corgan c8a0ac5fca Stop a node whose logs cannot be read from counting as a clean node
count_log_pattern summed a per-node `docker logs | grep -c ... || true`, so a
node the harness could not read contributed zero. The six assert_zero_count
callers are negative health assertions -- no panics, no ERROR lines, no AEAD
decrypt failures, no rekey msg2 failures -- and a contributed zero reads as
clean, so one unreadable node silently weakened the assertion and all of them
voided it. This is the family the harness-fallback issue was raised to high
priority for, and it is the one remaining audit residual that can make a green
rekey run lie about protocol code.

The read now fails the count rather than degrading it, printing a sentinel that
names the container. Both callers split the declaration from the assignment,
because `local c=$(fn)` takes local's exit status and discards the function's --
which is how the original defect stayed invisible.

Validated by breaking what it guards rather than by observing green: against
absent containers the old reader returns 0 and assert_zero_count passes
vacuously, while the new one returns rc=1 and reports a failure. Checked the
other direction too, since a fix that reds a legitimately clean run is no use: a
genuine zero across readable nodes still returns 0, and the sum is unchanged at
2+1+0 for a shimmed three-node read.
2026-07-23 15:30:01 +00:00
Johnathan Corgan 55331a80b5 Gate on shell functions whose exit status is a log call's
A bash function returns its last command's status, so one ending in a log
call returns 0 whatever it did, and a caller written as `if func` or
`func || fail` has a gate that cannot fire. This tree found two in one
day: run_chaos ended in record, making every chaos row unconditionally
green since 2026-03-09, and build_fips_for_e2e ended in log, letting five
end-to-end legs test the previous commit's binary and report green. Both
were repaired one at a time. This is the rule that catches the next one.

What it enforces is a contract, not a bug hunt: a function whose status a
caller tests must end in an explicit return rather than leaving its
success value to whatever the last log call produced. That distinction is
deliberate and worth stating, because all three instances found in the
tree were benign — each failure path already returned early, so the
trailing echo reported a real success. Reading the function is the only
way to know that, and the next edit that puts an unguarded command before
the final log converts the benign shape into the defect with nothing to
notice. An explicit return costs a line and makes the class unreachable.
The three are given one here.

Scoped on the call sites rather than the definitions, the same way
check-log-strings.py scopes on what a grep reads rather than what its file
mentions: 315 functions scanned, 42 end in a log call, and only the ones
whose status something consumes are reported. A function that ends by
reporting and is called for its output is idiomatic and is left alone.
Without that scoping the finding list would be 42 long and would stop
being read.

Validated by construction rather than by a green run: injecting a copy of
build_fips_for_e2e's exact shape — unguarded docker cp, then a log as the
last statement, with a `|| { ...; return 1; }` caller — makes the checker
report it and exit 1.

Wired into both runners beside the parity and log-string checks.
2026-07-23 14:09:04 +00:00
Johnathan Corgan 3181861341 Assert the Link MMP pane exists before asserting its colour
mmp_focused_pane_indicator checked that the unfocused Link MMP title is
not cyan with assert_ne! over fg_at. fg_at is find(..)? mapped to the
cell's foreground, so it returns None for a title that was never drawn,
and None != Some(Cyan). The assertion therefore passed just as happily
when the pane was missing entirely as when it was present and unstyled.

Assert presence first, so the colour check means "not highlighted"
rather than "not there".

Demonstrated rather than argued. Renaming the pane title in mmp.rs so
"Link MMP" is never rendered leaves the original assertion passing, and
makes the new one fail with the message it was given. Both files restored
after.

This is the only instance of the shape: it is the sole assert_ne! over
fg_at or find in the fipstop tree, and the only assert_ne! in snapshots.rs
at all.

Quartet clean: fmt, build, clippy --all-targets -D warnings, test --lib
at 1376 passed / 0 failed / 7 ignored.
2026-07-23 14:00:49 +00:00
Johnathan Corgan 0f27bdbd2c Give the UDP microbench a reason and record the retry asymmetry
The UDP recv microbenchmark carried a bare #[ignore] while its sibling in
the link module carries a self-describing one. Match it, so the reason
appears wherever the test is listed rather than only in the doc comment.

Record why [profile.ci] retries = 2 is not applied locally. The two gates
disagree on purpose: the hosted runner retries a flaky test twice, the
local sweep fails on the first failure. It exists for shared-runner packet
loss, a property of that environment rather than of the code, and applying
it locally would suppress a real local flake — a failure that only
reproduces under load is a robustness bug to fix, not to retry past.
Keeping the local sweep strict is what makes it the sharper gate. An
undocumented asymmetry is indistinguishable from an oversight, which is
why this is written down rather than left to be rediscovered.

The cost is stated rather than hidden: a test that fails once and passes
on retry is reported green with no separate signal, so a genuine
intermittent failure can be absorbed. If that starts mattering the fix is
to surface retried-but-passed tests, not to drop the retries.

Quartet clean: fmt, build --workspace, clippy --all-targets -D warnings,
and test --lib at 1376 passed / 0 failed / 7 ignored.
2026-07-23 13:48:11 +00:00
Johnathan Corgan d3eaad543d Make a skipped check visible in the verdict rather than in scrollback
Two suites could report a clean pass for work that did not run.

stun-faults skips Phase 2 when tc netem is unavailable, announcing it
several hundred lines above the result and then printing a bare
"stun-faults-test passed". The verdict line now carries the count and
names each skipped phase, and a run in which every phase was skipped
fails outright, since it tested nothing.

deb-install defines a skip() helper and a SKIP counter, prints "N
skipped" in its summary, and calls skip() from nowhere, so the number can
only ever be 0. Its exit tested FAIL alone, meaning a skip could not have
failed the run even once something did set the counter. Gate on SKIP too.
That changes no current outcome, which is the reason to do it now rather
than later: the machinery and the reported number already existed, so the
first skip path added would have printed "N skipped" beside a zero exit
and read as coverage.

Verified by driving the verdict logic at zero, one and three skips: clean
pass, pass with the skip named, and a non-zero exit when nothing ran.
2026-07-23 13:44:29 +00:00
Johnathan Corgan 2a6039995d Retire the ECN A/B test framing and record why suites are excluded
ecn-ab-test.sh was a -test.sh with no path to a failing result: it
asserts nothing, applies no threshold, and nothing invokes it. It also
could not have worked. It read a fixed sim-results/ecn-ab-on/ path while
the runner has written timestamped directories since 2026-03-20, and not
one ecn-ab result directory exists on disk, so it has found neither input
for months and the "+10.2% recv throughput" figure the README carried is
not reproducible from anything available.

Rename it to ecn-ab-compare.sh, fix the path resolution to glob the
timestamped directories, and stop swallowing a failed simulation with
|| true so a broken run cannot feed the comparison.

Deliberately not adding the threshold assertion that would make it
gateable. That needs a calibration corpus, and none exists precisely
because the tool has never produced a kept result; a number invented now
would assert a guess. The prerequisite is a calibration run set and a
decision about what ECN is expected to deliver, which is a protocol
question. Written in the script header rather than left implied.

Add the exclusion block to ci-local.sh naming every suite and scenario
neither runner runs, with the reason for each: interop, boringtun,
iperf-test, this comparison tool, mesh-lab, and the four chaos scenarios
outside CHAOS_SUITES. Until now "not in the suite list" was
indistinguishable from "forgotten".
2026-07-23 13:40:53 +00:00
Johnathan Corgan 1f149bda32 Run the convergence-gate unit tests in both CI runners
wait_until_connected decides whether every static suite proceeds or
gives up, so a regression in it turns those suites' verdicts into noise.
Its unit tests existed and were invoked by nothing.

Wire them into ci-local.sh beside the parity and log-string checks,
above the mode branches so --only and --test-only are gated too, and add
the matching step to the ci-parity job in ci.yml. Putting them in one
runner only would create exactly the drift check-ci-parity.sh exists to
catch, and neither placement is visible to that checker since this is not
a matrix suite.

Verified by breaking what it guards rather than by observing green:
setting wait_until_connected's default near-converged slack to 0 fails
case4, records FAIL wait-converge, and exits the sweep 1. Restored after.

They pass, which nothing had established before — 13 assertions, all
green. They also take about 45 seconds rather than the "a few seconds"
the header claimed, and nothing had contradicted that claim because no
runner had ever invoked it. Header corrected with the measurement.

The orphan sweep this called for is done and there are now no
zero-reference *-test.sh files under testing/. It also settles a
disagreement recorded between the audit and the task file: ecn-ab-test.sh
is invoked by nothing, its one reference being a README description
rather than a driver, so the audit was right and the earlier sweep wrong
to call it a documented manual tool. interop-test.sh does have a driver
in interop-stress.sh and iperf-test.sh one in iperf-compare-refs.sh, so
those two were correctly classified.
2026-07-23 13:38:25 +00:00
Johnathan Corgan 38a60c61da Record that churn-mixed's floors describe the invocation CI overrides
The baseline floors added for this scenario were calibrated from archived
runs, and those runs are all the 10-node 120-second variant ci-local
invokes as "churn-mixed --nodes 10 --duration 120". The file itself
defaults to 20 nodes and 600 seconds, and --nodes sed-patches num_nodes
in a copy before the scenario loads, so the gating run and a bare
chaos.sh run are different scenarios. Nothing at the site said so.

The floors hold for both, but only because the gating run is the smaller
one. Retuning them against a bare 20-node run, which clears them easily
at 20 answering, 1 root, 19 parented and 69 sessions, would put them past
what the 10-node run reaches and turn CI red while a manual run stayed
green. Say that where someone retuning them will read it.

Confirmed by running the gating invocation directly: 10 answered, 2
roots, 8 parented, 18 sessions, all inside the ranges the floors were
built from. churn-mixed is the only scenario CI overrides this way; the
other twelve run their files as written.
2026-07-23 07:27:16 +00:00
Johnathan Corgan 3d0a388511 Fail the ping test on an unknown topology profile
Every section of ping-test.sh is guarded by the profile name, so an
unrecognised profile fell through all of them and the script exited 0
having run no assertion. A typo in the caller's argument produced a green
run that tested nothing. Give it an else branch that names the valid
profiles and exits 2.

No existing leg changes behaviour: the hosted runner gates the ping step
on matrix.type == 'static', which only static-mesh and static-chain
carry, and ci-local passes only the two names in STATIC_SUITES. Worth
noting while confirming that, though: ping-test.sh accepts a mesh-public
profile that neither runner ever passes.

Also record why the convergence waits are `|| true` — they are settling
delays rather than assertions, and the directed-pair pings are what
decides the run — and add admission-cap to the suite list in the
ci-local header, which ran it without listing it.
2026-07-23 07:13:32 +00:00
Johnathan Corgan 5d3a3d7cad Give the assertion-free chaos scenarios a convergence floor
Five scenarios named by the audit carried no assertions at all, so a run
in which the mesh never formed exited 0 and reported green. Add a
baseline assertion covering how many nodes answered, how many distinct
roots they agreed on, how many took a parent, and optionally how many
sessions were established, and apply it to those five plus the two cost
scenarios left unasserted by the previous commit.

For ethernet-only, ethernet-mesh, tcp-mesh, smoke-10, mixed-technology
and depth-vs-cost the values are not calibrated: one root and N-1
parented nodes is what a spanning tree is, and all provably-completed
archived runs of each show exactly that. churn-mixed is different and
says so at the site. A scenario that stops and starts nodes on purpose
does not hold a single tree, and its six completed runs end with two or
three roots and seven or eight of ten nodes parented, so its floors sit
one step outside the observed range. That leaves them catching a mesh
that collapsed rather than one that churned, which is the most a sample
of six supports.

This gives Ethernet transport its only assertion anywhere in CI. The
three tests in src/node/tests/ethernet.rs are ignored for requiring
CAP_NET_RAW and neither runner passes --ignored, so until now nothing
exercised that transport with a verdict attached.

The floor is deliberately weak and deliberately not a substitute: it says
the mesh formed, not that it formed the tree the scenario describes.
Where those differ the scenario now says so in its own comments.
2026-07-23 07:09:36 +00:00
Johnathan Corgan e9ca741e53 Assert parent selection where the cost scenarios actually specify it
Four scenarios exist to test cost-based parent selection and each named
its expected outcome in a comment that nothing read. Add a tree_parents
assertion mapping a node to the parent it must have in the final tree
snapshot, and encode it for the two scenarios whose stated outcome the
implementation meets: cost-avoidance (n04 takes the fiber n03) and
bottleneck-parent (n06 takes the fiber n03, n09 keeps its only parent
n05). Both hold in all six provably-completed archived runs.

The other two are left unasserted on purpose, and each for a different
reason worth keeping distinct.

mixed-technology says n06 and n08 should both pick the fiber parent n03.
They do not. Across the five completed archived runs n06 picks n10 three
times and n08 picks n04 four times. Encoding the criterion as written
would red the scenario most runs, and encoding the observed behaviour
would bless something nobody specified, so the disagreement is recorded
at the site as an open question. n06 preferring n10 may well be correct
and the comment stale, since n10 reaches it by fiber too.

depth-vs-cost is different: its validation line does not name an outcome
at all, saying only that the choice "reflects the actual cost tradeoff",
which either answer satisfies. The corpus shows both occurring under one
seed, three runs to two. That scenario needs a protocol decision about
which parent is correct before it can have an assertion.

Compare parents by address resolved from the snapshot's own
my_node_addr, and fail rather than skip when a node is absent from the
snapshot or still claims to be its own root. Those two states are the
common ones in the older corpus and both produce the same "no match" a
wrong parent does, so only separating them keeps a harness problem from
reading as a routing verdict.
2026-07-23 07:05:50 +00:00
Johnathan Corgan 5a11cf091d Give congestion-stress the assertions its criteria described
The scenario listed four success criteria "verified via post-run
congestion snapshot". Nothing read the snapshot, so the scenario could
not fail on any of them.

Add a congestion_signals assertion taking a floor on the number of nodes
reporting each counter, and encode three of the four. The floors are one
node each because that is what the criteria say; tightening them to the
counts recently observed would assert something nobody wrote down.

The fourth criterion is not encoded, and that is the finding rather than
an omission. No node has reported a non-zero kernel_drop_events in any of
the 182 archived runs, so asserting it would red the scenario
permanently. It is recorded at the site as an unmet criterion and a
coverage gap: the transport drop-detection path the scenario names as its
second signal is exercised by nothing.

Two things about the corpus are worth carrying, both recorded in the
scenario. Only six archived runs carry a status.txt and are therefore
provably completed; all six meet the three encoded criteria with five to
nine nodes reporting each signal. The 176 older runs meet none of them,
and they are not invalid samples: each reached teardown far enough to
write an analysis.txt, and ECN landed before all but one of them. What
changed on 2026-07-22 is not established, since neither the scenario nor
netem.py, traffic.py or control.py has been touched.

Count the nodes reporting a signal rather than the magnitude of any one
counter, since a single node with a large count would satisfy a magnitude
test while proving the signal never propagated. A missing snapshot fails
rather than reading as an absence of congestion.
2026-07-23 06:59:21 +00:00
Johnathan Corgan aab149e215 Fail a chaos run whose nodes logged errors
The simulation exit ladder reported panics and failed assertions but said
nothing about ERROR-level log lines, so a run in which every node errored
on every line still exited 0.

Add a max_errors assertion and apply it to every scenario by default
rather than having each one opt in. This is a floor on what a green run
means rather than a property of an individual scenario, and a scenario
that has to ask for the floor is one that can forget to.

The default ceiling of 0 is what the archived corpus supports: across
2416 result directories no node log contains an ERROR-level line, while
the WARN counter extracted by the same code path ranges from 0 to 1135,
so the counter is known to discriminate rather than merely known to read
zero. A scenario that legitimately induces errors raises the ceiling in
its own YAML and says there why.

Reject rust_log: off alongside it, since that is the one level that would
leave the ceiling counting zero whatever the mesh did.
2026-07-23 06:53:56 +00:00
Johnathan Corgan 73e6917341 Give cost-stability the parent-switch ceiling its criterion described
The scenario's success criterion existed only as a comment: count
"Parent switched" in n04's log, expect at most 5. Nothing read it, and
the scenario declared no assertions at all, so its exit code could only
report that the mesh came up and nothing crashed.

Add a max_parent_switches assertion and wire that criterion to it.

The assertion takes an optional node scope, which is the part that
matters. The existing sibling counts mesh-wide, and a criterion written
about one node's log is a different quantity from the sum over every
node: across archived runs of this scenario n04 is 1 or 2 while the
mesh-wide total ranges 3 to 6. Asserting the sum against a per-node
threshold would check something other than what was specified, and at
this threshold would also have failed several runs that were fine.

Counting nothing must not read as stability. A node id absent from the
topology fails explicitly rather than matching zero log lines and
sailing under the ceiling, and a scenario that declares a parent-switch
assertion while setting a log level that suppresses the events it counts
is rejected at load rather than passing vacuously after a full run. The
parse rejects a mistyped key, a missing, negative, boolean or
non-integer ceiling, and a node written empty or as a non-string -- that
last one because YAML renders a bare `node:` as null, which would
silently revert to the mesh-wide count.

The scenario comment now records what the threshold is worth against
the 11 completed archived runs rather than a single sample: 5 is well
above anything observed, the daemon's own parent hold-down caps
switches near 6 per run, and nearly every switch lands during initial
tree formation before any mutation fires. So this catches only
near-pathological reparenting. Tightening to 3 would make it a real
detector, but that changes the stated criterion rather than fixing it,
so it is left as a decision.

Verified against a live run: the assertion evaluates n04 at 2 against
mesh-wide 4 and passes, and with the ceiling temporarily set to 0 the
scenario exits 3, the assertion-failure code, which it previously could
not reach.
2026-07-23 05:19:36 +00:00
Johnathan Corgan 300deb0476 Reject an unanswered show_mappings instead of reading it as zero
The mapping-reclaimed check expects zero mappings, and read the count
with `r.get('data',{}).get('mappings',[])`. An error response carries
no data field, so it parsed to zero and satisfied the check without the
gateway having answered at all. The expected value and the failure
value were the same number, which is the shape that lets an assertion
pass without asking anything.

Require the key to exist and be a list, and exit non-zero otherwise, so
the existing fallback turns an unanswered or malformed response into a
failure. The gateway always emits the key on success, including for an
empty set, so a genuine zero still reads as zero; confirmed against the
running gateway, where the reclamation check passes.

The earlier show_mappings poll shares the parse and is left alone: it
waits for a positive two, which no error response can produce. The
hazard was already documented there, and this is the call site that was
exposed to it.
2026-07-23 04:16:42 +00:00
Johnathan Corgan 700ac581ee Require a timeout and a drop verdict in the firewall assertions
Case (a) sends an unallowed inbound request that the ruleset should
drop, and accepted any non-zero curl status as proof. A drop produces
no RST, so curl can only hit its deadline and exit 28; connection
refused, an unroutable address or a missing listener all fail too, with
different codes, and the check counted those as a blocked connection.
Require 28 exactly, so the assertion distinguishes silently dropped
from failed for some other reason. Confirmed against the harness: the
real run returns 28.

The baseline check matched `counter packets`, which any counter rule
satisfies whatever its verdict, including one that accepts. Require the
rendered drop rule instead.

The drop-counter read had the same weakness plus a worse one: it took
field three of the first line mentioning a counter, which is the packet
count only when the line begins with `counter`. On a rule such as
`tcp dport 9 counter packets 42 bytes 3000 drop` it printed the port
number. That shape is reachable, because the shipped ruleset includes
the operator drop-in directory ahead of the trailing default deny and a
drop-in may add its own counted drop. Extract by position within the
matched text, and take the last match rather than the first, since case
(a) falls through to the default deny that the ruleset emits as the
final rule.

Note in place at the baseline check that it still only proves a counted
drop rule exists somewhere in the table, not that it is the trailing
one; asserting rule position is a larger change than this warrants.

The sample output in the README is updated to the new wording.
2026-07-23 04:16:32 +00:00
Johnathan Corgan 98560594cf Prove ACL rejection per peer by matching a single log line
The four rejection checks were independent greps over the whole log:
two npubs, a context and a decision. Together they proved only that
node-a mentioned each npub somewhere, rejected somebody on an inbound
handshake, and rejected somebody by denylist. Nothing tied a rejection
to a named peer, and node-a lists both denied peers as auto_connect
peers, so it logs their npubs on the outbound connect path whether or
not any rejection ever happened. The actual message was never matched.

Replace them with a helper that requires every given string on ONE
line, and assert per denied peer: the real message, that peer's npub,
and the denylist decision together. Strings match in any order by
chaining fixed-string greps over the surviving lines, so the assertion
does not depend on how the log formatter orders a message and its
fields. A grep over empty input yields the empty string rather than
anything a caller could mistake for a match, so an unreadable container
times out and fails instead of passing.

Two limits are written at the call sites rather than left implied. The
decision conjunct discriminates nothing, since the two allowing
variants return early and denylist is the only value that can reach
that warning. And node-a authorizes before dialing, so it emits a
fully formed rejection line for each denied peer on the outbound path;
these assertions are satisfiable without the inbound check running at
all, and it is the peer-count assertions that would catch that.

Verified against the running harness: all three find a real matching
line. The defect shape they replace was exercised offline first, with
the three strings spread across three lines, where the old checks
passed and the new ones fail.
2026-07-23 04:16:16 +00:00
Johnathan Corgan ebf34ae712 Read the wall clock directly for Nostr traversal timestamps
The traversal clock cached a Unix millisecond value and an Instant at
first use, then served every later call by advancing the cached value
with the monotonic elapsed time. A monotonic clock does not tick while
the host is suspended, so once a machine had slept the returned value
trailed real time by the sleep duration for the rest of the process
lifetime, and never re-synced.

Almost everything that clock feeds is an absolute timestamp. The NIP-40
expiration tags on adverts and traversal signals, and the issuedAt and
expiresAt fields of offers and answers, are all computed as now plus a
TTL; the freshness and cache-pruning paths compare it against a
peer-authored, signed created_at. Once the host had slept longer than
signal_ttl_secs, every offer was published already expired, relays
dropped it, and the initiator timed out waiting for an answer with
traversal broken until a restart.

Read the wall clock on every call instead. This also removes a
mismatch inside the traversal failure-state map, which was written
here from the cached clock but written and read from the node
lifecycle with the real one.

Not platform-specific: monotonic clocks exclude suspended time on
Linux and Windows as well, so this affected any host that suspends.
A laptop is simply where a process lives long enough across a sleep
to notice.

The interval-shaped consumers hold up under a clock step. A forward
step, which is what a resume produces, saturates the punch start delay
to zero, and the attempt's own bounds are monotonic deadlines that are
unaffected. A backward step lengthens that delay and can cost one punch
attempt, which retries. Early eviction from the replay window cannot
admit a replay under the shipped defaults, because the freshness window
a replayed offer must also satisfy is strictly narrower than the replay
window itself.

The added test pins the contract and fires on a host that has genuinely
suspended, but it is not a regression guard for this defect: nothing
reachable from a unit test can simulate a suspend, so on a machine that
has not slept the old implementation passes it too. Its comment says
so rather than leaving a false sense of coverage.

Reported in https://github.com/jmcorgan/fips/issues/128
2026-07-23 03:23:00 +00:00
Johnathan Corgan 291c4312dc Stop the ACL peer-count reader from turning "no answer" into zero
wait_for_peers_exact fell back to 0 when it could not read a container's peer
count. Two of its six call sites are the ACL denial checks, which expect
exactly 0 connected peers — so a failed docker exec satisfied them on the
first iteration and the isolation property the suite exists to prove was
never observed. Confirmed against a container that does not exist: the old
reader returns "0" and the expect-zero comparison passes.

The reader now returns the empty string when the daemon does not answer,
which no numeric comparison can satisfy, and the timeout message says which
of the two happened — never answered, or answered the wrong number. Same
shape as admission-cap-test.sh's read_peer_count.
2026-07-23 02:42:36 +00:00
Johnathan Corgan d21f818659 Check that log strings tests match on are still ones the daemon emits
A test that greps the daemon's log for a message the daemon stopped emitting
does not fail. It stops observing, and an assertion built on it — especially
one expecting a count of zero — then passes because nothing can be seen
rather than because nothing happened. Several findings have come from that
one class, so it is now checked mechanically.

testing/check-log-strings.py extracts the strings test code matches against
daemon log text and requires each to exist in src/. It reads four shapes:
python `"..." in line` tests, the first argument of the shell log helpers,
bash associative-array pattern tables, and greps whose input is daemon log
output. Patterns carry regex syntax, so each is reduced to the longest
literal run of every alternation branch, with escaping resolved in the same
pass — `\.` is a literal dot and `.` is a wildcard, and conflating them would
let a pattern match text that is not there.

Scoping is by what a grep READS, not by what its file mentions. A suite greps
its own analyzer output, fipsctl JSON, Tor's log and ping output, and none of
those have to correspond to a string in src/; scoping on the file produced 34
false positives. Strings that legitimately do not come from src/ are named in
ALLOWED with a reason, so the exceptions are reviewable rather than invisible.

It found six dead strings, three of which were not previously known: a second
copy of "Excessive decrypt failures" in the interop pattern table, a dead
"Handshake error" branch inside an alternation whose other branches still
matched, and "bootstrap failed" in the NAT suite. All six are corrected to
what the daemon emits, or dropped where the surviving branch already covers
the case. Verified by breaking it two ways and confirming it reports.

Also here, since it is the same failure shape one layer down: the shared log
library returned an empty string for a container whose logs it could not
read, so analysing a missing container reported no panics, no errors and no
sessions, and exited 0 — indistinguishable from a clean run. It now reports
which containers could not be read and raises, matching the fix already
landed in the chaos copy.
2026-07-23 02:42:25 +00:00
Johnathan Corgan 6c52b0e01e Let the static test network float so concurrent CI runs cannot collide
Two local CI runs on one host both asked docker for 172.20.0.0/24 and the
second lost its whole static family to "Pool overlaps". Docker honours a
fixed subnet request verbatim, so the only robust fix is to stop making one:
fips-net now requests no subnet and docker assigns from its own pool, which
cannot hand the same range to two runs.

That means node addresses are not known before `up`, so peers address each
other by container hostname instead. The generator emits node-<id>, or the
topology's docker_host where the compose hostname differs — only the gateway
profile, whose services are gw-*. External peers keep the address the
topology gives them, since it is not ours to assign. The resolv.conf mount
stays: dnsmasq is what forwards these names to docker's resolver and .fips
to the daemon, so removing it would take out every .fips assertion.

generated-configs is now per-run as well. A shared directory let two runs
overwrite each other's node configs, which the subnet collision had been
hiding by killing runs before that window opened. The generator, the compose
bind mounts and env_file, the six scripts that read it, and teardown all
follow FIPS_CI_NAME_SUFFIX; unset, every path renders as before. Teardown
keeps the directory after a failed run, where it is the evidence of what the
failing nodes were configured with.

Three things this exposed that were wrong independently:

admission-cap built its tcpdump patterns from the topology file's docker_ip
literals. Floating the subnet makes those match nothing, which would have
left its expect-zero "no Msg2 leaked" assertion passing because it could no
longer see anything at all. It now reads addresses from the running
containers. Restarting the denied peers together also made them swap
addresses, so each peer's counts were really the pair's total; they are
restarted one at a time now, and a check fails the suite outright if two
denied peers ever share an address, because per-peer attribution is
impossible once they do.

Attribute lookups in the generator used a fixed ten-line window and read the
next node's fields when a node omitted an attribute. An external node
followed by an internal one was classified as internal, which under hostname
peering would emit a name that resolves nowhere. Lookups are bounded to the
node's own block; generated output is byte-identical for all eight
topologies.

The rekey outbound-only variant used to rewrite peer addresses to hostnames
to set up its scenario. The generator now does that everywhere, so the
rewrite matched nothing and was silently doing no work. It asserts the
premise instead, and fails if a numeric address ever reappears.

Verified by running three instances of this compose at once — tcp-chain plus
two independent meshes — which drew 10.128.2/3/4.0/24 with no overlap while
both meshes passed ping-test 20/20 over the real .fips path. tcp-chain is
run by neither CI runner, so it was checked by hand: chain peer counts 1/2/1
and multi-hop .fips reachable both directions over TCP.

gateway-lan still pins its own IPv4 and fd02:: ranges and is unchanged here,
so the gateway profile is not yet concurrency-safe.
2026-07-23 02:05:56 +00:00
Johnathan Corgan 428773490f Close the gaps an adversarial review found in the new CI guards
Four of these are places the parity guard could stop covering something without saying so, which is the failure mode it exists to prevent. Removing the deb-install suite array left it green, because the dispatch cross-check compared against a hardcoded name rather than the array it was meant to read. An arm-shaped line at an unexpected indent, or one written in quotes, was dropped silently by the arm pattern; unexpected indents are now a hard error rather than a quiet skip, and quoted arms parse. A matrix leg with a type of chaos or deb-install but no scenario key raised a Python traceback instead of reporting a problem, and a leg carrying a scenario but no suite key was skipped entirely even though scenario is now the identity for those legs.

The e2e builder no longer folds docker create's stderr into the container id. Docker prints warnings on success as well as failure, so a platform-mismatch warning would have become part of the id and made every later reference to that container fail, turning a working extraction into a spurious red.

Also corrects the comment about registering a new chaos assertion type, which named only one of the two key sets that actually need it.
2026-07-22 22:41:24 +00:00
Johnathan Corgan 56f00058d4 Run the CI parity guard in both runners
The guard that keeps "local green" and "GitHub green" meaning the same thing was invoked by nothing. Its only entry point was an exec behind an explicit --check-parity flag, which by exec-ing could not be combined with an actual run, and no workflow step called it at all. A guard nobody runs is a guard that does not exist, which is how the mixed-profile divergence on next survived unnoticed.

It now runs as the first stage of every local run and as its own job on GitHub. Locally it reports through the same result-recording path as every other stage, so a divergence sets the run's exit status instead of scrolling past. It is deliberately placed above the mode branches, so a divergence also fails --only, --test-only and --build-only runs: whichever subset was asked for, the claim that a local result means what a GitHub result means is what has broken. The GitHub side installs pyyaml explicitly rather than assuming the runner image carries it.

--check-parity keeps working exactly as before. Three comment blocks that described the old folded comparison are corrected here, in the workflow, the local runner and the testing README; they were spread across three files and only two mention the guard by name, so the third is best found by searching for what it claims rather than for the script.
2026-07-22 22:03:08 +00:00
Johnathan Corgan e578a11b7a Compare every CI leg in the parity guard, not a folded token
The guard collapsed all thirteen chaos legs to the token "chaos" and all five deb-install legs to "deb-install", so sixteen of the thirty-four integration legs were invisible to it. Deleting twelve chaos legs and four deb-install legs from a copy of the workflow left it still reporting "CI parity OK". The fold existed because the guard compared the cosmetic suite name, which really does differ between the runners; both sides have carried a directly comparable scenario field all along, so the fix is to compare that instead, along with the chaos flags.

The local suite set is now discovered by sweeping ci-local.sh for suite arrays rather than reading a hardcoded list of variable names, and the deb-install distro list is read from the suite's own script. A suite dispatched with no backing array is invisible to either approach, so every run_suite arm is now checked to have one and the guard names any that does not. That is not hypothetical: next dispatches its mixed-profile suite without an array, and the guard could only report it as missing from the local runner without being able to say why.

The guard also now refuses to run rather than failing obscurely when python3 or pyyaml is absent, since a parity check that cannot run must not look like one that passed. Verified by breaking each thing it guards: legs deleted from the workflow, drifted chaos flags, a fabricated suite array, and a dispatch arm whose array was removed. That last case initially did not fire because the arm pattern was pinned to the wrong indentation, which the test caught.
2026-07-22 21:59:28 +00:00
Johnathan Corgan 99cb0a51fd Reject unknown keys when loading a chaos scenario
The scenario loader read every section and member through raw.get with a default, so a mistyped key was silently ignored and the block it belonged to stayed default-constructed while the YAML still looked correct on the page. Writing "assertion:" for "assertions:" disarmed a scenario's only assertions and left it unable to return a non-zero exit code; "link_flap:" for "link_flaps:" turned off the churn the scenario existed to inject, and a calm mesh then ran under the name of a chaos test.

Keys are now checked at load time, at the top level and inside every section with a fixed schema, including the netem policy bodies, the {min, max} ranges and the assertion bodies themselves. The error names the offending key, the section it appeared in, and the keys that section does understand. Three mappings are deliberately exempt because their keys are names the author chooses rather than a schema, and two sub-trees are passed through whole; all five are listed at the key sets with the reason.

Adding an assertion type now means registering it, so the next person to add one gets a rejection rather than a silent no-op. That coupling is the point and it is written down where they will see it. Verified against the seventeen scenarios in the tree, all of which still load, and against fixtures for each failure mode: a top-level typo, a nested typo, a typo inside an assertion body, and a section left empty, which previously raised an unrelated AttributeError from the next line of the loader.
2026-07-22 21:56:41 +00:00
Johnathan Corgan e9ca16f3a1 Stop the DNS resolver e2e tests certifying a binary that was never built
The e2e scenarios build fips and fips-gateway in a builder image and extract them with two docker cp calls whose stderr went to /dev/null and whose exit status was never tested. Because build_fips_for_e2e ended in a log call it returned 0 on every path, so the caller's guard could not fire. A failed extraction left the previous run's binaries in the cache, they satisfied the caller's executable check, and the five e2e legs then exercised the previous commit's code and reported green.

The extraction now deletes the cached binaries before it starts, which is what makes the stale case impossible rather than merely detected, and it checks each docker cp independently, reports the failure with docker's own message, verifies each extracted file is non-empty and executable, and returns an explicit status. This mirrors what the deb-install suite already does.

Confirmed by inducing the failure rather than by observing a green run. With both extractions broken and a stale binary in place, the old code logged "Cached fips (26936 bytes)" and went on to build the runtime image around it; the new code reports both docker errors, deletes the stale binaries and fails the suite. Breaking either extraction on its own is caught separately, and an unmodified run passes 7 of 7.
2026-07-22 21:54:37 +00:00
Johnathan Corgan ab750f60b3 Record why a chaos docker command failed
Compose commands run with their output captured and with check set, so a failure raised a CalledProcessError and nothing ever looked at what the daemon had said. That exception reports the argv and the exit status and nothing else, so every scenario that died bringing its containers up left a runner log saying only that docker compose returned non-zero exit status 1, and the reason was captured and then discarded. In the run that prompted this work twelve scenarios failed that way and why they failed can no longer be established at all. Log the captured stderr and stdout before the error goes anywhere, keeping the tail of each so a verbose failure cannot fill the log file. A command that times out raises before its exit status is ever read, so that path records whatever it had emitted too, rather than leaving the same hole one shape over. Reproduced against both of the shapes this is meant to catch: an unusable network prefix now records the daemon's parse error, and a name already held by another container now records the conflict and names the container holding it. The success path logs nothing new, and a completed scenario's runner log is unchanged line for line.

The raising behaviour is deliberately untouched, since the abort flag and the exit code that came with it depend on it. The check is applied here rather than by subprocess so that a caller who passed check false gets the same record: both such callers are the compose down in teardown, which today can leave a mesh behind without saying so.

The same omission was costing the veth path its reasons. Failing ip commands had their stderr logged at debug, which the runner does not emit unless asked for verbose output, so a failed pair creation reached the log as nothing more than the names of the two interfaces it could not create. Raise that to a warning. The deletes issued to clear a stale pair are expected to fail and still say nothing.
2026-07-22 08:26:59 +00:00
Johnathan Corgan 226c6994d3 Harvest chaos results only from a mesh that actually started
Teardown runs from a finally, so it also runs after a failed setup, and it was guarded only on the topology and the compose file, both of which are set well before any container exists. A scenario that died bringing its containers up therefore ran the whole harvest anyway: final snapshots, docker logs, the analysis, the assertions and the metadata, all addressing containers by names that are global to the host. What it left behind was not an empty directory an investigator would notice but a full and plausible one, in the recorded case ten node logs and an analysis reporting two promotions and two parent switches, every byte of it from a different scenario's mesh. Gate the harvest on whether the containers ever started, and leave the compose down outside that gate so a partly successful start still gets cleaned up.

Record the outcome in a status file in every result directory, naming the run as completed, interrupted, aborted, setup-failed or teardown-failed, alongside the scenario, the seed and the container names it used. With the harvest gated, the presence of an analysis file is now itself proof that the scenario's own mesh existed, and the status file says which of the several ways a run can end applies. A run cut short by a signal keeps the existing exit codes rather than gaining one of its own: what it collected before stopping is real and still worth reporting, the status file records that it was truncated, and every wrapper already reports a Ctrl-C of its own.

Log collection never looked at the status of docker logs. It concatenated stdout and stderr unconditionally, so collecting from containers that were not there wrote the daemon's "No such container" reply into each node log and analysed the result as a mesh with no panics, no errors and no sessions, which reads exactly like a clean run and exits zero. Check the return code, and treat a harvest that cannot read every container, or that reads none, as a failed teardown. A teardown that raises now also reports on the same footing as a run that never started, instead of escaping past the exit codes as a bare traceback and being read as a malformed command line.
2026-07-22 08:02:38 +00:00
Johnathan Corgan 4f55a281ac Let a failed chaos scenario fail the run
The simulation caught every exception a scenario raised, logged it, and exited 0, so a scenario that died during setup was recorded as a pass. Local CI discarded the exit code in any case: run_chaos ends in a call whose own last statement is an echo, so it returned 0 whatever the scenario did, and the parallel launcher's wait therefore always succeeded and always recorded a pass. Between them no chaos failure of any kind could turn a local run red, and none could since the script was written.

Carry the abort out of the run and give it its own exit code, ordered ahead of the two content codes so a run that never produced a mesh cannot report that mesh's panic and assertion counts. Return the scenario's status from run_chaos so the launcher sees it. The documented exit codes were stale before this change and are rewritten in full, including that the argument parser rejects a malformed command line with the same code that reports panics.

Expect scenarios that have been reporting green to start reporting red. In the last recorded local run twelve of the thirteen scenarios never got past starting their containers, and all thirteen were counted as passes; those that now execute have not run for months, and their assertions have never been evaluated against a mesh of their own.
2026-07-22 07:37:48 +00:00
Johnathan Corgan 2ef36c0071 Scope chaos host interface names per scenario and reap orphaned pairs
Ethernet edges get a veth pair created in the host namespace and then moved into the containers, named from the node ids alone. ethernet-mesh and ethernet-only both connect n01 to n04 and always run together, so one tore down the other's live link. Hash the scenario suffix to four hex characters and insert those after the vh prefix; 15 characters is far too few to carry the suffix itself.

The names are now run-specific and never regenerated, so the delete-before-create no longer reclaims an interface an earlier run abandoned. ci-cleanup.sh takes over, deriving the names from the suffixes a run's teardown hands it. It is the first thing that script removes that is neither a docker object nor labelled, so an unscoped reap can sever a running bare simulation's links; testing/README.md and the --reap help say so.
2026-07-22 07:35:11 +00:00
Johnathan Corgan 5be7c6d0cb Give each chaos scenario its own container names and config directory
Chaos scenarios run four at a time under local CI, but every one of them claimed the container names fips-node-nNN and wrote its generated configs and compose file to the same generated-configs/sim directory. Container names are global in Docker and are not scoped by the compose project, so concurrent scenarios collided on both, and a scenario could start containers from a compose file another had overwritten.

Thread the existing FIPS_CI_NAME_SUFFIX into the simulation. run_chaos narrows the run-wide suffix to the scenario, and the sim reads it once when the topology is built, applying it to the container names and to the config directory basename. The compose template renders the name through the topology accessor instead of duplicating the literal, so one expression produces every chaos container name.

The suffix is empty when the variable is unset, so a bare chaos.sh run and the hosted CI jobs render byte-identical names and paths. Verified by rendering every scenario's compose file before and after with the variable unset and diffing.
2026-07-22 07:35:11 +00:00
Johnathan Corgan 8ca2362e6c Follow IP semantics for the session datagram hop limit
The forwarding path tested the hop limit before testing whether the datagram was
addressed to this node, and decremented only on the forwarding branch. Two
consequences followed. A datagram addressed here that arrived with a zero hop
limit was dropped rather than delivered. And a forwarder receiving a transit
datagram with one hop left transmitted it with zero for the next hop to discard,
wasting a transmission on every expiring datagram.

Delivery to the addressed node is no longer gated on the hop limit, and the
decrement now happens before the drop decision, so a datagram that would leave
with zero is not sent. Saturating subtraction folds the two arrivals that cannot
be transmitted, already exhausted and last hop, into a single test.

The reachable radius does not change, which is worth stating because it is easy
to assume otherwise. The old behavior refused to deliver at zero but was willing
to send at zero, and the new behavior is the mirror of that, so the two cancel: a
path of h links still delivers for any source value of h or more. What this
actually buys is the wasted transmission, the exhaustion counter charging at the
node that makes the decision rather than the one after it, conformance with the
specified semantics, and one real gain during a rolling upgrade, where an
unupgraded forwarder feeding an upgraded destination delivers one hop further
than either version does on its own.

Coordinate cache warming moved ahead of both decisions, so a transit datagram
later dropped for hop limit still contributes its plaintext coordinates. The only
arrivals this newly warms from are those with a zero hop limit, and every insert
they can make is already achievable with a value of one, so it grants nothing
that was not already available and adds no memory growth the cap does not bound.

Four tests added: delivery at zero to this node, the transit drop at one, the
transit pass at two, and the one per hop decrement across a three node chain,
which brackets the emitted value from both sides since it is only observable
through what the next hop does. One existing test was renamed because its
destination was this node, so it never exercised the transit path its name
claimed, and it asserted nothing at all.
2026-07-22 01:53:53 +00:00
Johnathan Corgan 75d7077880 Open 0.4.2-dev cycle on maint after v0.4.1 release
Bump the version to 0.4.2-dev and update the status badge and prose. The
0.4.x patch line continues; v0.4.1 remains the shipped release it points at.

Follows the shape of the v0.4.0 rollover commit. No maint reset was needed
this time: v0.4.1 was cut from maint, so the tag is already on this line and
it simply continues.
2026-07-19 19:05:13 +00:00
Johnathan Corgan 15db6471db Set version to 0.4.1 and correct the v0.4.0 release date
Drop the -dev suffix from the package version and refresh the lockfile.
The version is single-sourced from CARGO_PKG_VERSION, so build.rs and
src/version.rs need no change.

Also fix the archived v0.4.0 release notes, which still read
"2026-06-21 (provisional)" while the changelog records the actual
release date of 2026-06-27. The date was confirmed in the changelog at
the time but not in the two release-notes files that carry it, so the
shipped notes have been showing a wrong date and the word provisional
since that release. The root mirror is regenerated for v0.4.1, so only
the archived copy needed correcting.
2026-07-19 17:59:37 +00:00
Johnathan Corgan 146d19a8d8 Add v0.4.1 changelog entry and release notes
Back-fill the changelog for the six user-facing changes on this line
since v0.4.0: the antipoison FPR cap default, the bloom probe and
secp256k1 context performance work, the coordinate-cache and path-MTU
fixes, and the removed parent_switched counter. The Unreleased section
was empty, so all six entries are new.

Write the v0.4.1 release notes and mirror them to the root copy. The
notes lead with the FPR cap change and state plainly that this is the
second raise of that default in two releases, that it buys headroom
rather than fixing the underlying fixed-filter constraint, and that the
structural remedy is the v2 filter work. They also flag the rolling
upgrade window, where an upgraded and a not-yet-upgraded node can
disagree about mesh size, and point consumers of parent_switched at
parent_switches.

Bump the README status badge and the roadmap prose together so the two
agree.
2026-07-19 17:21:57 +00:00
Johnathan Corgan bda327b5f5 Raise the bloom antipoison FPR cap default to 0.20
The inbound FilterAnnounce cap at 0.10 rejects aggregates that are
legitimately near their operating ceiling, before the network reaches
the fixed-filter capacity limit. Raise the default to 0.20, which
corresponds to fill 0.7248 at k=5, about 2,114 entries on the 1 KB
filter (Swamidass-Baldi).

Also remove the duplicate default definitions in BloomConfig. Each
default was written twice, once in impl Default and once in the serde
default function, with nothing enforcing that they agree, so a config
file that omits the key took a different path from one that sets it.
impl Default now delegates to the serde default functions, leaving a
single source of truth.
2026-07-19 17:18:49 +00:00
Johnathan Corgan 78377208af Fix two sidecar container references that skipped the per-run suffix
The isolation loop and the failure-log dump both built container names
without the run suffix, so under a suffixed run they addressed containers
that do not exist.

The consequence was worse than the visible failure. Two of the three
checks in that loop assert a ping FAILS, and a ping into a non-existent
container fails for the wrong reason - so both passed vacuously, and the
security assertion the loop exists for was silently a no-op. Only the
third check, which expects a ping to succeed, noticed anything was wrong.

Add a guard that fails loudly when the container is absent, so a future
naming slip cannot quietly turn these back into no-ops rather than
surfacing as one confusing failure among two false passes.

These sites were missed because the suite passes when run by hand: with no
suffix set the unsuffixed names are correct, and the defect only appears
under the automation that sets one. Verified this time with the suffix
set, which is the condition that matters: 18 of 18, container names
resolving to real containers.
2026-07-19 07:40:07 +00:00
Johnathan Corgan 26a579b1c9 Claim a free address range for the sidecar network instead of hardcoding one
Making the sidecar network name unique per run exposed a latent problem
rather than causing a new one. With a fixed name, compose found any
existing network and reused it, which quietly masked the fact that the
address range was hardcoded. With a unique name there is nothing to
reuse, so the range is requested every time and the run fails outright if
anything already holds it.

Claim the range instead of assuming it. The suite now tries to create its
network on a candidate range and, if the daemon reports an overlap, moves
to the next candidate. The daemon's own address pool becomes the arbiter,
so two concurrent runs cannot land on the same range at all - as opposed
to a range derived from the run identifier, which only makes a collision
unlikely, and a collision is the precise failure being avoided.

The node addresses are derived from whichever range is claimed rather than
written alongside it, including the gateway address the isolation check
probes. Moved to a range that nothing in the tree claims and that sits
outside the daemon's default pool.

Only an overlap is worth advancing on. Any other creation failure is real
and is reported with the daemon's own message, rather than being retried
sixty-four times and buried - a lesson from a failure elsewhere in the
harness this week that was undiagnosable because its error output was
discarded.

All three nodes now attach to the pre-created network as external, so
teardown removes it explicitly.

Verified: three concurrent claims take three distinct ranges; a
non-overlap error fails fast rather than looping; the suite passes 18/18;
and the claimed gateway address is confirmed pingable when not firewalled,
so the isolation assertions still mean something.
2026-07-19 07:17:19 +00:00
Johnathan Corgan 3ebb14eda4 Make the admission-cap final peer_count read tolerate a busy daemon
The suite red-ed with an empty final peer_count while every substantive
assertion passed: both denied peers showed inbound handshake attempts and
zero outbound responses, which is the gate this suite exists to verify.

Two pre-existing defects combined. The final count was sampled once, in
the same second the load driver issues its last round of peer restarts, so
the read can land on a daemon busy with those restarts and return nothing.
And the fallback meant to cover that could never fire, because the shell
applies it to the last stage of the pipeline, which succeeds on empty
input - so an unanswered query was indistinguishable from a genuine zero,
and reported as a cap violation.

Poll the final read for a short window instead, and give the two cases
distinct output. A real regression still fails, just after the retry
window, since the loop exits early only on the expected value. Extract the
read the two phases share rather than leaving them to drift.
2026-07-19 06:46:50 +00:00
Johnathan Corgan e4a854f6b0 Stop concurrent local CI runs from clobbering each other's containers
Two runs on one host destroyed each other's containers, producing
mid-test "No such container" failures that look like real defects. The
automated builder runs a full local CI on the same box every few minutes,
so the machine is contended almost always and this has red-ed both a hand
run and an automated gate.

Two independent causes are fixed here. Container names were hardcoded, and
docker names are global rather than scoped by compose project, so two runs
collided on the same name; every name now takes an optional suffix that
the harness sets from the run id. And the cleanup sweep matched a label
shared by every run, so one run's teardown force-removed another's
containers; resources now also carry a per-run label and the sweep can be
narrowed to it.

A third hazard turned up that was not in the original report: the sidecar
suite passes explicit compose project names, which override the run-scoped
project and put it outside the shared prefix entirely. Its project names,
network, and derived container references are now scoped too.

Both are default-off. With the suffix unset, names render exactly as they
do today and a bare compose invocation is unchanged, which is what keeps
the hosted CI and the documentation correct. A cleanup run with no run id
still reaps everything, which is what a manual "clear the box" wants.

The literal-name sweep was not sufficient: six scripts build container
names dynamically from node labels, and two suites create their own
containers outside compose. Those are handled at their construction sites.

Verified: syntax check on all modified scripts; compose validation on
every modified file with the suffix both set and unset; and a synthetic
two-run reproduction that shows the old cleanup destroying a bystander run
and the new one leaving it alone.

Known gap: subnets are still hardcoded, so two concurrent full runs will
still collide on address-pool overlap. That fix reaches into topology
configs, chaos scenarios, diagrams and production source, so it is left
for its own change rather than half-done here.
2026-07-19 06:41:25 +00:00
Johnathan Corgan 7a97599921 testing: floor the rekey second-cutover wait at the log-analysis threshold
The rekey suites' Phase 4 polled until the initiator-cutover count
reached its value at phase entry plus two, while Phase 6 asserts an
absolute minimum of four. On an unloaded host the ping phases can
outrun the jittered first rekey cycle, so Phase 4 can begin with a
single cutover on the books, release at three, and leave Phase 6 to
lose a sub-second race against the remaining first-cycle cutovers.
A rekey-outbound-only run failed exactly this way: five counted
cutover lines by t=32.6s of container life, with the Phase-6 count
snapshot at t~32.4s seeing three.

Floor the Phase-4 wait target at four so a released wait guarantees
the Phase-6 assertion — the docker-logs count is monotone over a
container's lifetime. A genuine rekey stall still times out the
bounded wait and fails Phase 6 with the real count.

Also drop the second-cycle framing from the comment, the phase
banner, and the assertion description: with rekey timers resetting at
each cutover a second cycle cannot begin inside this test's window,
so the counted events are first-cycle cutovers spread across the
topology's links, emitted once or twice per completed link rekey
depending on which side's promotion path logs them.
2026-07-19 04:12:17 +00:00
Johnathan Corgan fbb4fb8879 testing: wait for both second-cycle rekey cutovers before asserting the count
Phase 4 of the rekey suite waited for only one more initiator cutover
(guaranteeing three) while Phase 6 asserts at least four. The fourth
cutover then had to land in the brief window between the wait returning
and the Phase 6 log snapshot, so host load could push it past the window
and fail the run even though every cutover completed correctly. Wait for
two more cutovers, the full second rekey cycle, matching the Phase 6
threshold, so the asserted count is guaranteed before it is checked.
2026-07-16 00:42:04 +00:00
Johnathan Corgan 567e6a535e identity: reuse one shared secp256k1 context
Every sign/verify/key-derive site built a fresh context via
Secp256k1::new(), which allocates a Secp256k1<All> and runs
randomization/blinding table setup on each call. Introduce one
crate-wide LazyLock<Secp256k1<All>> and reuse it across the local, peer,
and auth sites (and their tests). Behavior-neutral: identical secp256k1
API calls, only the context lifetime changes, and the shared All context
still performs the standard construction-time blinding.
2026-07-12 16:33:53 +00:00
Johnathan Corgan 6011d233c1 discovery: keep tighter path_mtu when applying a LookupResponse
An originator handling a LookupResponse unconditionally overwrote the
cached path_mtu_lookup entry, so a looser (larger) estimate in a later
response could clobber a tighter value already learned from a reactive
MtuExceeded or PathMtuNotification. Read-and-compare before writing and
keep the minimum, so a looser discovery estimate no longer loosens the
clamp. Add a regression test.
2026-07-12 16:29:01 +00:00
Johnathan Corgan cb5a32693e tree: remove redundant parent_switched metric counter
parent_switched was incremented on the line immediately before
parent_switches at every site and never independently, so the two
counters were always identical. Drop parent_switched from TreeMetrics,
its snapshot, TreeStatsSnapshot, the show_tree fixture, and the fipstop
render, keeping parent_switches as the sole counter.
2026-07-12 15:53:03 +00:00
Johnathan Corgan 81e4207631 bloom: compute the SHA-256 digest once per double-hash probe
BloomFilter derived all k hash functions from one SHA-256 digest but
recomputed that digest inside the per-function loop, so every insert and
contains ran SHA-256 hash_count times (5x at the default) over the same
bytes. Hoist the digest out of the loop: base_hashes() computes it once
and returns (h1, h2), and bit_index() derives each of the k indices with
the same (h1 + k*h2) mod m arithmetic. Bit-for-bit identical output; this
is the hottest path in packet forwarding and mesh-size estimation.

Adds a test pinning the bit indices against the double-hashing formula
recomputed independently, proving the refactor is behavior-neutral.
2026-07-11 01:22:07 +00:00
Johnathan Corgan 1d277e67c7 fix(tree): invalidate coord cache on parent-lost via peer removal
handle_peer_removal_tree_cleanup reparents or self-roots the node when
its parent link drops, but omitted the coordinate-cache invalidation that
every other position-change path performs. Cached entries for downstream
destinations kept the node's now-stale coordinate prefix, and because
find_next_hop refreshes the TTL on every routing access, an actively
routed stale entry never self-expired — corrected only by a fresh insert.

Mirror the loop-detection branch: inside the parent-loss changed block,
invalidate both classes — invalidate_via_node (reparent) and
invalidate_other_roots (self-root). Add regression tests for a parent-link
removal that reparents (via-node entry dropped, same-root sibling
preserved) and one that self-roots (both via-node and stale old-root
entries dropped).
2026-07-02 22:03:36 +00:00
Johnathan Corgan 2491091868 docs(testing): document ci-local.sh run isolation, preemption, and cleanup
Add a "Running CI locally" section to testing/README.md covering the
ci-local.sh orchestrator: the per-run FIPS_CI_RUN_ID override and how it
scopes compose projects, image tags, and per-chaos-child subnets so
simultaneous runs on one host never collide; the cancellation exit codes
(130/143 vs 0/1) and how a preempting worker interprets them; and the
label-based cleanup via --reap / ci-cleanup.sh.
2026-06-29 16:47:37 +00:00
Johnathan Corgan 965de26239 testing: make ci-local.sh preemption-safe with per-run docker isolation
A CI worker may preempt an in-flight ci-local.sh run (SIGTERM, then SIGKILL
after a grace period) to restart on a newer commit. For that kill to be safe,
the script must clean up after itself and never let a dying run collide with
its restart. It previously had no signal handling, shared the default compose
project name across runs, and tore down each suite only at the suite end.

- Derive a per-run id (honoring FIPS_CI_RUN_ID, else short-sha+random) and
  namespace every docker resource to it: a fipsci_<run>_<suite> compose project
  per suite and per parallel chaos child, and per-run image tags
  (fips-test:<run>, fips-test-app:<run>) retagged to :latest only after both
  builds succeed so :latest never points at a half-built image.
- Install a bounded, idempotent teardown trap on SIGTERM/SIGINT (+ EXIT): reap
  parallel chaos children, then force-remove this run's docker resources via
  the new ci-cleanup.sh, wrapped in timeout so a stuck down cannot wedge it.
- Exit 143 (SIGTERM) / 130 (SIGINT), distinct from 0 (pass) / 1 (failed), so a
  preempting worker tells a cancelled run from a real failure.
- Add ci-cleanup.sh (also ci-local.sh --reap): force-removes leftover CI
  resources by the com.corganlabs.fips-ci=1 label and the fipsci_ project
  prefix, robust to however a prior run died.
- Label every per-suite docker resource so the label sweep reaps it after a
  SIGKILL regardless of network name: direct docker run/network resources, the
  sidecar compose services, and every per-suite compose network (acl-allowlist,
  boringtun, firewall, nat, static, both tor suites, and the chaos generator
  template). Parametrize the static/sidecar compose image refs so the per-run
  tags are honored.
- Give each parallel chaos child a unique /24 from 10.30.x (a new --subnet
  override on the sim CLI, assigned per-child in ci-local.sh) so parallel
  children never collide on a shared docker subnet, and a chaos net can never
  span a fixed-subnet suite (sidecar/static in 172.20.x). 10.30.x sits outside
  docker's default-address-pool range, so an auto-assigned net cannot land on
  it either; node IPs derive from the subnet, so no scenario config changes.
2026-06-29 14:23:46 +00:00
Johnathan Corgan ab915d0479 testing: convert remaining fixed-sleep settles to progress-aware polling
Replace the fixed post-first-rekey and post-second-rekey settle sleeps in
the rekey integration test, and the fixed-deadline baseline wait in the
interop test, with the deterministic wait_until_connected progress-aware
polling helper already used for the initial baseline convergence.

Each converted site fails fast with structured diagnostics when the mesh
is stuck and extends its deadline only while pairwise reachability is
still climbing, removing the wall-clock settle windows that produced
intermittent connectivity failures after a rekey.
2026-06-29 00:40:06 +00:00
Johnathan Corgan 8f30924fc7 Open 0.4.1-dev cycle on maint after v0.4.0 release
Fast-forward maint to the v0.4.0 release and open the 0.4.x patch
series: bump the version to 0.4.1-dev and update the status badge.
2026-06-27 18:29:34 +00:00
Johnathan Corgan d5ee526f0e Release v0.4.0: bump version and finalize CHANGELOG date
Bump the crate version from 0.4.0-dev to 0.4.0 and stamp the 0.4.0
CHANGELOG heading with the release date.
2026-06-27 15:57:58 +00:00
ArjenandJohnathan Corgan 22a5b3e5c6 packaging(openwrt): default .apk WAN port to DSA name 'wan'
The shared fips.yaml ships ethernet.wan.interface: "eth0", the default
WAN port on OpenWrt 24 and earlier. OpenWrt 25 (DSA) boards, which the
.apk package targets, name the WAN port "wan" instead. Rewrite the
staged copy at build time so the as-installed config binds the Ethernet
transport to the right port out of the box, without maintaining a second
copy of the config file. The .ipk package keeps "eth0".

Update the openwrt README to document eth0 (24) vs wan (25/DSA) and add
a CHANGELOG entry.
2026-06-26 03:31:17 +00:00
Johnathan Corgan 3ea7ca1fd1 ci(openwrt): retry Blossom upload and skip nostr event on failure
A transient timeout uploading the built .ipk to the Blossom CDN
(blossom.primal.net) failed the entire OpenWrt Package run, and because
the GitHub release job depends on the build jobs, a CDN blip would block
every OpenWrt artifact from the release. Blossom/nostr distribution is
supplementary; the package already ships as a GitHub release artifact.

Wrap the upload in a 3-attempt retry with backoff to absorb transient
timeouts, mark the step continue-on-error so a persistent outage no
longer fails the build, and guard the NIP-94 publish on the upload
succeeding so no event is created with an empty URL. Applied to both the
.ipk and .apk build jobs.

Claude-Session: https://claude.ai/code/session_01A2pYfSypNmmG4HyHwZuLex
2026-06-21 14:40:50 +00:00
Johnathan Corgan 262d98a8eb 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
2026-06-21 13:45:19 +00:00
Johnathan Corgan 274b09d4ff node: classify transit forwards by route class; regroup fipstop routing tab
Add six forwarding 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. Classification is computed from tree coordinates at the transit
chokepoint, so the error-signal routing callers are excluded.

The two "outside the chosen peer's subtree" classes are both up-and-over
forwards but differ in what they depend on. Tree-down-cross is the
dive-to-tree-child cut-through: we forward down to our own child for a
destination not beneath it, which is only possible because the child
advertised cross-link reach upward to us, beyond its own subtree. Its count
measures how much forwarding depends on that upward advertisement, i.e. what
would change if cross-link advertisements were narrowed to subtree-entry only.
Cross-link ascend, by contrast, uses the node's own lateral cross-link learned
from a peer's split-horizon advertisement, so it does not depend on any upward
advertisement.

Surface the counters through the forwarding stats snapshot (control socket,
show_routing and show_status) and reorganize the fipstop routing tab so its
two columns separate own/endpoint traffic (received, delivered, originated)
from forwarded/transit traffic (the route-class breakdown and drop reasons),
with the tree-down-cross line visually flagged.
2026-06-20 14:56:54 +00:00
ArjenandJohnathan Corgan 0f1fd18c25 packaging(openwrt): SDK-free .apk build for OpenWrt 25+ and control-socket fix
Add OpenWrt .apk packaging 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 like the .ipk: it reuses the
cargo-zigbuild cross-compile and the shared installed-filesystem payload
under openwrt-ipk/files, and assembles the ADB container with the
official `apk mkpkg` applet from apk-tools 3.0.5 built from source, so no
OpenWrt SDK image is needed.

The package-openwrt workflow is refactored so a single compile-binaries
job cross-compiles and strips each arch once; both the .ipk and .apk
packagers consume the binaries via a new --bin-dir flag instead of each
recompiling. A build-apk job (aarch64, x86_64) builds apk-tools,
packages, and structurally verifies the .apk with `apk adbdump`. Releases
now publish .apk artifacts and checksums alongside .ipk; the release
download is scoped to fips_* so the shared raw-binary artifacts are not
swept into the published release. apk-version.sh maps a release tag or
commit height to an apk-tools-valid version, covered by a case-table test.
Packages are unsigned, installed with `apk add --allow-untrusted`,
matching the .ipk posture.

Also fix the OpenWrt control socket: the init script now pre-creates
/run/fips before starting the daemon, the procd equivalent of the systemd
unit's RuntimeDirectory=fips. Without it, on a fresh boot the daemon
resolves its control socket to /tmp (since /run/fips does not yet exist),
while fips-gateway later creates /run/fips for its own gateway.sock,
leaving fipsctl/fipstop resolving a /run/fips/control.sock the daemon
never bound.
2026-06-20 14:44:05 +00:00
ArjenandGitHub 225fab29ab fix(tun): complete L4 checksum on hairpinned self-traffic (macOS) (#117)
Self-addressed TCP/UDP connections to a node's own <npub>.fips address
half-opened and hung on macOS. macOS routes self-traffic as loopback (a
LOCAL route via lo0), which defers the transport TX checksum, but the
point-to-point utun then egresses the packet into the daemon with only
the pseudo-header partial checksum present. The hairpin path added in
9a9e90a re-injected these verbatim, so the local stack dropped every
segment whose checksum MSS clamping didn't happen to rewrite: the
SYN/SYN-ACK got through (clamping recomputes them) but the bare ACK,
data, and FIN were dropped for a bad checksum, leaving the listener
stuck in SYN_RCVD.

Recompute the TCP/UDP checksum for self-addressed packets on the hairpin
path before re-injection, completing the self-delivery 9a9e90a started
(which only covered ICMP and the TCP handshake). Linux is unaffected: it
loops self-traffic via lo before the TUN, so the hairpin branch never
fires and checksums are already valid.

Confirmed on macOS: a self-connect that previously timed out now
completes in ~9ms with payload delivered.
2026-06-18 08:36:57 -07:00
ArjenandJohnathan Corgan effd69bd53 ci(openwrt): bump Node 20 actions to Node 24 majors
checkout v4->v6, cache v4->v5, upload-artifact v4->v7,
download-artifact v4->v8 to clear the Node 20 runner deprecation.
2026-06-17 17:58:37 +00:00
Johnathan Corgan 3749853716 packaging(openwrt): publish checksums-openwrt.txt alongside .ipk artifacts
The linux/macos/windows package workflows each publish a
checksums-<platform>.txt sha256 file with their release artifacts, but
the OpenWrt workflow attached only the .ipk files with no checksum
coverage. Generate checksums-openwrt.txt over the .ipk outputs using the
same sha256sum idiom as the other platforms and add it to the release
file set.
2026-06-17 17:58:37 +00:00
ArjenandJohnathan Corgan 289e5f8571 ci: bump Node 20 actions to Node 24 majors
Clear the GitHub Node 20 runner deprecation across CI, AUR, and the
Linux/macOS/Windows packaging workflows:

  actions/checkout          v4 -> v6
  actions/cache             v4 -> v5
  actions/upload-artifact   v4 -> v7
  actions/download-artifact v4 -> v8

package-openwrt.yml is handled separately on fix/openwrt-checksums.
2026-06-17 17:58:37 +00:00
sandwichandJohnathan Corgan 3733349d33 packaging(nix): add a Nix flake for reproducible from-source builds
Add a flake that builds all four binaries (fips, fipsctl, fips-gateway,
fipstop) on Linux and macOS, pinning the exact toolchain from
rust-toolchain.toml (1.94.1 + rustfmt/clippy) via fenix so Nix builds
match CI and the AUR/Debian packaging.

Wire up the build-time native deps the source tree needs: pkg-config and
bindgenHook (libclang) for the rustables/libdbus-sys bindgen step, and
dbus for bluer's BLE support. autoPatchelfHook rewrites the binary RPATHs
so the daemon resolves libdbus-1.so.3 and libgcc_s.so.1 from the Nix store
at runtime — without it `fips` fails to load on NixOS, which has no global
/usr/lib.

Outputs: packages.{default,fips}, apps for each binary, checks.fips, and a
devShell with the pinned toolchain plus cargo-edit. Tests are skipped in
the package build since they exercise TUN devices, raw sockets, and mDNS
that aren't available in the sandbox, mirroring the AUR/Debian packaging.

Verified end-to-end in a pure Nix store (nixos/nix container): nix build,
nix flake check, and running all four binaries succeed against the
committed flake.lock.

Documented across the install and developer docs: a Nix / NixOS section in
packaging/README.md, the from-source guide in docs/getting-started.md
(noting the flake produces binaries only, with NixOS system integration
through the system configuration rather than the installer), the CHANGELOG,
README, CONTRIBUTING, and the v0.4.0 release notes.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-06-17 16:36:38 +00:00
ArjenandJohnathan Corgan 9a9e90a32c fix(tun): loop back self-addressed packets for local delivery
A packet destined for our own mesh address reached the TUN reader on
macOS (utun egresses self-traffic into the daemon despite the lo0 host
route) and was pushed onto the mesh outbound path, where it was dropped
for lack of a session/route to self. Hairpin self-addressed packets back
to the TUN writer instead, so ping6 and connections to our own
<npub>.fips address are delivered locally.

On Linux the kernel already loops self-traffic via `lo` before it reaches
the TUN, so the branch never fires there; the check is kept unconditional
as a daemon-level delivery invariant and to keep it covered by Linux CI.
2026-06-17 14:32:17 +00:00
Johnathan Corgan 759f199518 packaging(aur): add clang to makedepends for the libclang build dep
The fips build runs bindgen (via the rustables crate that powers the LAN
gateway's nftables bindings), which needs libclang.so at build time.
makepkg -s installs only declared makedepends, so without clang the AUR
build panics with "Unable to find libclang". Add clang to the makedepends
of both PKGBUILD and PKGBUILD-git so makepkg installs it and AUR users
get the dependency. Surfaced by the always-on aur-build CI job.
2026-06-14 18:33:38 +00:00
Johnathan Corgan d3cf1d6f25 Land v0.4.0 pre-release source content: docs, changelog, packaging
Bring the source tree to its finished v0.4.0 content state ahead of the
release candidate. Documentation, changelog, release notes, and
packaging metadata only; no code or version-string changes.

CHANGELOG.md: backfill the operator-visible changes that landed since
v0.3.0 (the show_metrics scraper query and fipsctl stats metrics, the
discovery dedup-cache-full counter, the off-rx_loop control read
surface and its new daemon-resolved fields, the fipstop TUI overhaul,
the TCP inbound cap now honoring max_connections, host-map hot-reload,
log-noise demotions, and the net bug fixes), topic-grouped rather than
replayed per commit; intra-cycle fixes folded into their feature
entries; the duplicate Unreleased Fixed section merged into one.

Release notes: author docs/releases/release-notes-v0.4.0.md to the
operator-upgrade bar and mirror it byte-for-byte into the root
RELEASE-NOTES.md. Attribute each feature to its author in Contributors
(Nym transport and the mixnet demo to @oleksky, opt-in mDNS LAN
discovery to @mmalmi).

README.md: add the Nym transport to the support matrix (Linux, macOS,
Windows; OpenWrt pending verification), the multi-transport bullet, and
the "What works today" list; fold mDNS LAN discovery into the existing
Nostr-discovery bullets; refresh the status narrative to v0.4.0 over a
global, public test mesh.

packaging/common/fips.yaml: add commented Nym transport and mDNS LAN
discovery example stanzas with verified field names and defaults.

Cargo.toml: add homepage, keywords, and categories crate metadata.

docs/reference: update the cli-fips version example to the released
form; document the new control-socket output fields and the typed
RejectReason families in control-socket.md; sync cli-fipstop.md with the
overhauled TUI keybindings.
2026-06-14 17:23:44 +00:00
Johnathan Corgan e03a1ac50b docs: correct stale doc-comments for LAN handshake and mesh-size
poll_lan_discovery's comment said Noise XX, but LAN-discovered peers dial over
UDP through initiate_connection, which uses Noise IK (IK at FMP). compute_mesh_size's
header comment still described the obsolete sum-of-disjoint-subtrees estimate;
the function OR-unions every connected peer's inbound filter plus self and
estimates cardinality once (matching the body comment). Comment-only, no
behavior change.
2026-06-14 15:14:05 +00:00
Johnathan Corgan 507086e39d docs: refresh tutorials, how-to, design, reference, and examples for v0.4.0
Pre-cut documentation pass for the 0.4.0 release, verified against current source.

Corrections:
- fipsctl: stale 'show identities'/'show node' -> 'show status'
  (host-a-service, run-as-unprivileged-user)
- mesh address derivation: first 16 bytes of SHA-256(pubkey) with the leading
  byte set to 0xfd, not a fixed fd97: prefix (reach-mesh-services,
  ipv6-adapter-walkthrough)
- gateway control socket mode 0660 -> 0770 (troubleshoot-gateway)
- Tor example: add advertised_port: 8443 so the published port matches the
  prose (enable-nostr-discovery)
- bloom mesh-size estimate rewritten to the OR-union-of-peer-filters algorithm;
  plus mtu deep-link, gateway pool wording, and a NAT failure-mode line
- examples: delete orphaned nostr-rs-relay config, accept inbound to the local
  8443 TCP listener, fix fd::/8 -> fd00::/8 typos, dotless wireguard alias

Additions:
- new Nym mixnet transport section (fips-transport-layer) and the architecture
  transport list
- new LAN/mDNS discovery section (fips-nostr-discovery)
- reference docs: Nym transport, LAN discovery, and new control/stats surfaces;
  drop ble from the connect transport list
2026-06-14 15:14:05 +00:00
Johnathan Corgan 3e0d9f5726 ci: build and lint the AUR package on every CI trigger
Run the AUR package through makepkg + namcap on the same triggers as the
other package workflows (pushes to master/maint/next, pull requests,
tags, and manual dispatch), so a broken PKGBUILD is caught continuously
rather than only at release time. The build runs in an Arch container
(makepkg/namcap are not on ubuntu-latest) and packages the checked-out
tree from a local git-archive tarball, so it works for branch/PR builds
and unreleased rc tags that have no published GitHub source archive yet.
makepkg runs with --nocheck since the test suite is already covered by
ci.yml; this job validates packaging.

Publishing to the AUR is unchanged in intent but now gated to a real
(non-prerelease) release tag push, plus the existing manual-dispatch
republish path for packaging-only pkgrel bumps; it depends on the build
job so a package that fails to build or lint is never published. Branch
pushes and pull requests build and lint but never publish.

The PKGBUILD-patching logic is factored into a shared
packaging/aur/patch-pkgbuild.sh used by both jobs; the build path
sanitizes the version for makepkg (which forbids '-' in pkgver).
2026-06-14 03:49:00 +00:00
Johnathan Corgan 4e3890a780 ci: cover Debian 13 and Ubuntu 22.04 in the deb-install matrix
The GitHub deb-install matrix ran debian12/ubuntu24/ubuntu26, but the
local harness (testing/deb-install/test.sh) runs five distros. Add the
missing debian13 (trixie) and ubuntu22 legs so the cloud gate covers the
same distro set as local CI. Each new leg invokes the existing test.sh
scenario, so per-distro behavior is identical to the local run.

Update the granularity-only parity notes in ci.yml, ci-local.sh, and
check-ci-parity.sh to list the full distro set.
2026-06-14 02:38:09 +00:00
Johnathan Corgan a308e71ca1 Refresh in-semver dependencies for the v0.4.0 release
Pull the available point releases that sit within the current version
constraints: tun 0.8.11, tokio-socks 0.5.3, socket2 0.6.4, nostr 0.44.3,
nostr-relay-pool 0.44.1, ratatui 0.30.1, serde_json 1.0.150, simple-dns
0.11.3, mdns-sd 0.19.2, plus the transitive maintenance crowd. Lockfile
only; no Cargo.toml constraint changes.

Defer the out-of-semver crypto and identity majors (secp256k1 0.31, sha2
0.11 with hkdf 0.13, bech32 0.12) and the mdns-sd 0.20 bump to a separate
coordinated refresh with its own handshake, identity, and LAN validation.
2026-06-14 02:06:50 +00:00
Johnathan Corgan 3d771c6688 Wire the Tor transport connect_refused counter
The connect_refused stat counter (the Refused line in fipstop) was
defined but never incremented: every SOCKS5 connect failure recorded
socks5_errors instead, so the counter sat at zero and the operator-facing
gauge was permanently misleading. Both the synchronous connect path and
the background connect_async task now count a genuine SOCKS5 REP=0x05
refusal as connect_refused and every other failure as a socks5_error,
distinguishing them precisely via tokio_socks::Error::ConnectionRefused.
Extends the mock SOCKS5 server with a configurable reply code and adds
two tests covering the refused and general-failure paths.
2026-06-13 23:15:39 +00:00
olekskyandJohnathan Corgan 4e43cb81e9 Add Nym mixnet transport and single-container demo
Add an outbound-only Nym mixnet transport that tunnels FMP peer links
through a local nym-socks5-client SOCKS5 proxy into the Nym mixnet. It
structurally mirrors the Tor SOCKS5 transport (connection pool,
connect-on-send background promotion, FMP-v0 framing reused from TCP)
with the onion, inbound-listener, and control-port machinery removed.

Wires the transport through the full TransportHandle dispatch, NymConfig
(standard transport-instance pattern), and node instantiation, and
surfaces its counters in fipstop. Includes a mock SOCKS5 harness and unit
coverage for the address-parsing paths.

Also adds an isolated single-container example
(examples/sidecar-nostr-mixnet-relay/) demonstrating FIPS peering across
the mixnet end to end. No new crate dependencies: tokio_socks, socket2,
and futures are already pulled in by the Tor transport.
2026-06-13 19:38:01 +00:00
Johnathan Corgan fb8bb4fb97 Merge maint into master: fipstop SSH/tmux garble fix 2026-06-13 02:54:16 +00:00
Johnathan Corgan 5eac3a98f3 fipstop: fix garbled screen on startup and quit over SSH/tmux
Two independent rendering glitches, both most visible over SSH and inside
tmux:

Startup: ratatui::try_init() enters the alternate screen but never clears
it, and the first terminal.draw() only emits cells that differ from an
assumed-blank internal buffer. On terminals that don't hand back a cleared
alternate buffer (notably tmux, and amplified by SSH latency) the prior
contents show through. Force a full repaint with terminal.clear() before
the first draw.

Quit: the input EventHandler spawned a detached thread that polled stdin in
a loop outliving the main loop, so at quit it kept reading after raw mode
was disabled and stray bytes (a keystroke or a terminal query response)
echoed onto the restored screen. Give the thread a stop flag and join it
before restoring the terminal; poll on a short fixed interval (decoupled
from the refresh tick) so quit stays responsive.

git log --oneline -1
2026-06-13 02:51:13 +00:00
Johnathan Corgan a4802ccf9e Merge maint into master: demote rekey-abort logs to debug 2026-06-13 01:37:15 +00:00
Johnathan Corgan 7a74fa8ca2 rekey: demote exhausted-budget rekey-abort logs from warn to debug
When an FMP msg1 or FSP msg3 rekey retransmission budget is exhausted, the
cycle is abandoned and retried on the next timer. On lossy or high-latency
links this is an expected, self-limiting outcome: the existing session stays
valid and keeps carrying traffic, so the abort is not a failure that warrants
operator-level visibility. Demote both abandon-cycle messages from warn to
debug to cut steady-state log noise on nodes with many flapping peers.
2026-06-13 01:37:01 +00:00
Johnathan Corgan f5f4ebe76f Merge branch 'maint' 2026-06-12 23:41:38 +00:00
Johnathan Corgan fd30ab0994 node: notify the peer on manual disconnect so teardown is symmetric
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. Send the disconnected peer a scoped Disconnect, the
same message graceful shutdown sends to all peers, so both sides tear down and
re-handshake cleanly on the next connection.
2026-06-12 23:32:56 +00:00
Johnathan Corgan 5fc2359432 fipstop: TUI overhaul with render-snapshot harness and navigation model
Reworks the fipstop TUI across its rendering, the control read surface it
draws from, and its interaction model, on a machine-verified base.

Test infrastructure:
- Add a ratatui TestBackend snapshot harness (testkit + snapshots
  modules) that renders any ui::draw_* into an in-memory Buffer from
  canned show_* JSON and asserts the text grid plus per-cell style.
  Layout, columns, alignment, labels, grouping, and colour are now
  checkable under cargo test; every render below ships a snapshot.

Control read surface (each new field emitted byte-identically on the
live and off-loop builders, published once from the tick, with schema
fixtures regenerated and the parity asserts holding):
- show_status: effective persistence (persistent || nsec.is_some());
  root and is_root; and a per-configured-transport-type peer-count map
  in which idle-but-configured types stay visible at zero.
- show_peers: per-peer effective_depth (depth + link_cost, the value
  evaluate_parent ranks on), null when unmeasured or coordless so
  fipstop never recomputes it.
- show_tree: root_npub, resolved once daemon-side (self when root, an
  attested peer npub, or an identity-cache hit).
- show_bloom: the last-actually-sent uptree filter fill ratio and
  subtree estimate, null for a root or before the first announce.
- show_mmp: session-layer srtt, loss, and etx trend labels.

Rendering:
- Display a 6-byte non-UTF-8 TransportAddr as a colon-separated MAC at
  the type layer, so daemon logs, fipsctl, and JSON consumers all
  benefit; non-6-byte payloads stay bare hex.
- Right-justify the Bloom Peer Filters numerics into aligned fixed-width
  columns, render the Routing panes through a kv_lines helper that shares
  one value column across a key-value group, and right-justify the Graphs
  by-peer summary columns.
- Truncate an over-long peer name (the npub shown when no friendly name
  exists) in the Tree, Bloom, and MMP peer lists so it no longer runs
  into the next column.
- Group the Peers table by role (parent, then STP children, then other)
  and render it as a full grouped view with styled group labels and
  blank separators; the selection stays a peer index and the cursor only
  ever lands on a peer row. Apply the same role grouping to the Tree and
  Bloom peer lists, joining each peer's role from the peers view by node
  address.
- Show min in the Graphs plot titles, rest a steady non-zero metric on
  the baseline as a row of dots, render a genuine zero as an empty plot,
  and keep a distinct no-data placeholder.
- Replace the metric-by-peer grid, which squeezed plots to nothing once
  peers overflowed, with a master/detail Graphs view: a scrollable
  per-peer summary list that expands (Enter) to a full-pane btop plot,
  with up/down to flip peer, n/N to switch statistic, m to cycle mode,
  and Esc to return.
- Put inline colored trend arrows on the Link and Session MMP values
  (drawn only on a rising or falling trend, with a fixed blank slot when
  stable so the value columns stay aligned), via a shared helper.
- Cycle column sorting on the Link MMP, Session MMP, and Graphs by-peer
  tables (one key cycles the active column, another toggles direction),
  with the active column marked in each table's header.
- Render the new daemon-surfaced fields: the dashboard root line (a
  self-is-root marker, otherwise a truncated root hex), a
  transports-by-type line, and an "approx. mesh estimate" line; an
  effective_depth column and lines on the Peers, peer-detail, and Tree
  sites from the single daemon derivation, showing a dash placeholder
  when unmeasured rather than a misleading zero; the full Tree root hex
  plus an Npub line; and the Bloom uptree fill and subtree-estimate lines.

Interaction model:
- Add a declarative keybinding registry keyed by (Tab, UiMode) that both
  the context footer and the ? help overlay render from, so the two
  cannot drift; a test asserts every registry key has a dispatch handler.
- Add a modal ? help overlay, and a context-aware footer that shows the
  current state's actions first, drops global hints when the terminal is
  narrow, and always keeps a Help affordance as the overflow path.
- Generalize per-pane focus and scroll state on App, wired across the
  Tree, Filters, Routing, and MMP tabs (f cycles pane focus and the
  focused pane scrolls instead of clipping its overflow); on the MMP tab
  the column sort acts on the focused pane. Esc deselects the active row
  when no detail is open (detail-close still takes priority).
- Add a Del-disconnect confirmation modal naming the peer, the only
  state-mutating action, issuing the control-socket disconnect on confirm
  and noting that the peer stays disconnected until manually reconnected.
2026-06-12 23:05:58 +00:00
Johnathan Corgan 81cd10d5db control: serve the full show_* read surface off the rx_loop via a read-snapshot plane
Complete the control-plane read-isolation work: every pure-read show_*
query now renders in the control accept task from published read
snapshots, so none round-trips the data-plane receive loop. Only the
mutating connect/disconnect commands still reach that loop.

Three subsystem snapshots are published via ArcSwap and served through the
read handle's snapshot_dispatch:

- A routing read view (spanning tree, bloom filters, coordinate cache,
  identity cache, and the discovery F-queue summary scalars), published
  from the tick, serving show_tree/show_bloom/show_cache/show_routing/
  show_identity_cache.
- A per-entity read view (peers, sessions, links, connections, transports,
  and the MMP link/session views) as Vec<Arc<Row>> tables reconciled
  against the prior snapshot so a republish reuses unchanged rows by
  pointer and re-allocates only changed or new rows, keeping the per-tick
  publish cost bounded as the peer/session count grows. Serves
  show_peers/show_sessions/show_links/show_connections/show_transports/
  show_mmp.
- The stats snapshot is extended with the peer-ACL status and a per-peer
  metadata map (is_active, npub, display name), resolved at publish time,
  serving show_acl and the two per-peer stats queries.

Display names and other cross-subsystem fields are resolved at publish
time; time-relative fields are derived at render time from captured
absolute timestamps, so rendered output is byte-identical to the prior
on-loop handlers, which are retained as the equality oracle.

With every read query served off-loop, the show_* branch is removed from
the rx_loop control handler and the now-dead on-loop dispatcher deleted.
The snapshot projections are forward-compatible with the later structural
extraction of the derived-state and session tables: they become thin
views over the extracted types without changing the read-handle interface.
2026-06-10 17:45:49 +00:00
Johnathan Corgan 063c3a194a Merge branch 'maint' 2026-06-10 02:41:17 +00:00
Johnathan Corgan c77e564462 control: serve high-traffic show_* queries off the rx_loop hot path
Introduce a read-snapshot plane so pure-snapshot control queries render in
the control-socket task instead of round-tripping the rx_loop, removing the
head-of-line coupling that let a busy or slow rx_loop time out fipsctl and
fipstop observability.

- ControlReadHandle: a cloneable bundle the control accept loop holds, over
  the node's already-shared NodeContext and MetricsRegistry plus an
  ArcSwap-published StatsSnapshot. A snapshot_dispatch seam serves cut-over
  commands off-loop and falls through to the rx_loop for the rest, keeping
  the rx_loop's ownership of Node intact.
- StatsSnapshot is published from the tick (the natural and sole mutator of
  stats_history), carrying the history rings plus the scalar gauges and
  counts show_status reports. Readers serve the latest snapshot
  unconditionally, with staleness bounded by the tick interval and no
  IO_TIMEOUT-coupled fallback.
- Off-loop now: show_status, show_stats_history, show_stats_all_history,
  show_listening_sockets, show_stats_list, and a new counter-only
  show_metrics (exposed as fipsctl "stats metrics", the enabler for a
  Prometheus scraper at no hot-path cost). Queries that need live per-entity
  state (peers, links, sessions, routing, and the per-peer stats variants)
  stay on the rx_loop path pending later phases.

Quartet green; forward-merge to next verified clean.
2026-06-10 02:31:54 +00:00
Johnathan Corgan 1f457d84f9 gateway: pin virtual-IP mapping while data-plane traffic flows
The pool's TTL clock (VirtualIpMapping.last_referenced) 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 (a stale drain_start gave no grace effective
protection), breaking long-lived, bursty, or DNS-cached clients.

In tick(), refresh last_referenced whenever conntrack reports sessions > 0
so an actively used mapping never ages out, and recover a Draining mapping
to Active (clearing drain_start) when traffic resumes, so a later drain
gets a fresh grace window instead of a stale one. The Active arm now only
drains an idle mapping. The DNS-TTL / idle-reclaim-TTL wiring is unchanged.

Adds regression tests for continuous-traffic-survives-past-TTL, bursty
drain-then-recover, and fresh-grace-on-redrain.
2026-06-10 02:17:46 +00:00
Johnathan Corgan bdf571a2b2 Merge branch 'maint'
# Conflicts:
#	src/node/mod.rs
2026-06-10 00:05:17 +00:00
Johnathan Corgan 2eea20a216 tcp: drive inbound connection cap from node.limits.max_connections
The per-transport TCP inbound cap was hardwired to 256 and never read
node.limits.max_connections, so raising max_connections was a silent
no-op for inbound TCP. Resolve the effective cap with precedence:
explicit per-transport max_inbound_connections, then node-wide
max_connections, then the built-in default of 256. Established peers
remain bounded node-wide by add_connection, so deriving the per-transport
raw-accept ceiling from max_connections does not admit more real peers
across multiple transports.

Add effective_max_inbound on the TCP transport with a node_max_connections
setter wired from create_transports, plus a precedence unit test.
2026-06-09 23:55:48 +00:00
Johnathan Corgan ea9c7f2d8d mesh-size: union all peer filters, not just tree peers
Estimate the OR-union cardinality over self plus every connected peer's
inbound filter, dropping the parent/child tree gating in
compute_mesh_size. Filter propagation is split-horizon, so cross-links
advertise near-complete mesh views; unioning all peers yields the same
set as the tree-only union in steady state (OR dedups overlap and no
filter can over-count) while damping the node-count flap on parent
switches, since dropping the parent no longer collapses the upward leg.
This also removes the estimate's dependence on tree-declaration cache
freshness.

Rename the debug-log child_count to contributor_count, adapt the two
membership-invariant tests to all-peers semantics, and add a test that
the estimate stays stable across a parent drop when a healthy cross-link
is present.
2026-06-09 23:55:48 +00:00
Johnathan Corgan e09d9f8412 Merge branch 'maint'
# Conflicts:
#	CHANGELOG.md
2026-06-09 11:33:43 +00:00
Johnathan Corgan f3eb5bf4c2 bloom: raise max_inbound_fpr antipoison cap from 0.05 to 0.10
The inbound FilterAnnounce FPR cap rejects filters whose false-positive
rate (fill^k) exceeds the configured maximum. On the fixed 1 KB / k=5
filter, 0.05 corresponds to fill 0.549 (~1,300 reachable entries), and
the busiest nodes' aggregates were beginning to hit that ceiling as the
mesh grew. Raise the default to 0.10 (fill 0.631, ~1,630 entries) to
restore headroom toward the fixed-filter capacity limit. A saturated or
poisoned filter is ~100% FPR and remains rejected, so the antipoison
gate is not materially weakened.

Updates the config default, the config-reference and bloom-filter
design docs, and the changelog.
2026-06-09 11:32:58 +00:00
Johnathan Corgan 44f7451828 Merge branch 'maint' 2026-06-08 20:26:30 +00:00
Johnathan Corgan c2fb12d997 sidecar test: clear node-a peer env so it does not join the public mesh
The sidecar chain test intends node-a to be a standalone root ("node-a: no
outbound peers"), but started node-a without clearing FIPS_PEER_*, so it
inherited the external peer default from testing/sidecar/.env (a real public
mesh node, test-us01.fips.network) and auto-connected to the live mesh. The
whole a-b-c chain then attached under an external root, inflating tree depth and
breaking test isolation; this also produced spurious multi-hop failures.

Set FIPS_PEER_NPUB/FIPS_PEER_ADDR empty for node-a, matching how node-b and
node-c already get explicit inline peers, so the suite is hermetic regardless of
the .env default. Validated against the unmodified .env: node-a comes up with a
single link to node-b, no external attachment, multi-hop passes 18/18.
2026-06-08 20:26:05 +00:00
Johnathan Corgan bca981b79f Merge maint into master (single-uplink-leaf tree-attachment re-push fix; macOS resolver ::1 fix) 2026-06-08 18:18:48 +00:00
ArjenandJohnathan Corgan 03f7511a0e packaging: macOS resolver must point at ::1, not 127.0.0.1
Before bf77ece (Fix DNS responder silent-drop on systemd-resolved
deployments, 2026-04-29) the daemon defaulted dns.bind_addr to "::"
(wildcard, accepted v4 traffic too), so the macOS pkg's resolver shim
of `nameserver 127.0.0.1` reached the daemon fine over v4 loopback.

That commit tightened the default to "::1" — IPv6 loopback only,
which on Linux/macOS does not accept v4-mapped traffic — to defuse a
mesh-interface filter / IPV6_PKTINFO bug that was silently dropping
.fips queries on systemd-resolved hosts. The Linux side was updated
in the same commit: fips-dns-setup now writes [::1]:5354 in every
backend, and the gateway's DEFAULT_DNS_UPSTREAM moved to [::1]:5354
with an inline comment about the v4/v6 mismatch.

The macOS resolver shim in packaging/macos/build-pkg.sh was missed in
that sweep. Since 2026-04-29, every macOS install has shipped
/etc/resolver/fips with `nameserver 127.0.0.1` while the daemon
listened on `::1`, so .fips hostnames don't resolve via getaddrinfo
(ping6, curl, etc.) even though `dig @::1 -p 5354 …` works.

The mismatch is easy to miss: mDNSResponder swallows the timeout,
VPN clients that hijack DNS (NetworkExtension match-domain : *) mask
it entirely, and the symptom looks like "discovery hasn't found the
peer yet". Switch the shim to nameserver ::1 to match the daemon.
2026-06-08 18:16:32 +00:00
Johnathan Corgan d364933ca5 node: re-push TreeAnnounce when a peer advertises a worse root
A node with a single tree peer has its periodic parent re-evaluation
disabled (it needs at least two peers for a meaningful comparison), so
it depends entirely on its peer pushing a TreeAnnounce for it to attach.
That push happens once at promotion time plus on the parent's slow
periodic no-change re-broadcast. If the one-shot attaching announce is
lost, the single-uplink node falls back to self-root and cannot recover
until the next periodic re-broadcast (reeval_interval_secs later),
stranding it out of the tree and unreachable end-to-end in the interim.

Make tree-position exchange self-healing on the receive path: when an
accepted TreeAnnounce advertises a root strictly worse (higher NodeAddr,
since election is smallest-wins) than our own, echo our current
declaration back to that peer. A stranded self-root node's announce now
provokes its better-rooted peer to re-push its real position immediately,
so the node re-attaches within a round-trip instead of waiting for the
periodic cadence.

Echo only in that one direction. If the peer's root is lower (better)
than ours, we are the stale side: the peer would ignore our worse root
anyway and we converge via the parent re-evaluation that follows, so
echoing back is pure waste and would double announce traffic in the
learning direction during a root change or partition merge. Equal roots
are already converged. The echo is bounded by the existing per-peer
500 ms tree-announce rate limiter and is a no-op once the peer adopts our
root, so it adds no traffic in a converged mesh.

Add a spanning-tree unit test that drives a converged child back to
self-root with its peer-ancestry view of the root cleared (modelling the
lost attaching announce), and asserts the root re-pushes on the
resulting root disagreement and the child re-attaches.
2026-06-08 14:36:13 +00:00
Johnathan Corgan 42011a9a2f Merge maint into master (MMP stale-report rejection; convergence-gate near-budget hold) 2026-06-08 00:13:35 +00:00
Johnathan Corgan 1b7528ce89 testing: hold convergence gate for full budget when near-converged
wait_until_connected fail-fasted on stall even when the mesh was all but
converged, abandoning the run with budget still unspent because one hard
pair straggled to come up. On the rekey-outbound-only topology this turned a
rare deep-node timing straggle (stacked discovery backoff + late bloom
propagation, which clears well inside the budget) into a false baseline RED.

Add a near_converged_slack threshold (default 2): when the number of
still-failing pairs is at or below the slack and the stall window elapses,
keep polling toward max_secs instead of bailing. A mesh genuinely far from
convergence still fast-bails on stall, and a never-converging pair still
hits the hard cap, so a real regression is never masked.

Add a self-contained behavioral test (wait-converge-test.sh) covering the
four contract cases: near-converged hold (with a slack=0 contrast that
proves the slack is what saves the run), far-from-converged fast-bail,
never-converging hard cap, and four-argument backward compatibility.
2026-06-08 00:12:58 +00:00
Martti MalmiandJohnathan Corgan d548add18d Reject stale MMP receiver reports
Ignore duplicate or counter-regressed ReceiverReports before updating
RTT, loss, goodput, or ETX, so a delayed or reordered report can no
longer poison link metrics. Compute the RTT-from-echo sample with
checked timestamp arithmetic and reject zero, negative, or out-of-range
results instead of risking wrap or underflow on untrusted wire values.

On the sender side, when receiver dwell time overflows the u16 wire
field, suppress the timestamp echo (send 0) and saturate dwell to
u16::MAX rather than truncating, so a bogus small RTT cannot be formed.

Adds duplicate, out-of-order, wrapped-add, and future-dated (checked_sub)
sample tests, asserts loss and goodput stay unchanged on a dropped
duplicate, and covers the dwell-overflow echo suppression. Documents the
behavior in the MMP design note and CHANGELOG.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-06-07 23:30:35 +00:00
Johnathan Corgan 87bf17dd4d testing/interop: add multi-hop mixed-version topology with forwarding, continuity, and mesh-size checks
The interop harness only tested a full mesh, where every pair is a direct
one-hop FMP link. That left multi-hop forwarding, routing, coordinate
handling, and mesh-size estimation untested across versions.

Add an opt-in multi-hop topology and three new checks:

- generate-configs.sh: FIPS_INTEROP_EDGES selects an explicit undirected
  edge list instead of the full mesh, with symmetric peering, connectivity
  validation (no isolated node, graph connected), and per-node degree plus
  edge metadata in nodes.env. Unset means full mesh, unchanged.

- interop-test.sh: a --topology flag with a built-in multihop-3v-cycle
  (six nodes, two of each version, one cycle, two leaves). The per-node
  peer check now expects each node's adjacency degree rather than N-1, and
  the all-pairs ping now also exercises cross-version forwarding over
  non-adjacent pairs. Adds a control-differential data-plane continuity
  stream across the rekey window (Phase 5b) and a strict plus/minus 25
  percent mesh-size convergence check (Phase 7).

- interop-stress.sh: forward --topology through to the per-rep driver
  (mutually exclusive with a positional node-spec).

Phase 1 makes the strict retrying ping authoritative. The convergence
detector (one fully-clean, no-retry sweep of every directed pair) is now an
advisory settle-wait whose timeout is non-fatal; the strict retrying ping
decides pass/fail, matching how the post-rekey phases already work. Under
packet loss a clean no-retry sweep of a large mesh is statistically unlikely
even when the mesh is healthy (30 pairs at 2 percent loss leaves only about
55 percent of sweeps clean), so the detector otherwise falsely failed runs
whose strict ping reported full reachability. CONVERGENCE_TIMEOUT is now
env-overridable and defaults to 90s for larger meshes under loss.

Full-mesh runs are unaffected. Validated end-to-end with the
multihop-3v-cycle topology across three versions: all 30 directed pairs
reachable including multi-hop routed pairs, zero data-plane loss through
both rekey cutovers, and mesh-size converging to the true node count on
every node.
2026-06-07 13:59:55 +00:00
Johnathan Corgan 79b945b93d Merge maint into master (mesh-size OR-union estimate; log-level hygiene)
Brings the maint-line OR-union mesh-size estimator fix and the
per-peer / capacity-cap log-level demotions forward to master.

Conflict resolution: compute_mesh_size resolved to master's config()
accessor form carrying maint's OR-union rewrite (drop the stale summing
`total`); the log-level demotions auto-merged into master's handler
versions. The equivalent master-only log "connected UDP socket installed"
(connected_udp.rs, a file that does not exist on maint) was demoted
info -> debug as part of this merge so the connected-UDP path matches the
rest of the per-peer lifecycle logging.
2026-06-06 19:21:05 +00:00
Johnathan Corgan 974e146bb9 node: demote routine per-peer and capacity-cap events from info/warn to debug
On a saturated public-mesh node the connection-lifecycle and capacity-cap
events fire continuously and drown out the genuinely notable INFO/WARN
lines. Demote them to debug and drop a redundant duplicate:

- FMP K-bit cutover promotion (encrypted): info -> debug
- "Connection promoted to active peer" (handshake): info -> debug, and
  remove the duplicate "Inbound peer promoted to active" line that
  shadowed it on the inbound path
- "Peer restart detected" (handshake): info -> debug
- "Peer removed and state cleaned up" (dispatch): info -> debug
- "Rejecting inbound TCP connection (max_inbound_connections reached)"
  (tcp): warn -> debug
- "Congestion detected, CE flag set on forwarded packet" (forwarding):
  warn -> debug
- "Removing peer: link dead timeout" (mmp): warn -> debug

These are expected, high-frequency conditions on a busy public node (new
and reconnecting peers, ECN CE marking, the inbound connection cap, and
link-dead churn), not operator-actionable signals.
2026-06-06 19:11:45 +00:00
Johnathan Corgan 180950badf node: estimate mesh size by OR-union of filters instead of summing cardinalities
The mesh-size estimator summed the per-filter cardinality of the parent
filter and each child filter, which assumes those filters are perfectly
disjoint. When they overlap -- a stale or oversized parent filter, or a
routing loop -- the sum over-counts and inflates the reported mesh size
to as much as several times the true size.

Estimate the cardinality of the OR-union of the contributing filters
(self + parent + children) once instead. OR is idempotent, so any
overlap is deduplicated: the result equals the old sum in the disjoint
case and stays correct under overlap. The union is seeded from a clone
of a contributing filter so it keeps that filter's size class, and a
filter whose size class does not match is skipped rather than panicking.
The refuse-to-estimate behavior on a saturated or above-cap filter is
preserved.

Add a regression test with overlapping parent and child filters where
the naive sum over-counts and the union estimate tracks the distinct
member count.
2026-06-06 18:33:40 +00:00
Johnathan Corgan e5372cbe0f Merge maint into master (libclang build-prereq doc + transport mutex-poison recovery) 2026-06-06 13:34:40 +00:00
Johnathan Corgan 9dcc421f6f transport: recover poisoned mutex guards instead of panicking on lock
The transport layer used Mutex::lock().unwrap() at ten sites across the
UDP, BLE, and Ethernet code. A std mutex poisons if a thread panics
while holding it, after which every lock().unwrap() on that same mutex
also panics, turning one fault into a cascade. These critical sections
only perform short HashMap/Vec operations on locally constructed values
and are not reachable from peer input, but the idiom is fragile against
any future in-section panic. Replace each with
lock().unwrap_or_else(|e| e.into_inner()), which recovers the guarded
data and removes the cascade with no new dependency and no call-graph
change.

Also replace four self.local_addr.unwrap() calls in the UDP start and
adopt paths with a sentinel fallback. The value is provably set just
above each log line today, but the unwrap is brittle against a future
reordering; logging an unbound sentinel is harmless and cannot panic.
2026-06-06 13:31:21 +00:00
Johnathan Corgan 86c043cc94 docs: document libclang-dev as a mandatory Linux build prerequisite
Linux source builds pull in rustables, whose build script runs bindgen
to generate nftables bindings for the LAN gateway. bindgen needs
libclang.so on the build host, so a clean source build fails with
'Unable to find libclang' unless libclang-dev (or llvm) is installed.

The prerequisite text in README.md and CONTRIBUTING.md previously
listed only the optional BLE dependencies, and packaging/README.md had
no source-build prerequisite list at all. Document libclang-dev as a
mandatory Linux build dependency, distinct from the optional BLE deps,
and note that it is build-time only so pre-built .deb installs are
unaffected.
2026-06-06 12:16:26 +00:00
Johnathan Corgan 555d00cfa6 docs: record the file-descriptor tuning how-to in the changelog
The how-to for raising RLIMIT_NOFILE was added without a changelog entry;
record it under Added so the master Unreleased section reflects it.
2026-06-05 21:28:24 +00:00
Johnathan Corgan dd4074249c Merge branch 'maint'
# Conflicts:
#	CHANGELOG.md
2026-06-05 21:22:45 +00:00
Johnathan Corgan 43ad2ae946 docs: catch up the changelog with recent maint work
Record the changes that landed since the last changelog update: the
dual-auto_connect traversal-session election, the FMP link-layer rekey
reliability fixes (bounded msg1 retransmission with rekey-aware
heartbeat, and authenticate-before-cutover), the in-process loopback
test transport and progress-aware convergence wait that remove CI flake
classes, the local/GitHub CI suite-parity and single-source toolchain
selection, and the packaging change shipping the config as an example
that postinst seeds when absent.
2026-06-05 21:11:17 +00:00
Johnathan Corgan 8fd515e81f packaging: ship fips.yaml as an example, not a dpkg conf-file
Installing /etc/fips/fips.yaml as a live dpkg conf-file collides with a
configuration-management-rendered or operator-edited config on upgrade:
dpkg either prompts interactively (keep/replace), stalling unattended
upgrades, or clobbers the local file. Ship the default config as
/usr/share/doc/fips/fips.yaml.example (mode 644) and drop it from
conf-files. postinst now seeds /etc/fips/fips.yaml from the example only
when it does not already exist (mode 600), yielding to any existing
config without a prompt or clobber. Add ConditionPathExists for the
config to the service unit so a missing config skips the unit cleanly
rather than crash-looping.
2026-06-05 20:55:10 +00:00
Johnathan Corgan bf4e0df8c5 docs: add how-to for tuning the file-descriptor limit
A busy node opens roughly three file descriptors per established UDP peer
(a connect()-ed socket plus a 2-FD drain self-pipe), so the default 1024
soft RLIMIT_NOFILE is exhausted near 320 peers and further peer admission,
handshakes, and discovery fail with EMFILE. Document the FD budget, the
symptom, and the systemd (LimitNOFILE drop-in) and OpenWrt (procd nofile)
procedures to raise it, plus how to verify the per-peer ratio is bounded.
Link the new guide from the how-to index.
2026-06-05 20:41:19 +00:00
Johnathan Corgan c7218d8486 ci: align local/GitHub integration coverage and pin the toolchain source
Bring the local runner (testing/ci-local.sh) and GitHub CI into agreement
on two axes.

Suite coverage: the admission-cap integration suite ran only locally, so a
regression in it could never turn the GitHub gate red. Add an admission-cap
leg to the integration matrix, add testing/check-ci-parity.sh (wired as
'ci-local.sh --check-parity') to diff the two suite sets and fail on
unexpected drift, and document the deliberate local-only (live-Tor) and
granularity-only differences in a comment block atop both runners.

Toolchain selection: every CI and packaging job installed its toolchain
with dtolnay/rust-toolchain@stable, but the rust-toolchain.toml channel pin
overrode it for all compilation, wasting an install and printing a
misleading rustc version. Switch the stable call sites to
actions-rust-lang/setup-rust-toolchain, which reads rust-toolchain.toml as
the single source of truth. Keep the explicit cache steps (cache: false on
the new action), set rustflags empty so the action does not impose a global
-D warnings the previous setup never applied, fold the macOS cross-compile
target into the action input, and leave the OpenWrt nightly Tier-3 leg on
dtolnay/rust-toolchain@nightly.
2026-06-05 20:20:50 +00:00
Johnathan Corgan 3bc8e5611c Merge branch 'maint' 2026-06-05 17:17:14 +00:00
Johnathan Corgan 0ce9bb5b99 discovery/nostr: elect a single traversal session for dual-auto_connect peers
When two peers each auto_connect to the other, each runs both an
initiator and a responder NAT-traversal session and binds a separate
UDP socket per session. Each side adopts only the first Established
event and drops the loser session's socket; when the two sides adopt
mismatched sessions, each sends its Noise msg1 to a peer port the peer
has already stopped draining, and both handshakes stall.

Deterministically keep the session initiated by the smaller NodeAddr,
decided on the responder path: decline an incoming offer only when we
also have an in-flight outbound initiator for the same peer and our
NodeAddr is smaller. The peer's redundant initiator then times out,
leaving a single matching socket pair on both ends. Asymmetric
(one-sided) auto_connect has no co-active initiator and is never
suppressed, so connectivity is preserved; an undecidable NodeAddr falls
through to answering.

Reuses the NodeAddr tie-breaker convention already used by the
cross-connection and rekey dual-init paths. Adds a unit test for the
election helper.
2026-06-05 16:51:51 +00:00
Johnathan Corgan e7349202b5 Merge branch 'maint' into master 2026-06-05 04:40:44 +00:00
Johnathan Corgan f29c2e65fa test: replace fixed convergence timeouts with progress-aware connectivity wait
Add wait_until_connected to the shared convergence helpers: it polls a
suite's own pairwise pings (the signal it actually asserts on), 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.

Use it in the rekey, static-mesh, and sidecar suites in place of the
fixed wall-clock baseline timeout and the blind sleep, which timed out
under concurrent CI load while the mesh was still converging.
2026-06-05 04:37:43 +00:00
Johnathan Corgan de327e4527 test: run node-level mesh tests over an in-process loopback transport
Add a Loopback variant to TransportHandle backed by an unbounded
in-process channel and a shared address-to-receiver registry, so
node-level multi-node tests deliver packets directly between nodes
instead of over real localhost UDP sockets. This removes the kernel
UDP receive-buffer overflow that dropped handshake packets when many
tests ran in parallel under CPU contention, and lets the large-network
convergence tests run reliably in the default suite again (their
parallel-load ignore markers are removed).

The new transport and its enum variant are cfg(test)-gated, so the
daemon build is unaffected.
2026-06-05 03:32:57 +00:00
Johnathan Corgan 0b7daeb380 Merge maint into master (FMP cutover authenticate-before-promote; master keeps the decrypt-worker-integrated form) 2026-06-04 21:43:19 +00:00
Johnathan Corgan 4af3730be6 fmp: authenticate inbound frame against pending session before K-bit cutover promotion 2026-06-04 21:42:23 +00:00
Johnathan Corgan 36c830edfd fmp: authenticate inbound frame against pending session before K-bit cutover promotion 2026-06-04 21:42:23 +00:00
Johnathan Corgan 22a41cb1a0 Merge maint into master (FMP rekey msg1 resend cap + rekey-aware link-dead heartbeat) 2026-06-04 18:29:28 +00:00
Johnathan Corgan 25fe87ff60 fmp: bound rekey msg1 retransmission and make link-dead heartbeat rekey-aware
The FMP rekey msg1 resend driver retransmitted indefinitely with no cap
and no abandon, so a rekey that never completed kept resending msg1
forever. Give it a retransmission budget: cap resends at
handshake_max_resends with exponential backoff and abandon the rekey
cycle cleanly once the budget is exhausted, mirroring the FSP session
rekey msg3 driver.

With the cap in place the link-dead heartbeat can safely become
rekey-aware: check_link_heartbeats now suppresses teardown while a rekey
is in progress with msg1 budget remaining, instead of reaping a link
that is still actively carrying rekey-handshake traffic. The suppression
terminates deterministically (the budget abandons on exhaustion, cutover
clears the in-progress flag), so a genuinely dead link is still reaped on
the next cycle.

Adds a rekey_msg1_resend_count counter on ActivePeer reset at every
rekey-clear and cutover site, msg1 resend-budget unit tests, and two-node
heartbeat suppression/resume/regression integration tests.
2026-06-03 15:09:47 +00:00
Johnathan Corgan d9a4a7807c node: make the shared context the sole store of immutable state
Remove the duplicated immutable fields (config, identity, startup_epoch,
started_at, is_leaf_only, max_connections/peers/links) from the Node
struct so the Arc<NodeContext> bundle is the single source of truth.
Previously Node owned these fields and a parallel context copy, kept in
lockstep by rebuild_context() at every mutation site — pure overhead that
existed only because of the duplication.

- Replace rebuild_context() with replace_context(): a clone-edit-swap of
  the whole Arc. The per-instance context stays immutable; mutation swaps
  the Arc. This is the sole runtime mutation path (constructors, leaf_only,
  update_peers).
- Add Copy-returning accessors startup_epoch() and max_connections()/
  max_peers()/max_links(); migrate the remaining direct field readers onto
  the accessors. node_addr()/npub()/Debug now read identity/is_leaf_only
  from the context.
- update_peers reads the pre-update peer set from the live context Arc
  before building a fresh Config + context and swapping — preserving the
  read-before-write ordering its mutation-window test depends on.
- Remove the test-only set_max_* setters; tests set the limits on Config at
  construction instead (new make_node_with_max_peers/links helpers).
- Add a ci-local guard that fails if the Node struct re-declares a bundled
  field, so the single-store invariant can't silently regress.

cargo test --lib 1291/0; clippy -D warnings and release build clean.
2026-06-02 16:42:05 +00:00
Johnathan Corgan 08b8b3908e node: extract immutable state into a shared context and atomic metric registry
Store node counters in an atomic metric registry read through &self, and
introduce a shared NodeContext bundle holding the effectively-immutable
fields (config, identity, startup epoch, capability limits). Source the
immutable config and identity reads across the receive hot path, the
handshake/session/mmp/encrypted state machines, and the discovery, tree,
bloom, retry, and lifecycle modules through the context accessors rather
than direct field reads. The Node fields and the context are rebuilt in
lockstep at every mutation site.
2026-06-02 13:05:03 +00:00
Johnathan Corgan 2d0e8de8c8 transport/udp: detach peer-drain worker thread on Drop to avoid runtime-driver deadlock
PeerRecvDrain::drop previously called std::thread::join on the worker
thread synchronously. The worker uses packet_tx.blocking_send on a
tokio mpsc Sender, which internally parks the worker via
tokio::block_on on the same current_thread runtime that drives
rx_loop. Calling join from inside remove_active_peer (which runs on
the runtime thread, the runtime's sole driver) created a circular
wait:

  - rx_loop blocks in libc futex via Thread::join
  - the worker being joined cannot observe the stop flag because the
    runtime that polls it is the very thread now blocked joining it
  - all other PeerRecvDrain workers park on the same runtime via
    block_on, so a single peer's removal wedges every worker on the
    daemon

The /proc snapshot from a production wedge showed exactly this
shape: 107 of 108 threads in futex_do_wait, 101 of them named
fips-peer-drain. fipsctl became unresponsive (EAGAIN on control
socket), SIGTERM was ignored, and Docker SIGKILLed the container
after the 10 s grace period. Two confirmed wedges on the public
test deployment (52 min and 23 min uptime), plus a third on the
admission-gate-Msg2-silent-drop build at 2 min 21 sec — all ending
with the identical "Peer removed and state cleaned up
tree_changed=false" final log line preceding total silence.

Fix: detach the std::thread instead of joining. The stop flag plus
self-pipe write already signal the worker to exit; the worker's
kernel-level libc::poll inside the drain loop sees the wake, checks
the flag, exits, and the OS reclaims the thread state independently
of the JoinHandle being dropped.

The trigger was statistically amplified by aggressive multi-npub-
from-one-NAT peer reconnect patterns at the moment of the 30 s
link-dead-timeout peer-removal, but not bounded to them. Any
peer whose disconnect happens with the per-peer drain worker
parked in block_on can fire the bug. The admission-gate work
that landed earlier in this branch line compressed more handshake
work per rx_loop tick, increasing the rate at which workers are
parked in block_on and so reducing time-to-wedge — but the
underlying bug pre-dated the admission gate and pre-dated this
fix branch.

The deployed wedged daemon is mitigated operationally by blocking
the trigger IP at the host firewall; this commit removes the bug
class entirely.
2026-05-30 04:24:03 +00:00
Johnathan Corgan 5987b54730 Merge receive-path reject-reason discipline and reloadable config consolidation
Bring the refactor-hotpath integration branch into master: explicit
RejectReason counters for previously-silent receive-path drop sites
across the tree, discovery, and handshake handlers, plus the Reloadable
trait and ArcSwap-backed hot-reload consolidation for the host map and
peer ACL. Includes the new discovery dedup-cache-full reject counter.
2026-05-30 01:50:57 +00:00
Johnathan Corgan 53c6c78721 discovery: count dropped requests when the dedup cache is full
The discovery request dedup cache (recent_requests) silently dropped
LookupRequests once it reached MAX_RECENT_DISCOVERY_REQUESTS, with no
counter to surface the condition. Add a DiscoveryReject::ReqDedupCacheFull
reject reason backed by a req_dedup_cache_full counter on DiscoveryStats,
mirroring the existing duplicate-request counter, and record it at the
drop site so the rejection is visible in show_routing.
2026-05-30 01:50:57 +00:00
Johnathan Corgan 3c5d9fd4f2 Merge master into refactor-hotpath
Bring the runtime peer-list refresh and opt-in mDNS LAN discovery work
on master into the receive-path RejectReason / reloadable-config
integration branch. Code files auto-merge clean; the only conflict is
the CHANGELOG Unreleased section, resolved as the union of both sets of
entries.
2026-05-30 01:43:31 +00:00
Martti MalmiandJohnathan Corgan 7d7b551ca1 discovery: add opt-in mDNS LAN discovery
Add scoped mDNS / DNS-SD discovery for peers on the same local link,
giving sub-second pairing without a relay or NAT-traversal roundtrip.
A node advertises its npub, protocol version, and an optional network
scope over link-local multicast, and browses for matching adverts to
initiate Noise handshakes against same-LAN peers.

LAN discovery is disabled by default; operators enable it with
node.discovery.lan.enabled: true. Default-off avoids reintroducing a
per-LAN identity broadcast on nodes that have deliberately disabled
other discovery channels, and avoids any multicast surprise on upgrade.

The startup advertised-port picker now excludes bootstrap transports
and selects a non-bootstrap operational UDP transport with a stable
lowest-id selector, so the advertised port is deterministic across
restarts rather than dependent on HashMap iteration order. This
matches the per-dial transport selection used for discovered peers.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-05-30 01:32:06 +00:00
Martti MalmiandJohnathan Corgan da0d9d39a0 node: refresh active peer paths without dropping links
Add Node::update_peers for runtime peer-list refresh. It re-derives the
active peer connections from a new peer configuration, adding newly
configured peers and removing those no longer present, while keeping
links to peers that remain in the set rather than tearing every
connection down. The call returns an UpdatePeersOutcome summarizing the
added, removed, and retained peers.

PeerAddress gains a seen_at_ms recency field (with_seen_at_ms). Active
path selection now sorts address candidates by recency so the most
recently observed address wins when concurrent path probes race.

complete_rekey_msg2 now returns the remote peer's startup epoch
alongside the new Noise session, letting the rekey path detect a peer
restart and clear stale session state. A stale FSP session is cleared
when a peer restart is detected during FMP rekey or cross-connection
promotion, so the session-layer map no longer lingers out of sync with
the freshly promoted peer.

Per-tick work budgets bound the connection churn in a single node tick
(MAX_DISCOVERY_CONNECTS_PER_TICK, MAX_RETRY_CONNECTIONS_PER_TICK,
MAX_PARALLEL_PATH_CANDIDATES_PER_PEER); work beyond a tick's budget is
deferred to the next tick rather than discarded.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-05-30 00:46:38 +00:00
Johnathan Corgan d672ed865f node: migrate peer ACL to the Reloadable trait and hot-reload the host map
Move PeerAclReloader onto the Reloadable trait: its ACL snapshot is now
published through an arc_swap::ArcSwap so the authorization hot path reads
it without locking, and the former check_reload becomes the trait's
reload(). The node tick calls self.peer_acl.reload().await.

Wire the host map into the tick as well. The host map snapshot was
previously taken once at construction and never polled; it now hot-reloads
on /etc/fips/hosts mtime changes once per tick, alongside the ACL, so
hostname display reflects edits without a restart.

The path_mtu_lookup cache (event-driven, populated from observed traffic)
and the nostr_discovery subsystem (an async spawned task) are deliberately
left off the trait: neither reloads from a backing file, so a no-op reload()
would be misleading. The rationale is documented on the trait module.

The host map and the ACL's embedded alias reloader still stat /etc/fips/hosts
independently each tick. A single small-file stat per tick is cheap, so the
duplicate is left in place; sharing one mtime observation between the two is
a possible future cleanup.

Tests: a node-level test exercises the host-map tick reload end to end
through peer_display_name; the ACL reloader tests are updated to drive the
async reload().
2026-05-29 02:36:00 +00:00
Johnathan Corgan 0bb9ce09c6 node: introduce Reloadable trait and migrate host map to a lock-free snapshot
Add a `Reloadable` trait that normalizes the node's reloadable
configuration/resource pattern onto a single contract built around an
`arc_swap::ArcSwap` snapshot: a lock-free `load()` for the hot read path
and an async `reload()` that re-reads the backing source and atomically
swaps in a fresh snapshot. The trait carries the canonical Arc-wrapper
template documentation (single-writer node tick, many-reader hot path,
whole-snapshot swap so readers never observe a partial update).

Migrate the host map to this trait via a new `HostMapReloadable` that
reuses the existing load/merge/mtime helpers in upper::hosts. The Node
`host_map` field changes from `Arc<HostMap>` to `HostMapReloadable`, and
`peer_display_name` reads through a lock-free guard. The initial snapshot
is byte-identical to the previous construction, so behavior is unchanged.

The host map is still snapshotted once at construction and not polled;
`reload()` is exercised only by unit tests for now. Wiring the periodic
poll into the node tick, and deduplicating the hosts-file stat against
the ACL reloader's embedded copy, is left as a follow-up.

Add `arc-swap` as a dependency. Unit tests cover initial load (base +
file, base only), change/no-change/deletion/creation detection,
base-preserved-on-reload, and equivalence of the initial snapshot to the
pre-migration construction.
2026-05-29 01:23:29 +00:00
Johnathan Corgan 66732e89c1 node: route receive-path silent-rejection sites through typed RejectReason counters
Introduce a typed RejectReason enum and a NodeStats::record_reject
dispatch so every receive-path rejection-and-return site bumps a
machine-readable per-subsystem counter while keeping its operator-facing
log line. The top-level variants mirror the existing NodeStats subsystem
split (Tree, Bloom, Discovery, Forwarding) and add Handshake, Session,
Mmp, and Transport categories; HandshakeStats, SessionStats, and MmpStats
are new sub-stats.

Wired clusters: tree and MMP outbound sign-failure; the FSP session
unknown-session and state-machine cluster; the Noise IK handshake
state-machine cluster (msg1/msg2); and the decode / crypto / cap /
semantic tail across bloom, discovery, forwarding, mmp, and tree. The
TreeStats::ancestry_invalid counter, present since the scaffold but never
incremented, is now bumped from the validate_semantics ancestry rejection.
Several handshake, MMP, tree, and discovery paths that previously had no
counter at all are now counted, including the send_lookup_response
no-route drop (DiscoveryStats::resp_no_route).

Existing direct counters at the bloom / discovery / forwarding sites are
retained alongside the new dispatch while the rollout is in progress (the
bloom_poison tests expect the transitional +2 delta); a later change
collapses the duplicate increment.
2026-05-28 21:03:56 +00:00
Johnathan Corgan 8d94c0f29c Merge branch 'maint' 2026-05-28 20:17:33 +00:00
Johnathan Corgan e6e2a06879 node/tests: stabilize parallel-load flake-class large-network tests
Raise the in-process backpressure headroom in make_test_node_with_mtu
(request an 8 MiB recv_buf_size on UdpConfig and grow packet_channel from
256 to 8192) to reduce localhost-UDP receive overflow under parallel-CPU
scheduler contention, and mark the large-network convergence tests
#[ignore] so cargo test --lib stays green by default. The ignored tests
remain runnable on demand with --ignored or --test-threads=1.
2026-05-28 20:14:35 +00:00
Johnathan Corgan 6dee6dfe27 transport: cap max_inbound_connections on inbound count, not combined pool
Add per-direction pool_inbound/pool_outbound counters to TcpStats and
TorStats, updated at every pool-insert, receive-loop-exit, transport-stop,
and send-failure removal site. Compare the max_inbound_connections cap
against pool_inbound rather than the combined pool length, so outbound
connect-on-send connections no longer consume the operator-facing inbound
budget. The configuration field name and operator semantics are preserved;
only the cap-check comparison and accounting change.
2026-05-28 20:13:20 +00:00
Martti MalmiandGitHub 2809f0351e Fix connected UDP drain poll error spin (#106) 2026-05-27 13:55:41 -07:00
Johnathan Corgan f6429c19d2 Merge maint into master (admission-gate Msg2 silent-drop + integration suite) 2026-05-26 20:45:50 +00:00
Johnathan Corgan d575c1f986 testing: add admission-cap integration suite for inbound silent-drop gate
New integration scenario verifying the early-gate silent-drop behavior
of the inbound max_peers admission check at sustained scale, using the
existing 5-node mesh topology with one node's node.limits.max_peers
lowered to 1. This forces 2 of the cap'd node's 3 configured peers
into a sustained denied state, and asserts via tcpdump that no Msg2
responses go back to those denied peers across a 60s capture window.

A background load-driver restarts the denied peer containers every 15s
to reset their auto-reconnect exponential backoff (5s base / 300s cap),
producing fresh Msg1 bursts each cycle. Without this loop the gate
fires ~3-4 times per denied peer in a 60s window; with restarts the
observed rate is 15 per denied peer (~30 total firings), high enough
that any Msg2 leakage would be caught with strong statistical
confidence.

Local run on this branch: cap'd node-c converged to peer_count=1 with
node-b admitted; nodes d and e sustained-retried as denied; tcpdump
captured 30 inbound Msg1 (len 84) packets from the denied pair and 0
outbound Msg2 (len 104) packets, with final peer_count unchanged.

Files:
  testing/static/scripts/admission-cap-test.sh — new test script with
    inject-config subcommand (sets node.limits.max_peers) and a
    3-phase test driver (converge, capture-with-load, per-peer assert)
  testing/ci-local.sh — register admission-cap as a new suite category
    (ADMISSION_SUITES), wire run_admission_cap function, add to
    run_suite dispatch, list_suites, and the default integration sweep

Together with the existing unit-level coverage in src/node/tests/unit.rs
(handle_msg1_silent_drops_at_cap_for_new_peer with mock-transport Msg2
discriminator, and handle_msg1_admits_existing_peer_at_cap as the
bypass regression guard), the gate's silent-drop behavior is now
verified both at single-firing wire-observable resolution and at
sustained multi-firing cross-process scale.
2026-05-26 20:34:42 +00:00
Johnathan Corgan 5b229c03bf node: skip Msg1 → Msg2 reply when at max_peers cap
Move the max_peers cap check in handle_msg1 forward, from the late
check inside promote_connection (which fires after Msg2 has already
been built and put on the wire) to an early position after identity
verification but before index allocation and the Msg2 send. When the
gate fires for a net-new identity, the Msg1 is silent-dropped — no
response goes back to the peer, no AEAD compute or wire bytes are
spent.

Bypass preserved for known peers (reconnect / cross-connection): if
the sender's NodeAddr is already in self.peers, or if a pending
outbound connection is in flight to the same identity, the gate is
skipped so legitimate maintenance traffic continues to work. The
late check inside promote_connection is intentionally retained as
defense-in-depth against future call sites or a disconnect racing
between the early-gate decision and promotion.

Wire-cost rationale: a 45 s tcpdump at saturation observed ~3.6
cap-denials/s steady-state, each previously paying the full Noise IK
responder crypto + Msg2 (~104 B) on the wire before being rejected.
The bigger value is cleaner peer-side semantics — the peer no longer
sees a fake-completed handshake whose data frames subsequently fail
decryption locally.

Two new unit tests cover the cases:

- handle_msg1_silent_drops_at_cap_for_new_peer drives a wire-pumped
  Msg1 from a fresh identity into a saturated node and asserts no
  Msg2 reaches the sender socket. Stash-verifies as FAIL on the
  pre-fix tree (Msg2 hits the wire) and PASS post-fix.

- handle_msg1_admits_existing_peer_at_cap drives a Msg1 from an
  identity already in self.peers and asserts the gate does not evict
  it. This is a regression check (the no-gate tree behaves the same
  way here, but the test guards against an accidental future gate
  that breaks known-peer admit).
2026-05-26 19:54:21 +00:00
Johnathan Corgan 6991a152e6 Merge maint into master (outbound admission gate + mesh-size parent skip)
Brings two structural fixes landed on maint:
- compute_mesh_size: explicit parent skip in the children loop, so the
  disjoint-subtree invariant no longer depends on peer_declaration cache
  freshness.
- max_peers: outbound connection-initiation gated on the cap (auto-reconnect
  retries, Nostr-mediated discovery established adoption, and both sides
  of the NAT-traversal punch sequence). Inbound msg1 admission gate
  unchanged.
2026-05-26 17:31:47 +00:00
Johnathan Corgan d4687e5d30 node: gate outbound connection initiation on max_peers
node.limits.max_peers was honored only on inbound msg1 admission
(handshake.rs handle_msg1 returns PeerLimitExceeded when peers.len
is at the cap). Four outbound initiation paths proceeded unconditionally
at capacity: auto-reconnect retries (process_pending_retries),
Nostr-mediated discovery's BootstrapEvent::Established adoption
(poll_nostr_discovery), NAT-traversal punch initiation (the outgoing
side of the offer/answer/punch sequence in the Nostr discovery
runtime), and NAT-traversal punch response (the incoming side of the
same sequence). A saturated node burned CPU, UDP probes, STUN
observations, and Nostr relay traffic on connections that the inbound
gate would reject the moment they reached msg1.

Introduce Node::outbound_admission_check (peers.len < max_peers, or
true when max_peers == 0 as the no-cap sentinel) and gate the four
paths. The discovery runtime lives in a separate task and does not
hold a Node reference; bridge via an Arc<AtomicBool> the runtime
reads and Node refreshes once per tick from outbound_admission_check.
The atomic granularity is intentionally loose: one-tick lag is
acceptable because the inbound msg1 gate continues to be the
authoritative cap, and in-flight handshakes started below the cap
are allowed to complete.

Inbound gate at handshake.rs is unchanged.
2026-05-26 17:08:56 +00:00
Johnathan Corgan df43ac79b9 node: skip parent explicitly in compute_mesh_size children loop
The mesh-size estimator's children loop relied on the cached
peer_declaration(parent_id).parent_id() != my_addr check to exclude
the parent. That cached view briefly disagrees with our own latest
my_declaration().parent_id() during the window between a local
parent-switch and the new parent's next inbound TreeAnnounce: the
peer-declaration cache still names us as the parent's parent, so the
parent is iterated as if it were a child and its (typically dominant)
bloom cardinality is added a second time. Symptom: estimated mesh size
displayed in fipsctl show status and fipstop nearly-but-not-exactly
doubles during tree rebalancing.

Make the invariant structural with an explicit peer_addr == parent_id
skip at the head of the children loop. Per-peer 500 ms rate-limiter
and overall recompute cadence are unchanged.

Adds a regression test that constructs the stale-peer-declaration
scenario directly and asserts the parent is not double-counted.
2026-05-26 16:55:11 +00:00
Johnathan Corgan 0cfc85c154 Merge maint into master (periodic TreeAnnounce re-broadcast) 2026-05-26 15:11:40 +00:00
Johnathan Corgan 18f5c12ab9 mesh-lab: tree/mmp-targeted trace overlay (compose-trace-tree.yml + env-var gate)
Adds a tree/mmp-targeted compose overlay (compose-trace-tree.yml) and
a new FIPS_MESH_LAB_TRACE_TREE env-var gate in run_rekey_family,
layered independently of the existing FIPS_MESH_LAB_TRACE rekey-class
overlay. Trace targets: fips::node::tree, fips::tree,
fips::node::handlers::mmp, fips::node::handlers::handshake.

Used for tree-partition race investigations during multi-peer startup
where evaluate_parent inputs, send_tree_announce_to_all recipient
enumeration, and process_receiver_report first-RTT triggers all need
to be visible together to bracket the loss window.
2026-05-26 15:11:31 +00:00
Johnathan Corgan c4c3fdd94b mesh-lab: parse rekey Phase 1 baseline failure; add no-resource-limits gate
Extend parse_rekey to emit phase1_status / phase1_baseline_passed /
phase1_baseline_total fields in signature.json by scraping rekey-test.sh's
"Best observed baseline before timeout: N/M passed" line (the timeout
path) and "Pre-rekey baseline (all 20 pairs): N/M passed" (the success
path). Phase-5-shape parsing is unchanged.

Extend mechanism_match_rekey to also fire on the Phase 1 characteristic
12/20 split — the multi-hop-routing-failure shape where direct-peer pairs
pass and the four multi-hop pairs (x 2 directions) fail. The Phase 5
predicate remains intact for the pre-existing flake class.

Add FIPS_MESH_LAB_NO_RESOURCE_LIMITS=1 to run_rekey_family for
unconstrained characterization runs where the goal is to surface a race
or scheduling artefact rather than reproduce GHA pressure. Default
behaviour (variable unset) keeps the compose-resource-limits.yml overlay
engaged.

Documented in README.md and the in-script env-var header block.
2026-05-26 15:10:04 +00:00
Johnathan Corgan ffd78440a8 node: periodically re-broadcast TreeAnnounce on no-change in check_periodic_parent_reeval
Closes the eventually-consistent gap in spanning-tree state
distribution. Every existing send_tree_announce_to_all call site
gates on a local state-change event (parent switch, self-root
promotion, ancestry change, peer promotion, parent loss). Once a
partition latches — for example a parent-switch announce stranded
in the brief cross-init handshake swap window, where the announce
arrives on a session-index whose decrypt-worker entry has been
unregistered — neither side's state changes again, so neither
side ever re-broadcasts. The existing 60 s check_periodic_parent_reeval
was a re-evaluation, not a re-broadcast: it short-circuited
silently on no-change. Production-side healing depended on
incidental link churn; lab harnesses with stable docker-bridge
links had no equivalent path.

Add a final else branch that fires send_tree_announce_to_all
unconditionally on the no-change path, alongside the existing
switch and self-promote arms. Receivers coalesce by sequence
comparison (ParentDeclaration::is_fresher_than) and short-circuit
at the `if !updated` gate in handle_tree_announce; same-sequence
repeats drop silently with no cascade. The per-peer 500 ms
rate-limiter is well below this 60 s cadence and does not suppress
the heartbeat broadcast.

The fix is a general protocol-robustness improvement: it addresses
any in-flight TreeAnnounce loss class, not only the specific
cross-init swap-window drop site.

testing/static/scripts/rekey-test.sh BASELINE_CONVERGENCE_TIMEOUT
60 -> 65 so a partition healed by the periodic broadcast at T+60
lands inside the convergence window. wait_for_full_baseline
early-exits on PASS, so successful reps see no extra wall-clock.
2026-05-26 02:50:51 +00:00
Johnathan Corgan 00bd849ee1 node: unregister old decrypt-worker entry on cross-connection-won promotion
The cross-connection-won path in handle_msg1 removes the old peer and frees
its allocated index, but does not unregister the old (transport_id, our_index)
cache_key from the decrypt worker pool. The orphan entry sits in the
per-shard HashMap until the index allocator recycles old_idx to a different
peer and that peer's register_decrypt_worker_session call overwrites it.
In the interim, any decrypt job that lands at the recycled cache_key
resolves to the wrong session and AEAD silently fails — observed as
multi-hop routing failure in 5-node static-mesh on next-branch where
bidirectional auto_connect drives cross-connections at every peer pair
on startup.
2026-05-25 16:48:27 +00:00
Johnathan Corgan 18297283ad Merge maint into master (rx_loop tick-arm connect-on-send fix) 2026-05-25 05:02:01 +00:00
Johnathan Corgan 4d5380604a node: don't drive connect-on-send from the rx_loop tick path
The tick body's per-peer check_* loops (heartbeats, bloom
announces, MMP reports, tree announces) called transport.send
for every active peer, which on TCP/Tor fell through to a 5 s
connect-on-send wait for any peer whose pool entry was not yet
established. That wedged the entire tick body for the full
connect_timeout_ms per unreachable peer; under post-restart
convergence on a high-peer mesh, this cascaded into multi-
second tick stalls. On master, the same mechanism also starved
the per-tick control-snapshot republish and pushed fipsctl
queries onto an mpsc fallback that was itself queued behind
the wedged rx_loop, producing the 5-second fipsctl head-of-line
pattern operators observed on loaded nodes.

Gate send_encrypted_link_message_with_ce on
transport.connection_state before the send: proceed only when
Connected; on None, kick off a non-blocking background connect
(idempotent — TransportHandle::connect dedupes against the
connecting pool and spawns the timeout-bounded TcpStream::connect
inside its own tokio task) and fail this send fast with a
clear "transport connection not ready" error. A subsequent
tick retries once the pool has an entry. The reconnect
lifecycle (check_link_heartbeats, process_pending_retries,
poll_pending_connects) is unchanged. The connect-on-send
branch in transport.send_async itself remains in place for
code paths that legitimately need synchronous connect (e.g.,
explicit operator-driven fipsctl connect).
2026-05-25 04:50:30 +00:00
Johnathan Corgan 5dfbd05fe8 Merge maint into master (cross-init NAT-traversal tie-breaker) 2026-05-24 18:14:39 +00:00
Johnathan Corgan f396d71826 node: deterministic tie-breaker for cross-init NAT traversal adoption
When both peers' Nostr-mediated UDP punches complete within the
same scheduling window, each side's `BootstrapEvent::Established`
event arrives with `is_connecting_to_peer` already true: each side
received an inbound msg1 from the peer's pre-punch outbound
attempt, which created a connecting-state record. The deduplication
skip then fires on both sides, neither installs the fresh
traversal socket as canonical, and the peer-adoption budget
(45 s) expires. Cross-node wall-clock alignment of the skip log
line in observed failures was within ~1 ms — simultaneous dual-
fire under contention, the dual-initiation pattern.

Apply the deterministic NodeAddr tie-breaker already used at
`handlers/handshake.rs:269` for rekey dual-initiation and in
`peer::cross_connection_winner` for cross-connection resolution.
Smaller NodeAddr wins as adopter: enumerate the in-flight
connections whose `expected_identity` points at this peer, tear
them down via the canonical `cleanup_stale_connection` helper, and
fall through to `adopt_established_traversal`. Larger NodeAddr
loses and keeps the existing `continue` semantics; the loser's
in-flight outbound is reconciled by `handle_msg1`'s cross-
connection logic when the winner's fresh msg1 arrives over the
adopted socket.

`cleanup_stale_connection` visibility bumped from module-private
to `pub(in crate::node)` so it is callable from `lifecycle.rs`.
The defensive re-check inside `adopt_established_traversal`
itself is left as-is — after the outer cleanup the winner reaches
it with `is_connecting_to_peer == false`, so the inner skip
won't trip. The `BootstrapEvent::Failed` arm is unchanged: there
is no winning outcome on dual failure, and the existing skip +
retry-schedule semantics are correct.
2026-05-24 18:07:57 +00:00
Johnathan Corgan cc7f967128 testing/mesh-lab: nat-lan cpu-pinning sidecar + trace overlay + stall_signature
Three deltas to the mesh-lab nat-lan suite for stall characterization:

- FIPS_NAT_LAN_CPUSET env-var-driven CPU-pinning sidecar in
  run_nat_lan, mirroring the bloom-storm pattern. Pinning is needed
  because the mesh-lab compose-resource-limits.yml override is
  rekey-family service-name specific (rekey-* / rekey-accept-off-* /
  rekey-outbound-only-*), so it does not constrain the nat-lan
  containers. Default cpuset 0,1 mimics a GHA 2-core runner; empty
  disables the sidecar.

- New compose-trace-nat.yml overlay that bumps RUST_LOG to trace on
  discovery::nostr, transport::udp, node::lifecycle,
  handlers::handshake, handlers::forwarding — the modules covering
  the cross-init / adoption / handshake path. Picked up by the
  nat-test.sh COMPOSE array via a new FIPS_NAT_EXTRA_COMPOSE
  colon-separated env-var hook. run_nat_lan sets this hook when
  FIPS_MESH_LAB_TRACE is non-empty; the README env-var section
  updated to reflect that FIPS_MESH_LAB_TRACE now applies to nat-lan
  in addition to the rekey-family.

- parse_nat_lan extended with a per-node stall_signature emitting
  last-occurrence timestamps for eight event categories (startup,
  discovery, adoption, handshake_init, msg2_sent, cross_init_ignore_*,
  handshake_failed) plus derived last_meaningful_event_ts,
  last_event_category, silent_gap_s. Top-level stall_class binned as
  no_timeout / silent / localized / distributed / incomplete from
  the per-node categories. Aggregation phase consumes the per-rep
  signatures across a characterization run to classify stall
  mechanism.

Wired support in nat-test.sh: FIPS_NAT_EXTRA_COMPOSE colon-separated
list of repo-relative or absolute compose files layered onto the
base via the COMPOSE array; FIPS_NAT_SKIP_FINAL_CLEANUP gates the
success-path teardown so the mesh-lab harness can capture docker
logs before tearing down (failure paths already returned without
cleanup, leaving stall-state containers intact for capture).

Smoke-tested on idle profile with TRACE on: 1 rep PASS, 32/36 TRACE
lines per node, signature.json events all populated with the
expected category timestamps.
2026-05-24 17:49:37 +00:00
Johnathan Corgan dae33d4fd1 chaos: bump bloom-storm bloom_send_rate ceiling 30 → 40
The bloom-storm scenario's bloom_send_rate ceiling has been bumped
from 30 to 40 sends per node over the trailing 30 s window. A 59-rep
characterization run under `github-runner-equivalent` pressure with
per-container CPU pinning to `cpuset=0,1` (mimicking a 2-core
`ubuntu-latest` runner) measured n04 (the structural max-spike node)
at mean 24.4, P99 29, max 30. The original ceiling of 30 sat at the
lab's structural max, leaving no headroom for the asymmetric transient
spikes observed on GitHub Actions (n04=34 on master CI run 25933972365,
re-fired on run 26008950865). GHA fires do not reproduce on this lab
host even with the cpuset sidecar applied.

Rationale: lab max + ~2σ ≈ 39.4 → round to 40, giving 33 % margin over
the lab maximum while staying well below the deployment-scale storm
rate (~480× steady state). The companion `min_parent_switches` guard
is unchanged.

See README.md alongside this file for the updated threshold derivation.
2026-05-24 12:30:40 +00:00
Johnathan Corgan ce0eb71722 testing/mesh-lab: add bloom-storm chaos suite dispatch with CPU-pinning sidecar
Wires the bloom-storm chaos scenario into the mesh-lab harness as
a first-class suite, with optional per-container CPU pinning to
mimic GitHub Actions' 2-core ubuntu-latest budget.

Dispatch path — three new run-loop.sh functions plus the
dispatch_suite and dispatch_mechanism_match case-arm additions:

- `run_bloom_storm` invokes `bash testing/chaos/scripts/chaos.sh
  bloom-storm` and captures stdout+stderr into the rep's
  test-output.log. Chaos uses its own python sim runner
  (`python3 -m sim`), not docker-compose, so this suite gets no
  per-container compose override, no separate `docker logs`
  capture, and no in-container netem injection — the chaos
  scenario yaml owns its own netem and link-swap config.

- `parse_bloom_storm` extracts the bloom_send_rate result
  (pass/fail/unknown), ceiling, max-observed per-node delta,
  offenders list, full per-node delta distribution, the companion
  min_parent_switches result, and panic + error counts. Lands in
  the rep's signature.json. Two parser details: assertion greps
  are anchored on `^(PASS|FAIL)` so they only match the bare
  end-of-run summary line, not python-logger-prefixed lines that
  contain the same substring; and `grep -c` panic/error counts
  use `; true` + a defensive empty-string check instead of the
  common `|| echo 0` fallback (`grep -c` exits 1 on zero matches
  while also printing "0", so the fallback would corrupt the
  count to "0\\n0").

- `mechanism_match_bloom_storm` returns true when a rep both
  fails the bloom_send_rate assertion and the FAIL line carries
  a named offender (filtering the harness-side "failed to sample
  window endpoints" sub-failure out of the mechanism count).

CPU-pinning sidecar — bloom-storm's chaos sim spawns containers
directly via the docker SDK, so the mesh-lab compose-resource-
limits override does not apply. A poll-and-pin loop around the
chaos.sh invocation lists \`fips-*\` containers every 0.5 s and
applies \`docker update --cpuset-cpus <set>\` to each. Pinning is
idempotent (re-applying the same cpuset is a no-op). Default
cpuset \`0,1\` mimics the GHA 2-core budget; override via
\`FIPS_BLOOM_STORM_CPUSET=<set>\` (any comma-separated CPU list),
or set to the empty string to disable. Only applies to the
bloom-storm suite; other suites' dispatch paths are unchanged.

README's "Suites supported" entry covers the assertion class, and
the \`FIPS_BLOOM_STORM_CPUSET\` knob is documented alongside the
other mesh-lab env-var knobs.
2026-05-24 12:25:09 +00:00
Johnathan Corgan de78c94d58 node: register decrypt worker on cross-connection-won promotion
The cross-connection-won branch of `promote_connection` builds a
fresh ActivePeer with a new Noise session and our_index, inserts
it into peers, and registers identity, but did not hand the new
session to the decrypt shard worker pool. The normal-promotion
tail in the same function does make that call. A session
established via the cross-connection race path therefore missed
the worker fast-path for its lifetime, falling back to inline
decryption on the rx loop. Correctness was unaffected, but the
throughput/latency benefit of the worker pool was lost for peerspromoted through that path.

Mirror the normal-promotion tail and call
`register_decrypt_worker_session` after the fresh ActivePeer is
inserted into `self.peers` in the `this_wins` arm.
2026-05-23 18:11:39 +00:00
Johnathan Corgan 050483f3bf forwarding: log no-route SessionDatagram drops at debug level
Logs source, destination, and payload size at the existing no-route
drop site so investigations can attribute transit drops without
enabling trace-level instrumentation. Diagnostic-only; no behavior
change on the success path.
2026-05-23 14:34:40 +00:00
Johnathan Corgan 9c0dcd0f59 testing: add mesh reliability lab harness with rekey-test diagnostic improvements 2026-05-23 14:34:11 +00:00
Johnathan Corgan 7e424f34bc testing: add mixed-version interop harness 2026-05-23 14:06:07 +00:00
Johnathan Corgan 3fc0178192 Merge maint into master (FSP rekey overlapping-epoch, drain-erase fix) 2026-05-23 01:54:11 +00:00
Johnathan Corgan 6e5cb8965f Make FSP session rekey hitless under packet loss and reordering
An FSP session rekey could leave the two endpoints holding different
key sets for a brief window: if a handshake message was lost in
transit, one side rotated to the new keys while the other did not.
Traffic sealed in one key epoch then reached a peer still on the
other epoch and failed to decrypt, producing bursts of AEAD
decryption failures and dropped connectivity until a later rekey
cycle reconverged the pair. Choreographing the cutover order cannot
close this window: any fixed ordering still leaves a skew that
packet reordering widens.

Make rekey correctness independent of cutover timing by overlapping
the key epochs on the receive path. During a rekey transition the
receiver trial-decrypts each frame against every live session it
holds: current, the not-yet-promoted pending session, and the
draining previous session. The K-bit becomes a hint that orders the
trial-decrypt cascade rather than a hard gate, and a frame that
authenticates against the pending session is itself the cutover
signal. No rotation ordering and no packet reordering can then cause
a decryption failure.

The pre-rekey Noise session is held in the `previous` slot until the
peer has demonstrably moved off it. Its drain deadline is anchored
on the most recent frame the peer authenticated against that slot,
refreshed each time the trial-decrypt cascade lands there, rather
than on a fixed wall-clock timer started unilaterally at the local
cutover. A peer that never received the new keys keeps authenticating
against `previous` and the slot stays live; without this, a fixed
timer would erase the only key set that could decrypt the peer's
frames, producing a permanent silent decrypt failure on a live data
path. A peer that never catches up is handled by the existing FSP
session liveness path rather than by silent decrypt failure.

The lost-handshake liveness gap is closed separately by retransmitting
the third rekey handshake message until the peer is confirmed on the
new keys, with a bounded retry budget after which the rekey cycle is
cleanly abandoned and retried on the next timer.

Adds unit tests covering the trial-decrypt cascade (epoch selection,
promotion on pending decrypt, reordered old-epoch stragglers after
cutover, per-slot replay-window integrity), the msg3 retransmission
lifecycle, and the peer-progress-aware drain retirement.
2026-05-23 01:54:04 +00:00
Johnathan Corgan 13c9bdacac Merge maint into master (macOS package fix, AUR fips-dns fix) 2026-05-21 00:52:43 +00:00
Johnathan Corgan 66020bc318 changelog: document the macOS package-integrity fix
Commit 57a089f6 (the GitHub #102 fix) landed without a CHANGELOG
entry. Add the `[Unreleased]` / `### Fixed` line so the macOS
package-integrity fix is on record before the v0.3.1 cut.
2026-05-21 00:50:11 +00:00
sandwichandJohnathan Corgan 7a1365fb9e aur: install fips-dns helpers, fix fips/fips-git package transition
The AUR `fips` and `fips-git` packages did not install the
`fips-dns-setup` and `fips-dns-teardown` helper scripts that
`fips-dns.service` runs. The Debian package ships them to
`/usr/lib/fips/` through the `[package.metadata.deb]` assets, but the
AUR `package()` functions never replicated those install steps, so
`fips-dns.service` failed to start on Arch with "Unable to locate
executable /usr/lib/fips/fips-dns-setup".

Add the two `install -Dm0755` lines to both PKGBUILDs so the AUR
packages match the Debian layout.

Also harden the transition between the `fips` and `fips-git`
packages: each PKGBUILD now declares the other variant's `-debug`
split package as a conflict and opts out of the debug split, so a
stale debug build cannot retain ownership of installed files when
switching between the release and VCS packages. The `aur-publish`
workflow gains a validated `pkgrel` dispatch input so corrected
packaging can be republished against an existing release tag without
retagging.

Fixes #98

(cherry picked from commit 4cf550e23d)
2026-05-21 00:38:40 +00:00
Johnathan Corgan 57a089f6c3 macos package: derive package arch from the build target
The published v0.3.0 macOS installer is a structurally corrupt xar
archive: pkgutil and xar reject it even though its SHA-256 matches the
published checksum.

build-pkg.sh derived the architecture suffix in the .pkg filename from
`uname -m`. On the Apple-silicon macOS runner that always reports
arm64, so the cross-compiled x86_64 build also named its output
fips-<version>-macos-arm64.pkg. The release job downloads both build
artifacts with merge-multiple into one directory, where the two
identically named files collide and tear into a malformed result. The
x86_64 package never reaches the release at all.

Derive the package architecture from the Rust target triple, which is
authoritative for cross-compiles, instead of from the build host. Each
matrix leg now produces a distinctly named, arch-correct package, so
the two artifacts no longer collide.

Add a SHA-256 integrity chain so a corrupt or mismatched asset cannot
be published again:

- Capture the .pkg SHA-256 on the macOS runner, after the on-runner
  structural verification, into a sidecar file carried in the artifact.
- Add a verify-handoff job that runs on every trigger and asserts each
  downloaded .pkg still matches its macOS-runner SHA-256.
- Gate the release job on verify-handoff and repeat the check on the
  exact bytes about to be published.

The build step now asserts it produced the expected arch-named package
so a regression in the naming fails loudly rather than as a silent
collision.

Relates to #102. The published v0.3.0 macOS assets still need to be
rebuilt and reuploaded separately.
2026-05-20 22:55:10 +00:00
Martti MalmiandJohnathan Corgan 0a5c367edc data-plane perf overhaul: off-task encrypt + decrypt, GSO, connected UDP
Moves both AEAD layers (ChaCha20-Poly1305, one round per layer per
packet) plus the sendmsg syscall off the rx_loop task onto a per-shard
worker pool, adds per-peer connect(2)-ed UDP with SO_REUSEPORT, and
uses Linux UDP GSO (sendmsg+UDP_SEGMENT — kernel splits one super-skb
into N on-the-wire datagrams in a single TX-stack walk) when packets
in a batch are uniform-size. Same kernel primitive WireGuard's
in-kernel module and BoringTun use to hit 2.5–3.2 Gbps single-stream.

Single TCP stream on a 5-node docker-bridge mesh, 5 x 15 s x P=1:

  A→D:  1379 → 2708 Mbps  (1.96x, RTT +0.12 ms)
  A→E:  1394 → 2663 Mbps  (1.91x, RTT +0.11 ms)
  E→A:  1406 → 2624 Mbps  (1.87x, RTT +0.19 ms)

Static-peer pairs only — every CoV under 3%, 0 outliers, 0% ICMP
loss. The ~+100 µs RTT is the worker queue handoff cost; AEAD +
sendmmsg now run on a separate core in exchange.

What lands:

- src/node/encrypt_worker.rs: std::thread + crossbeam_channel
  workers; hash-by-destination dispatch pins a TCP flow to one
  worker so wire ordering is preserved; per-worker sendmmsg(2)
  batching up to 32; Linux uses sendmsg(2)+UDP_SEGMENT when
  packets in a group are uniform-size.

- src/node/decrypt_worker.rs: receive-side mirror. Each shard owns
  its session's recv cipher + replay window in a thread-local
  HashMap (no shared RwLock/Mutex). Sessions are handed off at
  promote_connection and re-registered on K-bit flip / rekey
  cutover.

- src/node/handlers/session.rs try_send_session_data_pipelined:
  FSP+FMP both seal in-place in the worker on one wire-buffer
  alloc; no intermediate inner_plaintext / fsp_payload Vecs.

- src/transport/udp/connected_peer.rs + peer_drain.rs: per-peer
  connect(2)-ed UDP socket with SO_REUSEPORT (set on the listen
  socket too — without that, EADDRINUSE on activation and every
  packet falls back to the wildcard path); the worker sends with
  msg_name=NULL and the kernel uses its cached 5-tuple. Tick-
  driven activation in handlers/connected_udp.rs, idempotent.

- src/transport/udp/mod.rs: mem::replace the recvmmsg backing buffer
  instead of buf.to_vec() per packet — single pointer swap, no
  MTU-sized memcpy.

- src/protocol/link.rs SessionDatagramRef: zero-copy borrowed view
  used by handle_session_datagram for the bulk local-delivery
  path; handle_session_payload takes the borrowed payload
  directly (no payload[35..].to_vec()).

- src/transport/mod.rs TransportAddr::from_socket_addr: collapses
  the two-alloc from_string(addr.to_string()) pattern to one.

- src/node/handlers/rx_loop.rs: decrypt-fallback drain promoted
  ahead of packet_rx in the select! (TCP ACK starvation fix);
  interleaved fallback drain every 32 packets inside the rx burst
  loop.

- noise::Session: send_cipher_clone / recv_cipher_clone /
  recv_replay_snapshot_owned / take_send_counter / accept_replay
  so off-task workers can hold a cloned cipher + reserved counter
  while the dispatcher keeps replay/counter sequencing serial.
  CipherState::cipher_clone returns a refcount-bumped LessSafeKey.
  AsyncUdpSocket: AsRawFd so workers issue raw sendmmsg / sendmsg
  without going through the tokio reactor.

- Worker pool sizing: both default to num_cpus, overridable via
  FIPS_ENCRYPT_WORKERS=N / FIPS_DECRYPT_WORKERS=N. Per-peer
  connected UDP can be disabled via FIPS_CONNECTED_UDP=0.

- src/perf_profile.rs: optional per-stage timing reporter under
  FIPS_PERF=1 (or FIPS_PIPELINE_TRACE=1). Off by default; zero
  overhead when disabled.

- All cfg(unix)-gated. Windows continues on the existing tokio-
  based send/recv.

Decrypt worker session lifecycle:

- Node::unregister_decrypt_worker_session mirrors the existing
  register helper. Wired at the two natural sites that already
  iterate peers_by_index: the rekey drain-completion block in
  handlers/rekey.rs (drops the worker entry for the old our_index
  once the drain window has expired and the cache_key is
  unreachable to any in-flight OLD-K packet), and remove_active_peer
  in handlers/dispatch.rs (drops the worker entry for each of the
  four index slots: current, rekey, pending, previous). Only
  our_index is normally registered; unregister_session is fire-
  and-forget for missing entries, so calling unconditionally on
  all four slots is correct and bounds the cleanup without per-
  slot accounting. Without these callers the per-worker sessions
  HashMap and the Node's decrypt_registered_sessions set would
  grow monotonically per rekey on long-lived peers.

Testing:

- testing/static/scripts/bench-multirun.sh: multi-run iperf3 +
  ping bench. N reruns (default 5), median / min / max / CoV % /
  per-run outlier flag, avg ping RTT, ICMP loss %, TCP retransmit
  total. Plain client→dest labels + topology header. Pre-bench
  peer-convergence check (FIPS_BENCH_CONVERGE_SECS, default 15);
  per-path route verification via stats.bytes_sent deltas — fails
  fast if traffic exits via a non-static-peer link.

- testing/static/docker-compose.yml: passes FIPS_ENCRYPT_WORKERS /
  FIPS_DECRYPT_WORKERS / FIPS_PERF through to containers for A/B
  benchmarking without rebuilds.

- testing/static/scripts/iperf-test.sh: same plain client→dest
  labels + topology header (was multihop/direct/N hop, which
  conflated topology distance with on-wire path).

- .config/nextest.toml: synthetic UDP node tests serialized
  through a max-threads=1 test group. Localhost handshakes drop
  on shared CI runners under parallel load; one-at-a-time keeps
  assertions reliable.

- src/node/tests/spanning_tree.rs: repair_missing_edge_handshakes
  — retries up to 5 times for synthetic edges whose msg1 was
  dropped, with a drain after each edge retry instead of after
  each attempt's full burst.

- src/node/decrypt_worker.rs::tests: two unit tests asserting
  WorkerMsg::UnregisterSession removes the worker-thread session
  HashMap entry (handle_msg_unregister_session_removes_entry) and
  is a no-op for never-seen cache_keys
  (handle_msg_unregister_session_idempotent_on_unknown_key), which
  is the safety invariant the unconditional unregister calls at
  the four index slots in remove_active_peer rely on.

- src/node/encrypt_worker.rs::unix_tests
  pipelined_send_wire_layout_roundtrips_canonical_decoders: mirrors
  the encoder geometry of try_send_session_data_pipelined (no
  coords, the common established-session path), runs the worker's
  real seal + send via flush_direct_batch_sync, and decodes the
  resulting wire packet using only canonical receive-side decoders
  (EncryptedHeader::parse, SessionDatagramRef::decode, FSP header
  parse, noise::open). Any divergence between the hand-rolled
  encoder offsets (fsp_aad_offset, fsp_plaintext_offset) and the
  decoders fails at one of the parse / open / decode steps before
  the inner-plaintext assertion fires. Complements the existing
  fsp_preseal_runs_before_outer_fmp_seal test which covers the
  seal-ordering invariant with synthetic headers but does not
  exercise the wire-layout invariant.

CHANGELOG.md [Unreleased] # Changed entry added describing the
worker-pool threading model, hash-by-destination dispatch,
sendmmsg/UDP_GSO, per-peer connected UDP, the operator-facing env
vars, and the bench numbers above.

Cherry-picks from mmalmi/master (paths translated from
crates/fips-core/src/ to src/): 9b7c723, 0deb5cb, 13f7339, e036c0e,
3740a68, 3792f83, 8510193, 4910b07, e53f545, e4e2896, 5fe4af5,
1d01ada, 8c37008, e12469e, 6eb2860.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-05-19 20:53:31 +00:00
Johnathan Corgan 6e7e44c8ff Merge maint into master (advert filter, PR-REVIEW) 2026-05-18 22:04:08 +00:00
Martti MalmiandJohnathan Corgan d418106034 nostr: filter unroutable direct advert endpoints 2026-05-18 19:35:53 +00:00
Johnathan Corgan 79ae430725 docs: add PR-REVIEW.md checklist and link from CONTRIBUTING
Publish the 13-criteria PR review checklist the maintainer runs on
every incoming PR so contributors (and their coding agents) can run
the same pass before opening, surfacing problems before the review
round trip. CONTRIBUTING.md gets a new 'Self-review against the
project review checklist' subsection under 'Submitting pull requests'
and a Further Reading entry. CHANGELOG [Unreleased] gets an Added
entry.
2026-05-18 17:09:55 +00:00
Martti MalmiandJohnathan Corgan c0ccedb491 nostr: start discovery without blocking node startup 2026-05-18 16:52:59 +00:00
Johnathan Corgan a83342cce8 Merge maint into master (path-1 ping retry + CHANGELOG backfill) 2026-05-18 01:33:48 +00:00
Johnathan Corgan 647b8155af changelog: backfill macOS recvmsg_x batched receive + platform-warning cleanup
Two [Unreleased] / Changed entries that should have landed alongside
the originating commits but didn't:

- macOS recvmsg_x batched receive (originally 59225ccf): completes
  the Linux-equivalent inbound batching shape on Apple builds. Now
  sequenced before the rx zero-copy entry so the section reads as a
  coherent receive-path progression.

- Platform-specific test-build warning cleanup (originally 6bd40640,
  PR #93): non-behavioral; documents the gating decisions that keep
  cross-platform builds warning-clean.
2026-05-18 01:31:55 +00:00
Johnathan Corgan 2bc9dd557a changelog: backfill surgical coord-cache invalidation + rekey-test ping retry
Two Fixed entries appended to [Unreleased]:

- The coord cache surgical invalidation (49bd2104): replaces the
  global CoordCache::clear() at parent-switch / become-root /
  loop-detection / root-change sites with two targeted methods
  (invalidate_via_node, invalidate_other_roots). Preserves cache
  entries that remain correct after the topology change.

- The rekey-test strict-ping retry (306e4555): Phase 1 / 3 / 5
  per-pair pings now retry up to 4 attempts. Brings the ICMP-noise
  miss-floor from ~33% per phase to ~3.2e-6 at 1% loss without
  changing the failure-shape signal the asserts target. Test
  scaffold only, no daemon code changes.
2026-05-18 00:50:44 +00:00
Martti MalmiandJohnathan Corgan 6bd40640bf chore: quiet platform-specific warnings
The non-Linux test build was emitting warnings from code that is
intentionally platform-specific: the nftables firewall parser is
Linux-only, the utun address-family helper is only used in macOS
TUN paths, and one macOS Ethernet test module trips a clippy
layout lint. These warnings made focused test runs noisy and
encouraged bundling unrelated warning fixes into behavioral PRs.

- Gate the firewall parser dead-code allowance to non-Linux
  targets, where the parser is compiled but not used.
- Mark the macOS utun helper and long TUN reader entry point with
  narrow allowances.
- Rewrite the small MAC-copy loop to satisfy clippy and mark the
  macOS Ethernet test module layout explicitly.

No runtime behavior change.
2026-05-17 17:55:52 +00:00
Johnathan Corgan 306e455513 rekey-test: retry strict-ping asserts on failure
The Phase 1, Phase 3, and Phase 5 strict asserts each fire a
single ping per directed pair. Under low-level packet loss
(e.g. 1% i.i.d. per-direction loss from a CI runner under
pressure), a single-shot round-trip fails at ~2% per pair, so a
20-pair strict assert misses with probability
1 - (0.98)^20 = ~33% per phase from ICMP noise alone, well above
the routing-state signal the asserts are meant to catch.

ping_one gains a max_attempts parameter (default 1, preserving
existing call sites). On failure it retries up to
MAX_PING_ATTEMPTS-1 additional times with PING_RETRY_DELAY
seconds between attempts. Per-pair worst case under the defaults
(4 attempts, 1 s spacing, 5 s ping6 -W timeout) is 4*5 + 3 = 23 s;
per-rep worst case scales with the failing-pair count.
Successful retries log "OK (RTT, attempt N)"; exhausted retries
log "FAIL (after N attempts)".

The retry budget is wired into all three strict asserts:

  - Phase 1 final ping_all (after wait_for_full_baseline converges)
  - Phase 3 ping_all (post-first-rekey)
  - Phase 5 ping_all (post-second-rekey)

The wait_for_full_baseline convergence loop itself stays
single-shot. Its job is to detect when the mesh first sees a
fully clean 20-pair batch, and retries inside the loop would
conflate transient ping loss with still-converging routing
state.

No daemon code changes.
2026-05-17 17:35:44 +00:00
Johnathan Corgan f51dde647f Merge maint into master (coord cache surgical invalidation) 2026-05-17 00:38:47 +00:00
Johnathan Corgan 49bd210480 cache: scope coord cache invalidation to entries actually affected by topology change
Replaces the unconditional `CoordCache::clear()` calls at parent-switch,
become-root, and loop-detection sites with two targeted invalidation
methods scoped to what actually makes an entry stale:

- `invalidate_via_node(node_addr)`: drop entries whose cached
  destination ancestry contains `node_addr`. Used at parent-position-
  change sites — our prefix changed, so destinations downstream of
  us have stale-prefix coords.
- `invalidate_other_roots(current_root)`: drop entries rooted under
  a different root than the current one. Used at root-change sites.

Under the previous global flush, parent switches blanked the cache
across the board, leaving `find_next_hop` returning `None` for every
non-direct-peer destination until the cache passively re-warmed via
incoming TreeAnnounces / SessionSetup. Surgical invalidation
preserves entries that remain correct after the topology change.

The cached coord describes a destination's tree position; that
position only goes stale relative to our own routing decisions when
our own prefix changes (entries we are downstream of) or the root
changes (entries in a different tree). Peer removal does not
invalidate cached coords: `Node::find_next_hop` recomputes the
next-hop decision on every call against the current peer set, bloom
filters, and tree state, and Discovery already triggers on
`no route to destination` errors when a destination becomes
unroutable through us. The peer-removal site retains the original
"no cache invalidation" behavior.

Each method returns the count of entries removed for observability.
Unit tests cover each method against the cases enumerated in the
acceptance criterion.
2026-05-16 21:53:55 +00:00
Martti MalmiandJohnathan Corgan b1af151aef rx: avoid copies in receive hot paths
- Borrowed SessionDatagramRef decoder is used in the forwarding
  handler so local delivery and coordinate-cache warming no longer
  allocate or copy the session payload. The owned SessionDatagram is
  materialized only when re-encoding for the next hop.
- Owned SessionDatagram::decode is reimplemented as Ref::decode +
  into_owned, so the two decoders cannot drift.
- recvmmsg / recvmsg_x (Linux + macOS) receive loop moves each filled
  slot buffer into ReceivedPacket via mem::replace instead of cloning
  it; a fresh empty buffer is installed for the next syscall.
- TransportAddr is formatted directly from the SocketAddr without
  going through an intermediate String.

Focused decode bench: ref 1.6 ns/op vs owned 34.7 ns/op (21.4x).
End-to-end iperf is neutral as expected for a ~30 ns saving per
packet.

Unit tests added:
  - test_session_datagram_ref_decode_borrows_payload (verifies the
    payload slice pointer equals the input slice's offset 35, a real
    zero-copy invariant guard against accidental future to_vec)
  - bench_session_datagram_decode_owned_vs_ref (ignored, run with
    --ignored --nocapture)
  - test_transport_addr_from_socket_addr
2026-05-15 21:35:46 +00:00
Martti MalmiandJohnathan Corgan 59225ccfe1 udp: batch macOS receive with recvmsg_x
The Linux recv path drains up to 32 datagrams per kernel wakeup via
recvmmsg(2), amortising the per-syscall + per-task-wakeup cost across
the burst. macOS still fell through to single-packet recv_from, so
the same overhead capped inbound rate on Apple builds.

Add an equivalent batch path for Darwin using recvmsg_x(2). It is a
xnu-private syscall (not in the public SDK) but is the canonical
amortisation primitive on macOS — same shape used by quinn-udp for
the same reason. ABI is the public msghdr layout plus a trailing
msg_datalen (per-datagram bytes-received output), declared via
`unsafe extern "C"` against a local repr(C) `msghdr_x`.

Same `(count, kernel_drops)` contract as the Linux `recv_batch`. macOS
has no SO_RXQ_OVFL equivalent, so `kernel_drops` is always 0 — the
1Hz `sample_transport_congestion()` detector simply sees no kernel
drop signal on Apple hosts (it already tolerates that, since the
field has been 0 there pre-batching too).

cmsg buffer is intentionally null: we never consume ancillary data on
this path, and quinn-udp documents that `recvmsg_x` does not overwrite
`msg_controllen` on macOS 10.15+ (zeroed init is the only safe state).

The udp_receive_loop dispatch widens from cfg(linux) to
cfg(any(linux, macos)); the per-packet recv_from path is now used
only on the remaining unix targets (BSDs etc.) and Windows.

Add test_burst_recv_batch exercising 10 in-flight datagrams to
verify per-datagram boundaries and arrival order across the batch.
Add an ignored bench_udp_recv_amortization measuring recv-side
syscall amortization across 1/2/4/8 sender threads on dedicated
blocking std threads (kernel rx queue stays saturated regardless of
tokio scheduling). Sample numbers on aarch64-apple-darwin (100B
payloads, 3s windows):

  senders=1:  recv_from 398k pps   recv_batch 432k pps   1.09x
  senders=2:  recv_from 353k pps   recv_batch 608k pps   1.72x
  senders=4:  recv_from 322k pps   recv_batch 503k pps   1.56x
  senders=8:  recv_from 353k pps   recv_batch 515k pps   1.46x

Gate the Linux-only IpAddr import in control::listening behind a
cfg(target_os = "linux") so the macOS test build is warning-clean
now that test code paths there compile.
2026-05-15 19:07:30 +00:00
Martti MalmiandJohnathan Corgan b05c80e5f5 testing: add boringtun throughput benchmark and iperf ref-compare harness
New testing/boringtun/ harness runs two Cloudflare BoringTun userspace
WireGuard containers with iperf3 between them, giving a single-hop
userspace tunnel baseline for comparison against FIPS throughput
numbers. Local WG key generation runs through the harness image so the
host needs no wireguard-tools.

New testing/static/scripts/iperf-compare-refs.sh builds two git refs
into separate fips-test:* images via git worktree and runs the same
static iperf topology against both, with RUNS-based repetition and
aggregate avg/min/max reporting.

testing/static/scripts/iperf-test.sh gains DURATION, PARALLEL,
SETTLE_SECONDS, IPERF_TIMEOUT env knobs and a per-path iperf timeout.
testing/static/docker-compose.yml selects the image under test via
FIPS_TEST_IMAGE; testing/scripts/build.sh respects CARGO_TARGET_DIR.

Author benchmark on aarch64 Docker Desktop:
  boringtun bob -> alice : 1000.13 Mbits/sec
2026-05-15 18:03:42 +00:00
Johnathan Corgan 09eb5ad6bf Merge maint into master (#87 stale-traversal fix) 2026-05-15 17:56:51 +00:00
Martti MalmiandJohnathan Corgan 87d1af0269 nostr: ignore stale traversal for active peers
Skip BootstrapEvent::Established and BootstrapEvent::Failed dispatch
in poll_nostr_discovery for peers that are already connected or
actively handshaking. Without these guards, stale traversal events
arriving after a peer connected through a different path would
either attempt to adopt a redundant socket against the live
connection (Established) or poison the per-peer failure-state
cooldown and trigger redundant retraversal via schedule_retry /
try_peer_addresses (Failed).

The four guard sites use a new is_connecting_to_peer helper extracted
from the existing closure inside initiate_peer_connection; the helper
checks for an in-flight outbound handshake state. adopt_established_traversal
gains a defense-in-depth check returning PeerAlreadyExists when called
against an already-promoted peer, so the invariant holds if a future
caller bypasses the outer dispatch guard.

Side benefit: narrows a cooldown-poisoning vector previously available
to an attacker injecting stale failure events for an active peer.

Test coverage for the new behavior:

- test_try_peer_addresses_skips_connected_peer
- test_try_peer_addresses_skips_connecting_peer
- test_nostr_traversal_failure_skips_connected_peer (Failed-arm event
  injection)
- test_nostr_traversal_established_skips_connected_peer (Established-arm
  event injection, mirror of the Failed test)
- test_adopted_traversal_skips_already_connected_peer
  (adopt_established_traversal defense-in-depth)

CHANGELOG entry under [Unreleased] / Fixed.

Closes #87
2026-05-15 15:06:54 +00:00
Johnathan Corgan d9ab58a285 Merge maint into master (rekey jitter, Phase 5 settle, acl-allowlist, CI concurrency) 2026-05-14 18:21:37 +00:00
Johnathan Corgan ab1e248ff4 changelog: add acl-allowlist + AUR-publish coverage, merge CI entries
Bring [Unreleased] into sync with all maint commits since v0.3.0:

- Add a Fixed entry for the acl-allowlist test-script poll-assertion
  conversion (commit e9dd316) that was previously missing.
- Add the AUR-publish workflow rewrite and new fips-git VCS workflow
  (commit 9bf9701) which had no entry, and merge them with the
  ci.yml cancel-in-progress block under a single "CI and
  release-publish workflows hardened" entry so the three workflow
  changes read as one operational theme.

Pure changelog content reshuffle. No code touched.
2026-05-14 18:17:50 +00:00
Johnathan Corgan 7f518731c8 ci: cancel in-progress runs on same-ref pushes
Add a top-level concurrency block to ci.yml keyed on
(workflow, ref) with cancel-in-progress: true. Pushes (including
force-pushes) to the same ref now retire any in-flight run for
that ref rather than letting the superseded and current-tip runs
both burn runner minutes.

Motivated by a 2026-05-14 force-push experience where two CI runs
ran concurrently against a feature branch — the original push at
c76ec99 continued for ~17 minutes alongside the amended push at
927ef47, despite only the latter being the live tip.

Scope deliberately limited to ci.yml. Tag-triggered release-build
workflows (package-*.yml, aur-publish-*.yml) are untouched — they
operate on per-tag refs that already form distinct concurrency
groups and release artifact builds generally should not be
cancellable by unrelated activity.
2026-05-14 18:15:22 +00:00
Johnathan Corgan 80fb086071 test(rekey): add Phase 5 settle window for post-second-rekey convergence
Phase 5's per-pair connectivity check ran immediately after the second
rekey cycle, with no settle for routing reconvergence. Under
GitHub-runner CPU contention, post-rekey parent-switches and
coord-cache flushes can take longer than the per-ping 5s timeout for
a small fraction of pairs (1-3 of 20 typically), even though the
rekey mechanism itself completes cleanly. Phase 6 log analysis stays
all-green on these failed runs; the failure is purely connectivity
timing.

Mirror Phase 3's existing 12-second settle pattern: reuse REKEY_SETTLE
and emit the same banner shape. Two-line change. Cost on the success
path is a fixed 12s per suite run.
2026-05-14 17:54:22 +00:00
Johnathan Corgan e9dd3167f2 test(acl-allowlist): poll log assertion to absorb XX-handshake timing race
Convert assert_log_contains from a one-shot grep snapshot into a
bounded poll that retries until the pattern appears or the timeout
elapses (default 15s). Same wait-with-timeout shape as
wait_for_peers_exact above it in the file.

The pre-existing flake on next-branch CI is structural: under XX
handshake, the cross-connection tie-breaker selects which side
reaches its ACL-check point first. When container-a wins the
tie-breaker on the first attempt against c and d, only the
outbound-handshake-context rejection fires immediately, and the
inbound-handshake-context rejection only emits on a later retry
when c or d's msg1 lands while a has no pending outbound. On one
2026-05-14 run the inbound rejection appeared 63ms after the test
had given up. The race window is small but real.

Polling the log instead of one-shot reading absorbs the
millisecond-to-second variance without slowing the success path
(the helper returns as soon as the pattern appears).
2026-05-14 17:07:32 +00:00
Johnathan Corgan 4f3d2f8471 rekey: apply symmetric jitter to desynchronize dual-initiation
Add a per-session signed jitter offset (uniform [-15, +15] seconds)
to the rekey timer triggers in check_rekey (FMP) and check_session_rekey
(FSP). The configured `node.rekey.after_secs` becomes the nominal
interval rather than a floor; mean is preserved. Desynchronizes
both endpoints in symmetric-start meshes so the dual-initiation
race stops occurring rather than being resolved after the fact by
the smaller-NodeAddr tie-breaker.

Per-session storage means each rekey cutover reconstructs the
session and redraws the jitter naturally — successive cycles get
independent offsets, preventing drift back into sync.
2026-05-14 16:32:07 +00:00
Johnathan Corgan 2e54edb920 Merge maint into master (MTU, rekey baseline, CONTRIBUTING) 2026-05-13 23:59:36 +00:00
Johnathan Corgan 7bd8d3b7a0 Update CHANGELOG for unreleased work on maint
Three entries under [Unreleased] for the three commits since the
v0.3.0 release tag:

- Sidecar example: FIPS_UDP_MTU env override (commit 32a3b58)
- CONTRIBUTING.md overhaul + new docs/branching.md (commit 538ce07)
- Rekey-test Phase 1 baseline-convergence headroom 36s -> 60s
  (commit 6533276)
2026-05-13 23:55:38 +00:00
Johnathan Corgan 538ce077df docs: rewrite CONTRIBUTING.md, add docs/branching.md
The previous CONTRIBUTING.md read like a stock Rust contributing
template that mentioned FIPS. Replace it with an entry-point doc
that gives new contributors the FIPS-specific mental model they
need to make a useful first PR: the FMP/FSP layering and why
mesh-level changes need multi-node testing, the three-branch
release model and how to choose a target branch, structured bug
reporting expectations, and PR submission requirements (scope
discipline, the local-CI ladder, separate requirements for feature
PRs vs bug-fix PRs, squash-merge mechanics).

Add a contributor-facing AI coding assistant policy: use is
welcome, but the contributor must do a thorough manual review and
editorial pass before submission. The agent is a tool; the
contributor is accountable for the submission. Review effort
scales with submission effort -- unreviewed agent output will
receive an agent reply in turn, without human review.

Add docs/branching.md as the long-form companion covering the
release workflow, version conventions, and merge-direction
rationale. The new CONTRIBUTING.md is the day-to-day entry point;
docs/branching.md is the reference.

All cross-references resolve against current HEAD. Previous
stale links to fips-intro.md, fips-wire-formats.md, and
fips-configuration.md are gone; the doc points at the current
docs/design/ layout, docs/getting-started.md,
docs/tutorials/join-the-test-mesh.md, and testing/README.md.

No code changes.
2026-05-13 23:24:48 +00:00
Johnathan Corgan 6533276eda test(rekey): bump Phase 1 baseline-convergence headroom 36s → 60s
The Phase 1 pre-rekey baseline in `wait_for_full_baseline`
occasionally times out on GitHub-hosted runners with one ping pair
failing to converge inside the BASELINE_CONVERGENCE_TIMEOUT window.
Phases 2–6 always pass cleanly when this happens — the rekey itself
is fine, the mesh just hasn't finished spanning-tree + bloom-filter
convergence by the time Phase 1 starts pinging.

The wait loop returns as soon as all 20 pairs converge, so the cost
on the success path is unchanged (typical local CI returns well
under the old 36s). The bump only adds headroom on the failure
path. Operators previously worked around this with
`gh run rerun --failed`; this aims to retire that workaround for
the IK/XK lines.

Distinct from the next-branch XX rekey dual-init race, which is a
real protocol bug tracked separately.
2026-05-13 21:13:03 +00:00
Johnathan Corgan 32a3b58d1f docs(sidecar): make UDP MTU env-overridable end-to-end
The sidecar entrypoint hardcoded `udp.mtu: 1472`, the Docker-bridge
IPv4 maximum (1500 MTU - 8 UDP - 20 IPv4 header). Promote it to
`FIPS_UDP_MTU` (defaulting to 1472, preserving behavior) so non-Docker
reuses of the example can override without editing the script. Plumb
the env var through `docker-compose.yml` so a host-level setting
reaches the container, and add a row to the README's env-var table.

Also annotate the static-CI node template with a comment explaining
the same 1472 rationale and the daemon's 1280 default. The template
keeps 1472 as the literal value since the CI suite runs on a Docker
bridge where that's correct.

No behavior change unless the host explicitly sets FIPS_UDP_MTU.
2026-05-13 21:13:03 +00:00
Johnathan Corgan aa8f276069 Merge maint into master (AUR publish fix) 2026-05-12 17:57:22 +00:00
ArjenandJohnathan Corgan 9bf9701d92 ci: fix AUR publish for fips, add fips-git publish workflow
The v0.3.0 stable AUR push silently failed: with updpkgsums: true,
makepkg downloaded fips-<ver>.tar.gz into the AUR working tree, where
it was then staged by the deploy action and rejected by AUR's 488 KiB
max-blob hook.

Fetch the upstream source tarball and compute its b2sum in CI, patch
pkgver and the b2sums SKIP placeholder in PKGBUILD in-place, then
publish with updpkgsums: false so the AUR clone stays metadata-only.
Recompute and patch the fips.sysusers / fips.tmpfiles asset b2sums in
the same step so they stay in sync with the local files; this safety
net was previously provided by updpkgsums.

Add aur-publish-git.yml for the VCS fips-git PKGBUILD, triggered on
master pushes that touch PKGBUILD-git or its 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.

Add a workflow_dispatch tag input on the stable workflow so historical
release tags can be re-published manually, and drop continue-on-error:
true so future regressions surface in CI.
2026-05-12 17:52:11 +00:00
Johnathan Corgan 212432a9c6 Merge maint into master after v0.3.1-dev open
Marker merge to record the maint dev-line as known on master
without taking maint's v0.3.1-dev opening commit (master is on
v0.4.0-dev). Future bug-fix forward-merges from maint to master
land cleanly on top of this base.
2026-05-12 13:45:07 +00:00
Johnathan Corgan 32697a16f0 chore: open v0.4.0-dev cycle on master
- Cargo: 0.3.0 → 0.4.0-dev
- CHANGELOG: fresh [Unreleased] block above [0.3.0]
- README: badge v0.3.0 → v0.4.0--dev
2026-05-12 13:39:01 +00:00
Johnathan Corgan 627fd3627b chore: open v0.3.1-dev cycle on maint
Reset maint to v0.3.0 to retire the v0.2.x maintenance window and
open a new tracking branch for v0.3.1-dev bug-fix work.

- Cargo: 0.3.0 → 0.3.1-dev
- CHANGELOG: fresh [Unreleased] block above [0.3.0]
- README: badge v0.3.0 → v0.3.1--dev
2026-05-12 13:36:22 +00:00
308 changed files with 43196 additions and 5388 deletions
+40 -1
View File
@@ -1,2 +1,41 @@
[profile.ci]
junit = { path = "junit.xml" }
junit = { path = "junit.xml" }
# Synthetic node tests build 250-edge meshes with one-shot UDP
# handshakes; on shared CI runners the localhost stack still drops the
# occasional msg1 under burst load even with the per-edge repair loop.
# Allow a retry rather than failing the whole CI run on a single
# dropped packet.
#
# Deliberately scoped to [profile.ci] and NOT applied locally, which makes
# the two gates disagree: the hosted runner retries a flaky test twice, the
# local sweep fails on the first failure. The asymmetry is intended and this
# is the record of why, since an undocumented one is indistinguishable from
# an oversight.
#
# It is here for shared-runner packet loss, a property of the hosted
# environment and not of the code. Applying it locally would suppress a real
# local flake, and a failure that only reproduces under load is a robustness
# bug to fix rather than to retry past. Keeping the local sweep strict is
# what makes it the sharper of the two gates.
#
# The cost, stated rather than hidden: a test that fails once and passes on
# retry is reported green here with no separate signal, so a genuine
# intermittent failure can be absorbed. If that starts mattering, the fix is
# to surface retried-but-passed tests, not to drop the retries.
retries = 2
[test-groups]
node-synthetic = { max-threads = 1 }
# nextest runs each test in a separate process, so in-process Tokio mutexes
# can't serialize the synthetic localhost UDP node tests on CI. Those tests
# send one-shot handshakes without production reconnect timers; under runner
# load even small topologies drop the lone msg1. Group all node tests so
# they run mutually exclusive — slower CI, reliable assertions.
[[profile.default.overrides]]
filter = 'test(node::tests::)'
test-group = 'node-synthetic'
[[profile.ci.overrides]]
filter = 'test(node::tests::)'
test-group = 'node-synthetic'
+57
View File
@@ -0,0 +1,57 @@
name: AUR Publish (fips-git)
on:
workflow_dispatch:
push:
branches:
- master
paths:
- 'packaging/aur/PKGBUILD-git'
- 'packaging/aur/fips.sysusers'
- 'packaging/aur/fips.tmpfiles'
- 'packaging/aur/fips.install'
jobs:
aur-publish-fips-git:
name: Publish fips-git to AUR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Patch PKGBUILD-git b2sums for local assets
run: |
set -euo pipefail
SYSUSERS_SUM=$(b2sum packaging/aur/fips.sysusers | awk '{print $1}')
TMPFILES_SUM=$(b2sum packaging/aur/fips.tmpfiles | awk '{print $1}')
if [ -z "$SYSUSERS_SUM" ] || [ -z "$TMPFILES_SUM" ]; then
echo "Failed to compute asset b2sums"; exit 1
fi
awk -v s1="$SYSUSERS_SUM" -v s2="$TMPFILES_SUM" '
/^b2sums=\(/ { in_block=1; count=0 }
in_block {
count++
if (count == 2) sub(/[a-f0-9]{128}/, s1)
if (count == 3) sub(/[a-f0-9]{128}/, s2)
if ($0 ~ /\)/) in_block=0
}
{ print }
' packaging/aur/PKGBUILD-git > packaging/aur/PKGBUILD-git.new
mv packaging/aur/PKGBUILD-git.new packaging/aur/PKGBUILD-git
echo "Patched PKGBUILD-git b2sums:"
awk '/^b2sums=\(/,/\)$/' packaging/aur/PKGBUILD-git
- name: Publish to AUR
uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
with:
pkgname: fips-git
pkgbuild: packaging/aur/PKGBUILD-git
updpkgsums: false
assets: |
packaging/aur/fips.sysusers
packaging/aur/fips.tmpfiles
packaging/aur/fips.install
commit_username: ${{ github.repository_owner }}
commit_email: ${{ secrets.AUR_EMAIL }}
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: "Update PKGBUILD-git (${{ github.sha }})"
+178 -12
View File
@@ -1,32 +1,198 @@
name: AUR Publish
on:
workflow_dispatch:
push:
branches:
- master
- maint
- next
tags:
- 'v*'
pull_request:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish (e.g. v0.4.0). Defaults to the tag the workflow was dispatched from.'
required: false
default: ''
pkgrel:
description: 'AUR pkgrel to publish. Use 2+ for packaging-only republishes of an existing tag.'
required: false
default: '1'
jobs:
aur-publish-fips:
name: Publish fips to AUR
# ───────────────────────────────────────────────────────────────────────────
# Build + lint the AUR package on every trigger, matching the coverage the
# other package workflows (linux/macos/windows/openwrt) give their artifacts:
# branch pushes, pull requests, tags, and manual dispatch. Uses makepkg +
# namcap in an Arch container (neither tool exists on ubuntu-latest) and builds
# the *checked-out tree* from a local git-archive tarball, so it works for
# branch/PR builds and unreleased rc tags whose GitHub source archive does not
# exist yet. This job never publishes.
# ───────────────────────────────────────────────────────────────────────────
aur-build:
name: Build and lint fips AUR package
runs-on: ubuntu-latest
continue-on-error: true
if: "!contains(github.ref_name, '-')"
container: archlinux:base-devel
steps:
- uses: actions/checkout@v4
- name: Update pkgver in PKGBUILD
- name: Install build and lint tooling
run: |
VERSION="${GITHUB_REF_NAME#v}"
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/aur/PKGBUILD
set -euo pipefail
pacman -Sy --noconfirm --needed base-devel namcap git curl
- uses: actions/checkout@v6
- name: Resolve package version
id: ver
env:
INPUT_TAG: ${{ inputs.tag }}
INPUT_PKGREL: ${{ inputs.pkgrel }}
run: |
set -euo pipefail
if [ -n "${INPUT_TAG:-}" ]; then
RAW="${INPUT_TAG#v}"
elif [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
RAW="${GITHUB_REF_NAME#v}"
else
# Branch push / PR: derive the version from the crate manifest.
RAW=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
fi
# makepkg forbids '-' in pkgver; map e.g. 0.4.0-rc1 -> 0.4.0rc1,
# 0.4.0-dev -> 0.4.0dev. The build only needs an internally consistent
# pkgver (it matches the git-archive prefix below); this is not the
# value the real publish uses.
VERSION="${RAW//-/}"
PKGREL="${INPUT_PKGREL:-1}"
case "$PKGREL" in
''|*[!0-9]*|0) echo "pkgrel '$PKGREL' must be a positive integer"; exit 1 ;;
esac
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "pkgrel=${PKGREL}" >> "$GITHUB_OUTPUT"
echo "Resolved AUR pkgver=${VERSION} pkgrel=${PKGREL}"
- name: Create non-root build user and fix ownership
run: |
set -euo pipefail
# makepkg refuses to run as root; create an unprivileged build user
# with passwordless sudo (needed for pacman dep installs during -s).
useradd -m -s /bin/bash builder
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
chmod 0440 /etc/sudoers.d/builder
# The checkout is owned by root; hand it to the build user.
chown -R builder:builder "$GITHUB_WORKSPACE"
- name: Build a local source tarball of the checkout
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
# The PKGBUILD source= points at GitHub archive/<tag>.tar.gz, which does
# not exist for a branch push, a PR, or an unreleased rc tag and would
# 404. Instead build the checked-out tree by packing it into a local
# tarball whose top-level directory matches what the PKGBUILD expects
# ("fips-<pkgver>/"); patch-pkgbuild.sh repoints source= at it.
TARBALL="packaging/aur/fips-${VERSION}.tar.gz"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" archive --format=tar.gz \
--prefix="fips-${VERSION}/" -o "$TARBALL" HEAD
chown builder:builder "$TARBALL"
ls -l "$TARBALL"
- name: Patch PKGBUILD
env:
TAG: v${{ steps.ver.outputs.version }}
VERSION: ${{ steps.ver.outputs.version }}
PKGREL: ${{ steps.ver.outputs.pkgrel }}
run: |
set -euo pipefail
LOCAL_TARBALL="packaging/aur/fips-${VERSION}.tar.gz" \
bash packaging/aur/patch-pkgbuild.sh
chown builder:builder packaging/aur/PKGBUILD
- name: makepkg build and namcap lint (as build user)
run: |
set -euo pipefail
sudo -u builder bash -euo pipefail -c '
cd packaging/aur
echo "::group::namcap PKGBUILD"
namcap PKGBUILD
echo "::endgroup::"
echo "::group::makepkg build"
# --nocheck: skip the PKGBUILD check() (cargo test --lib); the test
# suite is already covered by ci.yml. This job validates packaging.
makepkg -s --noconfirm --nocheck
echo "::endgroup::"
echo "::group::namcap built package"
for pkg in *.pkg.tar.*; do
echo "namcap $pkg"
namcap "$pkg"
done
echo "::endgroup::"
'
# ───────────────────────────────────────────────────────────────────────────
# Publish to the AUR. Runs only on a real (non-prerelease) release tag push,
# or a manual dispatch (packaging-only republish with explicit tag + pkgrel).
# Branch pushes and pull requests build+lint above but never reach this job.
# Gated on aur-build so a package that fails to build/lint is never published.
# ───────────────────────────────────────────────────────────────────────────
aur-publish-fips:
name: Publish fips to AUR
needs: aur-build
runs-on: ubuntu-latest
if: >-
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'push'
&& startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref_name, '-'))
steps:
- name: Resolve release tag
id: tag
env:
INPUT_TAG: ${{ inputs.tag }}
INPUT_PKGREL: ${{ inputs.pkgrel }}
run: |
set -euo pipefail
TAG="${INPUT_TAG:-$GITHUB_REF_NAME}"
PKGREL="${INPUT_PKGREL:-1}"
case "$TAG" in
v*) ;;
*) echo "Tag '$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
esac
case "$PKGREL" in
''|*[!0-9]*|0) echo "pkgrel '$PKGREL' must be a positive integer"; exit 1 ;;
esac
case "$TAG" in
*-*)
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then
echo "Pre-release tag '$TAG' — skipping AUR publish"
exit 1
fi
;;
esac
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "pkgrel=${PKGREL}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v6
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Patch PKGBUILD with pkgver, pkgrel, conflicts, and b2sums
env:
TAG: ${{ steps.tag.outputs.tag }}
VERSION: ${{ steps.tag.outputs.version }}
PKGREL: ${{ steps.tag.outputs.pkgrel }}
run: bash packaging/aur/patch-pkgbuild.sh
- name: Publish to AUR
uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
with:
pkgname: fips
pkgbuild: packaging/aur/PKGBUILD
updpkgsums: true
updpkgsums: false
assets: |
packaging/aur/fips.sysusers
packaging/aur/fips.tmpfiles
@@ -34,4 +200,4 @@ jobs:
commit_username: ${{ github.repository_owner }}
commit_email: ${{ secrets.AUR_EMAIL }}
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: "Update to ${{ github.ref_name }}"
commit_message: "Update to ${{ steps.tag.outputs.tag }}"
+93 -63
View File
@@ -11,6 +11,10 @@ on:
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
checks: write
contents: read
@@ -20,34 +24,83 @@ env:
RUST_BACKTRACE: 1
SOURCE_DATE_EPOCH: 0 # overridden per-step after checkout
# ─────────────────────────────────────────────────────────────────────────────
# CI parity invariant
#
# This GitHub integration matrix and the local default suite set
# (testing/ci-local.sh) MUST run the same integration suites, EXCEPT for the
# deliberate local-only entries below. Adding a suite to one runner without
# the other means "local green" and "GitHub green" stop being equivalent.
# testing/check-ci-parity.sh enforces this and fails on unexpected drift.
#
# Deliberate local-only (NOT on the GitHub gate), with reason:
# tor-socks5 — requires live Tor network; opt-in via --with-tor,
# unreliable on GitHub-hosted runners.
# tor-directory — same; live Tor dependency.
#
# The two runners express the same work in different matrix shapes, and the
# parity guard compares through that shape rather than around it: chaos legs
# are compared per scenario (and per flag) via their `scenario:` field,
# deb-install legs per distro. The one leg still compared at leg granularity
# is dns-resolver — a single leg here, running all of its scenarios
# internally, exactly as the local suite does.
# ─────────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# Job 1 Build matrix
#
# Builds on Linux x86_64, Linux aarch64, and macOS.
# ─────────────────────────────────────────────────────────────────────────────
jobs:
ci-parity:
name: CI parity
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Python deps
run: pip3 install --quiet pyyaml
- name: Check local and GitHub runners cover the same work
run: bash testing/check-ci-parity.sh
- name: Check test log matchers against the strings src/ emits
run: python3 testing/check-log-strings.py
- name: Check no tested function's exit status is a log call's
run: python3 testing/check-trailing-log.py
- name: Check nothing resolves the shared mutable test image
run: bash testing/check-image-scoping.sh
# Hermetic: synthetic ping functions, no containers, ~45s. Lives beside
# the other two so both runners gate on it identically — putting it in
# only one would create exactly the drift check-ci-parity.sh exists to
# catch, and it is invisible to that checker either way since it is not
# a matrix suite.
- name: Run convergence-gate unit tests
run: bash testing/lib/wait-converge-test.sh
fmt:
name: Format check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@v6
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt
cache: false
rustflags: ''
- run: cargo fmt --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy
cache: false
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -72,7 +125,7 @@ jobs:
- os: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set SOURCE_DATE_EPOCH from git (Unix)
if: runner.os != 'Windows'
@@ -94,10 +147,13 @@ jobs:
run: sudo nft -c -f packaging/common/fips.nft
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -126,7 +182,7 @@ jobs:
# Upload the Linux binary so integration jobs can use it without rebuilding
- name: Upload Linux binary
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: fips-linux
path: |
@@ -147,7 +203,7 @@ jobs:
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
@@ -156,10 +212,13 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -200,16 +259,19 @@ jobs:
runs-on: macos-latest
needs: [build]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -232,13 +294,16 @@ jobs:
name: Unit tests (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -267,7 +332,7 @@ jobs:
name: PowerShell lint (Windows packaging)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Run PSScriptAnalyzer
shell: pwsh
@@ -319,8 +384,6 @@ jobs:
- suite: rekey-outbound-only
type: rekey-outbound-only
topology: rekey-outbound-only
- suite: acl-allowlist
type: acl-allowlist
# ── Firewall baseline (fips0 nftables default-deny) ────────────
- suite: firewall
type: firewall
@@ -329,9 +392,6 @@ jobs:
type: gateway
topology: gateway
# ── Chaos / stochastic scenarios ───────────────────────────────────
- suite: chaos-smoke-10
type: chaos
scenario: smoke-10
- suite: churn-mixed-10
type: chaos
scenario: churn-mixed
@@ -345,30 +405,9 @@ jobs:
- suite: tcp-mesh
type: chaos
scenario: tcp-mesh
- suite: bottleneck-parent
type: chaos
scenario: bottleneck-parent
- suite: cost-avoidance
type: chaos
scenario: cost-avoidance
- suite: cost-reeval
type: chaos
scenario: cost-reeval
- suite: cost-stability
type: chaos
scenario: cost-stability
- suite: depth-vs-cost
type: chaos
scenario: depth-vs-cost
- suite: mixed-technology
type: chaos
scenario: mixed-technology
- suite: congestion-stress
type: chaos
scenario: congestion-stress
- suite: bloom-storm
type: chaos
scenario: bloom-storm
# ── Sidecar deployment ──────────────────────────────────────────
- suite: sidecar
type: sidecar
@@ -408,6 +447,12 @@ jobs:
- suite: deb-install-debian12
type: deb-install
scenario: debian12
- suite: deb-install-debian13
type: deb-install
scenario: debian13
- suite: deb-install-ubuntu22
type: deb-install
scenario: ubuntu22
- suite: deb-install-ubuntu24
type: deb-install
scenario: ubuntu24
@@ -426,11 +471,11 @@ jobs:
type: dns-resolver
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# Fetch the pre-built Linux binary from job 1
- name: Download Linux binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: fips-linux
path: _bin
@@ -574,21 +619,6 @@ jobs:
docker compose -f testing/static/docker-compose.yml \
--profile rekey-outbound-only down --volumes --remove-orphans
# ── ACL allowlist integration test ─────────────────────────────────────
- name: Run ACL allowlist integration test
if: matrix.type == 'acl-allowlist'
run: bash testing/acl-allowlist/test.sh --skip-build --keep-up
- name: Collect logs on failure (acl-allowlist)
if: matrix.type == 'acl-allowlist' && failure()
run: |
docker compose -f testing/acl-allowlist/docker-compose.yml logs --no-color
- name: Stop containers (acl-allowlist)
if: matrix.type == 'acl-allowlist' && always()
run: |
docker compose -f testing/acl-allowlist/docker-compose.yml down --volumes --remove-orphans
# ── Firewall baseline integration test ─────────────────────────────────
- name: Run firewall baseline integration test
if: matrix.type == 'firewall'
@@ -616,7 +646,7 @@ jobs:
- name: Upload sim results on failure (chaos)
if: matrix.type == 'chaos' && failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: sim-results-${{ matrix.scenario }}
path: testing/chaos/sim-results/
+9 -6
View File
@@ -19,7 +19,7 @@ jobs:
outputs:
linux_package_version: ${{ steps.linux_version.outputs.linux_package_version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -61,7 +61,7 @@ jobs:
deb_arch: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -72,11 +72,14 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libdbus-1-dev llvm
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
rustflags: ''
- name: Cache Cargo registry + build
if: ${{ env.ACT != 'true' }}
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -137,7 +140,7 @@ jobs:
- name: Upload artifact (GitHub only)
if: ${{ env.ACT != 'true' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: fips_${{ needs.determine-versioning.outputs.linux_package_version }}_${{ matrix.artifact_arch }}_linux
path: |
@@ -161,7 +164,7 @@ jobs:
steps:
- name: Download Linux artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
+127 -17
View File
@@ -19,7 +19,7 @@ jobs:
outputs:
macos_package_version: ${{ steps.macos_version.outputs.macos_package_version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -61,7 +61,7 @@ jobs:
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -69,13 +69,14 @@ jobs:
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Add cross-compile target
run: rustup target add ${{ matrix.target }}
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: ${{ matrix.target }}
cache: false
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -100,14 +101,22 @@ jobs:
shell: bash
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
set -euo pipefail
PKG_FILE=$(find deploy -maxdepth 1 -type f -name "fips-*-macos-*.pkg" | sort | head -n 1)
if [[ -z "$PKG_FILE" ]]; then
echo "Missing macOS package" >&2
# build-pkg.sh names the package from the build target, so each
# matrix leg produces a distinctly named, arch-correct asset.
# Assert that here: a regression in that naming then fails loudly
# at the build stage instead of as a silent collision when the
# release job merges both artifacts into one directory.
EXPECTED="deploy/fips-${{ needs.determine-versioning.outputs.macos_package_version }}-macos-${{ matrix.arch }}.pkg"
if [[ ! -f "$EXPECTED" ]]; then
echo "Expected package $EXPECTED was not produced" >&2
echo "deploy/ contains:" >&2
ls -la deploy >&2 || true
exit 1
fi
echo "pkg=$PKG_FILE" >> "$GITHUB_OUTPUT"
echo "pkg=$EXPECTED" >> "$GITHUB_OUTPUT"
- name: Verify .pkg structural correctness
shell: bash
@@ -185,16 +194,27 @@ jobs:
fi
echo "==> .pkg verification PASSED"
- name: SHA-256 hash
- name: SHA-256 hash and sidecar
shell: bash
run: |
set -euo pipefail
PKG="${{ steps.macos-assets.outputs.pkg }}"
echo "==> macOS release asset:"
shasum -a 256 "${{ steps.macos-assets.outputs.pkg }}"
# Capture the SHA-256 of the verified .pkg on the macOS runner and
# write it to a sidecar file next to the .pkg, in the standard
# `<hash> <basename>` shasum format. The verify-handoff and release
# jobs re-check the downloaded bytes against this value, so any
# corruption introduced after this point is detected before
# publication.
( cd "$(dirname "$PKG")" && shasum -a 256 "$(basename "$PKG")" | tee "$(basename "$PKG").sha256" )
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: fips_${{ needs.determine-versioning.outputs.macos_package_version }}_${{ matrix.arch }}_macos
path: ${{ steps.macos-assets.outputs.pkg }}
path: |
${{ steps.macos-assets.outputs.pkg }}
${{ steps.macos-assets.outputs.pkg }}.sha256
retention-days: 30
- name: Build summary
@@ -202,21 +222,111 @@ jobs:
echo "Build Summary for macOS/${{ matrix.arch }}:"
echo " Package: ${{ steps.macos-assets.outputs.pkg }}"
verify-handoff:
name: Verify macOS package handoff integrity
runs-on: ubuntu-latest
needs: build
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
- name: Verify .pkg integrity across the handoff
shell: bash
run: |
set -euo pipefail
cd dist
pkgs=$(find . -maxdepth 1 -type f -name '*.pkg' | LC_ALL=C sort)
if [[ -z "$pkgs" ]]; then
echo "FAIL: no .pkg artifacts were downloaded" >&2
exit 1
fi
fail=0
while IFS= read -r pkg; do
base=$(basename "$pkg")
sidecar="${pkg}.sha256"
if [[ ! -f "$sidecar" ]]; then
echo "FAIL: missing SHA-256 sidecar for $base" >&2
fail=1
continue
fi
expected=$(awk '{print $1}' "$sidecar")
actual=$(sha256sum "$pkg" | awk '{print $1}')
if [[ "$expected" != "$actual" ]]; then
echo "FAIL: $base SHA-256 mismatch across the artifact handoff" >&2
echo " expected (macOS runner): $expected" >&2
echo " actual (downloaded): $actual" >&2
fail=1
continue
fi
echo "PASS: $base matches the macOS-runner SHA-256 ($actual)"
done <<<"$pkgs"
if [[ "$fail" -ne 0 ]]; then
echo "==> macOS package handoff verification FAILED" >&2
exit 1
fi
echo "==> macOS package handoff verification PASSED"
release:
name: Publish macOS assets to GitHub Release
runs-on: ubuntu-latest
needs: build
needs: [build, verify-handoff]
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
- name: Validate .pkg bytes before publishing
shell: bash
run: |
set -euo pipefail
cd dist
pkgs=$(find . -maxdepth 1 -type f -name '*.pkg' | LC_ALL=C sort)
if [[ -z "$pkgs" ]]; then
echo "FAIL: no .pkg artifacts were downloaded" >&2
exit 1
fi
fail=0
while IFS= read -r pkg; do
base=$(basename "$pkg")
sidecar="${pkg}.sha256"
if [[ ! -f "$sidecar" ]]; then
echo "FAIL: missing SHA-256 sidecar for $base" >&2
fail=1
continue
fi
expected=$(awk '{print $1}' "$sidecar")
actual=$(sha256sum "$pkg" | awk '{print $1}')
if [[ "$expected" != "$actual" ]]; then
echo "FAIL: $base SHA-256 mismatch on the bytes about to be published" >&2
echo " expected (macOS runner): $expected" >&2
echo " actual (downloaded): $actual" >&2
fail=1
continue
fi
echo "PASS: $base matches the macOS-runner SHA-256 ($actual)"
done <<<"$pkgs"
if [[ "$fail" -ne 0 ]]; then
echo "==> pre-publish .pkg verification FAILED; not publishing" >&2
exit 1
fi
echo "==> pre-publish .pkg verification PASSED"
- name: Generate macOS release checksums
run: |
cd dist
+435 -32
View File
@@ -24,9 +24,10 @@ jobs:
runs-on: ubuntu-latest
outputs:
package_version: ${{ steps.version.outputs.package_version }}
apk_version: ${{ steps.version.outputs.apk_version }}
release_channel: ${{ steps.channel.outputs.release_channel }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -35,13 +36,20 @@ jobs:
shell: bash
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
# package_version is the human-readable label used in artifact
# filenames; apk_version is the apk-tools-compatible string embedded
# in the .apk metadata. apk_version is built directly from the same
# structured inputs (tag, or commit height) — no reparse of the
# flattened package_version. See packaging/openwrt-apk/apk-version.sh.
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
echo "package_version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
echo "apk_version=$(sh packaging/openwrt-apk/apk-version.sh tag "${GITHUB_REF_NAME}")" >> "$GITHUB_OUTPUT"
else
BRANCH=$(echo "$GITHUB_REF_NAME" | sed 's|/|-|g')
HEIGHT=$(git rev-list --count HEAD)
HASH=$(git rev-parse --short HEAD)
echo "package_version=${BRANCH}.${HEIGHT}.${HASH}" >> "$GITHUB_OUTPUT"
echo "apk_version=$(sh packaging/openwrt-apk/apk-version.sh dev "${HEIGHT}")" >> "$GITHUB_OUTPUT"
fi
- name: Determine release channel
@@ -64,8 +72,8 @@ jobs:
echo "release_channel=dev" >> "$GITHUB_OUTPUT"
fi
build:
name: Build .ipk (${{ matrix.openwrt_arch }})
compile-binaries:
name: Cross-compile (${{ matrix.openwrt_arch }})
runs-on: ubuntu-latest
needs: determine-versioning
@@ -96,23 +104,17 @@ jobs:
# x86 routers / VMs
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Initialize
run: |
PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.ipk
echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV
- name: Install Rust toolchain (stable)
if: matrix.rust_channel == 'stable'
uses: dtolnay/rust-toolchain@stable
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
targets: ${{ matrix.rust_target }}
target: ${{ matrix.rust_target }}
cache: false
rustflags: ''
- name: Install Rust toolchain (nightly, Tier 3)
if: matrix.rust_channel == 'nightly'
@@ -122,7 +124,7 @@ jobs:
- name: Cache Cargo registry + build
if: ${{ env.ACT != 'true' }}
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -151,6 +153,64 @@ jobs:
- name: Install llvm-strip
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends llvm
# Cross-compile + strip once; both the .ipk and .apk packagers consume
# these artifacts via --bin-dir, so the Rust build runs a single time
# per architecture instead of once per package format.
- name: Cross-compile and strip binaries
run: |
set -euo pipefail
cargo zigbuild --release --target ${{ matrix.rust_target }} \
--bin fips --bin fipsctl --bin fipstop --bin fips-gateway
RELEASE_DIR="target/${{ matrix.rust_target }}/release"
mkdir -p out
for b in fips fipsctl fipstop fips-gateway; do
llvm-strip "$RELEASE_DIR/$b" 2>/dev/null || true
cp "$RELEASE_DIR/$b" "out/$b"
done
ls -lh out/
- name: Upload binaries artifact
uses: actions/upload-artifact@v7
with:
name: fips-bins-${{ matrix.openwrt_arch }}
path: out/
retention-days: 1
build:
name: Build .ipk (${{ matrix.openwrt_arch }})
runs-on: ubuntu-latest
needs: [determine-versioning, compile-binaries]
strategy:
fail-fast: false
matrix:
# Must be a subset of compile-binaries' arches (this job consumes those
# binary artifacts). Currently both ship aarch64 + x86_64.
include:
- build_arch: aarch64
openwrt_arch: aarch64_cortex-a53
- build_arch: x86_64
openwrt_arch: x86_64
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Initialize
run: |
PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.ipk
echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV
- name: Download prebuilt binaries
uses: actions/download-artifact@v8
with:
name: fips-bins-${{ matrix.openwrt_arch }}
path: bins
- name: Install nak
shell: bash
run: |
@@ -199,8 +259,7 @@ jobs:
- name: Build .ipk
env:
PKG_VERSION: ${{ needs.determine-versioning.outputs.package_version }}
LLVM_STRIP: llvm-strip
run: ./packaging/openwrt-ipk/build-ipk.sh --arch ${{ matrix.build_arch }}
run: ./packaging/openwrt-ipk/build-ipk.sh --arch ${{ matrix.build_arch }} --bin-dir "$GITHUB_WORKSPACE/bins"
- name: Install shellcheck (if missing)
shell: bash
@@ -387,13 +446,13 @@ jobs:
- name: SHA-256 hashes
run: |
echo "==> Binaries:"
sha256sum target/${{ matrix.rust_target }}/release/fips target/${{ matrix.rust_target }}/release/fipsctl target/${{ matrix.rust_target }}/release/fipstop
sha256sum bins/fips bins/fipsctl bins/fipstop
echo "==> Package:"
sha256sum dist/${{ env.PACKAGE_FILENAME }}
- name: Upload artifact (GitHub only)
if: ${{ env.ACT != 'true' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ${{ env.PACKAGE_FILENAME }}
path: dist/${{ env.PACKAGE_FILENAME }}
@@ -401,6 +460,7 @@ jobs:
- name: Upload to Blossom
id: blossom_upload
continue-on-error: true
shell: bash
env:
BLOSSOM_SERVER: "https://blossom.primal.net"
@@ -408,17 +468,30 @@ jobs:
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
UPLOAD_RESPONSE=$(nak blossom upload \
--server "$BLOSSOM_SERVER" \
--sec "$NSEC" \
"dist/${{ env.PACKAGE_FILENAME }}" < /dev/null)
FILE_HASH=""
for attempt in 1 2 3; do
if UPLOAD_RESPONSE=$(nak blossom upload \
--server "$BLOSSOM_SERVER" \
--sec "$NSEC" \
"dist/${{ env.PACKAGE_FILENAME }}" < /dev/null); then
echo "Upload response (attempt $attempt):"
echo "$UPLOAD_RESPONSE"
FILE_HASH=$(echo "$UPLOAD_RESPONSE" | jq -r '.sha256')
if [ -n "$FILE_HASH" ] && [ "$FILE_HASH" != "null" ]; then
break
fi
echo "Upload response had no sha256 (attempt $attempt)"
else
echo "Blossom upload timed out or failed (attempt $attempt)"
fi
FILE_HASH=""
[ "$attempt" -lt 3 ] && sleep $((attempt * 10))
done
echo "Upload response:"
echo "$UPLOAD_RESPONSE"
FILE_HASH=$(echo "$UPLOAD_RESPONSE" | jq -r '.sha256')
if [ -z "$FILE_HASH" ] || [ "$FILE_HASH" = "null" ]; then
echo "Failed to extract hash from upload response"
echo "Blossom upload did not succeed after 3 attempts; non-fatal."
echo "The package still ships as a GitHub release artifact; only the"
echo "supplementary Blossom/nostr distribution is skipped this run."
exit 1
fi
@@ -429,6 +502,7 @@ jobs:
- name: Publish NIP-94 release event
id: publish
if: steps.blossom_upload.outcome == 'success'
shell: bash
env:
RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://offchain.pub"
@@ -450,6 +524,7 @@ jobs:
--tag A="${{ matrix.openwrt_arch }}" \
--tag v="$VERSION" \
--tag n="${{ env.PACKAGE_NAME }}" \
--tag format="ipk" \
--tag compression="none" \
> event.json 2> event.err
@@ -507,23 +582,351 @@ jobs:
echo " Release EventId: ${{ steps.publish.outputs.eventId }}"
echo " Blossom URL: ${{ steps.blossom_upload.outputs.url }}"
build-apk:
name: Build .apk (${{ matrix.openwrt_arch }})
runs-on: ubuntu-latest
needs: [determine-versioning, compile-binaries]
strategy:
fail-fast: false
matrix:
# Must be a subset of compile-binaries' arches.
include:
- build_arch: aarch64
openwrt_arch: aarch64_cortex-a53
# MT3000, MT6000, Flint 2, RPi 3/4/5 on OpenWrt 25+
- build_arch: x86_64
openwrt_arch: x86_64
# x86 routers / VMs on OpenWrt 25+
env:
# apk-tools commit OpenWrt pins for the .apk (ADB) format. Keep in sync
# with package/system/apk/Makefile in the targeted OpenWrt release so the
# packages we produce are readable by the apk on the device.
APK_TOOLS_VERSION: "3.0.5"
APK_TOOLS_COMMIT: "b5a31c0d865342ad80be10d68f1bb3d3ad9b0866"
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Initialize
run: |
PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.apk
echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV
- name: Download prebuilt binaries
uses: actions/download-artifact@v8
with:
name: fips-bins-${{ matrix.openwrt_arch }}
path: bins
- name: Install fakeroot
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends fakeroot
# apk mkpkg lives in apk-tools v3, which is not packaged for Ubuntu, so we
# build the pinned release from source. This is the SDK-free equivalent of
# how the .ipk path uses plain tar — one small C tool, no OpenWrt SDK.
- name: Build apk-tools (${{ env.APK_TOOLS_VERSION }}) from source
run: |
set -euo pipefail
sudo apt-get install -y --no-install-recommends \
git ca-certificates build-essential meson ninja-build pkg-config \
zlib1g-dev libssl-dev libzstd-dev liblzma-dev lua5.4-dev scdoc
git clone --quiet https://gitlab.alpinelinux.org/alpine/apk-tools.git /tmp/apk-tools
cd /tmp/apk-tools
git checkout --quiet "${APK_TOOLS_COMMIT}"
meson setup build
ninja -C build src/apk
APK_BIN=/tmp/apk-tools/build/src/apk
"$APK_BIN" --version 2>/dev/null || "$APK_BIN" version 2>/dev/null || true
echo "APK_BIN=$APK_BIN" >> "$GITHUB_ENV"
- name: Build .apk
env:
PKG_VERSION: ${{ needs.determine-versioning.outputs.package_version }}
APK_VERSION: ${{ needs.determine-versioning.outputs.apk_version }}
run: ./packaging/openwrt-apk/build-apk.sh --arch ${{ matrix.build_arch }} --bin-dir "$GITHUB_WORKSPACE/bins"
- name: Verify apk structural integrity
shell: bash
run: |
set -euo pipefail
APK="dist/${{ env.PACKAGE_FILENAME }}"
if [ ! -s "$APK" ]; then
echo "FAIL: produced apk not found or empty at $APK"
exit 1
fi
echo "==> file type:"; file "$APK"
# apk v3 packages are ADB containers; dump the whole manifest with the
# apk-tools we just built. Print it in full so the exact schema is
# always visible in the log if an assertion needs adjusting.
DUMP=$(mktemp)
"$APK_BIN" adbdump "$APK" > "$DUMP" 2>/dev/null || {
echo "FAIL: 'apk adbdump' could not read $APK"; exit 1; }
echo "==> full adbdump:"; cat "$DUMP"
fail=0
# Package metadata (flat keys under info:).
for needle in "name: fips" "version: ${{ needs.determine-versioning.outputs.apk_version }}" "arch: ${{ matrix.openwrt_arch }}"; do
if grep -qF "$needle" "$DUMP"; then
echo " PASS meta: $needle"
else
echo " FAIL meta: missing '$needle'"; fail=1
fi
done
# installed-size reflects the bundled binaries (4 stripped Rust
# binaries, several MB). A payload regression that drops them shows up
# here regardless of how the path tree is formatted.
SIZE=$(awk '/^[[:space:]]*installed-size:/ {print $2; exit}' "$DUMP")
echo " installed-size: ${SIZE:-unknown}"
if [ -z "${SIZE:-}" ] || [ "$SIZE" -lt 1000000 ]; then
echo " FAIL: installed-size implausibly small (binaries missing?)"; fail=1
else
echo " PASS: installed-size >= 1MB"
fi
# The adbdump paths: block is hierarchical. Each directory is a
# top-level list item "- name: <full relative dir>"; its files are
# "- name: <basename>" nested one indent level deeper under "files:".
# (There are no "path:" keys.) Reconstruct full file paths by keying
# off the indentation of the directory-level list items.
RECON=$(awk '
/^paths:/ {p=1; diri=-1; next}
p && /^[^ #-]/ {p=0} # a new top-level key ends paths:
!p {next}
match($0, /^ *- /) {
ind=RLENGTH; rest=substr($0, RLENGTH+1)
if (diri==-1) diri=ind # first list item = directory indent
if (ind==diri) { # directory entry (or the root acl: entry)
if (rest ~ /^name: /) { dir=rest; sub(/^name: /,"",dir) } else dir=""
next
}
if (rest ~ /^name: /) { # deeper item = a file under files:
f=rest; sub(/^name: /,"",f); print (dir==""?f:dir"/"f)
}
}
' "$DUMP")
echo "==> reconstructed paths:"; printf '%s\n' "$RECON"
for path in \
usr/bin/fips usr/bin/fipsctl usr/bin/fipstop usr/bin/fips-gateway \
etc/init.d/fips etc/init.d/fips-gateway \
etc/fips/fips.yaml etc/fips/firewall.sh etc/dnsmasq.d/fips.conf \
etc/sysctl.d/fips-gateway.conf etc/sysctl.d/fips-bridge.conf \
etc/hotplug.d/net/99-fips etc/uci-defaults/90-fips-setup \
lib/upgrade/keep.d/fips; do
if printf '%s\n' "$RECON" | grep -qxF "$path"; then
echo " PASS path: $path"
else
echo " FAIL path: missing $path"; fail=1
fi
done
if [ "$fail" -ne 0 ]; then
echo "apk structural verification FAILED"
exit 1
fi
echo "apk structural verification PASS"
- name: SHA-256 hashes
run: |
echo "==> Binaries:"
sha256sum bins/fips bins/fipsctl bins/fipstop
echo "==> Package:"
sha256sum dist/${{ env.PACKAGE_FILENAME }}
- name: Upload artifact (GitHub only)
if: ${{ env.ACT != 'true' }}
uses: actions/upload-artifact@v7
with:
name: ${{ env.PACKAGE_FILENAME }}
path: dist/${{ env.PACKAGE_FILENAME }}
retention-days: 30
- name: Install nak
shell: bash
run: |
NAK_VERSION="0.16.2"
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) NAK_ARCH="amd64" ;;
aarch64|arm64) NAK_ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
curl -fsSL "https://github.com/fiatjaf/nak/releases/download/v${NAK_VERSION}/nak-v${NAK_VERSION}-linux-${NAK_ARCH}" \
-o /usr/local/bin/nak
chmod +x /usr/local/bin/nak
nak --version
- name: Install jq
run: |
if ! command -v jq &>/dev/null; then
sudo apt-get update && sudo apt-get install -y jq
fi
# Priority: HIVE_CI_NSEC from env (loom job) > repo secret > generate ephemeral
- name: Resolve signing key
id: keys
shell: bash
env:
SECRET_NSEC: ${{ secrets.HIVE_CI_NSEC }}
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
if [ -n "${HIVE_CI_NSEC:-}" ]; then
echo "Using HIVE_CI_NSEC from loom job environment"
NSEC="$HIVE_CI_NSEC"
elif [ -n "$SECRET_NSEC" ]; then
echo "Using HIVE_CI_NSEC from repository secrets"
NSEC="$SECRET_NSEC"
else
echo "No nsec provided -- generating ephemeral keypair"
NSEC=$(nak key generate)
fi
PUBKEY=$(echo "$NSEC" | nak key public)
echo "::add-mask::$NSEC"
echo "nsec=$NSEC" >> "$GITHUB_OUTPUT"
echo "pubkey=$PUBKEY" >> "$GITHUB_OUTPUT"
echo "Publisher pubkey (hex): $PUBKEY"
- name: Upload to Blossom
id: blossom_upload
continue-on-error: true
shell: bash
env:
BLOSSOM_SERVER: "https://blossom.primal.net"
NSEC: ${{ steps.keys.outputs.nsec }}
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
FILE_HASH=""
for attempt in 1 2 3; do
if UPLOAD_RESPONSE=$(nak blossom upload \
--server "$BLOSSOM_SERVER" \
--sec "$NSEC" \
"dist/${{ env.PACKAGE_FILENAME }}" < /dev/null); then
echo "Upload response (attempt $attempt):"
echo "$UPLOAD_RESPONSE"
FILE_HASH=$(echo "$UPLOAD_RESPONSE" | jq -r '.sha256')
if [ -n "$FILE_HASH" ] && [ "$FILE_HASH" != "null" ]; then
break
fi
echo "Upload response had no sha256 (attempt $attempt)"
else
echo "Blossom upload timed out or failed (attempt $attempt)"
fi
FILE_HASH=""
[ "$attempt" -lt 3 ] && sleep $((attempt * 10))
done
if [ -z "$FILE_HASH" ] || [ "$FILE_HASH" = "null" ]; then
echo "Blossom upload did not succeed after 3 attempts; non-fatal."
echo "The package still ships as a GitHub release artifact; only the"
echo "supplementary Blossom/nostr distribution is skipped this run."
exit 1
fi
BLOSSOM_URL="${BLOSSOM_SERVER}/${FILE_HASH}"
echo "url=$BLOSSOM_URL" >> "$GITHUB_OUTPUT"
echo "hash=$FILE_HASH" >> "$GITHUB_OUTPUT"
echo "Uploaded to Blossom: $BLOSSOM_URL"
- name: Publish NIP-94 release event
id: publish
if: steps.blossom_upload.outcome == 'success'
shell: bash
env:
RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://offchain.pub"
NSEC: ${{ steps.keys.outputs.nsec }}
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
set -e
VERSION="${{ needs.determine-versioning.outputs.package_version }}"
CHANNEL="${{ needs.determine-versioning.outputs.release_channel }}"
nak event --sec "$NSEC" -k 1063 \
-c "FIPS Package: ${{ env.PACKAGE_NAME }} for ${{ matrix.openwrt_arch }} (apk)" \
--tag url="${{ steps.blossom_upload.outputs.url }}" \
--tag m="application/octet-stream" \
--tag x="${{ steps.blossom_upload.outputs.hash }}" \
--tag ox="${{ steps.blossom_upload.outputs.hash }}" \
--tag filename="${{ env.PACKAGE_FILENAME }}" \
--tag A="${{ matrix.openwrt_arch }}" \
--tag v="$VERSION" \
--tag n="${{ env.PACKAGE_NAME }}" \
--tag format="apk" \
--tag compression="none" \
> event.json 2> event.err
if [ ! -s event.json ]; then
echo "Failed to create event"
cat event.err 2>/dev/null || true
exit 1
fi
echo "=== Event JSON ==="
cat event.json
echo "=================="
EVENT_ID=$(jq -r '.id' event.json)
if [ -z "$EVENT_ID" ] || [ "$EVENT_ID" = "null" ]; then
echo "Failed to extract event ID"
exit 1
fi
# Publish to relays
cat event.json | nak event $RELAYS 2>&1
echo "eventId=$EVENT_ID" >> "$GITHUB_OUTPUT"
echo "Published NIP-94 event: $EVENT_ID"
- name: Build Summary
run: |
echo "Build Summary for ${{ matrix.openwrt_arch }} (apk):"
echo " Package: ${{ env.PACKAGE_FILENAME }}"
echo " apk version: ${{ needs.determine-versioning.outputs.apk_version }}"
echo " Release EventId: ${{ steps.publish.outputs.eventId }}"
echo " Blossom URL: ${{ steps.blossom_upload.outputs.url }}"
release:
name: Publish GitHub Release (github only)
runs-on: ubuntu-latest
needs: build
needs: [build, build-apk]
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- name: Download all .ipk artifacts
uses: actions/download-artifact@v4
- name: Download package artifacts
uses: actions/download-artifact@v8
with:
# Only the .ipk/.apk packages (named fips_<ver>_<arch>.*), not the
# fips-bins-* raw-binary artifacts shared between the build jobs.
pattern: fips_*
path: dist
merge-multiple: true
- name: Generate OpenWrt release checksums
run: |
cd dist
find . -maxdepth 1 -type f \( -name '*.ipk' -o -name '*.apk' \) -printf '%P\n' \
| LC_ALL=C sort \
| xargs sha256sum \
> checksums-openwrt.txt
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: dist/*.ipk
files: |
dist/*.ipk
dist/*.apk
dist/checksums-openwrt.txt
generate_release_notes: true
+9 -6
View File
@@ -19,7 +19,7 @@ jobs:
outputs:
package_version: ${{ steps.version.outputs.package_version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -52,7 +52,7 @@ jobs:
needs: determine-versioning
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -63,10 +63,13 @@ jobs:
echo "SOURCE_DATE_EPOCH=$epoch" >> $env:GITHUB_ENV
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -143,7 +146,7 @@ jobs:
}
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: fips_${{ needs.determine-versioning.outputs.package_version }}_x86_64_windows
path: deploy/fips-*-windows-*.zip
@@ -167,7 +170,7 @@ jobs:
steps:
- name: Download Windows artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
+5
View File
@@ -33,6 +33,11 @@ __pycache__/
*.egg-info/
*.egg
# Per-run build contexts created by testing/ci-local.sh. Its teardown normally
# removes them, but the CI worker's SIGKILL runs no trap, so one can survive a
# preempted run; ci-cleanup.sh sweeps the survivors.
/testing/docker-*/
# Runtime artifacts from running fips in-tree during local testing.
# Root-anchored so legitimately-tracked fips.yaml under packaging/ and
# examples/ stays included.
+877
View File
@@ -5,6 +5,883 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
### Changed
- `SessionDatagram::decrement_ttl` and `SessionDatagram::can_forward` now match
the forwarder's IP hop-limit semantics: `decrement_ttl` decrements first and
reports false when the result is zero, and `can_forward` is true only at a
TTL of 2 or more.
### Fixed
- Nostr NAT traversal no longer breaks after the host suspends. The traversal
clock cached a Unix timestamp once at startup and advanced it with a
monotonic `Instant`, which does not tick while a machine is asleep, so after
a suspend the daemon's idea of the time trailed real time by the suspend
duration for the rest of the process lifetime. Every NIP-40 expiration it
computed was therefore published already in the past: relays dropped the
offers as expired, the initiator logged a signal timeout waiting for an
answer, and traversal stayed broken until the daemon was restarted. The
clock now reads the wall clock on every call. This is not macOS-specific,
though a laptop that sleeps is where it is easiest to hit; any host that
suspends or hibernates was affected. Reported in
[#128](https://github.com/jmcorgan/fips/issues/128).
- `SessionDatagram` hop-limit handling now follows IP semantics. Delivery to
the addressed node is no longer TTL-gated, and a forwarder decrements before
deciding rather than after, so a datagram that would leave with a TTL of zero
is dropped instead of transmitted. Previously the TTL check ran ahead of the
local-delivery test, so a datagram addressed to this node that arrived with
TTL 0 was dropped, and a forwarder receiving a transit datagram at TTL 1
transmitted it at TTL 0 for the next hop to discard, wasting one transmission
per expiring datagram. The reachable radius is unchanged, because the two
behaviors compensated exactly: a path of `h` links still delivers for any
source TTL of `h` or more. During a rolling upgrade, an unupgraded forwarder
feeding an upgraded destination delivers one hop further than either version
does on its own; no version mix delivers less far. The `TtlExhausted` reject
counter now charges at the node that makes the decision rather than at the
hop after it.
## [0.4.1] - 2026-07-19
### Changed
- `node.bloom.max_inbound_fpr` default raised from `0.10` to `0.20`. The
cap rejects inbound `FilterAnnounce` whose FPR (`fill^k`) exceeds it. On
the fixed 1 KB / k=5 filter, `0.10` corresponds to fill 0.631 (~1,630
reachable entries), and the busiest nodes' aggregates had again begun to
reach it as the mesh grew. `0.20` (fill 0.7248, ~2,114 entries) restores
headroom without materially weakening the antipoison gate: a saturated or
poisoned filter is ~100% FPR and still rejected. This is the second raise
of this cap in two releases; the fixed 1 KB filter is the underlying
constraint, and the structural remedy is the v2 filter work rather than a
further raise. A node running this default accepts announcements that a
v0.4.0 node drops, so during a rolling upgrade the two versions can
disagree about mesh size.
- Bloom filter probing computes its SHA-256 digest once per operation
rather than once per hash function. All k indices were already derived
from a single digest, but the digest was recomputed inside the
per-function loop, so every insert and membership test hashed the same
bytes `hash_count` times (5x at the default). Output is bit-for-bit
identical; this is the hottest path in packet forwarding and mesh-size
estimation.
- Identity operations reuse one shared `secp256k1` context instead of
constructing a fresh one at every sign, verify, and key-derive site.
Each construction allocated a context and ran randomization and blinding
table setup. Behavior is unchanged: the same API calls are made, only the
context lifetime differs, and the shared context still performs the
standard construction-time blinding.
### Fixed
- Spanning tree: the coordinate cache is now invalidated when the parent
link is lost through peer removal. That path reparents or self-roots the
node but omitted the invalidation every other position-change path
performs, so cached entries for downstream destinations kept the node's
now-stale coordinate prefix. Because routing access refreshes an entry's
TTL, an actively routed stale entry never self-expired and was corrected
only by a fresh insert.
- Discovery: applying a `LookupResponse` now keeps the tighter of the
cached and received `path_mtu` rather than overwriting unconditionally.
A looser estimate arriving in a later response could clobber a tighter
value already learned from a reactive `MtuExceeded` or
`PathMtuNotification`, loosening a clamp that had been correctly
tightened.
### Removed
- The `parent_switched` spanning-tree metric counter. It was incremented on
the line immediately before `parent_switches` at every site and never
independently, so the two were always identical. `parent_switches`
remains as the sole counter. Consumers reading `parent_switched` from the
control socket or `fipstop` should use `parent_switches`.
## [0.4.0] - 2026-06-27
### Added
#### Transports (Nym, mDNS LAN discovery)
- Nym mixnet transport (`transports.nym`) for outbound peer links
tunneled through a local `nym-socks5-client` SOCKS5 proxy into the
Nym mixnet, as a privacy transport alongside Tor. Outbound-only and
not platform-gated, it reuses the existing FMP framing and adds no new
crate dependencies. A single-container example
(`examples/sidecar-nostr-mixnet-relay/`) demonstrates FIPS peering
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
sites across the node. Each rejection-and-return path now passes a
typed reason to `NodeStats::record_reject`, which routes it to a
per-subsystem counter, so operators can see what is being rejected
through stats counters rather than by scraping debug logs. New
`HandshakeStats`, `SessionStats`, and `MmpStats` sub-stats join the
existing `TreeStats`, `BloomStats`, `DiscoveryStats`, and
`ForwardingStats`, and `TreeStats::ancestry_invalid` is now
incremented from the `TreeAnnounce::validate_semantics` rejection
site that was previously silent. Several handshake, MMP, tree, and
discovery rejection paths that had no counter at all are now counted,
including the `send_lookup_response` no-route drop
(`DiscoveryStats::resp_no_route`).
- Internal atomic metric registry (`Arc<MetricsRegistry>`) that shadows
the plain-`u64` `NodeStats` counters, written alongside them and
validated by a whole-struct debug-build parity check. Covers the
forwarding receive counters, the full discovery counter family, and the
tree, bloom, congestion, and error-signal counter families, with
the hottest counters cache-line padded. Behavior-neutral:
`NodeStats` remains the serving path. Groundwork for sampling metrics
without contending the receive loop.
- `fipsctl stats metrics`, backed by a new counter-only `show_metrics`
control query that dumps the atomic metric registry as flat counter
name/value pairs. Serves a Prometheus-style scraper that samples node
counters without contending the receive loop.
- `pool_inbound` and `pool_outbound` counters on the TCP and Tor
transport stats (`TcpStats`, `TorStats`). Per-direction accounting
is updated at every pool-insert and receive-loop-exit site, plus on
transport stop and on send-failure-driven removal. Surfaces through
`TcpStatsSnapshot` and `TorStatsSnapshot` for `show_transports`.
#### Spanning-tree / mesh-size / routing
- 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
full. A saturated `recent_requests` cache
(`MAX_RECENT_DISCOVERY_REQUESTS`) previously dropped requests
silently; a new `DiscoveryStats::req_dedup_cache_full` counter (typed
reject reason `DiscoveryReject::ReqDedupCacheFull`) makes the drop
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
the maintainer runs against every incoming PR, published at the
repo root so contributors can run the same pass on their own change
(directly or by handing the document to a coding agent) before
opening. Linked from `CONTRIBUTING.md` under "Submitting pull
requests" and "Further reading". Running the checklist before
opening surfaces problems that would otherwise come back as review
comments, saving a round trip.
- [`docs/how-to/tune-file-descriptors.md`](docs/how-to/tune-file-descriptors.md)
— an operator how-to for raising `RLIMIT_NOFILE`. A busy node opens
roughly three file descriptors per established UDP peer (a
`connect()`-ed socket plus a 2-FD drain self-pipe), so the default
1024 soft limit is exhausted near 320 peers, after which further
admission, handshakes, and discovery fail with `EMFILE`. The guide
documents the per-peer FD budget and symptom, the systemd
(`LimitNOFILE` drop-in) and OpenWrt (procd `nofile`) procedures to
raise the limit, and how to verify the per-peer ratio stays bounded.
Linked from the how-to index.
### Changed
#### FMP/FSP rekey reliability
- `complete_rekey_msg2` now returns the remote peer's startup epoch
alongside the new Noise session, so the rekey path can detect a peer
restart and clear stale session state.
#### NAT traversal / Nostr discovery
- Nostr discovery startup is now non-blocking. `Node::start` no
longer waits for relay connect, subscribe, or initial advert
publish before returning. A slow or unreachable relay no longer
holds node startup hostage; local transports come up immediately
and the relay path catches up asynchronously in background tasks.
Subscribe retries with exponential backoff (2 s base, 60 s cap),
publish attempts time out at 10 s, and the new tasks are aborted
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
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
reachable entries) and had begun rejecting the busiest nodes' aggregates
as the mesh approached that size. `0.10` (fill 0.631, ~1,630 entries)
restores headroom toward the fixed-filter capacity limit without
materially weakening the antipoison gate: a saturated or poisoned filter
is ~100% FPR and still rejected.
- TCP inbound connection cap now honors `node.limits.max_connections`.
The per-transport TCP inbound accept ceiling was hardwired to 256 and
never read `max_connections`, so raising it was a silent no-op for
inbound TCP. The effective cap now resolves with precedence: explicit
per-transport `max_inbound_connections`, then node-wide
`max_connections`, then the built-in default of 256. Established peers
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`.
Every pure-read `show_*` query — `show_status`, the `show_stats_*`
family, `show_listening_sockets`, the new `show_metrics`,
`show_tree`/`show_bloom`/`show_cache`/`show_routing`/
`show_identity_cache`, `show_peers`/`show_sessions`/`show_links`/
`show_connections`/`show_transports`/`show_mmp`, and `show_acl` — now
renders in the control accept task from ArcSwap-published read
snapshots instead of round-tripping the data-plane receive loop; only
the mutating `connect`/`disconnect` commands still reach the loop.
This removes the head-of-line coupling where a busy or slow `rx_loop`
could time out `fipsctl` and `fipstop` observability (the five-second
query pattern operators saw on loaded nodes). Per-entity snapshots
reuse unchanged rows by pointer, so per-tick publish cost stays
bounded as peer/session count grows. New daemon-resolved fields
surface through the snapshots: effective persistence, root/is-root,
and a per-transport-type peer-count map in `show_status`; per-peer
`effective_depth` in `show_peers`; `root_npub` in `show_tree`; and the
last-sent uptree filter fill ratio and subtree estimate in
`show_bloom`.
- `fipstop` TUI overhaul: reworked rendering, navigation model, and the
control read surface it draws from, surfacing the new daemon-resolved
snapshot fields above. Built on a ratatui `TestBackend`
render-snapshot harness that asserts the text grid and per-cell
style of every `ui::draw_*` against canned `show_*` JSON.
- Steady-state log noise reduced on saturated public-mesh nodes.
Routine per-peer connection-lifecycle and capacity-cap events are
demoted from info/warn to debug — FMP K-bit cutover promotion,
connection-promoted-to-active-peer (a redundant duplicate
promotion line removed), peer-restart-detected, peer-removed-and-
cleaned-up, the TCP `max_inbound_connections`-reached rejection, and
the congestion-CE-flag line — so genuinely notable info/warn lines
are no longer drowned out. An exhausted FMP-msg1 / FSP-msg3 rekey
retransmission-budget abort (an expected, self-limiting outcome on
lossy or high-latency links) is likewise demoted from warn to debug.
- macOS UDP receive path now batches up to 32 datagrams per kernel
wakeup via `recvmsg_x(2)`, matching the Linux `recvmmsg(2)`
amortization shape introduced in v0.3.0. Previously macOS fell
through to single-packet `recv_from`, capping inbound rate on
Apple builds with the same per-syscall + per-task-wakeup overhead
Linux had already eliminated. `recvmsg_x` is an xnu-private syscall
declared via `unsafe extern "C"` against a local repr(C)
`msghdr_x`; same approach used by `quinn-udp`. Same
`(count, kernel_drops)` contract as the Linux path, with
`kernel_drops` always 0 on macOS (no `SO_RXQ_OVFL` equivalent).
Bench numbers on aarch64-apple-darwin (100B payloads, 3 s
windows): 1 sender 1.09x, 2 senders 1.72x, 4 senders 1.56x,
8 senders 1.46x.
- Receive hot path: removed two per-packet copies. New borrowed
`SessionDatagramRef` decoder is used in the forwarding handler so
local delivery and coordinate-cache warming no longer allocate or
copy the session payload; the owned `SessionDatagram` is materialized
only when re-encoding for the next hop. Owned `SessionDatagram::
decode` is reimplemented as `Ref::decode + into_owned`, so the two
decoders cannot drift. On Linux + macOS the `recvmmsg` / `recvmsg_x`
receive loop now moves each filled slot buffer into `ReceivedPacket`
via `mem::replace` instead of cloning it, and `TransportAddr` is
formatted directly from the `SocketAddr` without an intermediate
`String`. Focused decode bench: ref 1.6 ns/op vs owned 34.7 ns/op
(21.4x).
- Quieted non-Linux test-build warnings from intentionally
platform-specific code: the nftables firewall parser
(`#[allow(dead_code)]` now gated to non-Linux targets where the
parser is compiled but unused), the macOS `utun` address-family
helper and the long TUN reader entry point (narrow allowances),
and a macOS Ethernet test module's clippy struct-layout lint
(rewritten MAC-copy loop, explicit layout annotation). No
behavioral change; the goal is to keep `cargo test` and
`cargo clippy` clean on cross-platform builds so unrelated
warning fixes don't get bundled into behavioral PRs.
- Data-plane: AEAD encrypt and AEAD decrypt now run on per-shard
worker-pool threads (`std::thread` + `crossbeam_channel`), off the
rx_loop. Hash-by-destination dispatch pins each TCP flow to one
worker so wire ordering is preserved; per-worker `sendmmsg(2)`
batches up to 32 outbound packets per syscall, with UDP_GSO
(`UDP_SEGMENT`) when the batch is uniform-sized — the same kernel
primitive WireGuard's in-kernel module and Cloudflare's userspace
BoringTun use to hit multi-Gbps single-stream rates. On Linux +
macOS each established UDP peer also gets a dedicated `connect(2)`-
ed kernel socket bound to the same wildcard listen port via
`SO_REUSEPORT`, so the kernel caches per-packet route + neighbor
lookup and the worker sends with `msg_name = NULL`. The receive
side mirrors: per-shard thread-local `HashMap` owns each session's
recv cipher + replay window, replacing the previous shared
`RwLock`. Sessions are re-registered with the decrypt pool on
K-bit flip and rekey cutover, and unregistered on rekey drain
completion and peer removal so the per-shard tables stay bounded.
New `crossbeam-channel = "0.5"` dependency. Worker counts default
to `num_cpus`; both pools are overridable via
`FIPS_ENCRYPT_WORKERS` and `FIPS_DECRYPT_WORKERS` (the latter
accepts `0` to disable the pool and fall back to in-line decrypt
in rx_loop). Per-peer connected UDP can be disabled via
`FIPS_CONNECTED_UDP=0`. Optional per-stage timing reporter
available via `FIPS_PERF=1` (or `FIPS_PIPELINE_TRACE=1`); detailed
knob documentation is a follow-up at
`docs/how-to/tune-worker-pools.md`. Bench (5 × 15 s × 1 stream
medians, Linux x86_64, docker-bridge mesh): A→D 1379→2708 Mbps
(1.96×), A→E 1394→2663 Mbps (1.91×), E→A 1406→2624 Mbps (1.87×);
RTT +0.110.19 ms from the worker queue handoff. Windows
continues on the existing tokio-based send/recv path. Two issues in
the off-rx_loop drain path are resolved as part of the overhaul: the
per-peer drain worker is now detached on `Drop` rather than joined
synchronously (a synchronous join from the runtime thread could wedge
the whole daemon when a peer was removed with an in-flight worker),
and the connected-UDP drain no longer busy-spins on a poll error
(#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
conf-file. The default configuration is installed as an example at
`/usr/share/fips/fips.yaml.example`, and `postinst` seeds
`/etc/fips/fips.yaml` (mode 600) from it only when the file does not
already exist — so a configuration-management-rendered or
operator-edited config is never prompted for or clobbered on
upgrade, removing the need for a `dpkg-divert` workaround.
`fips.service` gains `ConditionPathExists=/etc/fips/fips.yaml`. The
example is placed under `/usr/share/fips`, deliberately outside
`/usr/share/doc`, which minimal and container installs path-exclude
(so the install-time seed source is never dropped).
- openwrt: the `.apk` package now defaults `ethernet.wan` to the
OpenWrt 25 DSA port name `wan`; the `.ipk` package keeps `eth0` for
OpenWrt 24 and earlier.
#### 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
the Rust toolchain selection given a single source of truth:
- The `admission-cap` integration suite, previously run only by
`ci-local.sh`, now also runs as a GitHub `ci.yml` matrix leg, so a
regression in it turns the GitHub gate red rather than depending on
a developer remembering to run local CI. A new
`testing/check-ci-parity.sh` (wired as `ci-local.sh
--check-parity`) diffs the two runners' integration-suite sets and
fails on unexpected drift; the deliberate local-only (live-Tor)
and granularity-only differences are documented in a comment block
atop both runners.
- CI and packaging jobs now select the toolchain with
`actions-rust-lang/setup-rust-toolchain` (which reads
`rust-toolchain.toml`) instead of `dtolnay/rust-toolchain@stable`.
The pinned channel already overrode the installed stable, so each
job downloaded an unused toolchain and logged a misleading `rustc`
version; the single-source action removes the waste and the
confusion. Existing cache steps are kept (`cache: false` on the
new action) and `RUSTFLAGS` is left untouched so no global
`-D warnings` is newly imposed. The OpenWrt nightly Tier-3 leg
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
#### FMP/FSP rekey reliability
- FMP link-layer rekey is now reliable under packet loss, bringing it up
to the FSP session layer's rekey discipline. The rekey msg1
retransmission driver was previously uncapped and never abandoned, so a
rekey that never completed resent msg1 forever; it now uses a bounded
retransmission budget (`handshake_max_resends` with exponential
backoff) and abandons the rekey cycle cleanly once the budget is
exhausted, mirroring the FSP rekey msg3 driver. With the cap in place
the link-dead heartbeat is rekey-aware: `check_link_heartbeats` no
longer reaps a link that is still actively carrying rekey-handshake
traffic, while a genuinely dead link is still reaped once the budget
abandons. At the K-bit cutover the receiver now authenticates an
inbound frame against the pending session before promoting it, instead
of promoting on the bare header K-bit; under jitter a node could
otherwise promote a stale pending session, leaving the two endpoints on
different keys and silently dropping traffic until the link died — the
same failure class already closed on FSP, now closed on FMP.
- FSP session rekey is now hitless under packet loss and reordering.
Previously, a rekey could leave the two endpoints holding different
key sets for a brief window — if a handshake message was lost in
transit one side rotated keys while the other did not, and traffic
sealed in one key epoch reached a peer still on the other epoch and
failed to decrypt, producing bursts of AEAD decryption failures and
dropped connectivity until a later rekey reconverged the pair. The
receive path now trial-decrypts each frame against every live key
epoch (current, pending, and the draining previous session) for the
duration of the rekey transition, so no rotation ordering and no
packet reordering can cause a decryption failure. The previous-epoch
slot is retained as long as the peer keeps using it, with its drain
deadline anchored on the last frame the peer authenticates against
it rather than a fixed wall-clock timer, so a peer that did not
receive the new keys is not stranded by a silent permanent decrypt
failure. The lost-handshake case is closed by retransmitting the
third rekey handshake message until the peer is confirmed on the
new keys, with a bounded retry budget after which the rekey cycle
is cleanly abandoned and retried. There are no FSP decryption
failures across a rekey under lossy, jittery links.
- ±15s symmetric jitter is applied per session to the FMP and FSP rekey
timer trigger, eliminating the steady-state dual-initiation race in
symmetric-start meshes (previously the smaller-NodeAddr tie-breaker
resolved correctness only after every cycle's collision).
`node.rekey.after_secs` becomes the nominal interval rather than a
floor; the mean is preserved.
- A stale FSP (session-layer) session is now cleared when a peer
restart is detected during FMP rekey or cross-connection promotion.
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 the freshly promoted peer.
#### NAT traversal / Nostr discovery
- Two nodes that each `auto_connect` to the other no longer stall their
Nostr-mediated NAT-traversal handshake. Each side ran both an
initiator and a responder traversal session, binding a separate UDP
socket per session, and adopted only the first `Established` event; if
the two sides adopted mismatched sessions, each sent its Noise msg1 to
a peer port the peer had already stopped draining and both handshakes
hung until the adoption budget expired. The responder now elects a
single session deterministically — it declines an incoming offer only
when it also has an in-flight outbound initiator for the same peer and
its own NodeAddr is smaller — so one matching socket pair survives on
both ends and the peer's redundant initiator times out harmlessly.
One-sided (asymmetric) `auto_connect` has no co-active initiator and is
never suppressed, so connectivity is preserved.
- NAT-traversal cross-init adoption is now deterministic under
simultaneous dual-initiation. Previously, when two peers'
Nostr-mediated UDP punches completed within the same scheduling
window, each side's bootstrap-completion event arrived with an
in-flight handshake already recorded against the other peer (each
side had received an inbound msg1 from the other's pre-punch
outbound attempt). The deduplication skip then fired on both
sides, neither installed the fresh traversal socket as canonical,
and the 45-second peer-adoption budget expired with both nodes
stuck waiting for an adoption that never happened. The handler now
applies the same deterministic NodeAddr tie-breaker the codebase
already uses for rekey dual-initiation and cross-connection
resolution: the smaller NodeAddr wins as adopter, tears down its
in-flight handshake state, and proceeds with adoption; the larger
NodeAddr keeps the skip semantics, and its in-flight outbound is
reconciled by the cross-connection logic when the winner's fresh
msg1 arrives over the adopted socket. The dual cross-init stall is
eliminated; cross-init NAT-traversal completes in well under a
second even under host CPU contention.
- 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.
- Nostr discovery now filters unroutable direct UDP/TCP advert
endpoints. Publisher and validator retain only endpoints that parse as
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
against the per-direction inbound count (`pool_inbound`) rather than
the combined pool size. Outbound connect-on-send connections share
the same pool data structure but no longer consume slots against the
operator-facing inbound cap. The configuration field name and
operator semantics are preserved; only the cap-check comparison and
accounting change. Operators with mixed outbound + inbound
deployments no longer see legitimate inbound peers rejected once
outbound connections fill the pool past the configured cap.
- Outbound connection initiation now honors the `node.limits.max_peers`
cap that was previously only checked on inbound msg1 admission. Four
paths gated: auto-reconnect retries (`process_pending_retries`),
Nostr-mediated discovery's `BootstrapEvent::Established` adoption, and
both sides of the Nostr-mediated NAT-traversal punch (offer initiation
in the runtime's outgoing path, offer acceptance in the responder's
incoming-offer handler). At saturation, a node now performs zero
outbound work on these paths; only existing peer maintenance and
overlay-advert refresh continue. The inbound gate at
`handshake.rs:1114` is unchanged. Introduces a shared
`Node::outbound_admission_check()` helper so the invariant is
grep-able and unit-testable.
- Inbound `handle_msg1` now silent-drops at `node.limits.max_peers`
saturation *before* building/sending Msg2, instead of replying with
Msg2 and then rejecting at `promote_connection`. Adds an early cap
check positioned after identity verification (so the
reconnect / cross-connection bypass for known peers still fires) and
before index allocation + Msg2 wire send. The late cap check inside
`promote_connection` is intentionally retained as
defense-in-depth. Wire savings observed in a 45 s tcpdump at
saturation: ~3.6 cap-denials/s × Msg2 (~104 B + AEAD compute) each.
Bigger win is cleaner peer-side semantics — no fake-completed
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
under filter overlap. It previously summed the per-filter cardinality
of the parent and each child filter, which assumes the filters are
perfectly disjoint; a stale or oversized parent filter or a routing
loop inflated the reported mesh size to several times the true value,
and dropping the parent on a tree rebalance collapsed the upward leg
and flapped the count (the symptom operators saw as the size
nearly-but-not-exactly doubling during rebalancing). The estimator now
computes the cardinality of the OR-union over self plus every
connected peer's inbound filter, dropping the parent/child tree gating
entirely. OR is idempotent, so any overlap is deduplicated — the
result equals the old sum in the disjoint case, stays correct under
overlap, damps the parent-switch flap, and removes the estimate's
dependence on tree-declaration cache freshness. The per-peer 500 ms
rate-limiter and overall recompute cadence are unchanged.
- Spanning-tree state distribution is now eventually-consistent.
Previously every `send_tree_announce_to_all` call site fired only
on a local state-change event (parent switch, self-root promotion,
ancestry change, peer promotion, parent loss). Once a partition
latched — for example, a parent-switch announce lost in transit
via the brief cross-init handshake swap window where one peer's
outbound session is about to become the loser session and the
receiver has no matching decrypt-worker entry — no node's state
changed again, so no node ever re-broadcast. The existing 60-second
`check_periodic_parent_reeval` short-circuited silently on no-change
(it was a re-evaluation, not a re-broadcast), and production-side
healing depended on incidental link churn (NAT keepalive refresh,
MMP timeout, peer re-promotion after a transport blip). The
function now ends with an unconditional `send_tree_announce_to_all`
on the no-change branch, alongside the existing switch and
self-promote arms; receivers coalesce by sequence comparison
(`ParentDeclaration::is_fresher_than`) and short-circuit at the
`if !updated` gate in `handle_tree_announce`, so same-sequence
repeats drop silently with no cascade. The per-peer 500 ms
rate-limiter is well below this 60-second cadence and does not
suppress the heartbeat broadcast. `BASELINE_CONVERGENCE_TIMEOUT`
in `testing/static/scripts/rekey-test.sh` is bumped from 60 to 65
so any partition healed by the periodic broadcast at T+60 lands
inside the convergence window; `wait_for_full_baseline` early-exits
on PASS, so successful reps see no extra wall-clock.
- A single-uplink node stranded out of the tree now re-attaches within
a round-trip instead of waiting for the periodic re-broadcast cadence.
A node with one tree peer has periodic parent re-evaluation disabled,
so a lost one-shot attaching `TreeAnnounce` left it self-rooted and
unreachable until the next periodic re-broadcast
(`reeval_interval_secs` later). Tree-position exchange is now
self-healing on the receive path: when an accepted `TreeAnnounce`
advertises a root strictly worse (higher NodeAddr; election is
smallest-wins) than our own, we echo our current declaration back to
that peer, provoking the better-rooted peer to re-push its real
position immediately. The echo fires only in that one direction and is
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
is eliminated. Previously, the tick body's per-peer `check_*`
loops (heartbeats, bloom announces, MMP reports, tree announces)
called `transport.send` directly for every active peer. For
TCP/Tor peers whose pool entry was not yet established,
`send_async` fell through to a synchronous connect-on-send
branch that wrapped `TcpStream::connect` in
`tokio::time::timeout(connect_timeout_ms, …)` — 5 seconds by
default — and blocked the entire tick body for the duration per
unreachable peer. Under post-restart convergence on a high-peer
mesh, this cascaded into multi-second tick stalls; the same
mechanism also starved the master-only per-tick control-snapshot
republish and pushed `fipsctl show *` queries onto an mpsc
fallback that was itself queued behind the wedged `rx_loop`,
producing the five-second `fipsctl` head-of-line pattern
operators observed on loaded nodes. The send path now gates on
`transport.connection_state(addr)` before sending: proceed only
when `Connected`; on `None`, kick off a non-blocking background
`connect` (idempotent — deduplicates against the connecting
pool, spawns the timeout-bounded `TcpStream::connect` inside its
own tokio task) and fail this send fast with a clear
`transport connection not ready` error. A subsequent tick
retries once the pool has an entry. The existing reconnect
lifecycle (heartbeat-dead detection in `check_link_heartbeats`,
scheduled retries via `process_pending_retries`, background-
connect polling via `poll_pending_connects`) is unchanged.
The connect-on-send branch in `transport.send_async` itself
remains in place for code paths that legitimately need
synchronous connect (e.g., explicit operator-driven
`fipsctl connect`); the tick path just no longer trips it.
#### Data-plane / metrics / observability
- The Tor transport now increments its `connect_refused` statistic (the
"Refused" line in fipstop) when a SOCKS5 connection is actively
refused, instead of recording every connect failure as a generic
SOCKS5 error. The counter previously stayed at zero.
- MMP sender metrics now ignore duplicate or regressed receiver reports
before updating RTT, loss, goodput, or ETX. Receiver reports also
suppress timestamp echo when dwell time overflows, so stale reports
cannot inflate SRTT.
- Reject-reason counters no longer double-count now that the rollout's
interim direct increments are removed. Six discovery counters
(`req_decode_error`, `req_duplicate`, `req_ttl_exhausted`,
`resp_decode_error`, `resp_identity_miss`, `resp_proof_failed`), six
bloom counters (`decode_error`, `invalid`, `non_v1`, `unknown_peer`,
`stale`, `fill_exceeded`), and five forwarding reject packet counters
(`decode_error_packets`, `ttl_exhausted_packets`,
`drop_no_route_packets`, `drop_mtu_exceeded_packets`,
`drop_send_error_packets`) were each incremented both by a direct bump
and again through the typed reject dispatch. The redundant direct
increments are removed — for the forwarding family the two calls are
collapsed into a single byte-aware reject entry point — so each counter
(and, for forwarding, its byte tally) counts once per event.
- 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.
#### Peer lifecycle / gateway
- 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.
#### 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
`fips-dns-setup` and `fips-dns-teardown` helpers into
`/usr/lib/fips/`, matching the Debian package. The AUR `package()`
step previously omitted them, so `fips-dns.service` failed to
start on Arch installs ("Unable to locate executable
`/usr/lib/fips/fips-dns-setup`", #98). The PKGBUILDs additionally
opt out of the debug split package and declare the `*-debug`
variant as a conflict, so a stale debug build cannot own installed
files across a package switch.
- macOS package build: the `.pkg` architecture is now derived from
the Cargo `--target` triple instead of the build host's
`uname -m`. The arm64 and x86_64 release legs build on the same
Apple-silicon runner, so `uname -m` named both outputs
`fips-0.3.0-macos-arm64.pkg`; the release job's `merge-multiple`
artifact download then interleaved the two identically named
files into a single corrupt xar archive, and no x86_64 package
reached the release at all. (This shipped as the broken v0.3.0
macOS `.pkg`, GitHub #102.) The release workflow now also asserts
the arch-named file is present and carries a SHA-256 integrity
chain from the build runner through to `gh release upload`, so a
recurrence fails CI instead of publishing.
#### fipstop
- `fipstop` no longer renders a garbled screen on startup or leaves
stray bytes on quit, most visible over SSH and inside tmux. Startup
forces a full repaint (`terminal.clear()`) before the first draw so
prior alternate-screen contents no longer show through; quit gives the
stdin-poll thread a stop flag and joins it before restoring the
terminal, so post-raw-mode keystrokes or terminal query responses no
longer echo onto the restored screen.
## [0.3.0] - 2026-05-11
### Added
+241 -68
View File
@@ -1,94 +1,267 @@
# Contributing to FIPS
## Getting Started
<!-- markdownlint-disable MD013 -->
Clone the repo:
FIPS is a mesh routing protocol for Nostr identities over arbitrary
transports. The architecture is layered, top to bottom:
```
- **IPv6 TUN compatibility layer** — presents the mesh as a local
network interface (`fips0`) so unmodified applications can use it.
Applications send IPv6 packets to `fd::/8` addresses derived from
Nostr pubkeys; the daemon converts between IPv6 packets and FSP
datagrams.
- **FSP** (FIPS Session Protocol) — end-to-end encrypted sessions
between identities, with periodic rekey.
- **FMP** (FIPS Mesh Protocol) — peer management, spanning tree,
bloom filters, routing and forwarding, and link encryption.
- **Transport** — the actual wire: UDP, TCP, Tor, Bluetooth LE,
Ethernet, and so on. Each transport plugs into FMP via a trait.
Most non-trivial changes affect behavior visible across the mesh —
how nodes find each other, how packets route, how sessions rekey, how
peers recover from failure. A single-node `cargo test` run is
necessary but not sufficient for that class of change; the integration
harness in [testing/](testing/) is where regressions actually surface.
This document covers the workflow assuming that context. Protocol
depth lives in [docs/design/](docs/design/).
## Quick start
```bash
git clone https://github.com/jmcorgan/fips.git
cd fips
```
Before changing code, read the protocol docs in this order:
- [docs/design/README.md](docs/design/README.md)
- [docs/design/fips-intro.md](docs/design/fips-intro.md)
- the specific design doc for the behavior you are touching
## Prerequisites
- Rust 1.94.1 and Linux with TUN support
- Use the pinned toolchain from [rust-toolchain.toml](rust-toolchain.toml) for deterministic builds
- For the default BLE-enabled build on Debian/Ubuntu:
`sudo apt install bluez libdbus-1-dev pkg-config`
- Docker is required for the integration harnesses under [testing/](testing/)
If you do not want BLE locally, build and test without default features:
```bash
cargo build --no-default-features --features tui
cargo test --no-default-features --features tui
```
## Local Verification
Choose the narrowest check that matches your change:
- Docs-only changes:
```bash
git diff --check
```
- Normal code changes:
```bash
cargo build
cargo test
cargo clippy --all -- -D warnings
```
- Local CI-style unit test run:
The pinned toolchain in [rust-toolchain.toml](rust-toolchain.toml) is
used for deterministic builds. On Linux, a source build requires
`libclang` (`sudo apt install libclang-dev` on Debian/Ubuntu): the LAN
gateway's nftables bindings are generated by `bindgen` at build time
and fail without it. BLE-capable builds additionally need `bluez`,
`libdbus-1-dev`, and `pkg-config` installed; the default build picks
up BLE if those are present and skips it cleanly if not.
On Nix, `nix develop` provides the pinned toolchain and all of these
build prerequisites without any manual install; see the Nix / NixOS
section of [packaging/README.md](packaging/README.md).
For multi-node integration runs, Docker is required. The harness
under [testing/](testing/) starts containerized topologies and
exercises real mesh behavior; see [testing/README.md](testing/README.md)
for the suite catalog.
For a guided first-run that joins the public test mesh, see
[docs/tutorials/join-the-test-mesh.md](docs/tutorials/join-the-test-mesh.md).
Pointing your local daemon at a `test-*` node is the cheapest way to
dogfood a change end-to-end before opening a PR.
## Choosing a branch to target
FIPS uses three long-lived branches, each a superset of the previous:
- **`maint`** — bug fixes for the latest released version.
- **`master`** — compatible work for the next feature release.
- **`next`** — wire-format-breaking and API-breaking work, staged for
the next forklift release.
Pick the branch that matches the scope of your change:
| Your change | Target |
| --- | --- |
| Bug fix in a feature that shipped in the latest release | `maint` |
| Bug fix in code added on `master` since the last release | `master` |
| Bug fix in `next`-only code (wire-format-breaking work) | `next` |
| New feature, no wire-format or API break | `master` |
| Wire-format-breaking or API-breaking change | `next` |
| Documentation, CI, contributor-facing changes | `maint` if they apply to released material, else `master` |
When in doubt, ask in the issue. The maintainer can retarget if
needed. The full release workflow, version conventions, and
merge-direction rationale are in [docs/branching.md](docs/branching.md).
## Reporting bugs
Search [open issues](https://github.com/jmcorgan/fips/issues) before
filing a new one — duplicates are common in a young project.
When you open a bug report, please include:
- **FIPS version** (`fipsctl --version`)
- **Rust toolchain version** (`rustc --version`)
- **OS / distro** (Linux distro + kernel, or macOS / Windows version)
- **What you expected to happen** — your mental model of the
behavior, ideally referencing the relevant docs or config field.
- **What actually happened** — the observed behavior, including the
surprise.
- **Reproduction steps** — minimal and deterministic if you can.
Multi-node bugs should include the topology and per-node config
excerpts.
- **Evidence** — relevant log excerpts (`journalctl -u fips` or stdout
with `RUST_LOG=info` or `debug`), `fipsctl show` output if relevant
(`peers`, `links`, `status`), and any visible mesh state.
One issue per bug. Don't bundle unrelated symptoms even if you
suspect they share a root cause — the maintainer will link them if
they turn out to be related.
## Submitting pull requests
### Scope discipline
Every PR should make one logical change. The reviewer should be able
to read the whole diff and trace every line back to the PR's stated
purpose.
- No drive-by reformatting of unrelated files.
- No unrelated refactors folded into a bug fix or a feature PR.
- No "while I was in there" cleanups in files outside the change's
natural footprint. Send them as separate PRs; they'll usually land
faster on their own.
- Pre-existing lint warnings in files you didn't touch are not yours
to fix in this PR.
### Required before opening any PR
Run these locally and confirm they all pass:
```bash
./testing/ci-local.sh --test-only
cargo fmt --check
cargo build
cargo clippy --all-targets -- -D warnings
cargo test
```
- Narrow integration run for transport, routing, Docker, or packaging-sensitive changes:
`fmt` and `clippy -D warnings` are CI gates — PRs with formatting
drift or new clippy warnings will fail CI and be sent back.
Then run the integration suite that exercises your change:
```bash
./testing/ci-local.sh --only static-mesh
./testing/ci-local.sh --only <suite>
```
See [testing/README.md](testing/README.md) for the available integration and chaos harnesses.
See [testing/README.md](testing/README.md) for the available suites
and what each covers. Routing, discovery, rekey, NAT, gateway, and
transport changes all have specific suites; pick the narrowest one
that touches your code path.
## Filing Issues
**Recommended before opening**: the full local CI run.
- Search existing issues before opening a new one.
- Include FIPS version, Rust version, and OS.
- For bugs: steps to reproduce, expected vs actual behavior.
```bash
./testing/ci-local.sh
```
## Pull Requests
This is the same matrix that runs on GitHub Actions. Catching a
regression locally is much cheaper than catching it in CI.
- All PRs must pass `cargo build`, `cargo test`, and `cargo clippy --all -- -D warnings`.
- Keep commits focused — one logical change per commit.
- Add tests for new functionality.
- Reference relevant design docs if the change touches protocol behavior.
- Pull requests are merged via squash-merge.
- Update docs in the same change when you modify:
- protocol or routing behavior
- wire formats
- configuration shape or defaults
- operational workflows or testing instructions
### Self-review against the project review checklist
In practice this usually means updating one or more of:
The 13-criteria checklist the maintainer runs on every incoming PR is
published at [PR-REVIEW.md](PR-REVIEW.md). Run your own change through
it before opening — or hand the document to your coding agent with
"review my branch against this checklist" and let it do the pass. The
checklist covers PR hygiene (body, commit shape, base freshness), diff
content (does the change do what the description says, does it fit the
codebase as a natural extension), and cross-cutting concerns (tests,
docs, dependencies, security, contributor-conventional Rust patterns).
- [docs/design/fips-mesh-operation.md](docs/design/fips-mesh-operation.md)
- [docs/design/fips-wire-formats.md](docs/design/fips-wire-formats.md)
- [docs/design/fips-configuration.md](docs/design/fips-configuration.md)
- [README.md](README.md)
- [testing/README.md](testing/README.md)
This is the first thing the maintainer does on any submission, so
running it yourself saves a review round trip.
## Questions
### Additional requirements for feature PRs
Open a GitHub issue for design or implementation questions.
- **New CI coverage.** Features added without a test that exercises
them won't be reviewed. Either extend an existing integration
suite or add a new one under `testing/`. Coverage of just the
happy path is fine for an initial PR; edge cases can land as
follow-ups.
- **Documentation updated alongside the code.** Protocol changes
update the relevant [docs/design/](docs/design/) page. Config
changes update the operator-facing docs in [docs/](docs/) and the
reference config. Behavior visible to operators updates
[README.md](README.md) and any tutorial it touches.
### Additional requirements for bug-fix PRs
- **A regression test** where practical. If a regression test isn't
tractable (some bugs only surface under timing or scale that's hard
to encode), say so in the PR description with a one-paragraph
explanation.
- **Commit message references the bug**: the symptom, the root cause
in one sentence, and the fix shape.
### Merge mechanics
PRs are merged via **squash-merge**. One logical change per PR
becomes one commit on the destination branch, which keeps `git
bisect` useful across the integration suite. Your in-PR commit
history doesn't matter for the final landed history — the maintainer
rewrites the commit message at merge time.
## AI coding assistant policy
Use of AI coding assistants (Claude Code, Copilot, Cursor, Aider, and
similar) in preparing a contribution is welcome. These tools are
force multipliers and we have no objection in principle to their use
in writing code, tests, documentation, or PR descriptions.
What we require is that the contributor does a thorough manual review
and editorial pass over the output before submission. Concretely:
- Verify that the code does what it claims, not just that it
compiles.
- Verify that any tests the agent wrote actually test something
useful, not just that they pass.
- Verify that any documentation matches the behavior.
- Spot-check the diff for nothing-surprising: no unrelated files
modified, no fabricated APIs, no references to symbols that don't
exist, no version bumps you didn't intend, no churn outside the
change's natural footprint.
- Be ready to discuss the design choices in the PR as if you wrote
every line, because for the purposes of accountability you did.
The coding agent is a tool. The contributor is the author of record
and is accountable for whatever they submit. PRs are reviewed on
what they contain, not on who or what wrote them.
**Review effort scales with submission effort.** A submission that
shows signs of being unreviewed agent output — irrelevant edits
scattered across the tree, hallucinated function names, mismatched
test/behavior pairs, fabricated API references, ChatGPT-style summary
prose in comments — will receive an AI-coding-agent reply in turn,
without human review. If you want a human reviewer's attention, do
the editorial pass yourself first.
Repeated submissions of unreviewed AI output will result in the
contributor being asked to step back and may result in account
restrictions.
## Where the conversation happens
- **GitHub issues** — bugs, feature requests, design discussions
that don't fit on a specific PR.
- **GitHub PRs** — design discussion specific to a change in
flight. Comment threads on the diff are the right place to push
back on a decision.
- **[fips.network](https://fips.network)** — community page, podcast,
and the project's Nostr account. Broader project conversation and
announcements happen here.
For implementation questions specific to your PR, ask in the PR
itself. For design or roadmap questions that don't have a clear PR
home yet, file a GitHub issue with the `design` label.
## Further reading
- [PR-REVIEW.md](PR-REVIEW.md) — the 13-criteria PR review checklist
the maintainer runs on every incoming PR; run it yourself before
opening to save a round trip.
- [docs/design/README.md](docs/design/README.md) — protocol design tree.
- [docs/branching.md](docs/branching.md) — full release workflow and
merge-direction rationale.
- [docs/getting-started.md](docs/getting-started.md) — operator
walkthrough for a new node.
- [docs/tutorials/join-the-test-mesh.md](docs/tutorials/join-the-test-mesh.md)
— how to dogfood your change against the public test mesh.
- [testing/README.md](testing/README.md) — integration suite catalog.
Generated
+379 -194
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -1,12 +1,15 @@
[package]
name = "fips"
version = "0.3.0"
version = "0.4.2-dev"
edition = "2024"
description = "A distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports"
license = "MIT"
authors = ["Johnathan Corgan <jcorgan@corganlabs.com>"]
repository = "https://github.com/jmcorgan/fips"
homepage = "https://fips.network"
readme = "README.md"
keywords = ["mesh", "p2p", "decentralized", "overlay-network", "nostr"]
categories = ["network-programming", "command-line-utilities", "cryptography"]
[dependencies]
ratatui = "0.30"
@@ -15,6 +18,7 @@ sha2 = "0.10"
hkdf = "0.12"
ring = "0.17"
rand = "0.10.1"
crossbeam-channel = "0.5"
thiserror = "2.0"
bech32 = "0.11"
serde = { version = "1.0", features = ["derive"] }
@@ -28,12 +32,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process", "io-util"] }
futures = "0.3"
simple-dns = "0.11.2"
mdns-sd = "0.19"
socket2 = { version = "0.6.2", features = ["all"] }
tokio-socks = "0.5"
portable-atomic = { version = "1", features = ["std"] }
nostr = { version = "0.44", features = ["std", "nip59"] }
nostr-sdk = "0.44"
arc-swap = "1"
[target.'cfg(unix)'.dependencies]
tun = { version = "0.8.7", features = ["async"] }
@@ -71,7 +77,7 @@ assets = [
["target/release/fips", "/usr/bin/", "755"],
["target/release/fipsctl", "/usr/bin/", "755"],
["target/release/fipstop", "/usr/bin/", "755"],
["packaging/common/fips.yaml", "/etc/fips/fips.yaml", "600"],
["packaging/common/fips.yaml", "/usr/share/fips/fips.yaml.example", "644"],
["packaging/common/hosts", "/etc/fips/hosts", "644"],
["packaging/common/fips.nft", "/etc/fips/fips.nft", "644"],
["packaging/debian/fips.service", "/lib/systemd/system/fips.service", "644"],
@@ -84,7 +90,7 @@ assets = [
["packaging/debian/fips-gateway.service", "/lib/systemd/system/fips-gateway.service", "644"],
["docs/design/fips-security.md", "/usr/share/doc/fips/fips-security.md", "644"],
]
conf-files = ["/etc/fips/fips.yaml", "/etc/fips/hosts", "/etc/fips/fips.nft"]
conf-files = ["/etc/fips/hosts", "/etc/fips/fips.nft"]
[dev-dependencies]
tempfile = "3.15"
+199
View File
@@ -0,0 +1,199 @@
# PR Review Checklist
<!-- markdownlint-disable MD013 -->
This is the 13-criteria checklist the maintainer runs against every
incoming PR. The first pass on any submission is exactly this list,
so executing it yourself before opening — or after pushing a fresh
revision — saves a review round trip and surfaces problems faster.
The document is also written so you can hand it to a coding agent
(Claude Code, Copilot, Cursor, Aider, etc.) with "review my branch
against this checklist" and get a structured pass. The agent gets
better results than a free-form "review my PR" because every concern
the maintainer cares about is enumerated below.
## Step 1 — Should this even be reviewed?
Skip the review (and say so) if the PR is:
- closed, merged, or marked draft
- automated (bot author, dependabot, etc.) and trivially OK
- so small and obviously correct (typo fix, single-line doc tweak)
that a thirteen-point pass is overkill — a one-paragraph informal
review is better in that case
## Step 2 — Gather context
Read these *before* analyzing the diff so the review is grounded:
1. PR metadata. Title, body, author, head ref, base ref, head SHA,
base SHA, mergeable status, CI rollup, commit list.
```bash
gh pr view <num> --json title,body,author,headRefName,baseRefName,headRefOid,baseRefOid,mergeable,statusCheckRollup,commits
```
2. The diff.
```bash
gh pr diff <num>
```
3. Base-branch freshness. How many commits have landed on the PR's
base since the PR forked from it.
4. Project guidance. Read [CLAUDE.md](CLAUDE.md) at the repo root and
any nested `CLAUDE.md` in directories the diff touches. These
describe project-specific conventions and constraints not visible
from the diff alone.
5. Related work on GitHub. Skim the [open issues](https://github.com/jmcorgan/fips/issues)
and other [open PRs](https://github.com/jmcorgan/fips/pulls) for
work that overlaps, duplicates, partially addresses, or is unblocked
by this PR.
6. For "this looks wrong" observations later: `git blame` the modified
lines and read recent commit history on the same files for context
before flagging something as a problem. What looks like a bug at
first glance is often a deliberate workaround documented in a prior
commit message.
## Step 3 — The 13 criteria
The review must address all 13 criteria below at some point. They
group naturally into PR hygiene, diff content, and cross-cutting
concerns — but the report itself is *not* organized this way; see
Step 4.
### Group A — PR hygiene (structural review)
1. **PR body and issue cross-reference**. Does the body accurately
describe the change (feature added or bug fixed) and match what
the diff actually does? Is there an associated issue that
should be referenced via `Closes #N` / `Fixes #N`?
2. **Commit hygiene and base freshness**. Is the PR a clean set of
commits (or a single commit) representing appropriately chunked
development items, or are there intermediate "WIP" / "fix typo" /
"address review" commits that should have been squashed? Is the
branch based off a recent `maint` / `master` / `next`, or has the
base diverged far enough that rebase work is needed?
3. **Commit message quality**. Are the commit messages well-structured
(subject + body where the change warrants), accurately referencing
everything actually in each commit, and free of extraneous footers
— particularly coding-assistant attribution (`Generated with
Claude Code`, `Co-Authored-By: Claude`, similar from other AI
tools)?
### Group B — Diff content
4. **Does it do what it says it does**. Walk each claimed behavior
from the PR body against the actual diff lines.
5. **Coherent whole**. Are all parts of the diff in service of the
stated goal, or are there drive-by formatting changes, unrelated
touch-ups, or scope creep?
6. **Fits the codebase as a natural extension**. Does the new code
use existing idioms, helpers, error types, and patterns, or does
it introduce new ones where existing ones would have served?
### Group C — Cross-cutting concerns
7. **New dependency surface**. Any new crates, system deps,
build-time requirements, or external-service dependencies?
8. **New test coverage**. Are the new code paths covered, are the
tests scoped correctly (unit / integration / end-to-end), and
are there obvious test gaps? Don't reflag anything CI already
enforces (formatting, lint, type errors, unit-test pass/fail).
9. **Documentation impact**. Does this need a CHANGELOG entry,
rustdoc updates, design-doc changes
([docs/design/](docs/design/)), README adjustments, or operator
doc updates in [docs/](docs/)?
10. **Security vulnerabilities**. Any new attack surface,
untrusted-input parsing, `unsafe` blocks, panic-on-untrusted
paths, secret-handling concerns, or side-channel exposure?
11. **Rust and OSS best practices**. Idiomatic error handling, no
silently-swallowed errors, no `unwrap` / `expect` on untrusted
input, no `#[allow]` without justification, appropriate
visibility (`pub` vs `pub(crate)` vs private), naming, and
module shape.
12. **Overlap with existing work**. Cross-check open issues and
other open PRs (and recently closed/merged ones) for related
work that overlaps, duplicates, partially addresses, or is
unblocked by this PR.
13. **Other concerns**. Anything not captured above — wire-format
implications, branch-flow questions (`maint` vs `master` vs
`next`; see [docs/branching.md](docs/branching.md)),
deployment / packaging impact, contributor coordination needs,
fragility notes for future maintainers.
## Step 4 — Compose the review
The review report is **not** a Q&A walk through the 13 criteria.
Write it as natural prose in a coherent, integrated narrative that
reads start-to-finish. All 13 criteria must be addressed at some
point in the body, but ordering, grouping, and emphasis follow the
actual shape of THIS PR — lead with what matters most for this PR,
not a fixed template.
A typical shape that often falls out naturally:
- **Opening paragraph**: what the PR does and the headline
observations (subsumes criteria 1 and 4).
- **Substantive body**: diff analysis, design fit, cross-cutting
concerns, surprises, fragilities, missing coverage,
cross-PR/issue overlap, anything unusual. Don't reference
criterion numbers in the prose.
- **Closing**: short summary and a proposed disposition — *land*,
*land-with-followups* (list them), *request-changes* (with the
blocking items called out), or *hold-for-thematic-batch*.
Short subheadings are fine where they aid scanning. Bullets are fine
for enumerable items (test names, file paths, follow-up actions).
Avoid bullets that just enumerate criterion responses.
## Step 5 — Filter aggressively
Quality over quantity. Do not flag:
- Pre-existing issues on lines the PR did not modify
- Issues that linter, type-checker, formatter, or CI would catch
- Pedantic style nitpicks a senior engineer would not call out
- Likely intentional changes related to the broader goal
- Things explicitly silenced by an `#[allow]` with justification
- Stylistic preferences not anchored in `CLAUDE.md` or the
surrounding codebase's idioms
When in doubt about whether something is worth surfacing: would a
senior maintainer skim past it, or would they want it raised?
Skim-past items don't belong in the report.
For every issue you *do* surface, include a concrete fix suggestion
inline ("rename X to Y", "extract this into the existing helper at
`foo.rs:42`", "add a test exercising the `Err` branch") so the
author can act without a round-trip.
## Step 6 — Citation discipline
When the review references a specific code location, use full-SHA
GitHub permalinks so the link survives future history rewrites:
```text
https://github.com/jmcorgan/fips/blob/<full-40-char-sha>/<path>#L<start>-L<end>
```
For multi-line ranges include at least one line of context before
and after the line(s) being discussed. After `gh pr checkout <num>`,
use `git rev-parse HEAD` to grab the full SHA — never partial SHAs
in permalinks.
## Notes
- The review is one human's read of the PR. Confidence calibration
matters: distinguish "this is a blocker" from "this is worth asking
about" from "this is a fragility note for future maintainers." The
closing disposition makes the action explicit.
- If a re-review is triggered after the author pushes new commits,
lead with the delta from the prior review rather than re-walking
the whole PR.
- This checklist exists to surface problems, not to assign blame.
If you're running it as the author or via an agent, treat each
finding as "would the maintainer ask about this?" — and either fix
it before opening, or pre-empt it in the PR body so the maintainer
doesn't have to ask.
+49 -20
View File
@@ -3,7 +3,7 @@
![banner](docs/logos/fips_banner.png)
[![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/)
[![Status](https://img.shields.io/badge/status-v0.3.0-green.svg)](#status--roadmap)
[![Status](https://img.shields.io/badge/status-v0.4.2--dev-green.svg)](#status--roadmap)
A self-organizing encrypted mesh network built on Nostr identities,
capable of operating over arbitrary transports without central
@@ -40,8 +40,8 @@ same way it would on a local network.
- **Self-organizing mesh routing.** Spanning-tree coordinates with
bloom-filter-guided discovery; no global routing tables, no
flooding.
- **Multi-transport.** UDP, TCP, Ethernet, Tor, and Bluetooth (BLE
L2CAP) ship today; transports compose on a single mesh and a
- **Multi-transport.** UDP, TCP, Ethernet, Tor, Nym, and Bluetooth
(BLE L2CAP) ship today; transports compose on a single mesh and a
node may run several at once.
- **Two-layer encryption.** Noise IK between peers (hop-by-hop) and
Noise XK between mesh endpoints (independent end-to-end), with
@@ -55,7 +55,8 @@ same way it would on a local network.
- **Nostr-mediated discovery and NAT traversal.** Peers publish
endpoint adverts on public Nostr relays, exchange candidates via
NIP-59 gift-wrapped offers and answers, and establish direct
paths through NATs using STUN-assisted hole punching.
paths through NATs using STUN-assisted hole punching. On the local
network, mDNS LAN discovery finds peers directly without relays.
- **LAN gateway.** Optional `fips-gateway` service folds an entire
unmodified LAN into the mesh: outbound (LAN clients reach mesh
destinations through a DNS-allocated virtual IPv6 pool and
@@ -97,9 +98,9 @@ This installs the daemon, CLI tools (`fipsctl`, `fipstop`), the
optional `fips-gateway` service, systemd units, and a default
`/etc/fips/fips.yaml` you can edit before starting.
For macOS, Windows, OpenWrt, the systemd tarball, or a from-source
build, see [docs/getting-started.md](docs/getting-started.md) for
the full multi-platform installation guide.
For macOS, Windows, OpenWrt, the systemd tarball, a Nix flake, or a
from-source build, see [docs/getting-started.md](docs/getting-started.md)
for the full multi-platform installation guide.
To join a live mesh and reach your first peer, follow the new-user
tutorial progression starting at
@@ -120,14 +121,34 @@ supported; transport availability varies by platform.
| TCP | ✅ | ✅ | ✅ | ✅ |
| Ethernet | ✅ | ✅ | ❌ | ✅ |
| Tor | ✅ | ✅ | ✅ | ✅ |
| Nym | ✅ | ✅ | ✅ | ❌ |
| BLE | ✅ | ❌ | ❌ | ❌ |
On Linux, BLE requires BlueZ and libdbus
(`sudo apt install bluez libdbus-1-dev` on Debian / Ubuntu) and is
On Linux, a source build requires `libclang` — the LAN gateway's
nftables bindings are generated by `bindgen` at build time, which
needs `libclang.so` on the build host. Install it before building
(`sudo apt install libclang-dev` on Debian / Ubuntu); without it the
build fails inside the `rustables` crate with an "Unable to find
libclang" error. This is a build-time prerequisite only — it is not a
runtime dependency, and the pre-built `.deb` artifacts do not need it.
BLE is optional and, on Linux, requires BlueZ and libdbus
(`sudo apt install bluez libdbus-1-dev` on Debian / Ubuntu). It is
gated on a build-script probe — install the dependencies first and
the `cargo build` line above picks it up. The OpenWrt ipk omits
BLE because libdbus is not available on the target.
Nym (mixnet) transport builds on all desktop platforms. The OpenWrt
❌ is provisional, pending verification of `nym-socks5-client`
availability on the target; it will flip to ✅ only if confirmed
buildable there.
Alternatively, the repo ships a [Nix flake](flake.nix): `nix develop`
drops you into a shell with the pinned toolchain and every build
prerequisite (libclang, dbus, pkg-config) already provided, and
`nix build .#fips` builds all four binaries with no host setup. See the
Nix / NixOS section of [packaging/README.md](packaging/README.md).
## Documentation
`docs/` is organised by reader purpose:
@@ -161,6 +182,12 @@ and [testing/README.md](testing/README.md).
reachable exclusively over the FIPS mesh. The relay container
shares the FIPS sidecar's network namespace and is isolated from
the host network.
- **[examples/sidecar-nostr-mixnet-relay/](examples/sidecar-nostr-mixnet-relay/)** —
Single-container demo of FIPS peering through a **mixnet**
(implemented with [Nym](https://nym.com/)): the FIPS daemon, the mixnet
proxy, and a strfry Nostr relay all in one isolated container, with
the direct route to the peer firewalled off so traffic provably
crosses the mixnet.
- **[examples/k8s-sidecar/](examples/k8s-sidecar/)** — Run FIPS as
a Kubernetes Pod sidecar. The sidecar creates `fips0` in the
Pod's shared network namespace so every other container in the
@@ -183,14 +210,16 @@ testing/ Docker-based integration test harnesses + chaos simulation
## Status & roadmap
FIPS is at **v0.3.0**. The core protocol works end-to-end over
UDP, TCP, Ethernet, Tor, and Bluetooth on a small live mesh of
deployed nodes. v0.3.0 is the testing-and-polishing track for
everything accumulated since v0.2.0 on the v0.2.x wire format —
Nostr-mediated peer discovery, UDP NAT traversal, peer ACL, the
DNS-responder fix, packaging hardening, and discovery rate-limit
retuning. New wire-format work is staged on the `next` branch for
the post-v0.3.0 release line.
FIPS is at **v0.4.2-dev** on the `maint` branch.
[v0.4.1](https://github.com/jmcorgan/fips/releases/tag/v0.4.1) has
shipped; this line carries patch-level fixes for the 0.4.x series. The
core protocol works end-to-end over
UDP, TCP, Ethernet, Tor, Nym, and Bluetooth on a global, public test
mesh of thousands of nodes. v0.4.0 added the Nym mixnet transport and
mDNS LAN discovery alongside the existing Nostr-mediated peer discovery,
UDP NAT traversal, peer ACL, and packaging hardening. New wire-format work
continues to be staged on the `next` branch for the subsequent
release line.
### What works today
@@ -208,10 +237,10 @@ the post-v0.3.0 release line.
estimation.
- ECN congestion signaling (hop-by-hop CE relay, IPv6 CE marking,
kernel-drop detection).
- UDP, TCP, Ethernet, Tor, and BLE transports (BLE via L2CAP CoC
with per-link MTU negotiation).
- UDP, TCP, Ethernet, Tor, Nym (mixnet), and BLE transports (BLE
via L2CAP CoC with per-link MTU negotiation).
- Nostr-mediated overlay endpoint discovery and UDP hole punching
for NAT traversal.
for NAT traversal, plus mDNS LAN discovery for local peers.
- LAN gateway (`fips-gateway`) with both outbound (LAN-to-mesh)
and inbound (mesh-to-LAN port-forwarding) modes.
- Peer ACL: per-npub allow / deny admission control at the link
+113 -731
View File
@@ -1,696 +1,136 @@
# FIPS v0.3.0
# FIPS v0.4.1
**Released**: 2026-05-11
**Released**: 2026-07-19
v0.3.0 is the testing-and-polishing release on the v0.2.x wire format.
It widens the platform reach of FIPS from Linux-only to Linux, macOS,
Windows, and OpenWrt; adds two large new mesh capabilities (Nostr-mediated
peer discovery with UDP NAT traversal, and the `fips-gateway` LAN bridge);
ships a default-deny security baseline for the mesh interface; introduces
mesh-peer access control; substantially speeds up session-layer crypto and
the Linux receive path; and tightens packaging across every supported
distribution channel.
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
antipoison cap on inbound bloom filter announcements, removes a redundant
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
cuts per-packet CPU in the bloom and identity paths. There is no wire
format change and no new feature surface.
v0.3.0 is wire-compatible with v0.2.x. Mixed meshes interoperate; there
is no flag-day upgrade.
v0.3.0 also rolls forward all changes from the v0.2.1 maintenance
release. The sections below cover the cumulative v0.2.0 → v0.3.0
delta; the per-section intros call out which entries first shipped
in v0.2.1.
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
with no coordinated restart, though one behavior change below is worth
reading before you start a rolling upgrade.
## At a glance
- 123 commits since v0.2.0 (109 non-merge), spanning 307 files with
+44,186 / -4,078 lines.
- 10 committers plus 3 issue reporters across feature work, fixes,
packaging, and reviews.
- 5 new GitHub Actions CI workflows (Linux Package, macOS Package,
Windows Package, OpenWrt Package, AUR Publish) plus expanded
integration matrices (gateway, NAT-cone, NAT-symmetric, NAT-LAN,
rekey-accept-off, `.deb` install across Debian 12/13 + Ubuntu
22/24/26, multi-backend `.fips` DNS resolver across the same five
distros).
- The long-standing systemd-resolved DNS-responder silent-drop is
closed end-to-end.
- Pre-1.0 control-socket JSON schema change for two query fields;
see [Upgrade notes](#upgrade-notes).
## What's new
### Mesh discovery and NAT traversal
Previously, two FIPS nodes could only become peers if they had a way
to find each other beforehand: a configured address, a shared LAN
segment, or a Bluetooth radio range. v0.3.0 introduces a Nostr-based
overlay-discovery channel that lets nodes find each other through any
public Nostr relay set, plus a STUN-assisted UDP hole-punching path
that connects peers across most consumer NATs.
Each participating node publishes a signed overlay advert as a Nostr
**Kind 37195** parameterized replaceable event. (The kind sits in the
application-defined replaceable range and the digits visually spell
*FIPS*: 7=F, 1=I, 9=P, 5=S.) The advert lists reachable transport
endpoints (UDP, TCP, Tor) and is consumed by other nodes to populate
fallback addresses for `via_nostr` peers. Under `policy: open`, the
advert cache is also dialed for non-configured peers within a budget
cap.
When both peers are behind NAT, the daemon coordinates a UDP hole
punch using NIP-59 gift-wrap signaling for the offer/answer exchange
and STUN for reflexive address discovery. A candidate-pair punch
planner attempts LAN-private and reflexive paths in parallel; on
success the live socket is handed into the standard FIPS UDP transport
via a bootstrap-handoff API.
Operators turn this on with `node.discovery.nostr.enabled: true` and
the configured relay set. `policy: open` adds best-effort dialing of
non-configured peers seen on the relays. New `peers[].via_nostr` and
per-transport `advertise_on_nostr` / `public` flags control what each
endpoint contributes to the published advert. Cross-field validation
runs at startup to catch mis-configured combinations early.
A Docker NAT lab covering cone, symmetric, and LAN scenarios is wired
into the integration CI matrix. A daemon-side failure-suppression
layer (per-npub cooldown after consecutive failures, ±60s clock-skew
tolerance, rate-limited WARN logs) keeps relay traffic well-mannered
when peers come and go from the open discovery cache. A separate
structural cooldown (`protocol_mismatch_cooldown_secs`, default 24h)
suppresses retraversal when a punched peer turns out to be running an
FMP version this daemon cannot handshake with: the punch completes at
the UDP layer, the rx loop spots the version-mismatched packet,
reverse-maps to the originating npub, and removes the peer from the
next sweep until either side upgrades.
The auto-connect retry loop pins itself to relay ground truth. Each
retry attempt refetches the cached overlay advert against the
configured `advert_relays` (one filter query, 2s timeout) before
dialing, so a peer whose NAT rebound to a fresh endpoint is recovered
on the next retry rather than looping on a stale cached address.
`NoTransportForType` triggers a fire-and-forget re-fetch that either
replaces or evicts the cache entry. A startup peer-init failure (no
operational transport, all addresses unreachable) now schedules a
retry instead of leaving the peer in a dead state until the daemon is
restarted. Adopted NAT-traversed UDP transports inherit the operator's
primary `[transports.udp]` listener config (MTU, recv/send buffer
sizes) instead of falling back to the 1280 IPv6-minimum default.
### Cross-platform reach
FIPS now ships first-class binaries for **Linux, macOS, Windows, and
OpenWrt**.
- **macOS** support uses the native `utun` TUN interface, raw
Ethernet via BPF, a `.pkg` installer with a launchd plist and
uninstall script, and an x86_64 cross-compile from arm64 build
hosts. A new CI matrix entry runs build and unit-test jobs on
macOS hosts.
- **Windows** support uses [wintun](https://www.wintun.net/) for the
TUN device, a TCP control socket on `localhost:21210` (replacing
the Unix domain socket Linux and macOS use), Windows Service
lifecycle (`fips.exe --install-service`, `--uninstall-service`,
`--service`), and a ZIP package with PowerShell install/uninstall
scripts.
- **MIPS** atomic-ABI portability lets the daemon build for 32-bit
MIPS targets (`mips`, `mipsel`, MIPS32r2) by routing through
`portable_atomic`. This unblocks OpenWrt deployments on
consumer-grade MIPS routers.
- **OpenWrt** packaging gets a procd init with dnsmasq forwarding,
proxy NDP, RA route advertisements, and IPv6 forwarding sysctls.
The `fips-gateway` is enabled by default in the OpenWrt build.
### FIPS gateway
The new `fips-gateway` binary lets unmodified LAN hosts reach FIPS
mesh destinations without running the FIPS daemon themselves. Two
flows ship together:
- **Outbound (LAN -> mesh)**: a virtual-IP pool (default
`fd01::/112`) is allocated on demand from `.fips`-name DNS lookups.
A state-machine lifecycle, conntrack-backed session tracking, proxy
NDP on the LAN interface, and TTL-based reclamation handle the
bookkeeping. A LAN host that resolves `peer.fips` gets a virtual
address it can reach over IP, and the gateway translates the flow
to the mesh.
- **Inbound (mesh -> LAN)**: new `gateway.port_forwards` config
installs prerouting DNAT rules so mesh peers can reach a configured
`host:port` on the gateway's LAN. A LAN-side masquerade is added
automatically when any forwards are configured, so replies flow
back through conntrack.
A dedicated control socket at `/run/fips/gateway.sock` exposes
`show_gateway` and `show_mappings`. `fipstop` adds a Gateway tab with
a pool gauge and mappings table.
The gateway's `dns.listen` source default is now `[::1]:5353`,
matching the canonical deployment model: the gateway sits on a host
already serving DHCP and DNS to a LAN segment (an OpenWrt AP, a Linux
router), port 53 there is taken by the existing resolver, and `.fips`
queries are forwarded to the gateway over loopback. The OpenWrt ipk
previously overrode the prior `[::]:53` source default in its packaged
config; that override is now redundant and has been dropped.
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, so forwarders
that reach the gateway over IPv4 loopback need an explicit IPv4
listen address.
The cold-boot startup race between `fips.service` and
`fips-gateway.service` is handled by a systemd `After=fips.service`
ordering, an `ExecStartPre` poll loop that waits up to 30 seconds for
the `fips0` interface to appear, and a DNS upstream probe in the
gateway itself that retries up to 5 times with 1-second backoff.
Packaging covers systemd, Debian, AUR, and OpenWrt. The full design
is in [`docs/design/fips-gateway.md`](../design/fips-gateway.md).
### Mesh-interface security baseline
The FIPS mesh is a flat layer-3 segment. Every authenticated peer can
route packets to every other peer's `fips0` address. Peer identity is
authenticated end-to-end by the FMP and FSP Noise handshakes, but
identity is not authorization. A service on a mesh host that binds to
a wildcard address is, by default, reachable from every peer in the
mesh.
v0.3.0 ships an opt-in default-deny baseline that closes this gap on
Linux:
- **`/etc/fips/fips.nft`** is installed as a documented operator
conffile. It defines a single `inet fips` nftables table with one
chain hooked at `input`, default-denies inbound traffic on
`fips0`, and is a no-op for every other interface.
- **`fips-firewall.service`** loads it. The unit ships **disabled by
default**; activation is an explicit
`systemctl enable --now fips-firewall.service`.
- Per-service allowances live in **`/etc/fips/fips.d/*.nft`**
drop-ins that the baseline includes.
Choosing opt-in keeps the mesh quick to bring up for evaluation while
giving operators a documented, packaged path to lock it down for
production. The full design (threat model, rule layout, conntrack
handling, drop-in mechanism, and the rationale for a conffile rather
than an auto-loaded package side-effect) is in
[`docs/design/fips-security.md`](../design/fips-security.md).
`fipstop`'s Node tab gains a **"Listening on fips0" panel** that
surfaces the answer to the operational question "what services on
this host are reachable from the mesh, and what does the firewall
currently say about each of them?" The panel lists every IPv6
listening socket bound to either the wildcard address or this node's
`fd00::/8` address, paired with its classification against the
running `inet fips` baseline chain: `OPEN` (canonical accept rule),
`filt` (falls through to drop), or `filt?` (referenced with matchers
the panel cannot fully decompose, e.g. saddr filters or jumps). When
`fips-firewall.service` is inactive, a yellow banner above the table
reminds the operator that every listener is mesh-exposed; wildcard
binds carry a trailing `*` in the Process column. The classifier is
built on a new `show_listening_sockets` control query (Linux-only),
which is also useful from `fipsctl` for scripting.
### Peer access control
Operators can now restrict which mesh peers a node will form direct
links with. Optional `/etc/fips/peers.allow` and `/etc/fips/peers.deny`
files (TCP-Wrappers style) match against npub, hex pubkey, host
alias, or `ALL`. Enforcement runs at three points:
1. Outbound connect (before dialing).
2. Inbound msg1 (the first FMP handshake message from a new peer).
3. Outbound msg2 (the response).
Files reload automatically on mtime change; a new `fipsctl acl show`
query reports the effective rule set. A six-node Docker integration
harness (`testing/acl/`) exercises allowlist and denylist patterns
end-to-end.
**Important scope distinction**: peer ACLs are an FMP-layer
restriction. They control who can establish a *direct link* with this
node. They do **not** control session-layer (FSP) reachability through
the mesh. A node that denies peer X with an ACL can still receive FSP
traffic from X relayed via other peers.
### Bluetooth Low Energy transport (experimental, Linux)
A new BLE L2CAP Connection-Oriented Channel transport lets FIPS nodes
peer over Bluetooth Low Energy without any IP infrastructure in
between. The transport handles per-link MTU negotiation, continuous
scan/probe peer discovery with cooldown-based deduplication,
continuous advertising, deterministic NodeAddr cross-probe
tie-breaker, and a configurable connection pool with eviction.
This transport is **experimental in v0.3.0**. It is implemented and
functional on Linux (BlueZ via `bluer`), but the reliability follow-up
logic (probe cooldown, cross-probe tie-breaker, pubkey timeout,
continuous advertising semantics, probe-promotion, fail-fast send) is
not yet behaviorally tested in CI. Its maturity path is field-driven;
please file issues with field reports. macOS BLE support is in
development as a separate track and is not part of v0.3.0.
### UDP transport profiles
The UDP transport gains posture flags organized around deployment
patterns:
- **Public-facing inbound nodes**: `bind_addr: "0.0.0.0:2121"`,
`accept_connections: true` (default), `public: true` for advert
publication. v0.3.0 adds STUN-based public-IP autodiscovery so
cloud nodes (AWS EIP, GCP, Azure 1:1 NAT) advertise the right
address even when the public IP isn't on a host interface.
- **Ephemeral leaf nodes**: `outbound_only: true` binds an ephemeral
port (`0.0.0.0:0`), refuses inbound msg1, and is never advertised
on Nostr regardless of `advertise_on_nostr`. Use this for client
postures that should connect outbound only, without exposing an
inbound listener on a known port.
- **General-purpose nodes**: `accept_connections: false` mirrors the
Ethernet/BLE knob without changing the bind address. The Node-level
handshake gate carves out msg1 from peers already established on
this transport so rekey continues to work.
Startup validation now rejects `bind_addr` set to a loopback address
when at least one peer has a non-loopback UDP address, closing a
silent-failure trap from v0.2.0 where Linux's source-address routing
check would drop outbound flows from the loopback-bound socket.
A new `external_addr` field on `transports.udp.*` and
`transports.tcp.*` lets operators specify the advertise-as address
explicitly. This is useful for UDP as a deterministic alternative to
STUN, and required for TCP on cloud-NAT setups (where binding to the
public IP fails with `EADDRNOTAVAIL` because the IP isn't on a host
interface).
### `.fips` DNS resolver overhaul
The IPv6 adapter's `.fips` name resolution has been rebuilt around
the constraints of contemporary systemd-based hosts. The default
`dns.bind_addr` is now `::1` (IPv6 loopback), and a setup script
picks one of five backends in priority order:
1. systemd-resolved global drop-in
(`/etc/systemd/resolved.conf.d/fips.conf`).
2. systemd dns-delegate (per-link configuration handed off to
systemd-resolved).
3. `resolvectl` per-link configuration.
4. Standalone `dnsmasq`.
5. NetworkManager's dnsmasq plugin.
Teardown reverses only what setup applied, recorded in a state file
at `/run/fips/dns-backend`. A new `testing/dns-resolver/` harness
exercises every backend across Debian 12, Debian 13, Ubuntu 22.04,
Ubuntu 24.04, and Ubuntu 26.04, so a regression in any of the five
backends shows up in CI rather than in the field.
This overhaul resolves the long-standing silent-drop case where the
`resolvectl dns fips0 [<fips0_addr>]:5354` target collided with the
daemon's mesh-interface filter on certain systemd-resolved
deployments (typically Ubuntu 22 with systemd 249's interface-scoped
routing).
### Operator tooling additions
A handful of additions land in `fipsctl`, `fipstop`, and the daemon's
configuration surface:
- **`node.log_level`** config field replaces the hardcoded
`RUST_LOG=info` previously baked into systemd units and the
OpenWrt procd init. The daemon now loads config before
initializing tracing so the configured level takes effect.
`RUST_LOG` still overrides when set.
- **`fipsctl show identity-cache`** is a new query that lists every
cached node identity (npub, IPv6 address, display name, LRU age)
alongside the configured cache capacity.
- **`fipsctl show peers / sessions / cache / routing`** are
substantially extended: per-peer security signals (replay
suppression count, consecutive decrypt failures), Noise session
counters, session indices, rekey lifecycle state, handshake resend
counts, K-bit epoch, coords-warmup remaining, drain state, per-peer
retry state, per-target lookup detail (attempt, age, last sent),
and pending TUN packet queue depth.
- **Historical statistics**: in-memory time-series rings on the
daemon (1-second × 3600 fast, 1-minute × 1440 slow) cover per-node
and per-peer metrics. New `show_stats_*` control-socket queries, a
`fipsctl stats list / peers / history` subcommand with Unicode
sparkline rendering, and a `fipstop` Graphs tab with btop-style
sparklines surface them to the operator.
### Performance
Two independent perf threads land in v0.3.0: a session-layer crypto
backend swap, and a Linux receive-path overhaul.
**Session-layer crypto backend.** The ChaCha20-Poly1305 backend used
by every FIPS Noise session (end-to-end FSP traffic and link-layer
FMP traffic alike) has been swapped from RustCrypto's
`chacha20poly1305` crate to `ring 0.17`. ring wraps BoringSSL's
hand-tuned ChaCha20-Poly1305 implementation, which dispatches to NEON
on aarch64 and AVX2 / AVX-512 on x86_64. Typical throughput is in the
3-5 GB/s/core range, versus the ~600-800 MB/s/core RustCrypto soft
path on the same hardware.
Wire format is unchanged. ChaCha20-Poly1305 is byte-deterministic for
a given `(key, nonce, plaintext, aad)`, so any correct AEAD
implementation produces identical ciphertext. A mixed mesh with some
nodes pre-swap and some post-swap interoperates without protocol
awareness; v0.3.0 can roll out across a mesh in any order.
Measurements on an aarch64 Apple Silicon docker target:
- Two-node TCP single-stream: 437 -> 1097 Mbps (about 2.5×).
- Two-node UDP at 1000 Mbit: 599 Mbps with 40% loss -> lossless at
line rate.
- Three-node ping under bulk-traffic load: 7.68 ms avg / 215 ms max
-> 0.72 ms / 3.6 ms max as the relay path stops being crypto-bound.
No operator-visible action is required; the swap is internal to the
session layer.
**Linux UDP receive path.** The Linux UDP receive path now uses
`recvmmsg(2)` with a 32-packet batch in place of single-packet
`recvmsg(2)`. A single `readable()` wakeup drains up to 32 datagrams
in one syscall before yielding back to the reactor, eliminating the
per-packet scheduler-hop and futex cost that previously capped
inbound rate at one event per scheduler quantum independent of CPU.
`SO_RXQ_OVFL` is sampled once per batch and surfaced through
`AsyncUdpSocket::recv_batch` so the existing 1Hz transport-congestion
detector continues to feed the per-transport `dropping` flag. macOS
and Windows fall through to the per-packet path; `recvmmsg` is
Linux-specific.
**Inner rx-loop drain batching.** `Node::run_rx_loop` drains up to
256 additional ready items via `try_recv()` after each
`tokio::select!` await fires on the packet and TUN-outbound branches,
in a tight inner loop before yielding. Previously the select cost a
full scheduler hop and futex per packet, capping throughput at one
event per scheduler quantum with the worker near-idle. `biased`
ordering keeps data-plane branches priority over tick / control / DNS
under sustained load; the 256 cap keeps the worker on a busy stream
between yield points (about 400 KB of contiguous traffic) while still
bounding the inner loop so a flood on one branch cannot starve the
periodic tick or control socket.
**Eager `pubkey_full` precompute.** `PeerIdentity::pubkey_full()`
precomputes the parity-aware full secp256k1 public key at
construction in `from_pubkey`. Previously the method fell through to
an EC point parse on every call when the full key wasn't passed at
construction (i.e. for every peer constructed from an npub or x-only
key), about 6% of per-packet CPU on the bulk-data send path for a
value that never changed after construction. The same parse already
runs at construction inside `NodeAddr::from_pubkey`, so the cost is
paid once where it would be paid anyway.
These three changes are a coordinated set: the syscall batching
removes the per-packet kernel cost, the inner-loop drain removes the
per-packet scheduler cost, and the pubkey-cache change removes the
per-packet crypto-derivation cost. Like the AEAD swap, they are all
internal and require no operator action.
### Examples
- **macOS WireGuard companion** ([#51](https://github.com/jmcorgan/fips/pull/51)):
run FIPS in a local Docker container and route `.fips` traffic
from the macOS host through a WireGuard tunnel to the container's
`fips0`. Only traffic destined for `fd00::/8` transits the
companion; regular internet traffic continues to use the host
network. Persistent FIPS and WireGuard key material is generated
on first run.
### Documentation
- **`docs/design/port-advertisement-and-nat-traversal.md`**
documents how nodes find each other through Nostr relays and the
STUN-assisted UDP hole punch.
- **`docs/design/fips-gateway.md`** documents the gateway's virtual
IP pool, lifecycle, control surface, and packaging.
- **`docs/design/fips-security.md`** documents the mesh-interface
security posture, threat model, default-deny baseline, and drop-in
workflow.
- **`CONTRIBUTING.md`** has been expanded with build prerequisites,
Rust toolchain setup, and first-build steps.
The `docs/` tree has been reorganized end-to-end into four sections
(*tutorials / how-to / reference / design*) with a new
[`docs/getting-started.md`](../getting-started.md) and per-section
landing pages. Content was reconciled against current source:
protocol-layer details, wire-format diagrams, configuration knobs,
and CLI references were brought back into agreement with the
implementation. See [Documentation pointers](#documentation-pointers)
below for entry points by reader intent.
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
- The `parent_switched` metric counter is gone. Use `parent_switches`.
- Spanning tree no longer serves stale coordinates after a parent link is
lost through peer removal.
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
- Bloom probing and identity operations do measurably less work per call,
with identical results.
## Behavior changes worth flagging
These default-config changes affect every operator on upgrade, even
those with no explicit configuration. Two items below — bloom-filter
fill-ratio validation and TreeAnnounce ancestry validation — first
shipped in v0.2.1 and roll forward into v0.3.0; the rest are
v0.3.0-net-new.
### The inbound filter FPR cap default doubles again
- **Discovery rate-limiting** has been retuned to be less aggressive
at cold start. v0.2.0 used a single-lookup-with-internal-retry
model where a timed-out lookup during bloom-filter propagation
could suppress retries for 30 seconds while none of the reset
triggers fired on a stable post-handshake topology. v0.3.0
replaces this with a per-attempt timeout sequence
(`node.discovery.attempt_timeouts_secs`, default `[1, 2, 4, 8]`,
15s total). Each attempt sends a fresh `LookupRequest` with a new
`request_id`, letting successive attempts take different
forwarding paths as the bloom and tree state evolve. Post-failure
suppression is **off by default**; operators with chatty
applications can opt back in via `backoff_base_secs` /
`backoff_max_secs`.
- **MMP report intervals** are retuned for constrained transports.
The steady-state floor moves from 100ms to 1000ms, the ceiling
from 2000ms to 5000ms, with a cold-start phase running 200ms for
the first 5 SRTT samples. This reduces BLE overhead by roughly
10× while keeping reports well above the EWMA convergence
threshold. Session-layer MMP intervals are unchanged.
- **Bloom filter fill-ratio validation** runs on every inbound
`FilterAnnounce`. Filters whose derived false-positive rate
exceeds `node.bloom.max_inbound_fpr` (default 0.05) are rejected
silently on the wire, logged at WARN, and counted in a new
`bloom.fill_exceeded` counter. A rate-limited WARN also fires
when the local outgoing filter exceeds the cap.
- **TreeAnnounce ancestry validation** is now run before tree-state
mutation, enforcing ancestry-self-match, root-single-entry,
parent-second-entry, and root-is-minimum-NodeAddr. Non-conforming
announces are rejected with a WARN. Mixed v0.2.0 / v0.2.1 / v0.3.0
meshes may produce WARN log lines on the v0.2.1+ side until all
peers upgrade; behavior is correct, log noise only.
- **Log noise reduction**: 35 info-level log messages have been
demoted to debug (handshake cross-connection mechanics, periodic
MMP telemetry, TUN/transport shutdown, retry scheduling). The
default `RUST_LOG` in systemd units is now `info`, where it
previously ran at `debug`. Operator-visible info output now
focuses on lifecycle events, peer promotions, session
establishment, parent switches, and transport start/stop.
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
inbound `FilterAnnounce` frames whose advertised false positive rate
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
aggregates had started reaching that ceiling as the mesh grew. `0.20`
corresponds to a fill of 0.7248 and roughly 2,114 entries.
Be aware that this is the second time in two releases that this default
has doubled, for the same reason both times. That is worth stating plainly
rather than repeating the previous release's framing: raising the cap buys
headroom, it does not fix anything. The real constraint is the fixed 1 KB
filter size, which is a protocol constant. The structural remedy is the v2
filter work, where filter capacity scales with the mesh instead of being
pinned. This release is an interim step to keep legitimate aggregates from
being rejected until that lands. It is not the start of a pattern of
raising the cap once per release, and if you are sizing capacity planning
around this number, plan against the v2 work rather than against a third
raise.
The antipoison property the cap exists for is preserved. A saturated or
deliberately poisoned filter still presents an FPR near 100% and is still
rejected.
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
drops the same frame, and the drop is silent on the wire with no NACK. The
cap also gates the mesh size estimator, which declines to produce a value
when any contributing filter is over the cap. So while a mesh is partly
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
different mesh sizes, or one can report a size while the other reports
unknown. This resolves once every node is on v0.4.1. If you want to avoid
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
your config before upgrading and remove it after the last node is done.
### The `parent_switched` counter is removed
`parent_switched` was incremented on the line immediately before
`parent_switches` at every site and never independently, so the two
counters always held the same value. `parent_switched` is now gone from
the tree metrics, the control socket snapshot, and the `fipstop` tree
view. `parent_switches` remains and is unchanged.
If you scrape the control socket, or have dashboards or alerts referencing
`parent_switched`, point them at `parent_switches`. Anything still asking
for `parent_switched` will find nothing rather than a zero.
## Notable bug fixes
These pre-existing v0.2.0 bugs are worth singling out because they
either affected real-world deployments or produced misleading
operator experiences. The CHANGELOG has the exhaustive list; this is
the operator-relevant subset. Four items below first shipped in
v0.2.1 and roll forward into v0.3.0: auto-connect Disconnect-reconnect,
`fipsctl connect` mesh-address rejection, `fd00::/8` routing
protection from Tailscale interception, and bloom-filter routing
greedy-tree fallback. The control-socket path-detection fix landed
in v0.2.1 as well, and the unified resolver below is the v0.3.0
refactor that builds on it.
### Stale coordinates after losing a parent through peer removal
- **DNS responder silent-drop on systemd-resolved** is fixed: the
responder no longer drops queries on Ubuntu 22 / Debian 13 and
similar deployments where systemd applies interface-scoped
routing. Default bind moves to `::1`; new global drop-in backend
available ([#52](https://github.com/jmcorgan/fips/issues/52),
[#77](https://github.com/jmcorgan/fips/issues/77)).
- **Auto-connect peers reconnect after a graceful Disconnect.**
Previously, a clean upstream shutdown left the auto-connect peer
orphaned; only the link-dead, decrypt-fail, and peer-restart
paths scheduled a reconnect
([#60](https://github.com/jmcorgan/fips/issues/60), reported by
[@SwapMarket](https://github.com/SwapMarket)).
- **`fipsctl connect` rejects FIPS mesh addresses** (`fd00::/8`)
for `udp`, `tcp`, and `ethernet` transports with a clear error
message, instead of echoing success while the daemon silently
failed the bind with `EAFNOSUPPORT`
([#61](https://github.com/jmcorgan/fips/issues/61), reported by
[@SwapMarket](https://github.com/SwapMarket)).
- **Default control-socket path resolution unified.** Daemon and
client tools now share a single resolver, eliminating a divergence
where `fipsctl` / `fipstop` could connect to a socket the daemon
never bound (notably on dev runs with `XDG_RUNTIME_DIR` set, or
after a prior packaged install left a root-owned `/run/fips`
behind). Canonical order is
`/run/fips` -> `$XDG_RUNTIME_DIR/fips/` -> `/tmp/fips-<name>`. The
`/run/fips` arm is selected by directory existence; the kernel
enforces actual access at `connect(2)` time, so users not yet in
the `fips` group get a clear `EACCES` rather than a silent path
mismatch and a misleading `No such file` fallback to
`$XDG_RUNTIME_DIR`. `XDG_RUNTIME_DIR` is validated as an existing
directory before being used so stale post-logout values are
treated as missing. The deployed fleet is unaffected: packaged
configs set `node.control.socket_path` explicitly
([#30](https://github.com/jmcorgan/fips/issues/30), reported by
[@Sebastix](https://github.com/Sebastix)).
- **`fd00::/8` routing protected from Tailscale interception.** The
daemon installs an IPv6 routing-policy rule
(`ip -6 rule to fd00::/8 lookup main priority 5265`) at TUN
setup, so Tailscale's table 52 default route can no longer divert
mesh traffic.
- **TCP-over-FIPS reliability on mixed-MTU paths** is markedly
improved. Four interlocking changes ship together:
`Node::transport_mtu()` is now deterministic across daemon
restarts (min across operational transports rather than
insertion-order-dependent); the TCP MSS clamp at the TUN boundary
reads per-destination path MTU instead of a single global ceiling;
reactive `MtuExceeded` from forwarders is mirrored back into the
TUN-side `path_mtu_lookup` so later flows pick up forward-path
bottlenecks without re-discovery; and the proactive end-to-end
`PathMtuNotification` echoed by the destination is mirrored into
the same TUN-side store. Without that fourth piece, on long-lived
stable paths where the destination's echo had tightened the
session MTU but no transit router had emitted a fresh
`MtuExceeded`, new TCP flows opened in that window were clamped by
the staler discovery-time value. The proactive mirror uses the
same tighter-only semantics as the reactive mirror, so it never
loosens the clamp. The Windows TUN reader receives the same
per-destination plumbing.
- **Bloom filter routing greedy-tree fallback.** `find_next_hop` no
longer returns `NoRoute` when the bloom candidate set is non-empty
but no candidate is strictly closer than the current node; it
falls through to greedy tree routing instead. Previously, this
caused dropped packets in topologies where the tree parent was
closer but not a bloom candidate.
- **`fipstop` graceful tty-init failure.** `ratatui::try_init()`
produces a clean error message instead of a hard crash when
terminal initialization fails (Docker on macOS Sequoia, ttyless
environments).
- **TreeAnnounce ancestry on self-root transitions.** When a node
had no smaller-NodeAddr peer to use as a parent, the spanning-tree
state correctly promoted it to root, but the ancestry advertised
on the next `TreeAnnounce` still referenced its previous parent's
path. Receiving peers rejected the announce as
`invalid ancestry: advertised root X is not the minimum path entry
Y`, blocking mesh transit on any path that needed to traverse the
node. The self-root transition is now detected explicitly in
`TreeState::become_root` and the advertised ancestry rebuilt to
start from self; the MMP receive handler corrects stale ancestry
inherited across reconnect eagerly rather than waiting for the
next observation tick.
- **Spanning-tree internal-path updates** that change only the
internal path between root and leaf (without changing the root or
the depth) now propagate to leaves correctly. Previously, a leaf
could continue routing against a stale internal path until the
parent or depth also changed.
When a node's parent link dropped via peer removal, the node correctly
reparented or self-rooted, but skipped the coordinate cache invalidation
that every other position-change path performs. Cached entries for
downstream destinations kept the node's old coordinate prefix. This did
not self-correct the way a stale cache entry normally would: routing
access refreshes an entry's TTL, so an entry that was actively being
routed through never expired, and was only fixed by an unrelated fresh
insert. Both invalidation classes now run on this path, matching the
loop-detection branch.
### Discovery could loosen a tightened path MTU clamp
An originator handling a `LookupResponse` overwrote its cached path MTU
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
already taught it a tighter value, a later, looser discovery estimate
would clobber that and re-loosen the clamp, risking a return to dropped
oversized packets. The cached and received values are now compared and the
tighter one is kept.
## Upgrade notes
Operator-actionable items when moving from v0.2.x to v0.3.0:
This is a drop-in upgrade from v0.4.0 with no wire format change, no
config migration, and no coordinated restart. Upgrade nodes in whatever
order you like.
- **Control socket JSON schema (breaking, pre-1.0).**
- `show_cache` response field `entries` has changed type from a
`u64` count to an array of entry objects. The previous scalar
value is now in a new `count` field.
- `show_routing` response field `pending_lookups` has changed
type from a `u64` count to an array of per-target lookup
objects.
- External tooling parsing these fields as numbers must be
updated. In-tree `fipstop` is adjusted to the new schema. The
control-socket interface remains pre-1.0 and is not covered by
stability guarantees.
Two things to do rather than assume:
- **Cargo feature flags removed.** `tui`, `ble`, `gateway`, and
`nostr-discovery` are gone. Subsystem inclusion is now driven by
platform `cfg` gates, so plain `cargo build` compiles everything
available on the target without `--features` invocations.
Source-build tooling that passed any of these features should be
updated to omit them.
1. If you monitor `parent_switched`, move to `parent_switches` before
upgrading, or your dashboards will go blank rather than error.
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
to potentially disagree about mesh size, per the FPR cap section above.
This is expected and self-resolves. Do not chase it as a bug unless it
persists after every node reports `0.4.1`.
- **Discovery rate-limiting defaults changed.** Post-failure
suppression is **off by default**
(`node.discovery.backoff_base_secs: 0`, `backoff_max_secs: 0`).
Operators relying on the prior 30s base / 300s cap behavior must
set those fields explicitly. The per-attempt sequence
(`attempt_timeouts_secs`, default `[1, 2, 4, 8]`) now governs
cold-start lookup behavior.
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
your setting is honored and nothing changes for you. The change only
affects nodes taking the default.
- **`.fips` DNS bind address default changed.** The default
`dns.bind_addr` is now `::1`. Operators with explicit overrides
of this field should review them; many existing overrides were
workarounds for the silent-drop bug that this release fixes
properly.
Downgrading to v0.4.0 is supported and needs no special handling.
- **Gateway `dns.listen` source default changed.** The
`fips-gateway` `dns.listen` default is now `[::1]:5353` (was
`[::]:53`), matching the canonical deployment model where a
pre-existing resolver on the host already owns port 53. The
OpenWrt ipk previously overrode this in its packaged config; the
override is now redundant and has been dropped. 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, so forwarders that
reach the gateway over IPv4 loopback need an explicit IPv4 listen
address.
- **systemd unit log level.** The shipped systemd units no longer
hardcode `RUST_LOG=info`; the daemon's effective log level is
driven by `node.log_level` (default `info`). `RUST_LOG`, when
set, still overrides.
- **UDP transport `bind_addr` validation.** Startup now rejects a
`bind_addr` set to a loopback address when at least one peer has
a non-loopback UDP address. Operators who configured a loopback
UDP bind as a workaround should switch to `outbound_only: true`
for the same effect, plus the correct semantics (kernel-assigned
ephemeral port, refuses inbound, never advertised).
- **Tor advert port.** If the Tor `HiddenServicePort` virtual port
isn't 443, set `transports.tor.advertised_port` to match. The
default is 443 and matches the conventional virtual-port choice.
## Documentation pointers
v0.3.0 ships a `docs/` tree reorganized into four sections
(*tutorials / how-to / reference / design*). A new top-level
[`docs/getting-started.md`](../getting-started.md) and per-section
landing pages anchor the entry points.
Entry points by reader intent:
- **New users**: [`docs/getting-started.md`](../getting-started.md)
and [`docs/tutorials/`](../tutorials/) cover guided introductions
for bringing up your first node, joining the test mesh,
advertising a node over Nostr, hosting a service, deploying a
gateway, walking through the IPv6 adapter, and resolving peers
via Nostr.
- **Operators with a specific task**:
[`docs/how-to/`](../how-to/) holds task-driven guides for enabling
Nostr discovery, deploying the gateway, troubleshooting the
gateway, deploying a Tor onion, hosting aliases, persistent
identity, running unprivileged, setting up a Bluetooth peer,
enabling the mesh firewall, tuning UDP buffers, and diagnosing
MTU issues.
- **Reference lookups**: [`docs/reference/`](../reference/) holds
the config field reference, control-socket query reference, the
`fips`, `fipsctl`, `fipstop`, and `fips-gateway` CLI references,
and the protocol diagram set.
- **Architectural background**: [`docs/design/`](../design/) holds
design rationale for FIPS as a whole, FMP and FSP, the spanning
tree, bloom-filter discovery, transports, the IPv6 adapter, the
Nostr discovery layer, and the gateway.
- **Security**: [`docs/design/fips-security.md`](../design/fips-security.md)
documents the mesh-interface security baseline, threat model, and
drop-in workflow.
## Getting v0.3.0
## Getting v0.4.1
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
[v0.3.0 release page](https://github.com/jmcorgan/fips/releases/tag/v0.3.0).
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
- **Arch Linux**: `fips` from the AUR.
- **macOS**: `.pkg` at the v0.3.0 release page.
- **Windows**: ZIP at the v0.3.0 release page.
- **OpenWrt**: `.ipk` at the v0.3.0 release page.
- **From source**: `cargo build --release` from a checkout of the
v0.3.0 tag.
- **macOS**: `.pkg` at the v0.4.1 release page.
- **Windows**: ZIP at the v0.4.1 release page.
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
at the v0.4.1 release page.
- **From source**: `cargo build --release` from a checkout of the v0.4.1
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
required Linux build prerequisite).
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
builds the binaries from source with the pinned toolchain and no manual
prerequisites (see the Nix section of `packaging/README.md`).
The full per-commit changelog lives in
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
@@ -698,67 +138,9 @@ The full per-commit changelog lives in
## Contributors
Thanks to everyone who contributed code, packaging work, bug reports,
or reviews to this release.
Thanks to everyone who contributed code, packaging work, bug reports, or
reviews to this release.
**Code and packaging**:
- [@jcorgan](https://github.com/jmcorgan): release shepherd, Nostr
discovery / NAT traversal, `fips-gateway`, ACL infrastructure,
packaging, security baseline, BLE follow-ups.
- [@Origami74](https://github.com/Origami74): macOS platform support,
from-source Docker companion build and `fipstop` terminal-init
handling, gateway co-development, OpenWrt BLE-feature build fix,
AUR-workflow follow-ups.
- [@jodobear](https://github.com/jodobear): Linux release-artifact
workflow and target-aware build scripts, CONTRIBUTING.md
expansion, rekey integration-test stabilization.
- [@tidley](https://github.com/tidley): Nostr-mediated overlay
discovery and UDP NAT traversal
([#53](https://github.com/jmcorgan/fips/pull/53)).
- [@alexxie16](https://github.com/alexxie16): peer ACL enforcement
([#50](https://github.com/jmcorgan/fips/pull/50)),
macOS WireGuard companion example
([#51](https://github.com/jmcorgan/fips/pull/51)),
follow-up ([#67](https://github.com/jmcorgan/fips/pull/67)).
- [@osh](https://github.com/osh): diagnostic queries for security
validation and mesh debugging
([#42](https://github.com/jmcorgan/fips/pull/42)).
- [@OceanSlim](https://github.com/0ceanSlim): Windows platform
support ([#45](https://github.com/jmcorgan/fips/pull/45)).
- [@mmalmi](https://github.com/mmalmi): ring AEAD backend
([#80](https://github.com/jmcorgan/fips/pull/80)),
hot-path drain batching + recvmmsg + eager pubkey_full
([#81](https://github.com/jmcorgan/fips/pull/81)),
TreeAnnounce self-root ancestry + overlay-advert retry hygiene
([#82](https://github.com/jmcorgan/fips/pull/82)),
NAT-traversal MTU inheritance
([#83](https://github.com/jmcorgan/fips/pull/83)).
- [@dskvr](https://github.com/dskvr): initial Arch Linux AUR
packaging ([#21](https://github.com/jmcorgan/fips/pull/21)) and
the AUR publish workflow.
- [@SatsAndSports](https://github.com/SatsAndSports): rekey
message-1 admit fix on non-accepting transports
([#49](https://github.com/jmcorgan/fips/pull/49)),
TreeAnnounce semantic validation, gateway test image fix
([#69](https://github.com/jmcorgan/fips/pull/69)).
- [@andrewheadricke](https://github.com/andrewheadricke): MIPS
atomic-ABI portability via `portable_atomic`
([#62](https://github.com/jmcorgan/fips/pull/62)).
- [@sh1ftred](https://github.com/sh1ftred): Arch packaging namcap
fixes ([#63](https://github.com/jmcorgan/fips/pull/63)).
- [@oleksky](https://github.com/oleksky): macOS WireGuard companion
collaboration on [#51](https://github.com/jmcorgan/fips/pull/51).
**Issue reports that drove fixes in this release**:
- [@deavmi](https://github.com/deavmi): MIPS daemon build support
([#26](https://github.com/jmcorgan/fips/issues/26)).
- [@Sebastix](https://github.com/Sebastix): fipsctl/fipstop
control-socket path detection
([#30](https://github.com/jmcorgan/fips/issues/30)).
- [@SwapMarket](https://github.com/SwapMarket): auto-connect
reconnect after graceful disconnect
([#60](https://github.com/jmcorgan/fips/issues/60)) and
fipsctl mesh-address rejection
([#61](https://github.com/jmcorgan/fips/issues/61)).
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
and discovery fixes, bloom and identity performance work, antipoison cap
change, and testing.
+143
View File
@@ -0,0 +1,143 @@
# FIPS Branching and Merging Strategy
<!-- markdownlint-disable MD013 -->
This document explains how the three long-lived branches relate, when
to target each one, and how merges propagate fixes and features. For
the day-to-day "how do I send a PR" workflow, see
[CONTRIBUTING.md](../CONTRIBUTING.md).
## Branch Structure
Three long-lived branches track parallel development streams:
```text
next ──●──●──●──●──●──────────────●──●── (wire-format-breaking work)
\ /
master ────●──●──●──●──●──●──────●──●──●── (compatible features, latest release line)
\ /
maint ────────●──●──●──●──●────────────── (bug fixes for the latest release)
```
### maint
- Reset to each minor release tag at release time
- Accepts only bug fixes for functionality that shipped in the
latest release
- No new features, no API changes, no wire-format changes
- Patch releases tag from here (e.g., `v0.3.1`, `v0.3.2`)
- Periodically merged forward into `master` so fixes propagate
### master
- Compatible development for the next feature release
- Multiple feature releases may ship from master (`v0.4.0`, `v0.5.0`)
before `next` promotes
- No wire-format breaking changes; no API breaks
- Receives merges from `maint` so released-line fixes flow forward
- Periodically merged forward into `next`
### next
- Accumulates work that breaks wire format, API, or compatibility
- Receives merges from `master` so it stays current with bug fixes
and compatible feature work
- Cargo version on `next` is the expected release version with a
`-dev` suffix, updated if `master` ships additional minor
releases first
- Becomes the new `master` at the next breaking release; at the same
point the old `master` becomes the new `maint`
## Versioning
While the project is in the `0.x` era, semver treats minor bumps as
potentially breaking. Both `master` and `next` bump the minor version;
the distinction between compatible and breaking is captured in the
changelog and in which branch the work landed on.
The `-dev` suffix in `Cargo.toml` indicates an unreleased development
state on the branch.
## Merge Direction
Fixes and features flow in **one direction only**: `maint → master → next`.
Never merge backward (`next` into `master`, or `master` into `maint`).
```text
maint ──→ master ──→ next
```
This guarantees:
- Bug fixes shipped in a release reach all subsequent branches
- Compatible features reach `next`
- Wire-format-breaking work stays isolated on `next` until release
If you submit a PR on `next` that should also be on master or maint
(rare, since the criteria for needing it on multiple branches are
usually mutually exclusive), the PR stays on its target; the
maintainer either backports as a separate commit on the upstream
branch or asks you to.
## Choosing a Branch for Your PR
Pick the branch that matches the scope of your change:
| Your change | Target branch | Why |
| --- | --- | --- |
| Bug fix in a feature that shipped in the latest release | `maint` | Fix forward-merges to `master` and `next` |
| Bug fix in code added on `master` since the last release (not in any released version) | `master` | The released v0.x.y line is unaffected, so `maint` does not need the change |
| Bug fix in code added on `next` (wire-format-breaking work) | `next` | The bug only exists where the breaking work exists |
| New feature that does not break wire format or API | `master` | Becomes part of the next compatible release |
| Wire-format breaking change, API break, or fundamental protocol shape change | `next` | Stays isolated until the next forklift release |
| Documentation, CI, or contributor-facing changes | `maint` if they apply to released material, else `master` | Forward-merges propagate naturally |
If you are not sure, ask in the related issue. The safest defaults
are `master` for new features and `maint` for bug fixes; the
maintainer will retarget the PR if needed.
## Release Workflow
### Bug fix release (from `maint`)
1. Fix on `maint`
2. Bump patch version, tag (e.g., `v0.3.1`)
3. Merge `maint` into `master`
4. Merge `master` into `next`
### Compatible feature release (from `master`)
1. Finalize features on `master`
2. Merge `maint` into `master` to pick up any pending fixes
3. Set version, tag (e.g., `v0.4.0`)
4. Reset `maint` to the new tag
5. Bump `master` to the next `-dev` version
6. Merge `master` into `next`
### Breaking release (from `next`)
1. Finalize features on `next`
2. Merge `master` into `next` to pick up pending fixes and features
3. Assign version as the next minor after `master`'s last release, tag
4. `master` becomes the new `maint`
5. `next` becomes the new `master`
6. Create a new `next` branch from `master`
## Practical Guidelines
- **Commit to the appropriate branch for the scope of the change.**
Do not commit bug fixes to `master` when they apply to the latest
release — put them on `maint` and let the forward-merge propagate.
- **Feature branches base off the long-lived branch they target.**
Create with `git checkout -b my-feature maint` (or `master` or
`next`), not `git checkout -b my-feature origin/maint`. The
`origin/`-prefixed form auto-sets the new branch's upstream to
the source ref, which can cause `git push` to land on the wrong
ref under some configurations.
- **When in doubt about whether a change is compatible**, target
`next`. The maintainer can advise on retargeting.
- **Resolve merge conflicts on the receiving branch**, preserving
both the inherited fix and the new development.
- **PRs are merged via squash-merge.** One logical change per PR
becomes one commit on the destination branch, making bisect
clean across the integration suite.
+5 -3
View File
@@ -221,9 +221,11 @@ discovery protocol, and error-recovery integration view live in
## Transport Abstraction
FIPS treats the communication medium as a pluggable component. UDP,
TCP, raw Ethernet, Tor, and BLE all implement the same small datagram
interface (send, receive, report MTU) and feed peers into a single FMP
routing layer; radio and serial transports are in the planned set.
TCP, raw Ethernet, Tor, BLE, and Nym all implement the same small
datagram interface (send, receive, report MTU) and feed peers into a
single FMP routing layer; radio and serial transports are in the
planned set. Nym (an outbound-only mixnet transport) and Tor are
privacy-oriented deployment modes rather than failover paths.
Multi-transport nodes bridge between networks transparently. The
transport-layer specification — including per-transport categories,
the trait surface, the connection model, and implementation status —
+20 -10
View File
@@ -180,7 +180,7 @@ network with no overlap (excluding the node itself at the split point).
All peers — including non-tree mesh shortcuts — still **receive**
FilterAnnounce messages and **store** received filters locally. These
stored filters are consulted during routing (step 3 of `find_next_hop()`)
stored filters are consulted during routing (step 4 of `find_next_hop()`)
for single-hop shortcut discovery. However, mesh peer filters contain
only the mesh peer's own tree-propagated information, not transitive
entries from the broader network.
@@ -339,25 +339,35 @@ positions that folding produces.
## Mesh Size Estimation
Each filter's saturation can be inverted into an estimated entry count
A filter's saturation can be inverted into an estimated entry count
via the standard formula `n ≈ -(m/k) · ln(1 X/m)`, where `m` is the
filter size in bits, `k` is the hash count, and `X` is the population
count. Combining the parent's inbound filter with the children's
inbound filters gives an estimate of the whole network: parent + each
child's subtree are disjoint by construction, and adding 1 for the
node itself yields the total. The result is cached on the node and
exposed through the control socket and `fipstop` dashboard.
count. Rather than estimate per-filter and sum, the node first builds
an **OR-union of every connected peer's inbound filter** — all routing
peers, including cross-links, not just the tree parent and children —
inserts its own address into the union, and inverts the cardinality
**once on the resulting union**. Because filter propagation is
split-horizon (each outgoing filter excludes the peer it routes back
to), every routing peer advertises a near-complete "whole mesh minus
my subtree" view, so the union covers the network. OR-ing is
idempotent, so overlapping bits deduplicate instead of over-counting,
and folding in all peers rather than only the tree neighborhood damps
the count flap on a parent switch (the cross-links still carry the
upward coverage) and removes any dependence on tree-declaration cache
freshness. The result is cached on the node and exposed through the
control socket and `fipstop` dashboard. (See `compute_mesh_size()` in
`src/node/mod.rs`.)
The estimator refuses to produce a value when any contributing filter
is above the antipoison FPR cap (`node.bloom.max_inbound_fpr`,
default `0.05`); a partial aggregate would silently underestimate.
default `0.20`); a partial aggregate would silently underestimate.
Consumers handle the resulting `None` by displaying an "unknown"
state rather than a misleading number.
## Antipoison: Inbound FPR Cap
Inbound `FilterAnnounce` payloads are checked against
`node.bloom.max_inbound_fpr` (default `0.05`). Filters whose
`node.bloom.max_inbound_fpr` (default `0.20`). Filters whose
estimated false positive rate exceeds the cap are dropped silently
(no NACK on the wire) — they would otherwise inflate downstream
candidate evaluation cost without contributing useful discrimination.
@@ -378,7 +388,7 @@ as described above.
| 500ms rate limiting | **Implemented** |
| FilterAnnounce gossip (all peers) | **Implemented** |
| Filter cardinality logging | **Implemented** |
| Mesh size estimation (parent + children + 1) | **Implemented** |
| Mesh size estimation (OR-union of peer filters) | **Implemented** |
| Inbound FPR cap (antipoison) | **Implemented** |
| Size class negotiation | Future direction |
| Folding support | Future direction |
+1 -1
View File
@@ -218,7 +218,7 @@ involving the DNS proxy or the pool.
### Virtual IP Pool
The pool allocates IPv6 addresses from a configured CIDR (default
The pool allocates IPv6 addresses from a required CIDR (commonly
`fd01::/112`). Each address maps to one mesh destination, keyed by
`NodeAddr` rather than by hostname — different `.fips` aliases for
the same node share a virtual IP. Address 0 (the network-equivalent)
+5
View File
@@ -100,6 +100,11 @@ inter-frame processing delays inflate spin bit RTT measurements
unpredictably. Timestamp-echo from ReceiverReports (with dwell-time
compensation) is the sole SRTT source.
Duplicate or regressed ReceiverReports are ignored before any RTT, loss,
goodput, or ETX update. If receiver-side dwell time exceeds the wire
field, the report keeps its counters but sends a zero timestamp echo so
the sender cannot form an invalid RTT sample.
The spin bit lives in the link-layer FMP inner header, so this
mechanism applies to link-layer MMP only. Session-layer MMP carries
its spin bit in the FSP encrypted inner header but uses it the same
+1 -1
View File
@@ -251,7 +251,7 @@ would later drop.
The adapter integrates with the MTU subsystem rather than owning it.
The "why we clamp and what `max_mss` means" lives here in the MTU
design; the "how the clamp is implemented at the TUN" lives in the
[IPv6 adapter](fips-ipv6-adapter.md#tcp-mss-clamping) doc.
[IPv6 adapter](fips-ipv6-adapter.md#tun-side-tcp-mss-clamping) doc.
## ICMP Packet Too Big
+186 -1
View File
@@ -1,4 +1,13 @@
# FIPS Nostr-Mediated Discovery and NAT Traversal
# FIPS Discovery: Nostr-Mediated and LAN/mDNS
FIPS nodes have two discovery mechanisms beyond the static `peers[]`
list. The bulk of this document describes **Nostr-mediated discovery**,
which works across the internet using public Nostr relays as a
signaling channel and can punch through UDP NAT. A second, much
simpler mechanism — **LAN/mDNS discovery** — finds peers on the same
local link with no relay, STUN, or NAT traversal at all; it is
described in its own section near the end. The two are independent: a
node can enable either, both, or neither.
Nostr-mediated discovery lets FIPS nodes find each other, and if
necessary, punch through UDP NAT, using public Nostr relays as the
@@ -377,6 +386,182 @@ semaphore and replay-cache layers downstream.
advert says "I am npub X at 1.2.3.4:5678" but whose FMP handshake
presents a different static key is rejected at the mesh layer.
## LAN/mDNS discovery
LAN discovery is a separate, link-local discovery mechanism that finds
peers on the same broadcast domain using mDNS / DNS-SD
([RFC 6762](https://www.rfc-editor.org/rfc/rfc6762) /
[RFC 6763](https://www.rfc-editor.org/rfc/rfc6763)). Unlike
Nostr-mediated discovery, it contacts no relay, runs no STUN
observation, and performs no NAT traversal: an endpoint learned from a
LAN advert is by construction routable from the consumer's own link.
The result is sub-second peer pairing on the same LAN.
It is unrelated to the "LAN candidate" terminology used in the
NAT-traversal sections above (which refers to a host's own
locally-bound address offered as a hole-punch candidate). LAN/mDNS
discovery is a distinct subsystem under `src/discovery/lan/`.
### Role
LAN discovery adds two capabilities, both confined to the local link:
- **Advertising.** The node publishes a `_fips._udp.local.` DNS-SD
service advert carrying its `npub`, its protocol version, and (if
configured) a discovery scope. The advert is multicast on the local
link only; it does not leave the broadcast domain unless the
operator's network bridges mDNS.
- **Browsing.** The node concurrently browses for the same service
type, learns the endpoints of other FIPS nodes on the link, and
initiates a normal FMP link to each newly-seen peer.
The mDNS service type is `_fips._udp.local.`
(`src/discovery/lan/mod.rs:45`). Per RFC 6763 the `_udp` label denotes
the IP transport used for the advert, not the FIPS upper protocol —
both UDP and TCP FIPS endpoints announce under the same service type
because the link-layer handshake travels over UDP either way. (In
practice LAN discovery dials only over a UDP transport; see the
handshake subsection.)
### When to use it
- **You run several FIPS nodes on one LAN** (a lab bench, an office
segment, a home network) and want them to find each other without
hand-maintaining `peers[]` blocks or standing up Nostr discovery.
- **You want the lowest-latency pairing path.** Same-link pairing
completes in well under a second with no relay round-trip.
Skip it when nodes are not on a shared broadcast domain (mDNS does not
cross routed boundaries), or when you do not want the node to multicast
its identity on the local link. LAN discovery is **opt-in and disabled
by default**, so doing nothing leaves it off.
### How it works
The LAN discovery runtime (`src/discovery/lan/mod.rs`) is started
during node initialization when `node.discovery.lan.enabled` is true.
It is independent of Nostr discovery and runs even when Nostr is
disabled (`src/node/lifecycle.rs:1159-1162`). Startup requires an
operational UDP transport: the node advertises the port of its
lowest-`TransportId` operational, non-bootstrap UDP transport, chosen
deterministically so the advertised port is stable across restarts
(`src/node/lifecycle.rs:1169-1180`). If no such port exists, the
runtime returns `NoAdvertisedPort` and LAN discovery does not start
(`src/discovery/lan/mod.rs:156-158`).
The runtime does two things concurrently:
1. **Responder.** It registers a DNS-SD service with instance name
`fips-<first-16-chars-of-npub>` and a TXT record carrying the keys
below. `mdns-sd`'s address auto-detection appends every non-loopback
interface address, with `127.0.0.1` seeded so same-host peers and
integration tests can still resolve the advert
(`src/discovery/lan/mod.rs:182-203`).
2. **Browser.** A background pump receives `ServiceResolved` events for
the same service type. For each resolved advert it extracts the
`npub` and `scope` TXT values, drops adverts that echo the node's own
npub, drops cross-scope adverts (see scope filtering), drops records
without an `npub`, and surfaces one `LanDiscoveredPeer` per routable
interface address (`src/discovery/lan/mod.rs:212-299`). IPv6
unicast link-local addresses without an interface scope id are
skipped, since they cannot be dialed unambiguously
(`src/discovery/lan/mod.rs:348-365`).
The TXT record carries three keys (`src/discovery/lan/mod.rs:47-55`):
| TXT key | Contents |
| --- | --- |
| `npub` | bech32-encoded npub of the advertising node |
| `scope` | the node's discovery scope, if one is configured (omitted otherwise) |
| `v` | FIPS protocol version (the same `PROTOCOL_VERSION` used by the Nostr advert) |
Once per node tick, the node drains browser events and acts on them in
`poll_lan_discovery()` (`src/node/lifecycle.rs:907`, called from
`src/node/handlers/rx_loop.rs:266`). For each discovered peer it finds
a UDP transport whose family matches the peer address, parses the
`npub` into a `PeerIdentity`, skips peers it is already connected to or
currently connecting to, and otherwise initiates a connection.
### Handshake: Noise IK
LAN-discovered peers are dialed through the standard FMP outbound link
path. `poll_lan_discovery()` calls `initiate_connection()`
(`src/node/lifecycle.rs:380`), which, for connectionless transports
such as UDP, allocates a link and **starts the Noise IK handshake**
(documented at `src/node/lifecycle.rs:373-374`). This is the same
link-layer handshake used by every other FMP connection — IK at the
link layer per the FIPS architecture — not a different pattern for LAN
peers.
The mDNS advert is **unauthenticated**: anyone on the link can
multicast a TXT claiming any `npub`. Identity is proven end-to-end by
the Noise IK handshake against the observed endpoint. A spoofed advert
carrying another node's npub fails the handshake — the impostor does
not hold the matching static key — and the half-open link is dropped.
The mDNS advert is therefore a routing hint, never an identity
assertion, exactly as a Nostr advert is treated (a successful contact
is not trusted until FMP's Noise IK handshake completes).
> Note: a stale source doc-comment at `src/node/lifecycle.rs:904-906`
> describes this path as a "Noise XX" handshake. That comment is
> inaccurate — the path uses Noise IK as described above. The comment
> is flagged for a separate source fix and does not reflect actual
> behavior.
### Scope filtering
When a discovery scope is configured, the advert carries it in the
`scope` TXT entry and the browser surfaces only peers whose advert
carries a matching scope. Nodes on the same physical LAN but configured
for different mesh networks therefore do not cross-feed each other.
The scope is resolved by `lan_discovery_scope()`
(`src/node/lifecycle.rs:880-902`): the explicit
`node.discovery.lan.scope`, if non-empty, is used directly. Otherwise
the node falls back to deriving a scope from the Nostr discovery `app`
tag (stripping the `fips-overlay-v1:` prefix when present). This lets
an application keep its public, relay-visible Nostr `app` tag generic
while still isolating LAN discovery per private network, or share one
value across both. A node with no scope on either side surfaces all
adverts it sees on the link.
### Configuration
LAN discovery is configured under `node.discovery.lan.*`
(`src/config/node.rs:222-227`, `src/discovery/lan/mod.rs:88-129`):
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `node.discovery.lan.enabled` | bool | `false` | Master switch. LAN discovery is opt-in; default-off avoids an unexpected per-link identity multicast on upgrade. |
| `node.discovery.lan.service_type` | string | `_fips._udp.local.` | DNS-SD service type. Overridable mainly so integration tests can isolate multiple services on one loopback interface. |
| `node.discovery.lan.scope` | string (optional) | unset | Application/network scope carried in the LAN-only `scope` TXT record. Kept deliberately separate from the public Nostr `app` tag. When unset, the scope falls back to the derived Nostr `app` value. |
The identity surface published over mDNS (`npub`, version, optional
scope) is a strict subset of what `nostr.advertise` already publishes
publicly, so enabling LAN discovery adds no marginal privacy cost
beyond making the node's presence observable on its own local link.
### Relationship to Nostr discovery
The two mechanisms are complementary and independent:
| | Nostr-mediated | LAN/mDNS |
| --- | --- | --- |
| Reach | Internet-wide, via relays | Same broadcast domain only |
| Signaling channel | Public Nostr relays | mDNS multicast on the local link |
| NAT traversal | STUN + UDP hole-punch for `udp:nat` peers | None — endpoint is link-routable by construction |
| Identity carrier | signed kind 37195 advert (authenticated at publish) | unauthenticated mDNS TXT (routing hint only) |
| Identity proof | FMP Noise IK on the connection | FMP Noise IK on the connection |
| Default | disabled (`nostr.enabled: false`) | disabled (`lan.enabled: false`) |
| Scope key | `app` tag (public) | `scope` TXT (link-local), falls back to `app` |
Both ultimately converge on the same trust boundary: discovery only
supplies candidate endpoints, and no peer is trusted until FMP's Noise
IK handshake confirms the claimed identity. A node may run both at
once — for example, advertising globally over Nostr while also pairing
instantly with same-LAN peers — with no interaction between the two
beyond the shared scope fallback.
## See also
- [../how-to/enable-nostr-discovery.md](../how-to/enable-nostr-discovery.md)
+120 -1
View File
@@ -120,6 +120,7 @@ for internet connectivity:
| UDP/IP | host:port | 12801472 | Unreliable | Primary internet transport |
| TCP/IP | host:port | Stream | Reliable | Requires length-prefix framing |
| Tor | .onion | Stream | Reliable | High latency, strong anonymity |
| Nym | host:port | Stream | Reliable | Mixnet, outbound-only, strong anonymity |
**Shared medium transports** operate over broadcast- or multicast-capable
media:
@@ -190,6 +191,7 @@ proceed.
| --------- | ---------------- |
| TCP/IP | TCP three-way handshake |
| Tor | Circuit establishment (typically 1060s, default timeout 120s) |
| Nym | SOCKS5 connect through mixnet (minutes possible, default timeout 300s) |
| BLE | L2CAP CoC or GATT connection |
| Serial | Physical connection (static) |
@@ -599,6 +601,120 @@ SOCKS5-level errors, MTU rejections, accepted/rejected inbound
connections, and Tor control-port errors. The full counter table
lives in [../reference/transports.md](../reference/transports.md).
## Nym: The Mixnet Transport
The Nym transport routes FIPS traffic through the Nym mixnet, providing
network-level anonymity via Sphinx packet routing and timing
obfuscation. It uses the "mixnet-as-proxy" pattern: a node connects
outbound through a local `nym-socks5-client` SOCKS5 proxy, which carries
the traffic into the mixnet. The `nym-socks5-client` runs as a separate
process alongside the fips daemon and must be started independently.
Like Tor, Nym is a privacy-oriented deployment mode chosen for the
anonymity properties of the mixnet, not a failover for other transports.
Like TCP and Tor, it is connection-oriented and reliable; the same
TCP-over-TCP considerations apply, and cost-based parent selection
naturally deprioritizes the high-latency Nym links.
### Architecture
The Nym transport is a separate `NymTransport` implementation. It reuses
the FMP header-based stream reader (`tcp/stream.rs`) for packet framing
on the underlying byte stream, and follows the same connection-pool
pattern as the TCP and Tor transports.
It maintains two pools: a `ConnectingPool` for background SOCKS5
connection attempts, and an established pool of `NymConnection` entries.
Each `NymConnection` holds a write half, a per-connection receive task,
the configured MTU, and a connection timestamp.
| Property | Value |
| -------- | ----- |
| Addressing | IP:port or hostname:port |
| Default MTU | 1400 bytes |
| Framing | FMP header-based (shared with TCP) |
| Connection model | Outbound-only, non-blocking connect through SOCKS5 |
| Platform | Cross-platform (requires external nym-socks5-client) |
### Outbound-Only
The Nym transport is strictly outbound. It supports no inbound service:
`accept_connections()` returns `false` and `discover()` returns no
peers. A node using the Nym transport can initiate links to remote peers
through the mixnet, but cannot accept inbound connections over Nym. (A
node can still accept inbound links over other transports it runs.)
### Address Types
The Nym transport accepts two address formats, parsed into an internal
target address:
- **IP:port** — a numeric IP and port, sent to the SOCKS5 proxy as a
numeric target.
- **Hostname:port** — the hostname is passed through SOCKS5 so it is
resolved on the exit side rather than locally.
Both forms are routed through the same SOCKS5 proxy.
### Connection Establishment
Connection setup follows the same non-blocking pattern as the TCP and
Tor transports. When FMP needs to reach a peer, the node initiates a
background connect (`connect_async`). The transport spawns a background
tokio task that opens a SOCKS5 connection through the local
`nym-socks5-client`, configures the socket (including TCP keepalive),
splits the stream, and spawns a per-connection receive loop using the
shared FMP stream reader. The call returns immediately while the connect
proceeds in the background.
SOCKS5 connection setup through the mixnet can take much longer than a
direct TCP connection because each connection traverses multiple mix
nodes with timing obfuscation. Accordingly the connect timeout defaults
to 300 seconds (`connect_timeout_ms`). Non-blocking connect is essential
here — a blocking connect would stall the FMP event loop for the
duration of mixnet setup. As a fallback, `send_async(addr, data)`
performs a connect-on-send if no connection to the address yet exists.
Each outbound packet is checked against the configured MTU before being
written; an oversized packet is rejected with an MTU-exceeded error
rather than being sent.
### Startup Readiness
At startup the transport validates the configured `socks5_addr` and then
probes the SOCKS5 port to wait for `nym-socks5-client` to become ready,
using exponential backoff (starting at 1 second, capped at 10 seconds
between attempts) up to `startup_timeout_secs` (default 120 seconds). If
the proxy does not become reachable within that window, the transport
logs a warning and starts anyway; outbound connections then fail until
the `nym-socks5-client` becomes available.
### Session Independence
Same as TCP and Tor: loss of a Nym connection does **not** tear down the
FIPS peer. Noise keys, MMP state, and FSP sessions survive reconnection.
### Configuration
The Nym transport block (`transports.nym.*`) has the following fields:
| Field | Default | Description |
| ----- | ------- | ----------- |
| `socks5_addr` | `127.0.0.1:1080` | Address (host:port) of the local nym-socks5-client SOCKS5 proxy |
| `connect_timeout_ms` | `300000` | Outbound SOCKS5 connect timeout in milliseconds (300s) |
| `mtu` | `1400` | Maximum FIPS packet size for Nym connections, in bytes |
| `startup_timeout_secs` | `120` | Seconds to wait for nym-socks5-client to become ready at startup |
The Nym transport requires an external `nym-socks5-client`. Named
instances are supported for multiple proxy endpoints. Unknown
configuration keys are rejected.
### Statistics
The Nym transport exposes per-instance counters covering successful
send/receive, send/receive errors, connection establishment, SOCKS5-level
errors, connect timeouts, and MTU rejections.
## Discovery
Discovery determines that a FIPS-capable endpoint is reachable at a given
@@ -725,7 +841,8 @@ TransportType {
}
```
Predefined types exist for UDP, TCP, Ethernet, WiFi, Tor, and Serial.
Predefined types exist for UDP, TCP, Ethernet, WiFi, Tor, Nym, BLE, and
Serial.
### Congestion Reporting
@@ -750,6 +867,7 @@ on all forwarded datagrams.
| UDP | `SO_RXQ_OVFL` kernel drop counter | `recvmsg()` ancillary data on every packet |
| TCP | Not implemented | Returns `None` (TCP handles congestion internally) |
| Tor | Not implemented | Returns `None` (TCP handles congestion internally) |
| Nym | Not implemented | Returns `None` (TCP handles congestion internally) |
| Ethernet | Not implemented | Returns `None` |
### Transport Addresses
@@ -780,6 +898,7 @@ transitions through `Starting` to `Up` (operational). `stop()` moves to
| Ethernet | **Implemented** | AF_PACKET SOCK_DGRAM, EtherType 0x2121, beacon discovery, Linux only |
| WiFi | **Implemented** (via Ethernet transport, infrastructure mode) | mac80211 translates 802.11↔802.3; broadcast beacons unreliable through APs |
| Tor | **Implemented** | Outbound SOCKS5, inbound via onion service, .onion and clearnet addressing |
| Nym | **Implemented** | Outbound-only SOCKS5 through nym-socks5-client, mixnet anonymity, IP/hostname addressing |
| BLE | **Implemented** (Linux/glibc only; experimental) | L2CAP CoC, ATT_MTU negotiation, per-link MTU; musl/macOS/Windows skip |
| Radio | Future direction | Constrained MTU (51222 bytes) |
| Serial | Future direction | SLIP/COBS framing, point-to-point |
@@ -526,7 +526,7 @@ own.
| Failure | Symptom | Mitigation |
| --- | --- | --- |
| Symmetric NAT (one side) | Punch timeout | Retry with port-prediction heuristics; otherwise fall back to a relay or different transport |
| Symmetric NAT (one side) | Punch timeout | Retry with port-prediction heuristics; otherwise fall back to an application-level relay |
| Symmetric NAT (both sides) | Punch timeout | Application-level relay required |
| Relay latency > 60 s | Stale reflexive address | Use low-latency relays; consider self-hosted relay |
| Relay does not support ephemeral kinds | Signaling events persist | Use NIP-40 expiration + NIP-09 deletion as fallback |
+17
View File
@@ -88,6 +88,23 @@ See [packaging/README.md](../packaging/README.md) for per-format
build details, cross-target options, and the full `make` target
list.
### With Nix (flake)
On Nix/NixOS, a [flake](../flake.nix) at the project root builds the
binaries from source with the pinned toolchain and no manual
prerequisite install:
```sh
nix build .#fips # all four binaries, into ./result/bin
nix develop # dev shell with the toolchain + build deps
```
This path produces binaries only — it does not run the installer, so
there are no systemd units, no `fips` group, and no default `fips.yaml`.
On NixOS, wire the daemon in through your system configuration using the
flake's `packages.<system>.fips` output instead. See the Nix / NixOS
section of [packaging/README.md](../packaging/README.md).
## What's installed and running
Here's what the installer leaves on your machine, what's
+1
View File
@@ -18,6 +18,7 @@ X" to "X is done".
| [enable-nostr-discovery.md](enable-nostr-discovery.md) | Turn on Nostr-mediated discovery (3 capabilities — resolve, advertise, open — across 5 scenarios) |
| [deploy-tor-onion.md](deploy-tor-onion.md) | Run a Tor onion service for inbound FIPS connections |
| [tune-udp-buffers.md](tune-udp-buffers.md) | Set host sysctls so FIPS UDP sockets don't get clamped |
| [tune-file-descriptors.md](tune-file-descriptors.md) | Raise `RLIMIT_NOFILE` so a busy node doesn't exhaust file descriptors (`EMFILE`) as peer count grows |
| [run-as-unprivileged-user.md](run-as-unprivileged-user.md) | Run the daemon under a dedicated unprivileged service account (drops the default-root posture) |
| [deploy-gateway.md](deploy-gateway.md) | Manually deploy `fips-gateway` on a non-OpenWrt Linux host (LAN-to-mesh outbound + mesh-to-LAN inbound port-forwards). For the OpenWrt path, see the gateway tutorial. |
| [troubleshoot-gateway.md](troubleshoot-gateway.md) | Diagnostic recipes for the gateway, organised by half (outbound, inbound, common) |
+1
View File
@@ -284,6 +284,7 @@ transports:
tor:
mode: directory
socks5_addr: "127.0.0.1:9050"
advertised_port: 8443
directory_service:
hostname_file: "/var/lib/tor/fips/hostname"
bind_addr: "127.0.0.1:8444"
+1 -1
View File
@@ -169,7 +169,7 @@ sudo usermod -aG fips $USER
Then:
```sh
fipsctl show node
fipsctl show status
```
## Caveats
+1 -1
View File
@@ -151,7 +151,7 @@ rather than `flush ruleset`, which destroys every table on the host.
Symptom: `nc -U /run/fips/gateway.sock` fails with "Permission
denied" or "No such file or directory".
The socket is owned by root with mode `0660` (group `fips`). Either
The socket is owned by root with mode `0770` (group `fips`). Either
run `nc` as root (`sudo nc -U ...`) or add your user to the `fips`
group and re-login. If the file does not exist at all, the gateway
either failed to start (check `journalctl -u fips-gateway`) or
+144
View File
@@ -0,0 +1,144 @@
# Tune the File-Descriptor Limit for FIPS
A busy FIPS node opens many file descriptors, and the count grows with
the number of peers it serves. On most systemd distributions the daemon
inherits a soft `RLIMIT_NOFILE` of 1024, which a well-connected node can
exhaust — at which point peer admission, handshakes, and discovery start
failing with `EMFILE` ("Too many open files").
This guide explains the FD budget, shows how to raise the limit on
systemd and on OpenWrt, and how to verify the result.
## Why FIPS is FD-hungry
Unlike a service that multiplexes all traffic over one socket, the FIPS
data plane allocates descriptors **per peer**. The dominant term is:
```text
fds ≈ 3·P + fixed overhead (~30)
```
where `P` is the number of established UDP peers. Each such peer consumes
**3 file descriptors**:
- one `connect()`-ed UDP socket dedicated to that peer, plus
- a 2-FD self-pipe owned by that peer's receive-drain worker (used to
wake and stop the worker cleanly).
The remaining consumers are bounded and do not scale with peer count:
- the TUN device (one descriptor, process-lifetime),
- the wildcard UDP listen socket(s) (one per bound UDP transport),
- TCP and Tor transport listeners and the Tor control socket,
- Nostr relay websockets (one per configured discovery relay),
- the control socket (one `UnixListener`, plus short-lived per-request
client connections for `fipsctl` / `fipstop`),
- and base runtime descriptors (epoll, eventfd, logs).
Together these add a roughly flat overhead of about 30 descriptors. The
per-peer term is what drives the daemon toward the FD ceiling.
## The symptom
The systemd and distro default **soft** `RLIMIT_NOFILE` is **1024**.
With the `3·P` budget above, that ceiling is reached near **~320 peers**
(3 × 320 ≈ 960, plus the fixed overhead). Once the process is out of
descriptors, every syscall that allocates one — `socket()`, `accept()`,
`open()`, `pipe()` — fails with `EMFILE`, which surfaces as:
- failed peer admission (new peers cannot be accepted),
- failed handshakes (the daemon cannot open the per-peer socket), and
- dropped discovery (relay or probe sockets cannot be created).
These symptoms appear only under load, once the node has accumulated
enough peers to cross the ceiling, so they can be easy to misattribute.
## Raise the limit on systemd
Create a drop-in override for the service:
```sh
sudo systemctl edit fips.service
```
Add:
```ini
[Service]
LimitNOFILE=65535
```
A single `LimitNOFILE=` value sets **both** the soft and the hard limit,
so no separate soft/hard syntax is needed here.
Reload systemd and restart the daemon so the new limit takes effect:
```sh
sudo systemctl daemon-reload
sudo systemctl restart fips
```
`65535` (2¹⁶ 1) is the conventional headroom value for network
daemons. With the `3·P` budget, it supports roughly **~21,800 peers**
before the FD ceiling binds — well beyond any plausible single-node FIPS
mesh degree. Past that point other limits (threads, memory, CPU) bind
first, so raising `LimitNOFILE` higher buys nothing.
## Raise the limit on OpenWrt
OpenWrt uses procd, not systemd, so `LimitNOFILE` does not apply.
Set the equivalent limit in the init script at `/etc/init.d/fips`,
inside the block that starts the service:
```sh
procd_set_param limits nofile="65535 65535"
```
The two values are the soft and hard limits respectively; setting them
equal mirrors the single-value systemd behaviour above.
Restart the service to apply:
```sh
/etc/init.d/fips restart
```
## Verify
Compare the live descriptor count against the established peer count:
```sh
ls /proc/$(pidof fips)/fd | wc -l
fipsctl show peers | wc -l
```
At steady state, expect a stable ratio of about **3 descriptors per
peer** plus the flat ~30-descriptor overhead. A ratio that holds steady
as peers come and go confirms healthy, bounded scaling.
If the descriptor count climbs steadily while the peer count stays flat,
that would indicate a descriptor leak rather than legitimate scaling —
the limit bump would only delay the wall. The current data plane has
been audited as leak-free (every per-peer descriptor has a guaranteed
close on every teardown path), so a climbing ratio at fixed peer count
would be a regression worth investigating.
## A note on deployment lines
The per-peer connected-UDP socket — the amplifier behind the
`3·P` term — is present on the master and next data planes. It is **not
yet present on the maintenance line**. On maintenance-only deployments
the 3-descriptor-per-peer term does not apply, and FD pressure comes
only from the fixed consumers listed above. Raising `LimitNOFILE` there
is still worthwhile as forward-looking headroom, and harmless where the
amplifier is absent.
## See also
- [tune-udp-buffers.md](tune-udp-buffers.md) — host sysctls so FIPS UDP
sockets don't get clamped
- [run-as-unprivileged-user.md](run-as-unprivileged-user.md) — run the
daemon under a dedicated service account
- [../reference/configuration.md](../reference/configuration.md) —
transport and discovery configuration that influences the fixed FD
overhead
+1 -1
View File
@@ -28,7 +28,7 @@ controlled through the standard service control manager.
| Flag | Argument | Description |
| ---- | -------- | ----------- |
| `-c`, `--config` | `FILE` | Use `FILE` as the configuration. Skips the default search paths. |
| `-V` | — | Print the short version (e.g. `0.3.0-dev (rev abcdef1)`). |
| `-V` | — | Print the short version (e.g. `0.4.0 (rev abcdef1)`). |
| `--version` | — | Print the long version: short version plus build target triple. |
| `-h`, `--help` | — | Print usage and exit. |
| `--install-service` | — | (Windows only) Install `fips` as a Windows service. Requires Administrator. |
+2 -1
View File
@@ -69,6 +69,7 @@ Time-series metrics from the in-process history rings.
| Subcommand | Control-socket command | Description |
| ---------- | ---------------------- | ----------- |
| `stats list` | `show_stats_list` | Enumerate available metrics, their units, and the per-ring retention windows. |
| `stats metrics` | `show_metrics` | Dump current counter values for every protocol metric family (`forwarding`, `discovery`, `tree`, `bloom`, `congestion`, `errors`). |
| `stats peers` | `show_stats_peers` | List peers tracked in stats history (active or recently active). |
| `stats history <metric> [options]` | `show_stats_history` | Fetch a time-series window for one metric. |
@@ -104,7 +105,7 @@ Tell the daemon to dial a peer over a specific transport.
| -------- | ----------- |
| `peer` | npub (bech32) or hostname from `/etc/fips/hosts`. |
| `address` | Transport endpoint, e.g. `192.168.1.10:2121`, `[2001:db8::1]:2121`, or a Tor onion. FIPS-mesh ULAs (`fd00::/8`) are rejected for the IP-based transports (udp, tcp, ethernet). |
| `transport` | One of `udp`, `tcp`, `tor`, `ethernet`. |
| `transport` | One of `udp`, `tcp`, `tor`, `nym`, `ethernet`. The named transport must be configured and running. |
### `disconnect <peer>`
+47 -4
View File
@@ -15,8 +15,11 @@ socket, polls a small set of `show_*` queries on a timer, and renders
the state in a tabbed full-screen UI. A separate poll runs against the
gateway control socket when the Gateway tab is active.
`fipstop` is read-only — it cannot mutate daemon state. Use
[`fipsctl`](cli-fipsctl.md) for `connect` / `disconnect` and friends.
`fipstop` is almost entirely read-only: the only state-mutating action
it offers is disconnecting a peer (`Del` on a selected Peers row, with
a confirmation prompt — see [Keybindings](#keybindings)). For
`connect` and other mutating commands, use
[`fipsctl`](cli-fipsctl.md).
## Options
@@ -86,6 +89,11 @@ empty list and the panel hides.
## Keybindings
Press `?` at any time for an in-app help overlay. The overlay and the
status-bar hint footer both read from a single keybinding registry
keyed by `(tab, mode)`, so the always-visible hints describe exactly
the keys the current context accepts.
### Global
| Key | Action |
@@ -94,7 +102,8 @@ empty list and the panel hides.
| `Tab` | Next tab. |
| `Shift-Tab` | Previous tab. |
| `g` | Jump to the Graphs tab. |
| `Esc` | Close detail view (if open). |
| `?` | Toggle the help overlay. |
| `Esc` | Close an open detail view; otherwise deselect the active table row. |
### Table tabs (Peers, Sessions, Transports, Gateway)
@@ -102,6 +111,13 @@ empty list and the panel hides.
| --- | ------ |
| `Up`, `Down` | Move row selection. |
| `Enter` | Open detail view for the selected row. |
| `Esc` | Deselect the row (return to the tab's overview state). |
### Peers tab (extra)
| Key | Action |
| --- | ------ |
| `Del` | Disconnect the selected peer. Opens a `Y`/`N` confirmation modal first; this is the only state-mutating action in `fipstop`. |
### Transports tab (extra)
@@ -112,16 +128,43 @@ empty list and the panel hides.
| `e` | Expand all transports. |
| `c` | Collapse all transports. |
### Multi-pane scrolling tabs (Tree, Filters, Routing)
Each lays out stacked panes that scroll independently.
| Key | Action |
| --- | ------ |
| `f` | Move focus to the next pane. |
| `Up`, `Down` | Scroll the focused pane by one row. |
| `PageUp`, `PageDown` | Scroll the focused pane by ten rows. |
| `Home`, `End` | Jump to the top / bottom of the focused pane. |
### Performance tab (extra)
The Performance tab lays out two panes (Link MMP, Session MMP).
| Key | Action |
| --- | ------ |
| `f` | Move focus between the Link and Session MMP panes. |
| `Up`, `Down` | Scroll the focused pane. |
| `PageUp`, `PageDown` | Scroll the focused pane by ten rows. |
| `Home`, `End` | Jump to the top / bottom of the focused pane. |
| `s` | Cycle the sort column of the focused pane. |
| `Shift-S` | Toggle the sort direction of the focused pane. |
### Graphs tab (extra)
| Key | Action |
| --- | ------ |
| `Up`, `Down` | Scroll within the stacked plots. |
| `Up`, `Down` | Scroll the stacked plots; in `MetricByPeer` mode, move the by-peer selection (and follow it when the by-peer detail is open). |
| `Right`, `Space` | Next time window. Cycles `1m / 1s``10m / 1s``1h / 1s``24h / 1m`. |
| `Left` | Previous time window. |
| `Enter` | In `MetricByPeer` mode, expand the selected peer summary into a full-pane plot. |
| `m` | Cycle view mode: `Node` (stacked node metrics) → `MetricByPeer` (one per-peer metric across all peers) → `PeerByMetric` (all per-peer metrics for one peer). |
| `n` | Next selector (next per-peer metric in MetricByPeer; next peer in PeerByMetric). |
| `Shift-N` | Previous selector. |
| `s` | Cycle the sort column of the by-peer summary list. |
| `Shift-S` | Toggle the sort direction of the by-peer summary list. |
## Exit Codes
+52 -2
View File
@@ -235,6 +235,29 @@ addresses for the punch socket port.
During punching, compatible private-subnet candidates and reflexive candidates
are attempted in parallel; the first successful path wins.
#### LAN Discovery (`node.discovery.lan.*`)
Peer discovery on the local link via mDNS / DNS-SD (RFC 6762 / RFC
6763). When enabled, the node publishes a `_fips._udp.local.` service
advert carrying its `npub` (and optional scope) and concurrently
browses for the same service type to learn same-broadcast-domain peers.
The result is sub-second peer pairing with no Nostr-relay roundtrip,
STUN observation, or NAT traversal: the observed endpoint is by
construction routable from the consumer's LAN.
mDNS adverts are unauthenticated, so a LAN advert is treated only as a
routing hint. Identity is still proven end-to-end by the Noise XX
handshake the node initiates against the observed endpoint; a spoofed
advert carrying another peer's npub fails the handshake and is dropped.
LAN discovery requires an active UDP transport (peers dial the
advertised UDP port to begin the handshake).
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `node.discovery.lan.enabled` | bool | `false` | Master switch. Opt-in: enable for sub-second same-LAN pairing. Default-off avoids reintroducing a per-LAN identity broadcast on nodes that have deliberately disabled other discovery channels |
| `node.discovery.lan.service_type` | string | `"_fips._udp.local."` | DNS-SD service type. Primarily an override for integration tests running multiple isolated services on one loopback interface; leave at the default in production |
| `node.discovery.lan.scope` | string | *(none)* | Optional application/network scope carried in a `scope=<name>` TXT entry. Browsers with a scope set only surface peers advertising the same scope, so nodes on the same physical LAN configured for different mesh networks do not cross-feed. Intentionally separate from `node.discovery.nostr.app` so relay-visible adverts can stay generic while LAN discovery is isolated per private network |
### Spanning Tree (`node.tree.*`)
Controls tree construction and parent selection.
@@ -254,7 +277,7 @@ Controls tree construction and parent selection.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `node.bloom.update_debounce_ms` | u64 | `500` | Debounce interval for filter update propagation |
| `node.bloom.max_inbound_fpr` | f64 | `0.05` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.05` corresponds to fill 0.549 at k=5 (≈3,200 entries on the 1 KB filter) |
| `node.bloom.max_inbound_fpr` | f64 | `0.20` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.20` corresponds to fill 0.7248 at k=5 (≈2,114 entries on the 1 KB filter); a saturated/poisoned filter is still ~100% FPR and rejected |
Bloom filter size (1 KB), hash count (5), and size classes are protocol
constants and not configurable.
@@ -576,6 +599,25 @@ HiddenServiceDir /var/lib/tor/fips
HiddenServicePort 8443 127.0.0.1:8444
```
### Nym (`transports.nym.*`)
Nym transport routes FIPS traffic through the Nym mixnet for
metadata-resistant anonymity. Outbound-only: connections are made
through a `nym-socks5-client` SOCKS5 proxy that must be running
separately (e.g. as a service running alongside the fips daemon or as a
container). There is no inbound listener — a Nym-only node initiates
outbound links but is not reachable for unsolicited inbound handshakes.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `transports.nym.socks5_addr` | string | `"127.0.0.1:1080"` | `nym-socks5-client` SOCKS5 proxy address (host:port) |
| `transports.nym.connect_timeout_ms` | u64 | `300000` | Outbound connect timeout in milliseconds. Mixnet SOCKS5 connections traverse 3 mix nodes with timing obfuscation and can take several minutes, so this is generous (300s). |
| `transports.nym.mtu` | u16 | `1400` | Default MTU |
| `transports.nym.startup_timeout_secs` | u64 | `120` | Seconds to wait for `nym-socks5-client` to become ready at startup before giving up |
**Named instances.** Like other transports, multiple Nym instances can
be configured with named sub-keys for different SOCKS5 proxy endpoints.
### BLE (`transports.ble.*`)
Bluetooth Low Energy transport using L2CAP Connection-Oriented Channels.
@@ -889,6 +931,9 @@ node:
backoff_base_secs: 0
backoff_max_secs: 0
forward_min_interval_secs: 2
# lan: # uncomment to enable mDNS LAN discovery
# enabled: true # opt-in, default false
# scope: "my-mesh" # optional per-network scope filter
tree:
announce_min_interval_ms: 500
parent_hysteresis: 0.2 # cost improvement fraction for parent switch
@@ -899,7 +944,7 @@ node:
flap_dampening_secs: 120 # extended hold-down on flap
bloom:
update_debounce_ms: 500
max_inbound_fpr: 0.05 # antipoison cap on inbound FilterAnnounce FPR
max_inbound_fpr: 0.20 # antipoison cap on inbound FilterAnnounce FPR
session:
default_ttl: 64
pending_packets_per_dest: 16
@@ -983,6 +1028,11 @@ transports:
# # bind_addr: "127.0.0.1:8443"
# # max_inbound_connections: 64
# # advertised_port: 443 # public-facing onion port for Nostr adverts
# nym: # uncomment to enable Nym mixnet transport (outbound-only)
# socks5_addr: "127.0.0.1:1080" # nym-socks5-client SOCKS5 proxy address
# connect_timeout_ms: 300000 # connect timeout (300s for mixnet)
# mtu: 1400 # default MTU
# startup_timeout_secs: 120 # wait for nym-socks5-client to be ready
# ble: # uncomment to enable BLE transport (Linux only, requires BlueZ)
# adapter: "hci0" # HCI adapter name
# psm: 0x0085 # L2CAP PSM (133)
+31 -6
View File
@@ -100,21 +100,22 @@ table below lists every command currently registered.
| Command | Params | `data` shape (top-level keys) |
| ------- | ------ | ----------------------------- |
| `show_status` | — | `version`, `npub`, `node_addr`, `ipv6_addr`, `state`, `is_leaf_only`, `peer_count`, `session_count`, `link_count`, `transport_count`, `connection_count`, `tun_state`, `tun_name`, `effective_ipv6_mtu`, `control_socket`, `pid`, `exe_path`, `uptime_secs`, `estimated_mesh_size`, `forwarding`, `sparklines`. |
| `show_status` | — | `version`, `npub`, `node_addr`, `ipv6_addr`, `state`, `is_leaf_only`, `is_root` (bool — this node is the spanning-tree root), `root` (hex node-addr of the current tree root), `persistent` (bool — identity is persisted, i.e. `persistent` set or an `nsec` configured), `peer_count`, `session_count`, `link_count`, `transport_count`, `connection_count`, `transport_peer_counts` (object mapping transport-type name to its connected-peer count; configured transports appear with `0`), `tun_state`, `tun_name`, `effective_ipv6_mtu`, `control_socket`, `pid`, `exe_path`, `uptime_secs`, `estimated_mesh_size`, `forwarding`, `sparklines`. |
| `show_acl` | — | `allow_file`, `deny_file`, `enforcement_active`, `effective_mode`, `default_decision`, `allow_all`, `deny_all`, `allow_file_entries`, `deny_file_entries`, `allow_entries`, `deny_entries`. |
| `show_peers` | — | `peers[]` — per-peer object: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `connectivity`, `link_id`, `direction`, `transport_addr`, `transport_type`, `is_parent`, `is_child`, `tree_depth`, `stats`, `noise`, `current_k_bit`, `mmp`, plus optional `nostr_traversal`, `rekey_in_progress`, `rekey_draining`. |
| `show_peers` | — | `peers[]` — per-peer object: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `connectivity`, `link_id`, `direction`, `transport_addr`, `transport_type`, `is_parent`, `is_child`, `tree_depth`, `effective_depth` (`tree_depth + link_cost` — the metric `evaluate_parent` ranks on; `null` when the peer has no coords, or is unmeasured while another peer has an SRTT sample, per the cold-start gate), `stats`, `noise`, `current_k_bit`, `mmp`, plus optional `nostr_traversal`, `rekey_in_progress`, `rekey_draining`. |
| `show_links` | — | `links[]``link_id`, `transport_id`, `remote_addr`, `direction`, `state`, `created_at_ms`, `stats`. |
| `show_tree` | — | `my_node_addr`, `root`, `is_root`, `depth`, `my_coords[]`, `parent`, `parent_display_name`, `declaration_sequence`, `declaration_signed`, `peer_tree_count`, `peers[]`, `stats`. |
| `show_tree` | — | `my_node_addr`, `root`, `root_npub` (bech32 npub of the current tree root), `is_root`, `depth`, `my_coords[]`, `parent`, `parent_display_name`, `declaration_sequence`, `declaration_signed`, `peer_tree_count`, `peers[]`, `stats`. |
| `show_sessions` | — | `sessions[]``remote_addr`, `npub`, `display_name`, `state` (`established`, `initiating`, `awaiting_msg3`, `unknown`), `is_initiator`, `last_activity_ms`, `stats`, optional `mmp`, `current_k_bit`, `is_draining`. |
| `show_bloom` | — | `own_node_addr`, `is_leaf_only`, `sequence`, `leaf_dependent_count`, `leaf_dependents[]`, `peer_filters[]`, `stats`. |
| `show_bloom` | — | `own_node_addr`, `is_leaf_only`, `sequence`, `leaf_dependent_count`, `leaf_dependents[]`, `peer_filters[]`, `uptree_fill_ratio` (fill ratio of the last filter actually sent to the tree parent), `uptree_estimated_count` (cardinality estimate of that uptree filter — this node's whole subtree under split-horizon, not the mesh; both are `null` for a root node or before the first announce), `stats`. |
| `show_mmp` | — | `peers[]` (link-layer per peer), `sessions[]` (session-layer per session). Each entry includes loss/RTT/ETX/goodput, smoothed values, trends. |
| `show_cache` | — | `count`, `max_entries`, `fill_ratio`, `default_ttl_ms`, `expired`, `avg_age_ms`, `entries[]` — per-destination coords, depth, age, last-used, optional `path_mtu`. |
| `show_connections` | — | `connections[]` — pending handshakes: `link_id`, `direction`, `handshake_state`, `started_at_ms`, `idle_ms`, `resend_count`, optional `expected_peer`. |
| `show_transports` | — | `transports[]``transport_id`, `type`, `state`, `mtu`, `name`, `local_addr`, optional `tor_mode`, `onion_address`, `tor_monitoring`, `stats`. |
| `show_routing` | — | `coord_cache_entries`, `identity_cache_entries`, `pending_lookups[]`, `pending_tun_destinations`, `pending_tun_packets`, `recent_requests`, `retries[]`, `forwarding`, `discovery`, `error_signals`, `congestion`. |
| `show_routing` | — | `coord_cache_entries`, `identity_cache_entries`, `pending_lookups[]`, `pending_tun_destinations`, `pending_tun_packets`, `recent_requests`, `retries[]`, `forwarding`, `discovery` (request/response sub-counters; includes `req_deduplicated` — requests suppressed as recent duplicates — and `req_dedup_cache_full` — requests admitted because the dedup cache was full), `error_signals`, `congestion`. |
| `show_identity_cache` | — | `entries[]`, `count`, `max_entries`. Each entry: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`. |
| `show_listening_sockets` | — | `fips0_addr`, `firewall_active` (bool — `inet fips` table loaded), `sockets[]`. Each entry: `proto` (`tcp` / `udp`), `local_addr` (`::` or the node's fd00::/8 address), `port`, `pid` (nullable), `process` (nullable), `wildcard_bind` (bool — `local_addr == ::`), `filter` (`accept` / `drop` / `unknown` / `no_firewall`). Linux-only; returns an empty `sockets[]` on other platforms. |
| `show_stats_list` | — | `metrics[]` (each with `name`, `unit`, `scope`), `fast_ring_seconds`, `slow_ring_minutes`, `peer_retention_seconds`. |
| `show_metrics` | — | Flat snapshot of every counter family in the metrics registry: `forwarding`, `discovery`, `tree`, `bloom`, `congestion`, `errors`. Each value is that family's counter snapshot object. Counter-only — gauges/histograms that need the live node are excluded. Served off the main loop. Silent-rejection sites classify their reason as a typed `RejectReason` and increment the matching per-family counter exposed here — see [Rejection reasons](#rejection-reasons). |
| `show_stats_history` | `metric` (req), `peer` (req for per-peer metrics), `window` (`<N>s` / `<N>m` / `<N>h`, default `10m`), `granularity` (`1s` / `1m`, default `1s`) | A single `Series`: `metric`, `unit`, `granularity_seconds`, `values[]`. |
| `show_stats_all_history` | `peer` (optional npub), `window`, `granularity` | `granularity_seconds`, `window_seconds`, `peer`, `series[]` (one per metric). |
| `show_stats_peers` | — | `peers[]`, `count`. Each entry: `npub`, `node_addr`, `display_name`, `is_active`, `first_seen_secs_ago`, `last_contact_secs_ago`. |
@@ -124,11 +125,35 @@ The schema of each query response is pinned by snapshot tests in
`src/control/snapshots/`; intentional schema changes regenerate those
fixtures.
### Rejection reasons
Silent-rejection paths across the node classify why a message was
dropped via a typed `RejectReason` rather than only logging it, so the
*what* of a rejection is visible in the counter snapshots above. The
top-level reason set has eight families, mirroring the protocol-layer /
subsystem split of the metrics:
- **Tree** — spanning-tree `TreeAnnounce` processing rejections.
- **Bloom** — bloom-filter `FilterAnnounce` processing rejections.
- **Discovery** — discovery request / response processing rejections.
- **Handshake** — Noise handshake state-machine rejections.
- **Session** — FSP session state-machine rejections.
- **Mmp** — MMP link-layer rejections.
- **Forwarding** — forwarding-path rejections (no-route, TTL, MTU).
- **Transport** — transport-layer rejections (admission caps, framing).
Each rejection increments the corresponding counter in its family's
stats, surfaced through `show_metrics` (the `tree`, `bloom`,
`discovery`, and `forwarding` families carry their own counters; the
`errors` family and the remaining subsystem counters carry the rest).
The full per-family variant list lives in `src/node/reject.rs`; it is
not reproduced here to avoid duplicating the source.
### Mutating commands
| Command | Required params | Behaviour |
| ------- | --------------- | --------- |
| `connect` | `npub` (bech32), `address` (transport endpoint), `transport` (`udp`, `tcp`, `tor`, `ethernet`) | Asks the node to dial the peer over the named transport. Returns the API result on success or an error string on failure. |
| `connect` | `npub` (bech32), `address` (transport endpoint), `transport` (`udp`, `tcp`, `tor`, `nym`, `ethernet`) | Asks the node to dial the peer over the named transport. The named transport must be configured and running. Returns the API result on success or an error string on failure. |
| `disconnect` | `npub` (bech32) | Asks the node to drop the link to the named peer. |
Both commands run on the daemon's main task and may block briefly
+19
View File
@@ -34,6 +34,8 @@ module.
| `connections_rejected` | Rejected inbound connections (limit exceeded) |
| `connect_timeouts` | Connection timeout count |
| `connect_refused` | Connection refused count |
| `pool_inbound` | Current inbound connections held in the connection pool (gauge) |
| `pool_outbound` | Current outbound connections held in the connection pool (gauge) |
## Ethernet
@@ -61,6 +63,23 @@ module.
| `connections_accepted` | Accepted inbound connections via onion service |
| `connections_rejected` | Rejected inbound connections (limit exceeded) |
| `control_errors` | Tor control port errors |
| `pool_inbound` | Current inbound connections held in the connection pool (gauge) |
| `pool_outbound` | Current outbound connections held in the connection pool (gauge) |
## Nym
| Counter | Description |
| ------- | ----------- |
| `packets_sent` / `bytes_sent` | Successful sends |
| `packets_recv` / `bytes_recv` | Successful receives |
| `send_errors` / `recv_errors` | Send/receive failures |
| `mtu_exceeded` | Packets rejected for MTU violation |
| `connections_established` | Successful SOCKS5 connections through `nym-socks5-client` |
| `connect_timeouts` | Connection timeout count |
| `socks5_errors` | SOCKS5 protocol errors |
Nym is outbound-only (no inbound listener), so there are no
`connections_accepted` / `connections_rejected` counters.
## Bluetooth
+321
View File
@@ -0,0 +1,321 @@
# FIPS v0.4.0
**Released**: 2026-06-27
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
Nym mixnet transport and opt-in mDNS LAN discovery), overhauls the data
plane for higher single-node throughput and lower per-packet CPU, moves
the entire operator read surface off the data-plane hot path so
observability stays responsive under load, ships a reworked `fipstop`
TUI, and hardens FMP and FSP rekey to be hitless under packet loss in
both directions. It also folds in the accumulated mesh-convergence,
admission-control, and packaging fixes from the maintenance line.
v0.4.0 is wire-compatible with v0.3.0. Mixed meshes interoperate; there
is no flag-day upgrade. A deployed v0.3.0 node and an upgraded v0.4.0
node peer, rekey, and route normally, so you can roll the upgrade out
across a mesh in any order.
## At a glance
- New outbound Nym mixnet transport with a single-container demo and a
new mixnet-relay example.
- Opt-in mDNS / DNS-SD discovery on the local link.
- Data-plane overhaul: off-task encrypt and decrypt worker pools, GSO,
connected-UDP send path, copy-avoidance on receive, batched macOS
receive.
- The full `show_*` read surface now serves off the receive loop, so
`fipsctl` and `fipstop` stay responsive on loaded nodes; a new
counter-only `show_metrics` query enables a Prometheus scraper at no
hot-path cost.
- Reworked `fipstop` TUI on a machine-verified render-snapshot base.
- 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
### Nym mixnet transport
FIPS can now peer over the [Nym](https://nymtech.net/) mixnet for
metadata-resistant connectivity. The new `transports.nym` transport
makes outbound connections through a `nym-socks5-client` SOCKS5 proxy
that you run alongside the daemon (for example as a service running
alongside the fips daemon, or as a sidecar container). The transport
waits at startup for the nym-socks5-client to become ready before giving
up.
This is a privacy and anonymity deployment mode chosen for its own
properties. It mixes your FIPS traffic into the Nym cover-traffic
network so that link-level observers cannot correlate which mesh peers
are talking. A new `examples/sidecar-nostr-mixnet-relay/` demonstrates a
FIPS-reachable Nostr relay peered across the mixnet end to end, and a
single-container demo ships with the transport.
Enable it by adding a `transports.nym` instance and pointing it at your
running nym-socks5-client. See the transports reference for the field
set.
### mDNS LAN discovery
Nodes on a shared local link can now find each other with zero address
configuration. The opt-in `node.discovery.lan` path runs an mDNS /
DNS-SD responder and browser: each node advertises a FIPS service record
on the link and adopts the peers it discovers. This complements the
existing Nostr-mediated overlay discovery for the common case where the
peers are simply on the same LAN.
Turn it on with `node.discovery.lan.enabled: true`. `service_type` and
`scope` tune the advertised service record and which interfaces
participate. Discovery on the local link needs no relay and no STUN.
### Data-plane throughput overhaul
The receive and send paths were reworked for higher single-node
throughput and lower per-packet CPU, building on the v0.3.0
crypto-backend swap:
- **Off-task encrypt and decrypt.** Per-peer encrypt and decrypt now run
on dedicated worker tasks rather than inline on the receive loop, so a
single busy peer no longer serializes the whole node's crypto.
- **GSO and connected-UDP send.** The Linux send path uses generic
segmentation offload and a connected-UDP socket where available,
cutting syscall overhead on bulk flows.
- **Copy-avoidance on receive.** The receive hot path avoids buffer
copies it previously made per packet.
- **Batched macOS receive.** macOS gains a `recvmsg_x` batched receive,
mirroring the Linux `recvmmsg` batching from v0.3.0.
- **Shared immutable-state context and an atomic metric registry.**
Immutable per-node state moved into a single shared context, and
counters live in an atomic metric registry that the new `show_metrics`
query reads without touching the hot path.
These are all internal to the data plane and require no operator action.
### Observability off the hot path
Every read-only control query now renders from a snapshot published once
per tick into a lock-free `ArcSwap`, served from the control accept task
instead of round-tripping the data-plane receive loop. This covers
`show_status`, `show_stats_*`, `show_peers`, `show_sessions`,
`show_links`, `show_connections`, `show_transports`, `show_mmp`,
`show_tree`, `show_bloom`, `show_cache`, `show_routing`,
`show_identity_cache`, `show_acl`, `show_listening_sockets`, and the new
`show_metrics`. Only the mutating `connect` and `disconnect` commands
still reach the loop.
The practical effect: on a loaded node where the receive loop was busy,
`fipsctl` and `fipstop` queries previously stalled or timed out (the
five-second query pattern operators saw). They now answer promptly
regardless of data-plane load. Per-entity snapshots reuse unchanged rows
by pointer, so the per-tick publish cost stays bounded as peer and
session counts grow.
A new **`show_metrics`** query (surfaced as `fipsctl stats metrics`)
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
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
`fipstop` gets a rendering, navigation, and read-surface overhaul on a
machine-verified base: a render-snapshot harness asserts the exact text
grid and per-cell style of every view against canned control-socket
output. New daemon-resolved fields surface through the snapshots,
including effective persistence, root and is-root state, a
per-transport-type peer-count map, per-peer effective depth, the root
npub, and the last-sent uptree filter fill ratio with the subtree size
estimate.
A separate fix clears a garbled-screen problem on startup and stray
bytes on quit, most visible over SSH and inside tmux: startup now forces
a full repaint before the first draw, and quit stops and joins the
stdin-poll thread before restoring the terminal, so post-raw-mode
keystrokes no longer echo onto the restored screen.
### Rekey reliability
FMP and FSP session rekey are now hitless under packet loss and
reordering in both directions:
- Inbound frames are authenticated against the pending session before
the K-bit cutover promotes it, so a spoofed or stale frame cannot
derail a rekey in progress.
- Rekey message-1 retransmission is bounded, and the link-dead heartbeat
is rekey-aware so an in-flight rekey is not mistaken for a dead link.
- FSP session rekey holds connectivity across the rekey window under
loss and reordering.
- Dual-initiation races (both peers starting a rekey at once on a
high-latency link) are desynchronized with symmetric jitter so the two
sides converge on one session rather than fighting.
- An exhausted retransmission-budget abort, an expected and self-limiting
outcome on lossy or high-latency links, is logged at debug rather than
warn.
The net operator takeaway: rekey completes cleanly without dropping
traffic, even on lossy or high-latency links, and the log no longer
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
These affect operators on upgrade.
- **Bloom filter antipoison cap raised.** `node.bloom.max_inbound_fpr`
moves from 0.05 to 0.10, accepting filters with a higher derived
false-positive rate before rejecting them. This reduces spurious
filter rejections on larger meshes while keeping the antipoison
protection in place.
- **TCP inbound cap honors `max_connections`.** The TCP inbound accept
ceiling now resolves from explicit per-transport
`max_inbound_connections`, then node-wide
`node.limits.max_connections`, then the built-in default of 256.
Previously the TCP inbound ceiling was hardwired to 256 and ignored
`max_connections`, so raising it had no effect on inbound TCP.
- **Static host aliases hot-reload.** `/etc/fips/hosts` now reloads on
mtime change once per tick rather than only at startup, so display
names in `fipsctl` and `fipstop` reflect edits without a daemon
restart. The peer ACL reloads through the same lock-free snapshot
mechanism.
- **Quieter logs on busy public-mesh nodes.** Routine per-peer
connection-lifecycle and capacity-cap events, no-route session-datagram
drops, and exhausted rekey-budget aborts are demoted to debug, so
genuinely notable info and warn lines are no longer drowned out.
- **More visible drops.** Receive-path silent rejections now flow
through typed reject-reason counters, and discovery counts requests
dropped when the dedup cache is full (`req_dedup_cache_full`, visible
via `show_routing`). Drops that were previously silent are now
countable.
- **Tor connect-refused accounting.** The Tor transport increments its
`connect_refused` statistic (the "Refused" line in `fipstop`) on an
actively-refused SOCKS5 connect, instead of recording every connect
failure as a generic SOCKS5 error.
## Notable bug fixes
The CHANGELOG has the exhaustive list. This is the operator-relevant
subset of fixes for behavior that shipped in v0.3.0.
- **Symmetric peer teardown on manual disconnect.** A manual
`fipsctl disconnect` now sends the peer a scoped Disconnect so both
ends tear down and re-handshake cleanly. Previously a manual
disconnect tore down only the local side, leaving the peer with a
stale session that was never re-adopted as a child and whose bloom
filter was never re-recorded.
- **Gateway holds long-lived and DNS-cached mappings.** `fips-gateway`
no longer drops a virtual-IP mapping while traffic is still flowing.
The mapping TTL clock previously advanced only on DNS re-query, so a
busy long-lived or DNS-cached client could have its mapping reclaimed
mid-flow. The tick now refreshes the mapping whenever conntrack reports
active sessions and recovers a draining mapping to active when traffic
resumes; only genuinely idle mappings drain.
- **Accurate mesh-size estimate under filter overlap.** The mesh-size
estimator now estimates the cardinality of the OR-union of self plus
every connected peer's inbound filter, instead of summing per-filter
cardinalities of tree peers. Summing assumed the filters were disjoint,
so a stale or oversized parent filter or a routing loop inflated the
reported mesh size and a tree rebalance flapped the count. OR-union
deduplicates overlap, equals the old result in the disjoint case, and
removes the estimate's dependence on tree-declaration cache freshness.
- **Single-uplink node reattaches within a round-trip.** A node with one
tree peer, which has periodic parent re-evaluation disabled, was left
self-rooted and unreachable if its one-shot attaching TreeAnnounce was
lost, until the next periodic re-broadcast. Tree-position exchange is
now self-healing on the receive path: a node that hears an announce
advertising a strictly worse root echoes its own declaration back,
provoking the better-rooted peer to re-push its real position
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
Operator-actionable items moving from v0.3.0 to v0.4.0:
- **Wire-compatible, no flag day.** v0.4.0 peers with v0.3.0. Upgrade
nodes in any order. During a rolling upgrade you may see some log lines
on the upgraded side as it interacts with not-yet-upgraded peers;
behavior is correct, log noise only.
- **Bloom antipoison cap default changed.** `node.bloom.max_inbound_fpr`
now defaults to 0.10 (was 0.05). If you set this explicitly, review
whether you still want the old value.
- **New optional config surfaces.** `transports.nym` (outbound Nym
mixnet) and `node.discovery.lan` (mDNS LAN discovery) are both opt-in
and off by default. Adding them is the only way to turn the new paths
on.
- **TCP inbound cap.** If you relied on the old hardwired 256 inbound-TCP
ceiling, note it now honors `max_inbound_connections` then
`node.limits.max_connections` then 256.
- **New observability query.** `fipsctl stats metrics` (the
`show_metrics` control query) returns a counter-only snapshot suitable
for a scraper.
## Getting v0.4.0
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
[v0.4.0 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.0).
- **Arch Linux**: `fips` from the AUR.
- **macOS**: `.pkg` at the v0.4.0 release page.
- **Windows**: ZIP 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
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
required Linux build prerequisite).
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.0 tag
builds the binaries from source with the pinned toolchain and no manual
prerequisites (see the Nix section of `packaging/README.md`).
The full per-commit changelog lives in
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
## Contributors
Thanks to everyone who contributed code, packaging work, bug reports, or
reviews to this release.
- [@jcorgan](https://github.com/jmcorgan): release shepherd, high-level
design, control read plane, rekey hardening, admission, bug fixes,
testing, packaging, PR coordination, and issue resolution.
- [@mmalmi](https://github.com/mmalmi): opt-in mDNS LAN discovery and
data-plane performance work.
- [@Origami74](https://github.com/Origami74): macOS packaging and
website coordination.
- [@dskvr](https://github.com/dskvr): AUR packaging.
- [@oleksky](https://github.com/oleksky): Nym mixnet transport and the
single-container mixnet demo.
+146
View File
@@ -0,0 +1,146 @@
# FIPS v0.4.1
**Released**: 2026-07-19
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
antipoison cap on inbound bloom filter announcements, removes a redundant
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
cuts per-packet CPU in the bloom and identity paths. There is no wire
format change and no new feature surface.
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
with no coordinated restart, though one behavior change below is worth
reading before you start a rolling upgrade.
## At a glance
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
- The `parent_switched` metric counter is gone. Use `parent_switches`.
- Spanning tree no longer serves stale coordinates after a parent link is
lost through peer removal.
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
- Bloom probing and identity operations do measurably less work per call,
with identical results.
## Behavior changes worth flagging
### The inbound filter FPR cap default doubles again
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
inbound `FilterAnnounce` frames whose advertised false positive rate
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
aggregates had started reaching that ceiling as the mesh grew. `0.20`
corresponds to a fill of 0.7248 and roughly 2,114 entries.
Be aware that this is the second time in two releases that this default
has doubled, for the same reason both times. That is worth stating plainly
rather than repeating the previous release's framing: raising the cap buys
headroom, it does not fix anything. The real constraint is the fixed 1 KB
filter size, which is a protocol constant. The structural remedy is the v2
filter work, where filter capacity scales with the mesh instead of being
pinned. This release is an interim step to keep legitimate aggregates from
being rejected until that lands. It is not the start of a pattern of
raising the cap once per release, and if you are sizing capacity planning
around this number, plan against the v2 work rather than against a third
raise.
The antipoison property the cap exists for is preserved. A saturated or
deliberately poisoned filter still presents an FPR near 100% and is still
rejected.
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
drops the same frame, and the drop is silent on the wire with no NACK. The
cap also gates the mesh size estimator, which declines to produce a value
when any contributing filter is over the cap. So while a mesh is partly
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
different mesh sizes, or one can report a size while the other reports
unknown. This resolves once every node is on v0.4.1. If you want to avoid
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
your config before upgrading and remove it after the last node is done.
### The `parent_switched` counter is removed
`parent_switched` was incremented on the line immediately before
`parent_switches` at every site and never independently, so the two
counters always held the same value. `parent_switched` is now gone from
the tree metrics, the control socket snapshot, and the `fipstop` tree
view. `parent_switches` remains and is unchanged.
If you scrape the control socket, or have dashboards or alerts referencing
`parent_switched`, point them at `parent_switches`. Anything still asking
for `parent_switched` will find nothing rather than a zero.
## Notable bug fixes
### Stale coordinates after losing a parent through peer removal
When a node's parent link dropped via peer removal, the node correctly
reparented or self-rooted, but skipped the coordinate cache invalidation
that every other position-change path performs. Cached entries for
downstream destinations kept the node's old coordinate prefix. This did
not self-correct the way a stale cache entry normally would: routing
access refreshes an entry's TTL, so an entry that was actively being
routed through never expired, and was only fixed by an unrelated fresh
insert. Both invalidation classes now run on this path, matching the
loop-detection branch.
### Discovery could loosen a tightened path MTU clamp
An originator handling a `LookupResponse` overwrote its cached path MTU
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
already taught it a tighter value, a later, looser discovery estimate
would clobber that and re-loosen the clamp, risking a return to dropped
oversized packets. The cached and received values are now compared and the
tighter one is kept.
## Upgrade notes
This is a drop-in upgrade from v0.4.0 with no wire format change, no
config migration, and no coordinated restart. Upgrade nodes in whatever
order you like.
Two things to do rather than assume:
1. If you monitor `parent_switched`, move to `parent_switches` before
upgrading, or your dashboards will go blank rather than error.
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
to potentially disagree about mesh size, per the FPR cap section above.
This is expected and self-resolves. Do not chase it as a bug unless it
persists after every node reports `0.4.1`.
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
your setting is honored and nothing changes for you. The change only
affects nodes taking the default.
Downgrading to v0.4.0 is supported and needs no special handling.
## Getting v0.4.1
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
- **Arch Linux**: `fips` from the AUR.
- **macOS**: `.pkg` at the v0.4.1 release page.
- **Windows**: ZIP at the v0.4.1 release page.
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
at the v0.4.1 release page.
- **From source**: `cargo build --release` from a checkout of the v0.4.1
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
required Linux build prerequisite).
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
builds the binaries from source with the pinned toolchain and no manual
prerequisites (see the Nix section of `packaging/README.md`).
The full per-commit changelog lives in
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
## Contributors
Thanks to everyone who contributed code, packaging work, bug reports, or
reviews to this release.
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
and discovery fixes, bloom and identity performance work, antipoison cap
change, and testing.
+2 -3
View File
@@ -98,11 +98,10 @@ is what you want.
Or via the daemon:
```sh
sudo fipsctl show identities
sudo fipsctl show status
```
The first JSON entry has `local: true` and a `ula` field — that
is your address.
The JSON has an `ipv6_addr` field — that is your address.
For the rest of this tutorial we will write the address as
`<your-fips0-addr>`. Substitute the actual `fd97:...` value
+5 -3
View File
@@ -63,9 +63,11 @@ mesh address:
dig npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98.fips AAAA +short
```
You should see one AAAA record returning a `fd97:...` address.
The prefix is the FIPS ULA range (`fd00::/8`, with `fd97:...`
covering the address space derived from npubs).
You should see one AAAA record returning an address such as
`fd97:...`. The prefix is the FIPS ULA range (`fd00::/8`): only
the leading `fd` byte is fixed, and everything after it is hash
output derived from the npub, so the digits beyond `fd` vary per
node.
The query went through `systemd-resolved` (or your platform
equivalent), which routed `.fips` queries to the daemon's local
+5 -2
View File
@@ -56,8 +56,11 @@ hostname on the public internet. There is no separate
the tool takes a hostname, it accepts a `.fips` hostname.
> **Where the address comes from.** Every FIPS node's mesh
> address is the SHA-256 of its public key, truncated to the
> bottom 64 bits and prepended with `fd97:`. Names of the form
> address is the first 16 bytes of SHA-256 of its public key,
> with the leading byte replaced by `0xfd` (the `fd00::/8` ULA
> prefix). The remaining bytes are hash output, so an address
> like `fd97:...` is per-node — the `97` is part of the hash,
> not a fixed prefix shared across nodes. Names of the form
> `<npub>.fips` and any shortname mapped in `/etc/fips/hosts`
> are aliases for that address. The daemon's local DNS
> responder hands the answer back to your kernel without ever
+1 -1
View File
@@ -39,7 +39,7 @@ fips sidecar:
4. Starts dnsmasq and then `exec`s the FIPS daemon.
The app container starts concurrently and immediately sees `lo`, `eth0`, and
`fips0`. DNS for `<npub>.fips` names resolves to `fd::/8` addresses via the
`fips0`. DNS for `<npub>.fips` names resolves to `fd00::/8` addresses via the
dnsmasq → FIPS daemon pipeline.
```text
+37
View File
@@ -0,0 +1,37 @@
# FIPS-over-Nym-mixnet demo configuration.
# Override these values or create a .env.local file.
# Node identity — generate with: fipsctl keygen
# Must be set before running: export FIPS_NSEC=<your-nsec>
FIPS_NSEC=
# Peer configuration (leave FIPS_PEER_NPUB empty for standalone operation).
# The peer MUST expose a TCP endpoint in nym mode — the Nym SOCKS5 proxy
# tunnels TCP streams. Find more public peers at https://join.fips.network/
# Default peer test-us03 exposes tcp:54.183.70.180:443 and udp:…:2121.
# For udp mode (see FIPS_PEER_TRANSPORT below) change FIPS_PEER_ADDR to
# 54.183.70.180:2121.
# The alias doubles as the peer's .fips hostname (<alias>.fips), so it
# must be a plain hostname label — no dots.
FIPS_PEER_NPUB=npub136yqae6na688fs75g95ppps3lxe07fvxefj77938zf47uhm6074sxw8ctm
FIPS_PEER_ADDR=54.183.70.180:443
FIPS_PEER_ALIAS=test-us03
# Transport — THE switch that selects mixnet vs. direct: nym | tcp | udp
# nym : peer traffic goes through the Nym mixnet via the in-container
# nym-socks5-client (started automatically, before FIPS). DEFAULT.
# tcp : direct TCP to FIPS_PEER_ADDR; the nym client is NOT started.
# udp : direct UDP — also set FIPS_PEER_ADDR to the peer's :2121 endpoint.
# To go back to a direct link, just set this to tcp (or udp) and re-run
# `docker compose up`. Nothing else needs to change for tcp.
FIPS_PEER_TRANSPORT=nym
# ----- Nym mixnet (only used when FIPS_PEER_TRANSPORT=nym) -----
# Network-requester service provider. Leave empty to auto-discover the
# best-scored provider from https://harbourmaster.nymtech.net/ at startup.
NYM_SERVICE_PROVIDER=
NYM_CLIENT_ID=fips-nym-client
FIPS_NYM_SOCKS5_ADDR=127.0.0.1:1080
# Logging
RUST_LOG=info
@@ -0,0 +1 @@
.env.local
@@ -0,0 +1,151 @@
# Single-container FIPS-over-Nym-mixnet demo.
#
# Everything runs in ONE container: the FIPS daemon, the nym-socks5-client
# mixnet proxy, the strfry Nostr relay, nginx, and dnsmasq. The entrypoint
# starts them in strict order so the SOCKS5 proxy is up before FIPS dials
# its peer through the mixnet.
#
# Platform: the image builds NATIVE for the host. The FIPS daemon must NOT
# run under emulation — Rosetta mis-translates the amd64 ChaCha20-Poly1305
# assembly (ring/BoringSSL), silently failing AEAD on larger frames (bloom
# filter announces are the first casualty). fips is therefore always
# compiled for the native arch.
#
# nym-socks5-client is the official prebuilt amd64 binary (Nym ships no
# other arch). On amd64 hosts it runs natively; on arm64 (Apple Silicon)
# it runs via Docker Desktop's binfmt/Rosetta handler, with the x86-64
# loader + glibc copied in from an amd64 stage. The nym client tolerates
# emulation; fips does not.
# ── Build stage: compile FIPS from source ──
FROM rust:1.94-slim-trixie AS builder
# bluer (BLE) and rustables (nftables) are unconditional dependencies on
# glibc Linux: bluer needs the dbus headers, rustables runs bindgen
# (libclang) against the libnftnl headers.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
pkg-config libdbus-1-dev libnftnl-dev libclang-dev clang && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY Cargo.toml Cargo.lock rust-toolchain.toml build.rs ./
COPY src ./src
RUN cargo build --release && \
cp target/release/fips target/release/fipsctl target/release/fipstop /usr/local/bin/
# ── strfry stage: collect the binary and its musl runtime ──
# The official strfry image is alpine (musl) based; the runtime stage below
# is debian (glibc), so the musl dynamic loader and the exact set of shared
# libraries strfry links against must come along.
FROM ghcr.io/hoytech/strfry:latest AS strfry
RUN mkdir -p /strfry-libs && \
ldd /app/strfry | awk '$3 ~ /^\// {print $3}' | xargs -I{} cp {} /strfry-libs/ && \
cp /lib/ld-musl-*.so.1 /strfry-libs/
# ── nym stage: official prebuilt amd64 binary + its glibc runtime ──
# Pinned amd64-only stage regardless of host arch. Collects the x86-64
# dynamic loader and the binary's library closure so the binary can run
# inside the native (possibly arm64) runtime image via binfmt/Rosetta.
FROM --platform=linux/amd64 debian:trixie-slim AS nym
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
# Bumping the version = edit the tag in this URL.
RUN curl -sSL --retry 3 \
"https://github.com/nymtech/nym/releases/download/nym-binaries-v2026.11-xynomizithra/nym-socks5-client" \
-o /nym-socks5-client && \
chmod +x /nym-socks5-client
RUN mkdir -p /nym-rt/lib64 /nym-rt/libs && \
cp /lib64/ld-linux-x86-64.so.2 /nym-rt/lib64/ && \
ldd /nym-socks5-client | awk '$3 ~ /^\// {print $3}' | xargs -I{} cp {} /nym-rt/libs/
# ── Runtime stage ──
FROM debian:trixie-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
iproute2 iputils-ping dnsutils dnsmasq iptables \
openssh-client openssh-server python3 \
tcpdump netcat-openbsd curl jq iperf3 nginx && \
rm -rf /var/lib/apt/lists/*
# Install nak (Nostr Army Knife) — detect arch and download the correct binary.
# Asset naming: nak-<version>-linux-<arch>
RUN ARCH=$(dpkg --print-architecture) && \
case "$ARCH" in \
amd64) NAK_ARCH="linux-amd64" ;; \
arm64) NAK_ARCH="linux-arm64" ;; \
armhf) NAK_ARCH="linux-arm" ;; \
*) echo "Unsupported arch: $ARCH" && exit 1 ;; \
esac && \
NAK_VERSION=$(curl -sSL --retry 3 \
"https://api.github.com/repos/fiatjaf/nak/releases/latest" \
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') && \
echo "Installing nak ${NAK_VERSION} for ${NAK_ARCH}" && \
curl -sSL --retry 3 \
"https://github.com/fiatjaf/nak/releases/download/${NAK_VERSION}/nak-${NAK_VERSION}-${NAK_ARCH}" \
-o /usr/local/bin/nak && \
chmod +x /usr/local/bin/nak && \
nak --version
# nym-socks5-client: prebuilt amd64 binary plus its x86-64 loader/glibc.
# On amd64 these COPYs overwrite identical files; on arm64 they add the
# x86-64 runtime alongside the native one (paths don't collide). The
# version check doubles as a binfmt/Rosetta smoke test on arm64 hosts.
COPY --from=nym /nym-socks5-client /usr/local/bin/nym-socks5-client
COPY --from=nym /nym-rt/lib64/ /lib64/
COPY --from=nym /nym-rt/libs/ /lib/x86_64-linux-gnu/
RUN echo "Installed:" && /usr/local/bin/nym-socks5-client --version
# Setup SSH server with no authentication (test only!)
RUN mkdir -p /var/run/sshd && \
ssh-keygen -A && \
sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \
sed -i 's/#PermitEmptyPasswords no/PermitEmptyPasswords yes/' /etc/ssh/sshd_config && \
sed -i 's/UsePAM yes/UsePAM no/' /etc/ssh/sshd_config && \
passwd -d root
# dnsmasq: forward .fips to FIPS daemon, everything else to Docker DNS
RUN printf '%s\n' \
'port=53' \
'listen-address=127.0.0.1' \
'bind-interfaces' \
'server=/fips/127.0.0.1#5354' \
'server=127.0.0.11' \
'no-resolv' \
>> /etc/dnsmasq.conf
# strfry: binary, musl loader, and its libraries in a private directory.
# /etc/ld-musl-<arch>.path tells the musl loader where to search, keeping
# the musl libraries invisible to the system glibc loader.
COPY --from=strfry /app/strfry /usr/local/bin/strfry
COPY --from=strfry /strfry-libs/ /opt/strfry-libs/
RUN mv /opt/strfry-libs/ld-musl-*.so.1 /lib/ && \
echo "/opt/strfry-libs" > /etc/ld-musl-$(uname -m).path && \
mkdir -p /usr/src/app/strfry-db && \
strfry --version
# nginx: reverse proxy port 80 (IPv4 + IPv6) → strfry 127.0.0.1:7777
RUN printf 'server {\n\
listen 80;\n\
listen [::]:80;\n\
location / {\n\
proxy_pass http://127.0.0.1:7777;\n\
proxy_http_version 1.1;\n\
proxy_read_timeout 1d;\n\
proxy_send_timeout 1d;\n\
proxy_set_header Upgrade $http_upgrade;\n\
proxy_set_header Connection "Upgrade";\n\
proxy_set_header Host $host;\n\
}\n\
}\n' > /etc/nginx/conf.d/nostr-relay.conf && \
rm -f /etc/nginx/sites-enabled/default
COPY --from=builder /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop /usr/local/bin/
COPY examples/sidecar-nostr-mixnet-relay/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
@@ -0,0 +1,7 @@
target/
target-*/
.git/
.github/
testing/
examples/
!examples/sidecar-nostr-mixnet-relay/
@@ -0,0 +1,173 @@
# FIPS over a Mixnet — Single-Container Demo (Nym)
An isolated environment demonstrating how FIPS peer traffic can travel
through a **mixnet** — a network that hides traffic patterns by routing
each packet through several relays with cover traffic and timing
obfuscation. The mixnet here is [Nym](https://nym.com/), but the FIPS side
is transport-agnostic: it just sees a SOCKS5 proxy, so any mixnet exposing
one would slot in the same way.
**Everything runs in one Docker container**: the FIPS daemon, the mixnet
proxy (`nym-socks5-client`), a [strfry](https://github.com/hoytech/strfry)
Nostr relay behind nginx, and dnsmasq.
```
┌────────────────────────── one container ───────────────────────────┐
│ │
│ nginx :80 ──► strfry :7777 (Nostr relay, fips0-only) │
│ │
│ fips daemon ── transports.nym ──► nym-socks5-client :1080 │
│ │ │ │
│ ▼ ▼ Sphinx packets │
│ fips0 (TUN, fd00::/8) Nym gateway ► 3 mix hops ► │
│ network requester ► peer (TCP) │
│ │
│ iptables: direct route to the peer is DROPped — the FIPS link │
│ can only exist through the mixnet. │
└────────────────────────────────────────────────────────────────────┘
```
How the pieces interlock:
- The FIPS **nym transport** dials peers through a local SOCKS5 proxy; the
proxy routes each TCP stream through the mixnet (gateway → 3 mix hops →
network requester), which performs the final TCP connection to the peer.
The peer address must therefore be a **TCP endpoint** — find public peers
at <https://join.fips.network/>.
- The `nym-socks5-client` is started by the entrypoint **only when the
generated FIPS config contains a `transports.nym` block**
(`FIPS_PEER_TRANSPORT=nym`), and always **before** the FIPS daemon, so
the proxy is listening by the time FIPS dials.
- In nym mode, iptables **drops the direct route to the peer**: if the peer
handshake completes, the traffic provably went through the mixnet.
## Quick start
```bash
# 1. Generate a node identity (any machine with fipsctl, or reuse one):
fipsctl keygen
# 2. Put the nsec into the environment:
export FIPS_NSEC=<your-nsec>
# 3. Build and run (native image; FIPS compiles for your host's arch):
docker compose up --build
```
Watch the logs: the entrypoint auto-discovers a Nym service provider,
bootstraps the SOCKS5 client (`Nym SOCKS5 proxy ready …`), and only then
starts FIPS. After the mixnet handshake completes (can take 30120 s):
```bash
docker compose exec fips fipsctl show transports # nym transport: up
docker compose exec fips fipsctl show peers # test-us03: active
```
## Switching transport: mixnet ↔ direct (TCP/UDP)
The single knob is `FIPS_PEER_TRANSPORT` in `.env` (or an inline override).
It selects how FIPS reaches the peer **and** whether the mixnet proxy runs
at all — the two are always in sync.
```bash
# Default — through the Nym mixnet (anonymized, ~1-2 s RTT):
FIPS_PEER_TRANSPORT=nym docker compose up -d # or just `docker compose up -d`
# Direct TCP (no mixnet, ~50-300 ms RTT). The nym client is NOT started:
FIPS_PEER_TRANSPORT=tcp docker compose up -d
# Direct UDP — also point FIPS_PEER_ADDR at the peer's UDP endpoint:
FIPS_PEER_TRANSPORT=udp FIPS_PEER_ADDR=54.183.70.180:2121 docker compose up -d
```
What changes under the hood for each value:
| `FIPS_PEER_TRANSPORT` | nym client | FIPS config block | peer endpoint used | direct route to peer |
| --- | --- | --- | --- | --- |
| `nym` (default) | started, before FIPS | `transports.nym` | `FIPS_PEER_ADDR` (TCP) via SOCKS5 | **firewalled off** |
| `tcp` | not started | `transports.tcp` | `FIPS_PEER_ADDR` (TCP) direct | allowed |
| `udp` | not started | `transports.udp` | `FIPS_PEER_ADDR` (UDP `:2121`) direct | allowed |
To **switch back to a direct link**, set the value to `tcp` (no other change)
or `udp` (and swap `FIPS_PEER_ADDR` to the `:2121` endpoint), then re-run
`docker compose up -d`. To **return to the mixnet**, set it back to `nym`.
Persist your choice by editing `.env` instead of prefixing the command.
The same node can be compared both ways — direct shows ~50-300 ms RTT,
the mixnet ~1-2 s, which is the visible signature that traffic is routing
through the Sphinx mix hops.
## Verifying the traffic really crosses the mixnet
```bash
# The direct route to the peer is dropped — the only way packets reach
# the peer is via the nym-socks5-client:
docker compose exec fips iptables -L OUTPUT -v -n # DROP rule for peer IP
# Mixnet activity (Sphinx packet flow) in the nym client output:
docker compose logs fips | grep -i nym
# End-to-end data plane across the mesh. FIPS addresses every node by its
# key as <npub>.fips (each npub maps into fd00::/8); short names like
# `test-us03` are only local aliases for the peer you configured. Pick a
# node you are NOT directly linked to — grab a current npub from
# https://join.fips.network/ — so the ICMPv6 echo routes over the mixnet
# to your peer and then hop-by-hop across the mesh to the target:
docker compose exec fips ping6 -c3 <peer-npub>.fips
# A reply while the direct route is DROPped proves the traffic crossed the
# mixnet; the seconds-range RTT is the Sphinx path's signature, and a few
# extra hundred ms over reaching your own peer is the added mesh hops (a
# direct, non-mixnet connection would be ~30 ms).
```
The Nostr relay answers only over the FIPS mesh (fd00::/8) and on the
container's loopback — inbound eth0 traffic, including the host's port-80
mapping, is dropped by the isolation rules. Check it from inside:
```bash
docker compose exec fips curl -s -H "Accept: application/nostr+json" http://127.0.0.1/
```
## Configuration (.env)
| Variable | Default | Meaning |
| --- | --- | --- |
| `FIPS_NSEC` | *(required)* | Node identity, `fipsctl keygen` |
| `FIPS_PEER_NPUB` | test-us03's npub | Peer to dial; empty = standalone |
| `FIPS_PEER_ADDR` | `54.183.70.180:443` | **TCP** endpoint in nym/tcp mode (use `:2121` for udp) |
| `FIPS_PEER_TRANSPORT` | `nym` | `nym` \| `tcp` \| `udp` — see "Switching transport" above |
| `NYM_SERVICE_PROVIDER` | *(auto)* | Network requester; empty = pick the best-scored from [harbourmaster](https://harbourmaster.nymtech.net/) |
| `NYM_CLIENT_ID` | `fips-nym-client` | Nym client identity (kept in the `nym-data` volume) |
With `FIPS_PEER_TRANSPORT=tcp` or `udp` the nym client is **not started at
all** and FIPS connects directly — useful as a baseline comparison.
## Troubleshooting
- **`could not auto-discover a Nym service provider`** — the harbourmaster
API was unreachable or returned no providers; pick one manually from
<https://harbourmaster.nymtech.net/> and set `NYM_SERVICE_PROVIDER`.
- **Slow or failing mixnet bootstrap** — service providers and gateways
vary in quality. Delete the client state and retry with another provider:
`docker compose down -v && NYM_SERVICE_PROVIDER=<other> docker compose up`.
(The provider is baked into the client state at init; changing it
requires wiping the `nym-data` volume.)
- **Peer never becomes active** — confirm the peer's TCP endpoint is
reachable from the open internet (the network requester dials it from
the Nym exit side, not from your machine).
- **Never run this image under emulation** — the image builds native for
a reason: under Rosetta/qemu, the FIPS daemon's ChaCha20-Poly1305
assembly (ring/BoringSSL) silently fails AEAD on larger frames; bloom
filter announces are dropped and multi-hop routing never converges,
while small control traffic keeps working — a maddeningly subtle
failure mode. Only the embedded amd64 `nym-socks5-client` (Nym ships no
other arch) runs emulated on Apple Silicon, which it tolerates.
## Notes
- The container's lifecycle follows the FIPS daemon; strfry, nginx and the
nym client run as background processes inside the same container and are
restarted with it (`restart: unless-stopped`).
- SSH (port 22, no auth) and tools like `tcpdump`, `nak`, `iperf3` are
inside the image for poking around — this is a demo image, do not expose
it beyond your machine.
@@ -0,0 +1,55 @@
networks:
fips-net:
name: ${FIPS_NETWORK:-fips-mixnet-net}
driver: bridge
ipam:
config:
- subnet: ${FIPS_SUBNET:-172.20.2.0/24}
services:
# Single container running ALL services: fips daemon, nym-socks5-client,
# strfry Nostr relay, nginx, dnsmasq. See entrypoint.sh for start order.
fips:
# Builds NATIVE for the host — fips must not run under emulation
# (Rosetta breaks its AEAD on larger frames). Only the embedded
# amd64 nym-socks5-client is emulated on Apple Silicon.
build:
context: ../..
dockerfile: examples/sidecar-nostr-mixnet-relay/Dockerfile
hostname: fips-mixnet
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
restart: unless-stopped
ports:
- "2121:2121/udp" # FIPS UDP transport
- "8443:8443/tcp" # FIPS TCP transport
- "80:80/tcp" # Nostr relay WebSocket (via nginx)
environment:
- RUST_LOG=${RUST_LOG:-info}
- FIPS_NSEC=${FIPS_NSEC}
- FIPS_PEER_NPUB=${FIPS_PEER_NPUB:-}
- FIPS_PEER_ADDR=${FIPS_PEER_ADDR:-}
- FIPS_PEER_ALIAS=${FIPS_PEER_ALIAS:-peer}
- FIPS_PEER_TRANSPORT=${FIPS_PEER_TRANSPORT:-nym}
- FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121}
- FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280}
- FIPS_UDP_MTU=${FIPS_UDP_MTU:-1472}
- FIPS_NYM_SOCKS5_ADDR=${FIPS_NYM_SOCKS5_ADDR:-127.0.0.1:1080}
- NYM_CLIENT_ID=${NYM_CLIENT_ID:-fips-nym-client}
- NYM_SERVICE_PROVIDER=${NYM_SERVICE_PROVIDER:-}
volumes:
- ./resolv.conf:/etc/resolv.conf:ro
- ./relay/strfry.conf:/usr/src/app/strfry.conf:ro
- relay-data:/usr/src/app/strfry-db
- nym-data:/root/.nym
networks:
fips-net:
ipv4_address: ${FIPS_IPV4:-172.20.2.20}
volumes:
relay-data:
nym-data:
+221
View File
@@ -0,0 +1,221 @@
#!/bin/bash
# Single-container entrypoint: generate the FIPS config, apply iptables
# isolation, start the Nostr relay (strfry + nginx), start the Nym SOCKS5
# client (only when the FIPS config enables the nym transport), and launch
# FIPS last — so the mixnet proxy is provably up before FIPS dials its peer.
set -e
# --- Generate FIPS config from environment variables ---
FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}"
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
FIPS_TCP_BIND="${FIPS_TCP_BIND:-0.0.0.0:8443}"
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
FIPS_UDP_MTU="${FIPS_UDP_MTU:-1472}"
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-nym}"
FIPS_NYM_SOCKS5_ADDR="${FIPS_NYM_SOCKS5_ADDR:-127.0.0.1:1080}"
NYM_CLIENT_ID="${NYM_CLIENT_ID:-fips-nym-client}"
NYM_STARTUP_TIMEOUT="${NYM_STARTUP_TIMEOUT:-180}"
mkdir -p /etc/fips
# Build peers section
PEERS_SECTION=""
if [ -n "$FIPS_PEER_NPUB" ] && [ -n "$FIPS_PEER_ADDR" ]; then
FIPS_PEER_ALIAS="${FIPS_PEER_ALIAS:-peer}"
PEERS_SECTION=" - npub: \"${FIPS_PEER_NPUB}\"
alias: \"${FIPS_PEER_ALIAS}\"
addresses:
- transport: ${FIPS_PEER_TRANSPORT}
addr: \"${FIPS_PEER_ADDR}\"
connect_policy: auto_connect"
fi
# The nym transport block is emitted only in nym mode; the SOCKS5 client
# below starts only when this block is present in the config.
NYM_SECTION=""
if [ "$FIPS_PEER_TRANSPORT" = "nym" ]; then
NYM_SECTION=" nym:
socks5_addr: \"${FIPS_NYM_SOCKS5_ADDR}\"
startup_timeout_secs: 120"
fi
cat > /etc/fips/fips.yaml <<EOF
node:
identity:
nsec: "${FIPS_NSEC}"
tun:
enabled: true
name: fips0
mtu: ${FIPS_TUN_MTU}
dns:
enabled: true
bind_addr: "127.0.0.1"
transports:
udp:
bind_addr: "${FIPS_UDP_BIND}"
# 1472 = Docker bridge IPv4 max (1500 MTU - 8 UDP - 20 IPv4 header).
# Override with FIPS_UDP_MTU=1280 for IPv6-min-safe deploys.
mtu: ${FIPS_UDP_MTU}
tcp:
bind_addr: "${FIPS_TCP_BIND}"
${NYM_SECTION}
peers:
${PEERS_SECTION:- []}
EOF
echo "Generated /etc/fips/fips.yaml"
# --- Start local DNS first ---
# resolv.conf points at 127.0.0.1; dnsmasq must be up before anything
# below (peer-IP resolution, harbourmaster query, nym gateway lookup).
dnsmasq
# --- Apply iptables rules for strict network isolation ---
#
# Goal: only FIPS transport traffic may use eth0. All other eth0 traffic is
# dropped. fips0 and loopback are unrestricted, so the relay (sharing this
# namespace) is reachable only over the FIPS mesh.
#
# In nym mode the direct route to the peer is explicitly DROPped before the
# general TCP accept for the mixnet gateways: if the peer comes up, the
# connection can only have travelled through the mixnet.
# IPv4: allow only FIPS transport on eth0
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o eth0 -p udp --dport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -p udp --sport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --dport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --sport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --sport 443 -j ACCEPT
PEER_HOST="${FIPS_PEER_ADDR%:*}"
PEER_PORT="${FIPS_PEER_ADDR##*:}"
case "$FIPS_PEER_TRANSPORT" in
nym)
# Block the direct path to the peer (mixnet-only proof). IPv4 only:
# eth0 IPv6 is dropped wholesale by the ip6tables rules below.
if [ -n "$FIPS_PEER_ADDR" ]; then
PEER_IP=$(getent ahostsv4 "$PEER_HOST" | awk '{print $1; exit}' || true)
if [ -n "$PEER_IP" ]; then
iptables -A OUTPUT -o eth0 -p tcp -d "$PEER_IP" --dport "$PEER_PORT" -j DROP
echo "Direct path to peer ${PEER_HOST} (${PEER_IP}:${PEER_PORT}) blocked — mixnet only"
fi
fi
# … then allow outbound TCP for the nym client's gateway connections.
iptables -A OUTPUT -o eth0 -p tcp -j ACCEPT
iptables -A INPUT -i eth0 -p tcp -m state --state ESTABLISHED,RELATED -j ACCEPT
;;
tcp)
# Allow dialing the peer's TCP endpoint directly, and inbound FIPS TCP.
if [ -n "$FIPS_PEER_ADDR" ]; then
iptables -A OUTPUT -o eth0 -p tcp --dport "$PEER_PORT" -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --sport "$PEER_PORT" -m state --state ESTABLISHED,RELATED -j ACCEPT
fi
iptables -A INPUT -i eth0 -p tcp --dport "${FIPS_TCP_BIND##*:}" -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --sport "${FIPS_TCP_BIND##*:}" -j ACCEPT
;;
esac
iptables -A OUTPUT -o eth0 -j DROP
iptables -A INPUT -i eth0 -j DROP
# IPv6: allow fips0 and loopback, block eth0
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A OUTPUT -o fips0 -j ACCEPT
ip6tables -A INPUT -i fips0 -j ACCEPT
ip6tables -A OUTPUT -o eth0 -j DROP
ip6tables -A INPUT -i eth0 -j DROP
echo "iptables isolation rules applied"
# --- Start the Nostr relay app (strfry + nginx) ---
(cd /usr/src/app && exec strfry relay) &
nginx
echo "Nostr relay started (strfry on 127.0.0.1:7777, nginx on :80)"
# --- Start the Nym SOCKS5 client (only if the config enables nym) ---
if [ "$FIPS_PEER_TRANSPORT" = "nym" ]; then
NYM_HOST="${FIPS_NYM_SOCKS5_ADDR%:*}"
NYM_PORT="${FIPS_NYM_SOCKS5_ADDR##*:}"
# Auto-discover a network-requester service provider when none is set.
if [ ! -d "${HOME}/.nym/socks5-clients/${NYM_CLIENT_ID}" ]; then
# The provider is only consulted at init time, so auto-discovery
# runs only when a fresh client must be initialized. Failures
# (harbourmaster down or serving HTML) must not crash the
# container with a bare jq error — hence the `|| true` guards
# and the explicit empty-check below.
if [ -z "$NYM_SERVICE_PROVIDER" ]; then
echo "NYM_SERVICE_PROVIDER not set — querying harbourmaster.nymtech.net …"
NYM_SERVICE_PROVIDER=$(curl -fsSL --retry 3 \
"https://harbourmaster.nymtech.net/v2/services?order_by=routing_score&order_direction=desc&size=100" \
2>/dev/null \
| jq -r '[.items[] | select(.routing_score == 1.0)]
| sort_by(.last_updated_utc) | last
| .service_provider_client_id // empty' 2>/dev/null \
|| true)
if [ -z "$NYM_SERVICE_PROVIDER" ]; then
# Last resort: a provider known to work at the time of
# writing (2026-06). Providers are volatile community
# infra — if the mixnet connects but no traffic flows
# ('no node with identity … is known' warnings), this
# fallback has gone stale: pick a current one from
# https://harbourmaster.nymtech.net/ and set it in .env.
NYM_SERVICE_PROVIDER="${NYM_FALLBACK_PROVIDER:-7sfw3sEtSPwhWLmEasVmPXKxqioCo4GaXRkm9bW6yWGZ.CkhMoH85wfNcV2fwoBjc6QDbcaFZHzKqFFvXWfYMw19y@4ScsM6AVowhKTMWaH98NLntKDwbu2ZMEycUk4mZiZppG}"
echo "WARNING: harbourmaster auto-discovery failed — using the" >&2
echo "baked-in fallback provider (may be stale; see .env):" >&2
echo " ${NYM_SERVICE_PROVIDER}" >&2
else
echo "Auto-selected service provider: ${NYM_SERVICE_PROVIDER}"
fi
fi
echo "Initializing Nym SOCKS5 client '${NYM_CLIENT_ID}' …"
nym-socks5-client init \
--id "${NYM_CLIENT_ID}" \
--provider "${NYM_SERVICE_PROVIDER}" \
--port "${NYM_PORT}" \
--host "${NYM_HOST}"
else
# The provider is baked into the client state at init time — a value
# set or discovered now does NOT apply to an existing client. Surface
# the one actually in effect so a stale/dead provider isn't chased
# silently (symptom: 'no node with identity … is known' warnings).
STORED_PROVIDER=$(grep -m1 -oE '[1-9A-HJ-NP-Za-km-z]{20,}\.[1-9A-HJ-NP-Za-km-z]{20,}@[1-9A-HJ-NP-Za-km-z]{20,}' \
"${HOME}/.nym/socks5-clients/${NYM_CLIENT_ID}/config/config.toml" 2>/dev/null || true)
echo "Reusing existing Nym client state (provider: ${STORED_PROVIDER:-unknown})."
echo "To switch provider, remove the nym-data volume: docker compose down -v"
fi
echo "Starting Nym SOCKS5 client (mixnet bootstrap may take a minute) …"
nym-socks5-client run \
--id "${NYM_CLIENT_ID}" \
--port "${NYM_PORT}" \
--host "${NYM_HOST}" &
# FIPS must not start dialing before the proxy accepts connections.
elapsed=0
until nc -z "$NYM_HOST" "$NYM_PORT" 2>/dev/null; do
if [ "$elapsed" -ge "$NYM_STARTUP_TIMEOUT" ]; then
echo "ERROR: Nym SOCKS5 proxy not ready after ${NYM_STARTUP_TIMEOUT}s" >&2
exit 1
fi
sleep 2
elapsed=$((elapsed + 2))
done
echo "Nym SOCKS5 proxy ready at ${FIPS_NYM_SOCKS5_ADDR} (after ~${elapsed}s)"
fi
# --- Launch FIPS (container lifecycle follows the daemon) ---
echo "Starting FIPS daemon..."
exec fips --config /etc/fips/fips.yaml
@@ -0,0 +1,71 @@
##
## strfry configuration for FIPS mesh deployment.
## Full reference: https://github.com/hoytech/strfry
##
db = "/usr/src/app/strfry-db/"
dbParams {
# Maximum size of the database (bytes). 10 GiB is a safe default.
mapsize = 10737418240
}
relay {
# Bind on all interfaces so the FIPS TUN (IPv4 + IPv6) can reach it.
bind = "0.0.0.0"
port = 7777
nofiles = 0
info {
name = "FIPS Nostr Relay"
description = "A Nostr relay accessible over the FIPS mesh network."
pubkey = ""
contact = ""
}
# Maximum size of an inbound WebSocket message (bytes).
maxWebsocketPayloadSize = 131072
# Send a ping every N seconds to keep connections alive.
autoPingSeconds = 55
# Enable per-message compression.
enableTcpNoDelay = false
rejectFutureEventsSeconds = 900
rejectEphemeralEventsOlderThanSeconds = 60
rejectEventsNewerThanSeconds = 900
maxFilterLimit = 500
maxSubsPerConnection = 20
writePolicy {
# Plugin executable for write-policy decisions (leave empty to allow all).
plugin = ""
}
compression {
enabled = true
slidingWindow = true
}
logging {
dumpInAll = false
dumpInEvents = false
dumpInReqs = false
dbScanPerf = false
}
numThreads {
ingester = 3
reqWorker = 3
reqMonitor = 3
negentropy = 2
}
negentropy {
enabled = true
maxSyncEvents = 1000000
}
}
@@ -0,0 +1 @@
nameserver 127.0.0.1
+11 -7
View File
@@ -73,13 +73,14 @@ ws://npub1xxxx.fips:80
The sidecar pattern enforces strict network isolation on the app container:
- **No IPv4 access**: iptables blocks all eth0 traffic except FIPS UDP
transport (port 2121) and TCP transport (port 443). The app container
- **No IPv4 access**: iptables blocks all eth0 traffic except the FIPS UDP
transport (port 2121), the local FIPS TCP listener (port 8443), and
outbound TCP to peers' published endpoints (port 443). The app container
cannot reach the Docker bridge, the host network, or any IPv4 address.
- **No IPv6 on eth0**: ip6tables blocks all IPv6 traffic on eth0. The app
container cannot use link-local or any Docker-assigned IPv6 addresses.
- **FIPS mesh only**: The only routable network path is through `fips0`
(`fd::/8`). All application traffic traverses the FIPS mesh with
(`fd00::/8`). All application traffic traverses the FIPS mesh with
end-to-end encryption.
- **Loopback allowed**: `lo` is unrestricted for inter-process communication
within the shared namespace.
@@ -105,7 +106,7 @@ with the transport layer directly.
│ Interfaces: │
│ lo — loopback (unrestricted) │
│ eth0 — Docker bridge (iptables: FIPS only) │
│ fips0 — FIPS TUN (fd::/8, unrestricted) │
│ fips0 — FIPS TUN (fd00::/8, unrestricted) │
└───────────────────────────────────────────────────┘
```
@@ -118,7 +119,8 @@ before launching the FIPS daemon:
- ACCEPT on `lo` (both directions)
- ACCEPT UDP sport/dport 2121 on `eth0` (FIPS UDP transport)
- ACCEPT TCP dport 443 / sport 443 on `eth0` (FIPS TCP transport)
- ACCEPT TCP dport 443 / sport 443 on `eth0` (outbound to peers' TCP endpoints)
- ACCEPT TCP dport/sport 8443 on `eth0` (local FIPS TCP listener, `FIPS_TCP_BIND`)
- DROP everything else on `eth0`
**IPv6 rules** (ip6tables):
@@ -132,7 +134,7 @@ before launching the FIPS daemon:
DNS inside the container is handled by dnsmasq (127.0.0.1:53):
- `.fips` queries are forwarded to the FIPS daemon's built-in DNS resolver
(127.0.0.1:5354), which resolves npub-based names to `fd::/8` addresses
(127.0.0.1:5354), which resolves npub-based names to `fd00::/8` addresses
- All other queries are forwarded to Docker's embedded DNS (127.0.0.11)
The `resolv.conf` mount points the container's resolver at 127.0.0.1,
@@ -165,7 +167,8 @@ From the app container:
# Ping a mesh node by npub (resolves via .fips DNS):
docker exec sidecar-nostr-relay-app-1 ping6 -c3 npub1xxxx.fips
# Fetch a web page from a mesh node over FIPS:
# Fetch a web page from some other mesh node over FIPS
# (:8000 is a stand-in for that node's own service; this relay serves :80):
docker exec sidecar-nostr-relay-app-1 curl -6 "http://npub1xxxx.fips:8000/"
# Docker bridge is blocked — this should fail:
@@ -187,6 +190,7 @@ docker exec sidecar-nostr-relay-app-1 ping -c1 127.0.0.1
| `FIPS_TCP_BIND` | `0.0.0.0:8443` | TCP transport bind address |
| `FIPS_PEER_TRANSPORT` | `udp` | Peer transport type (`udp` or `tcp`) |
| `FIPS_TUN_MTU` | `1280` | TUN interface MTU |
| `FIPS_UDP_MTU` | `1472` | UDP transport MTU (default is Docker bridge IPv4 max; set to `1280` for IPv6-min-safe deploys) |
| `FIPS_NETWORK` | `fips-sidecar-net` | Docker network name (set to join external network) |
| `FIPS_SUBNET` | `172.20.1.0/24` | Docker network subnet |
| `FIPS_IPV4` | `172.20.1.20` | Sidecar's IPv4 address on the Docker network |
@@ -31,6 +31,7 @@ services:
- FIPS_PEER_ALIAS=${FIPS_PEER_ALIAS:-peer}
- FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121}
- FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280}
- FIPS_UDP_MTU=${FIPS_UDP_MTU:-1472}
- FIPS_PEER_TRANSPORT=${FIPS_PEER_TRANSPORT:-udp}
volumes:
- ./resolv.conf:/etc/resolv.conf:ro
+6 -1
View File
@@ -8,6 +8,7 @@ FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}"
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
FIPS_TCP_BIND="${FIPS_TCP_BIND:-0.0.0.0:8443}"
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
FIPS_UDP_MTU="${FIPS_UDP_MTU:-1472}"
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-udp}"
mkdir -p /etc/fips
@@ -41,7 +42,9 @@ dns:
transports:
udp:
bind_addr: "${FIPS_UDP_BIND}"
mtu: 1472
# 1472 = Docker bridge IPv4 max (1500 MTU - 8 UDP - 20 IPv4 header).
# Override with FIPS_UDP_MTU=1280 for IPv6-min-safe deploys.
mtu: ${FIPS_UDP_MTU}
tcp:
bind_addr: "${FIPS_TCP_BIND}"
@@ -67,6 +70,8 @@ iptables -A INPUT -i eth0 -p udp --dport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --sport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --sport 443 -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --dport "${FIPS_TCP_BIND##*:}" -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --sport "${FIPS_TCP_BIND##*:}" -j ACCEPT
iptables -A OUTPUT -o eth0 -j DROP
iptables -A INPUT -i eth0 -j DROP
@@ -1,49 +0,0 @@
# nostr-rs-relay configuration for FIPS mesh deployment.
# Full reference: https://github.com/scsibug/nostr-rs-relay
[info]
# Shown in NIP-11 relay information document.
relay_url = "" # Set to ws://[<your-fips-ipv6>]:8080 after first start
name = "FIPS Nostr Relay"
description = "A Nostr relay accessible over the FIPS mesh network."
pubkey = "" # Optional: your npub (hex) as relay operator
contact = "" # Optional: contact address
[database]
# SQLite database path inside the container (mapped to relay-data volume).
data_directory = "/usr/src/app/db"
[network]
# Bind on all interfaces (IPv4 + IPv6) — FIPS delivers traffic via the fips0
# TUN as IPv6 (fd00::/8). Using 0.0.0.0 with port 8080; the relay also needs
# to accept IPv6 connections so we set port separately and rely on the OS
# dual-stack socket (IPV6_V6ONLY=0).
# nostr-rs-relay address field is just the IP, port is separate.
address = "0.0.0.0"
port = 8080
ping_interval_seconds = 300
[limits]
# Adjust to taste. These are conservative defaults for a personal relay.
messages_per_sec = 10
subscriptions_per_min = 20
max_blocking_threads = 4
max_event_bytes = 131072 # 128 KiB per event
max_ws_message_bytes = 131072
max_ws_frame_bytes = 131072
broadcast_buffer = 16384
event_persist_buffer = 4096
[authorization]
# Set to true to require NIP-42 AUTH before accepting events.
# Useful for a private relay — only authenticated users can publish.
nip42_auth = false
nip42_dms = false
[verified_users]
# NIP-05 verification (optional).
mode = "disabled"
[pay_to_relay]
# Lightning payments for relay access (optional).
enabled = false
+3 -1
View File
@@ -20,7 +20,9 @@ transports:
peers:
- npub: "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"
alias: "fips.v0l.io"
# alias becomes the peer's .fips hostname (<alias>.fips), so it must be a
# plain label with no dots — the connection address below is the real host.
alias: "v0l"
addresses:
- transport: tcp
addr: "fips.v0l.io:8443"
Generated
+100
View File
@@ -0,0 +1,100 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1781527054,
"narHash": "sha256-1fX9ev2Fh5QoKQ41G9dYutjo5j/jywu6tZse5Eb1Ck4=",
"owner": "nix-community",
"repo": "fenix",
"rev": "8c2e51dffefc040a21975da7abf6f252c8c9b783",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1781074563,
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1781453968,
"narHash": "sha256-+V3nK4pCngbmgyVGXY6Kkrlevp4ocPkJJLf2aqwkDNA=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "cc272809a173c2c11d0e479d639c811c1eacf049",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+128
View File
@@ -0,0 +1,128 @@
{
description = "FIPS a distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
flake-utils,
fenix,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
# Honor the toolchain the repo pins in rust-toolchain.toml
# (channel 1.94.1 + rustfmt, clippy) so Nix builds match CI and the
# AUR/Debian packaging exactly, including the edition-2024 frontend.
rustToolchain = fenix.packages.${system}.fromToolchainFile {
file = ./rust-toolchain.toml;
sha256 = "sha256-zC8E38iDVJ1oPIzCqTk/Ujo9+9kx9dXq7wAwPMpkpg0=";
};
rustPlatform = pkgs.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
};
cargoToml = pkgs.lib.importTOML ./Cargo.toml;
# libdbus-sys (pulled in transitively by `bluer`, Linux/glibc only)
# runs `bindgen` against the system D-Bus headers at build time.
nativeBuildInputs = [
pkgs.pkg-config
rustPlatform.bindgenHook # sets LIBCLANG_PATH + clang for bindgen
];
buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [
pkgs.dbus # libdbus-1.so.3, linked via bluer→libdbus-sys
pkgs.stdenv.cc.cc.lib # libgcc_s.so.1, needed by every Rust binary
];
fips = rustPlatform.buildRustPackage {
pname = "fips";
version = cargoToml.package.version;
src = pkgs.lib.cleanSourceWith {
src = ./.;
# Drop the build dir and the usual editor/VCS noise so the source
# hash is stable and unrelated edits don't trigger rebuilds.
filter =
path: type:
(pkgs.lib.cleanSourceFilter path type) && (baseNameOf path != "target");
};
cargoLock.lockFile = ./Cargo.lock;
inherit buildInputs;
# autoPatchelfHook rewrites the RPATH of the built binaries so the
# daemon finds libdbus-1.so.3 (linked via bluer→libdbus-sys) in the
# Nix store at runtime — without it the `fips` binary fails to load
# on NixOS where there is no global /usr/lib.
nativeBuildInputs =
nativeBuildInputs ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.autoPatchelfHook ];
# The test suite exercises TUN devices, raw sockets and mDNS, none of
# which exist in the build sandbox. The AUR/Debian packaging likewise
# ships the release binaries without running the integration tests
# here, so keep the package build hermetic and skip them.
doCheck = false;
meta = {
description = cargoToml.package.description;
homepage = cargoToml.package.homepage;
license = pkgs.lib.licenses.mit;
mainProgram = "fips";
platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin;
};
};
mkApp = name: {
type = "app";
program = "${fips}/bin/${name}";
meta.description = "Run the ${name} binary from the FIPS package";
};
in
{
packages = {
default = fips;
fips = fips;
};
apps = {
default = mkApp "fips";
fips = mkApp "fips";
fipsctl = mkApp "fipsctl";
fips-gateway = mkApp "fips-gateway";
fipstop = mkApp "fipstop";
};
# `nix flake check` builds the package (and thus validates the flake on
# the current system).
checks.fips = fips;
devShells.default = pkgs.mkShell {
inherit buildInputs;
nativeBuildInputs = nativeBuildInputs ++ [
rustToolchain
pkgs.cargo-edit
];
# Point rust-analyzer at the matching std sources.
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
};
formatter = pkgs.nixfmt;
}
);
}
+6 -2
View File
@@ -6,7 +6,8 @@
# Usage:
# make deb Build a Debian/Ubuntu .deb package
# make tarball Build a systemd install tarball
# make ipk Build an OpenWrt .ipk package
# make ipk Build an OpenWrt .ipk package (opkg, OpenWrt 24.x and earlier)
# make apk Build an OpenWrt .apk package (apk-tools, mandatory on OpenWrt 25+)
# make aur Build fips-git AUR package and validate with namcap
# make pkg Build a macOS .pkg installer
# make zip Build a Windows .zip package
@@ -17,7 +18,7 @@ SHELL := /bin/bash
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
.PHONY: all deb tarball ipk aur pkg zip clean
.PHONY: all deb tarball ipk apk aur pkg zip clean
all: deb tarball
@@ -30,6 +31,9 @@ tarball:
ipk:
@bash $(PACKAGING_DIR)/openwrt-ipk/build-ipk.sh
apk:
@bash $(PACKAGING_DIR)/openwrt-apk/build-apk.sh
aur:
@bash $(PACKAGING_DIR)/aur/build-aur.sh
+83 -7
View File
@@ -8,13 +8,36 @@ All build outputs go to `deploy/` at the project root.
```sh
make deb # Debian/Ubuntu .deb
make tarball # systemd install tarball
make ipk # OpenWrt .ipk
make ipk # OpenWrt .ipk (opkg, OpenWrt 24.x and earlier)
make apk # OpenWrt .apk (apk-tools, mandatory on OpenWrt 25+)
make aur # Arch Linux AUR package (fips-git, local build + namcap)
make pkg # macOS .pkg installer
make zip # Windows .zip package
make all # deb + tarball (default)
```
## Build Prerequisites
These targets build FIPS from source, so the host needs a build
environment in addition to a Rust toolchain (the version pinned in
`rust-toolchain.toml` is auto-installed by rustup).
On Linux, `libclang` is **required**: the LAN gateway's nftables
bindings are generated by `bindgen` at build time, which needs
`libclang.so` on the build host. Without it the build fails inside the
`rustables` crate with an "Unable to find libclang" error.
```sh
sudo apt install libclang-dev # Debian / Ubuntu
```
This is a build-time prerequisite only — it is not a runtime
dependency, so hosts installing a pre-built `.deb` do not need it.
BLE support is optional and, when building with it, additionally needs
`bluez`, `libdbus-1-dev`, and `pkg-config`; the build picks up BLE if
those are present and skips it cleanly if not.
## Directory Structure
```text
@@ -24,7 +47,8 @@ packaging/
debian/ Debian/Ubuntu .deb packaging via cargo-deb
macos/ macOS .pkg installer via pkgbuild
systemd/ Generic Linux systemd tarball packaging
openwrt/ OpenWrt .ipk packaging via cargo-zigbuild
openwrt-ipk/ OpenWrt .ipk packaging via cargo-zigbuild (opkg)
openwrt-apk/ OpenWrt .apk packaging via cargo-zigbuild + apk mkpkg
windows/ Windows .zip package with service scripts
```
@@ -33,10 +57,20 @@ packaging/
### Debian/Ubuntu (`.deb`)
Built with [cargo-deb](https://github.com/kornelski/cargo-deb). Installs
`fips`, `fipsctl`, and `fipstop` to `/usr/bin/`, places config at
`/etc/fips/fips.yaml` (preserved on upgrade), and enables the systemd
`fips`, `fipsctl`, and `fipstop` to `/usr/bin/`, and enables the systemd
service.
The default configuration ships as an example at
`/usr/share/fips/fips.yaml.example` and is **not** a dpkg conf-file.
(It is deliberately **not** under `/usr/share/doc`, which minimal and
container installs path-exclude, since the postinst reads it at install
time.)
On install, `postinst` seeds `/etc/fips/fips.yaml` (mode 600) from the
example **only if it does not already exist**, so a configuration that
was rendered by configuration management or edited by an operator is
never prompted for or clobbered on upgrade. To reset to defaults, remove
`/etc/fips/fips.yaml` and reinstall, or copy the example back manually.
```sh
# Build
make deb
@@ -68,7 +102,7 @@ sudo ./fips-<version>-linux-<arch>/install.sh
See [systemd/README.install.md](systemd/README.install.md) for full
installation and configuration instructions.
### OpenWrt (`.ipk`)
### OpenWrt (`.ipk`, opkg — OpenWrt 24.x and earlier)
Cross-compiled with cargo-zigbuild and assembled as a standard `.ipk`
archive. Supports aarch64, mipsel, mips, arm, and x86\_64 targets.
@@ -78,12 +112,33 @@ archive. Supports aarch64, mipsel, mips, arm, and x86\_64 targets.
make ipk
# Build for a specific architecture
bash packaging/openwrt/build-ipk.sh --arch mipsel
bash packaging/openwrt-ipk/build-ipk.sh --arch mipsel
```
See [openwrt/README.md](openwrt/README.md) for router-specific
See [openwrt-ipk/README.md](openwrt-ipk/README.md) for router-specific
installation instructions.
### OpenWrt (`.apk`, apk-tools — mandatory on OpenWrt 25+)
OpenWrt 25 makes apk-tools the mandatory package manager (it is opt-in on
24.10). Same SDK-free approach
(cargo-zigbuild), but the `.apk` container is assembled by `apk mkpkg`
rather than hand-rolled, so the build additionally needs an apk-tools v3
`apk` binary built from source. The installed-filesystem payload is shared
with the `.ipk` package.
```sh
# Build (default: aarch64; also x86_64)
make apk
# Build for a specific architecture
bash packaging/openwrt-apk/build-apk.sh --arch x86_64
```
Packages are unsigned; install with `apk add --allow-untrusted`. See
[openwrt-apk/README.md](openwrt-apk/README.md) for building apk-tools and
router-specific installation.
### macOS (`.pkg`)
Built with `pkgbuild` (included with Xcode command-line tools). Installs
@@ -145,6 +200,27 @@ yay -S fips # release build from latest tag
See [aur/README.md](aur/README.md) for AUR publication instructions
and maintainer guide.
### Nix / NixOS (flake)
A [flake](../flake.nix) at the project root builds all four binaries
(`fips`, `fipsctl`, `fips-gateway`, `fipstop`) from source. It pins the
exact toolchain from `rust-toolchain.toml` via
[fenix](https://github.com/nix-community/fenix) and wires up the
build-time native dependencies (`libclang` for `bindgen`, plus `dbus`
and `pkg-config` for BLE), so it needs no system setup beyond Nix with
flakes enabled.
```sh
nix build .#fips # build the package (all four binaries)
nix run .#fips -- --help # run a binary directly
nix run .#fipsctl -- status
nix develop # dev shell with the pinned toolchain + cargo-edit
nix flake check # build + validate the flake
```
Add to a NixOS configuration via the flake's `packages.<system>.fips`
output, e.g. `environment.systemPackages = [ fips.packages.${system}.default ];`.
## Shared Assets
`common/` contains assets used across packaging formats:
+7 -3
View File
@@ -7,9 +7,9 @@ url="https://github.com/jmcorgan/fips"
license=('MIT')
arch=('x86_64')
depends=('gcc-libs' 'glibc')
makedepends=('cargo')
makedepends=('cargo' 'clang')
optdepends=('systemd-resolved: .fips DNS resolution')
conflicts=('fips-git')
conflicts=('fips-git' 'fips-git-debug')
backup=('etc/fips/fips.yaml' 'etc/fips/hosts' 'etc/fips/fips.nft')
install=fips.install
source=("$pkgname-$pkgver.tar.gz::https://github.com/jmcorgan/fips/archive/v$pkgver.tar.gz"
@@ -18,7 +18,7 @@ source=("$pkgname-$pkgver.tar.gz::https://github.com/jmcorgan/fips/archive/v$pkg
b2sums=('SKIP' # tarball hash computed by CI via updpkgsums on release
'25a0552f3d67d12f48dfd40fe4776ad7c46afeeab76bd2674b48e234db3c145810a24569a8c1a7f4c186eb546f0fae2ebe1550080c0e91d8eb72ba9934c752a6'
'844257cb8e09cd935d0d6345922d0f3ec777411daca20e24175b346a7b3cb95ebce12631a9466c4d94f1588ed8d62d92514ff24025ccfd0efb358e542b454b00')
options=('!lto')
options=('!lto' '!debug')
prepare() {
cd "$pkgname-$pkgver"
@@ -53,6 +53,10 @@ package() {
install -Dm0644 packaging/debian/fips-gateway.service "$pkgdir/usr/lib/systemd/system/fips-gateway.service"
install -Dm0644 packaging/debian/fips-firewall.service "$pkgdir/usr/lib/systemd/system/fips-firewall.service"
# DNS helper scripts referenced by fips-dns.service
install -Dm0755 packaging/common/fips-dns-setup "$pkgdir/usr/lib/fips/fips-dns-setup"
install -Dm0755 packaging/common/fips-dns-teardown "$pkgdir/usr/lib/fips/fips-dns-teardown"
# Config files (from packaging/common/)
install -Dm0600 packaging/common/fips.yaml "$pkgdir/etc/fips/fips.yaml"
install -Dm0644 packaging/common/hosts "$pkgdir/etc/fips/hosts"
+6 -2
View File
@@ -7,10 +7,10 @@ url="https://github.com/jmcorgan/fips"
license=('MIT')
arch=('x86_64')
depends=('dbus' 'gcc-libs' 'glibc')
makedepends=('cargo' 'git')
makedepends=('cargo' 'clang' 'git')
optdepends=('systemd-resolved: .fips DNS resolution')
provides=('fips')
conflicts=('fips')
conflicts=('fips' 'fips-debug')
backup=('etc/fips/fips.yaml' 'etc/fips/hosts' 'etc/fips/fips.nft')
install=fips.install
source=("fips::git+https://github.com/jmcorgan/fips.git"
@@ -60,6 +60,10 @@ package() {
install -Dm0644 packaging/debian/fips-gateway.service "$pkgdir/usr/lib/systemd/system/fips-gateway.service"
install -Dm0644 packaging/debian/fips-firewall.service "$pkgdir/usr/lib/systemd/system/fips-firewall.service"
# DNS helper scripts referenced by fips-dns.service
install -Dm0755 packaging/common/fips-dns-setup "$pkgdir/usr/lib/fips/fips-dns-setup"
install -Dm0755 packaging/common/fips-dns-teardown "$pkgdir/usr/lib/fips/fips-dns-teardown"
# Config files (from packaging/common/)
install -Dm0600 packaging/common/fips.yaml "$pkgdir/etc/fips/fips.yaml"
install -Dm0644 packaging/common/hosts "$pkgdir/etc/fips/hosts"
+12
View File
@@ -31,6 +31,8 @@ package:
- Binaries: `fips`, `fipsctl`, `fipstop`, `fips-gateway`
- Systemd units: `fips.service`, `fips-dns.service`, `fips-gateway.service`,
`fips-firewall.service`
- DNS helpers: `/usr/lib/fips/fips-dns-setup`,
`/usr/lib/fips/fips-dns-teardown`
- Config: `/etc/fips/fips.yaml`, `/etc/fips/hosts`, `/etc/fips/fips.nft`
- sysusers/tmpfiles fragments for the `fips` group and `/run/fips/`
@@ -39,6 +41,11 @@ operator edits to the nftables ruleset survive package upgrades.
`fips-firewall.service` is shipped disabled by default, matching the Debian
package: operators opt in by enabling it explicitly.
Both PKGBUILDs opt out of makepkg's automatic `*-debug` split packages. The
package metadata still conflicts with stale peer debug package names
(`fips-debug` / `fips-git-debug`) so switching between release and development
variants removes old debug-file owners cleanly.
## Local Build and Validation
Build and validate the `-git` package locally using the Makefile target:
@@ -321,3 +328,8 @@ Push an update when a new version is tagged. The steps are:
Phase 4 CI automation will handle this workflow automatically on new GitHub
releases.
For a packaging-only republish of an existing release tag, run the AUR Publish
workflow manually with the existing tag and incremented `pkgrel` (for example,
`tag=v0.3.0`, `pkgrel=2`). This keeps the upstream source tarball unchanged
while forcing AUR helpers to rebuild with the corrected package metadata.
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Patch packaging/aur/PKGBUILD in place with the release pkgver, pkgrel,
# conflicts, options, and b2sums.
#
# Shared by the AUR publish workflow's real-publish job and its dry-run job
# so the patching logic lives in exactly one place.
#
# Required environment variables:
# TAG - release tag (e.g. v0.4.0 or v0.4.0-rc1)
# VERSION - tag without the leading 'v' (e.g. 0.4.0)
# PKGREL - AUR pkgrel (positive integer)
#
# Source of the tarball b2sum:
# Default: fetch the GitHub source archive for $TAG and hash it.
# Dry-run: set LOCAL_TARBALL to a path; its b2sum is used instead and the
# PKGBUILD source= line is rewritten to point at that local file.
# This lets a dry-run validate an rc tag that has no published
# GitHub release archive yet (the archive URL would 404).
#
# Uses $GITHUB_REPOSITORY for the source URL (owner/repo).
set -euo pipefail
: "${TAG:?TAG must be set}"
: "${VERSION:?VERSION must be set}"
: "${PKGREL:?PKGREL must be set}"
PKGBUILD="packaging/aur/PKGBUILD"
SYSUSERS_SUM=$(b2sum packaging/aur/fips.sysusers | awk '{print $1}')
TMPFILES_SUM=$(b2sum packaging/aur/fips.tmpfiles | awk '{print $1}')
if [ -n "${LOCAL_TARBALL:-}" ]; then
# Dry-run path: hash the locally-created tarball of the checkout.
echo "Using local tarball $LOCAL_TARBALL for b2sum (dry-run)"
SOURCE_SUM=$(b2sum "$LOCAL_TARBALL" | awk '{print $1}')
else
URL="https://github.com/${GITHUB_REPOSITORY:?GITHUB_REPOSITORY must be set}/archive/${TAG}.tar.gz"
echo "Fetching $URL"
SOURCE_SUM=$(curl -fsSL --retry 3 "$URL" | b2sum | awk '{print $1}')
fi
for v in SOURCE_SUM SYSUSERS_SUM TMPFILES_SUM; do
eval val=\$$v
if [ -z "$val" ]; then echo "$v is empty"; exit 1; fi
done
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD"
sed -i "s/^pkgrel=.*/pkgrel=${PKGREL}/" "$PKGBUILD"
sed -i "s/^conflicts=.*/conflicts=('fips-git' 'fips-git-debug')/" "$PKGBUILD"
sed -i "s/^options=.*/options=('!lto' '!debug')/" "$PKGBUILD"
if [ -n "${LOCAL_TARBALL:-}" ]; then
# Repoint the first source entry at the local tarball so makepkg builds the
# checked-out tree instead of fetching the (possibly unpublished) GitHub
# archive. makepkg resolves a bare filename source against $startdir.
LOCAL_BASE=$(basename "$LOCAL_TARBALL")
sed -i "s|^source=(\"\$pkgname-\$pkgver.tar.gz::[^\"]*\"|source=(\"\$pkgname-\$pkgver.tar.gz::${LOCAL_BASE}\"|" "$PKGBUILD"
fi
sed -i "s|^b2sums=('SKIP'.*|b2sums=('${SOURCE_SUM}'|" "$PKGBUILD"
awk -v s1="$SYSUSERS_SUM" -v s2="$TMPFILES_SUM" '
/^b2sums=\(/ { in_block=1; count=0 }
in_block {
count++
if (count == 2) sub(/[a-f0-9]{128}/, s1)
if (count == 3) sub(/[a-f0-9]{128}/, s2)
if ($0 ~ /\)/) in_block=0
}
{ print }
' "$PKGBUILD" > "$PKGBUILD.new"
mv "$PKGBUILD.new" "$PKGBUILD"
echo "Patched PKGBUILD:"
grep -E "^(pkgver|pkgrel|conflicts|options|source)=" "$PKGBUILD"
awk '/^b2sums=\(/,/\)$/' "$PKGBUILD"
+29
View File
@@ -33,6 +33,23 @@ node:
# - "stun:stun.l.google.com:19302"
# - "stun:stun.cloudflare.com:3478"
# - "stun:global.stun.twilio.com:3478"
#
# Optional mDNS-based LAN discovery for sub-second same-LAN pairing.
# Opt-in (default false): default-off avoids a per-LAN identity
# broadcast on nodes that have deliberately disabled other discovery
# channels, and avoids any multicast surprise on upgrade. Requires an
# operational UDP transport (the advertised port is the one peers dial).
# lan:
# enabled: false
# # Optional application/network scope carried in the LAN-only TXT
# # record. Browsers that set a scope ignore adverts for other scopes.
# # Kept separate from the Nostr discovery `app` tag so relay-visible
# # adverts can stay generic while LAN discovery stays per-private-network.
# # scope: "lab-floor-3"
# # Advanced: overrides the mDNS service type. Leave unset in normal
# # use — only needed to run multiple isolated services on one
# # interface (e.g. test isolation on loopback).
# # service_type: "_fips._udp.local."
tun:
enabled: true
@@ -86,6 +103,18 @@ transports:
# auto_connect: true
# accept_connections: true
# Nym transport — outbound-only connections through the Nym mixnet for
# sender/receiver privacy. This is a privacy posture, not a NAT-traversal
# or failover transport: it only dials out (no inbound listener) and is
# chosen for its anonymity properties. Requires a nym-socks5-client
# running separately as its own process; FIPS dials it over SOCKS5.
# nym:
# socks5_addr: "127.0.0.1:1080" # nym-socks5-client SOCKS5 address
# connect_timeout_ms: 300000 # outbound connect timeout (300s);
# # mixnet round-trips can take minutes
# mtu: 1400 # per-connection MTU
# startup_timeout_secs: 120 # wait for nym-socks5-client readiness
# Outbound LAN gateway. Allows non-FIPS hosts on the LAN to reach
# mesh destinations via DNS-allocated virtual IPs and kernel NAT.
# Requires: IPv6 forwarding enabled, fips daemon running with DNS.
+5
View File
@@ -3,6 +3,11 @@ Description=FIPS Mesh Network Daemon
After=network-online.target
Wants=network-online.target
# The config file is no longer a packaged conf-file; postinst seeds it
# if absent. Skip the unit (inactive, not failed) rather than crash-loop
# if it is ever missing.
ConditionPathExists=/etc/fips/fips.yaml
[Service]
Type=simple
ExecStart=/usr/bin/fips --config /etc/fips/fips.yaml
+10
View File
@@ -9,6 +9,16 @@ case "$1" in
groupadd --system fips
fi
# Seed /etc/fips/fips.yaml from the shipped example only if it
# does not already exist. The live config is no longer a dpkg
# conf-file; this copy-if-absent yields to any operator- or
# configuration-management-rendered file and never clobbers it.
if [ ! -e /etc/fips/fips.yaml ]; then
install -m 600 -o root -g root \
/usr/share/fips/fips.yaml.example \
/etc/fips/fips.yaml
fi
# Drop-in directory for operator nftables rules included by
# /etc/fips/fips.nft. Empty by default; the include glob matches
# nothing cleanly out of the box.
+19 -3
View File
@@ -55,7 +55,23 @@ while [[ $# -gt 0 ]]; do
done
VERSION="${VERSION_OVERRIDE:-$(grep '^version' "${PROJECT_ROOT}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')}"
ARCH="$(uname -m)"
# Derive the package architecture from the build target, not the build
# host. When cross-compiling (for example building the x86_64 package on
# an Apple-silicon machine) `uname -m` reports the host architecture and
# would mislabel the package; the Rust target triple is authoritative.
if [[ -n "${TARGET_TRIPLE}" ]]; then
case "${TARGET_TRIPLE}" in
aarch64-*) ARCH="arm64" ;;
x86_64-*) ARCH="x86_64" ;;
*)
echo "Unsupported target triple: ${TARGET_TRIPLE}" >&2
exit 1
;;
esac
else
ARCH="$(uname -m)"
fi
PKG_NAME="fips-${VERSION}-macos-${ARCH}"
DEPLOY_DIR="${PROJECT_ROOT}/deploy"
STAGING_DIR="$(mktemp -d)"
@@ -105,9 +121,9 @@ cp "${PACKAGING_DIR}/common/hosts" "${STAGING_DIR}/usr/local/etc/fips/hosts.defa
# LaunchDaemon plist
cp "${SCRIPT_DIR}/com.fips.daemon.plist" "${STAGING_DIR}/Library/LaunchDaemons/"
# DNS resolver
# DNS resolver. Must match the daemon's dns.bind_addr (defaults to ::1).
cat > "${STAGING_DIR}/etc/resolver/fips" <<EOF
nameserver 127.0.0.1
nameserver ::1
port 5354
EOF
+98
View File
@@ -0,0 +1,98 @@
# FIPS OpenWrt Package (apk)
Builds a FIPS `.apk` for **OpenWrt 25+**, where apk-tools is the mandatory
package manager. apk is also available opt-in on **24.10** (where opkg remains
the default). For OpenWrt 24.x and earlier, the `.ipk` package in
[`../openwrt-ipk/`](../openwrt-ipk/) still works.
Like the `.ipk` build, this is **SDK-free**: it cross-compiles with
`cargo-zigbuild` and assembles the package directly — no OpenWrt SDK image. The
`.ipk` format is a plain tar.gz we can hand-roll, but the `.apk` (apk-tools v3
ADB) container is not, so we drive the official `apk mkpkg` applet — the same
tool OpenWrt's own [`include/package-pack.mk`](https://github.com/openwrt/openwrt/blob/main/include/package-pack.mk)
calls. The only extra requirement over the `.ipk` build is the `apk` binary.
## Layout
| File | Purpose |
|---|---|
| `build-apk.sh` | Cross-compile + assemble the `.apk` via `apk mkpkg` |
| `apk-version.sh` | Map a release tag / commit height to an apk-tools-valid version |
| `apk-version.test.sh` | Case-table test for `apk-version.sh` (`sh apk-version.test.sh`) |
The installed-filesystem payload (init scripts, `fips.yaml`, sysctl drop-ins,
hotplug, uci-defaults, …) is **shared** with the `.ipk` package — there is one
canonical copy in [`../openwrt-ipk/files/`](../openwrt-ipk/files/). `build-apk.sh`
stages from there, so the two packages always ship the same files. Keep the
staging block in `build-apk.sh` in sync with `../openwrt-ipk/build-ipk.sh`.
## Versioning
apk-tools enforces a strict version grammar
(`<digit>(.<digit>)*(_<suffix><digit>*)*(-r<N>)`). `apk-version.sh` builds a
valid version from structured inputs rather than rewriting an already-flattened
string:
| Input | apk version |
|---|---|
| `tag v1.2.3` | `1.2.3-r0` |
| `tag v1.2.3-rc1` | `1.2.3_rc1-r0` |
| `dev 1234` (commit height) | `0.0.0_git1234-r0` |
The human-readable version (`v1.2.3`, `master.123.abcdef0`) is still used for the
artifact filename; only the metadata embedded in the package is normalized.
## Building
### Prerequisites
| Requirement | Notes |
|---|---|
| `cargo install cargo-zigbuild` + `zig` | Rust musl cross-compilation (as for `.ipk`) |
| apk-tools v3 `apk` binary | Provides `apk mkpkg`; not packaged for most distros — build from source |
| `fakeroot` | Optional; makes packaged files root-owned on an unprivileged build host |
apk-tools is not in Debian/Ubuntu repos, so build the pinned release from source.
Pin the same commit the targeted OpenWrt release ships (see
`package/system/apk/Makefile` upstream) so the `.apk` is readable by the device's
`apk`. CI builds **3.0.5** (`b5a31c0d…`):
```bash
sudo apt-get install -y build-essential meson ninja-build pkg-config \
zlib1g-dev libssl-dev libzstd-dev liblzma-dev lua5.4-dev scdoc
git clone https://gitlab.alpinelinux.org/alpine/apk-tools.git
cd apk-tools && git checkout b5a31c0d865342ad80be10d68f1bb3d3ad9b0866
meson setup build && ninja -C build src/apk
export APK_BIN="$PWD/build/src/apk"
```
### Build the package
```bash
# from the repo root
./packaging/openwrt-apk/build-apk.sh --arch aarch64 # or x86_64, mipsel, mips, arm
```
Output: `dist/fips_<version>_<openwrt-arch>.apk`. Override the version with
`PKG_VERSION` (filename) and `APK_VERSION` (embedded metadata); otherwise both are
derived from git.
## Installing on the router
Packages are **unsigned** (the same posture as our `.ipk`), so install with
`--allow-untrusted`:
```bash
scp -O dist/fips_<version>_<arch>.apk root@192.168.1.1:/tmp/
ssh root@192.168.1.1 apk add --allow-untrusted /tmp/fips_<version>_<arch>.apk
```
On OpenWrt 25.x, installing from a *signed repository* requires the publisher's
key; a single `--allow-untrusted` package install does not. If we ever publish an
apk feed, add ECDSA (prime256v1) signing via `apk mkpkg --sign` and distribute the
public key to `/etc/apk/keys/`.
`/etc/fips/fips.yaml` is marked as a config file (via
`/lib/apk/packages/fips.conffiles`), so apk preserves local edits across upgrades,
and `/lib/upgrade/keep.d/fips` preserves `/etc/fips/` across `sysupgrade` — the
same guarantees as the `.ipk` package.
+74
View File
@@ -0,0 +1,74 @@
#!/bin/sh
# Emit an apk-tools-compatible version string for FIPS.
#
# apk-tools enforces a strict version grammar:
# <digit>(.<digit>)*(_<suffix><digit>*)*(-r<N>)
# where <suffix> is a recognised pre-release/post-release token
# (alpha, beta, pre, rc, cvs, svn, git, hg, p).
#
# Unlike a regex rewrite of an already-flattened version string, this
# helper builds the apk version directly from the *structured* inputs the
# caller already has (a release tag, or a commit height). There is no
# parsing-back-out of a "branch.height.hash" blob, so there is no fragile
# reparse step to get wrong.
#
# Usage:
# apk-version.sh tag <git-tag> # e.g. v1.2.3, v1.2.3-rc1
# apk-version.sh dev <height> # e.g. 1234 (git rev-list --count HEAD)
# apk-version.sh auto # derive from the current git checkout
#
# Examples:
# apk-version.sh tag v1.2.3 -> 1.2.3-r0
# apk-version.sh tag v1.2.3-rc1 -> 1.2.3_rc1-r0
# apk-version.sh dev 1234 -> 0.0.0_git1234-r0
set -eu
mode="${1:-auto}"
case "$mode" in
tag) raw_tag="${2:?tag mode requires a tag argument}"; height="" ;;
dev) raw_tag=""; height="${2:?dev mode requires a height argument}" ;;
auto)
if raw_tag="$(git describe --exact-match --tags 2>/dev/null)"; then
height=""
else
raw_tag=""
height="$(git rev-list --count HEAD 2>/dev/null || echo 0)"
fi
;;
*)
echo "usage: $0 [auto | tag <git-tag> | dev <height>]" >&2
exit 2
;;
esac
if [ -n "$raw_tag" ]; then
# Release tag: vX.Y.Z or vX.Y.Z-<pre>. Strip the leading 'v', split the
# core (X.Y.Z) from the pre-release token, and map our hyphen separator
# to apk's '_' pre-release marker.
body="${raw_tag#v}"
core="${body%%-*}"
case "$body" in
*-*) pre="${body#*-}" ;;
*) pre="" ;;
esac
case "$pre" in
"") suffix="" ;;
alpha*|beta*|pre*|rc*) suffix="_${pre}" ;;
*)
# Unknown pre-release token: apk would reject or misorder it, so
# drop it rather than emit an invalid version. The human-readable
# PACKAGE_VERSION (the raw tag) is still used for the filename.
suffix=""
;;
esac
printf '%s%s-r0\n' "$core" "$suffix"
else
# Untagged build: no meaningful semver, so anchor at 0.0.0 and encode the
# monotonic commit height as a _git pre-release component. This keeps apk's
# ordering sane across dev builds without smuggling the hash/branch into a
# field that cannot represent them.
printf '0.0.0_git%s-r0\n' "${height:-0}"
fi
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
# Case-table test for apk-version.sh. Run: sh apk-version.test.sh
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
SUT="$HERE/apk-version.sh"
fail=0
check() {
# check <expected> <args...>
expected="$1"; shift
actual="$(sh "$SUT" "$@")"
if [ "$actual" = "$expected" ]; then
printf ' PASS %-22s -> %s\n' "$*" "$actual"
else
printf ' FAIL %-22s -> %s (expected %s)\n' "$*" "$actual" "$expected"
fail=1
fi
}
echo "== apk-version.sh =="
# Plain release tags.
check "1.2.3-r0" tag v1.2.3
check "0.4.0-r0" tag v0.4.0
check "10.20.30-r0" tag v10.20.30
# Pre-release tags: hyphen separator becomes apk's '_' marker.
check "1.2.3_rc1-r0" tag v1.2.3-rc1
check "1.2.3_alpha1-r0" tag v1.2.3-alpha1
check "1.2.3_beta2-r0" tag v1.2.3-beta2
check "1.2.3_pre1-r0" tag v1.2.3-pre1
# Unknown pre-release token is dropped (apk cannot represent it).
check "1.2.3-r0" tag v1.2.3-weird9
# Dev builds: monotonic commit height as a _git component.
check "0.0.0_git1234-r0" dev 1234
check "0.0.0_git0-r0" dev 0
if [ "$fail" -ne 0 ]; then
echo "FAILED"
exit 1
fi
echo "OK"
+296
View File
@@ -0,0 +1,296 @@
#!/bin/bash
# Build a FIPS .apk package for OpenWrt without the OpenWrt SDK.
#
# apk-tools (.apk) is the mandatory package manager from OpenWrt 25 onward; it
# is also available opt-in on 24.10, where opkg (.ipk) remains the default. The
# .ipk package in ../openwrt-ipk/ still covers OpenWrt 24.x and earlier; this
# .apk package is what you need on 25+. Unlike the .ipk format (a plain tar.gz
# of tarballs that we
# assemble by hand in ../openwrt-ipk/build-ipk.sh), the .apk container is the
# apk-tools v3 ADB format, which is impractical to hand-roll. Instead we drive
# the official `apk mkpkg` applet — the same tool OpenWrt's build system calls
# in include/package-pack.mk — so no SDK is required, only the `apk` binary.
#
# Usage:
# ./packaging/openwrt-apk/build-apk.sh [--arch <name>]
#
# Architectures (--arch): aarch64 [default], x86_64, mipsel, mips, arm
# (the apk CI matrix ships aarch64 + x86_64; the rest are buildable locally).
#
# Output: dist/fips_<version>_<openwrt-arch>.apk
#
# Prerequisites:
# cargo install cargo-zigbuild (Rust musl cross-compilation)
# apk-tools v3 `apk` binary on PATH, or pointed at via APK_BIN=/path/to/apk
# (build from source — see README.md; CI builds apk-tools 3.0.5).
# fakeroot (optional but recommended; makes packaged files root-owned).
#
# Install on a router (packages are unsigned, like our .ipk):
# scp -O dist/fips_<version>_<arch>.apk root@192.168.1.1:/tmp/
# ssh root@192.168.1.1 apk add --allow-untrusted /tmp/fips_<version>_<arch>.apk
set -euo pipefail
# ---------------------------------------------------------------------------
# Arguments
# ---------------------------------------------------------------------------
ARCH="aarch64"
BIN_DIR="" # if set, use prebuilt binaries from here instead of compiling
while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--arch=*) ARCH="${1#*=}"; shift ;;
--bin-dir) BIN_DIR="$2"; shift 2 ;;
--bin-dir=*) BIN_DIR="${1#*=}"; shift ;;
*) echo "Unknown argument: $1" >&2; exit 1 ;;
esac
done
# ---------------------------------------------------------------------------
# Architecture mapping
#
# RUST_TARGET — passed to cargo --target
# OPENWRT_ARCH — apk "arch:" field and the package filename
#
# Kept in sync with ../openwrt-ipk/build-ipk.sh (same target table).
# ---------------------------------------------------------------------------
case "$ARCH" in
aarch64)
RUST_TARGET="aarch64-unknown-linux-musl"
OPENWRT_ARCH="aarch64_cortex-a53"
;;
mipsel)
RUST_TARGET="mipsel-unknown-linux-musl"
OPENWRT_ARCH="mipsel_24kc"
;;
mips)
RUST_TARGET="mips-unknown-linux-musl"
OPENWRT_ARCH="mips_24kc"
;;
arm)
RUST_TARGET="arm-unknown-linux-musleabihf"
OPENWRT_ARCH="arm_cortex-a7"
;;
x86_64)
RUST_TARGET="x86_64-unknown-linux-musl"
OPENWRT_ARCH="x86_64"
;;
*)
echo "Unknown arch: $ARCH" >&2
echo "Valid: aarch64, mipsel, mips, arm, x86_64" >&2
exit 1
;;
esac
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# The installed-filesystem payload (init scripts, config, sysctl, etc.) is
# shared with the .ipk package; there is one canonical copy in openwrt-ipk/.
FILES_DIR="$PROJECT_ROOT/packaging/openwrt-ipk/files"
DIST_DIR="$PROJECT_ROOT/dist"
PKG_NAME="fips"
# Human-readable version for the filename (e.g. v0.4.0 or master.123.abcdef0),
# mirroring the .ipk artifacts and the CI/NIP-94 plumbing.
PKG_VERSION="${PKG_VERSION:-$(cd "$PROJECT_ROOT" && git describe --tags --always --dirty 2>/dev/null || echo "0.1.0")}"
# apk-tools-compatible version embedded inside the package metadata.
APK_VERSION="${APK_VERSION:-$(cd "$PROJECT_ROOT" && sh "$SCRIPT_DIR/apk-version.sh" auto)}"
APK_BIN="${APK_BIN:-apk}"
if ! command -v "$APK_BIN" >/dev/null 2>&1; then
echo "Error: apk-tools binary not found (looked for '$APK_BIN')." >&2
echo " Build apk-tools v3 from source or set APK_BIN=/path/to/apk." >&2
echo " See packaging/openwrt-apk/README.md." >&2
exit 1
fi
echo "==> Building $PKG_NAME $PKG_VERSION (apk version $APK_VERSION) for $OPENWRT_ARCH ($RUST_TARGET)"
# ---------------------------------------------------------------------------
# 1. Obtain binaries
#
# Either use a directory of prebuilt binaries (--bin-dir; CI cross-compiles
# once in a shared job and hands them to both the .ipk and .apk packagers), or
# compile from source here for a self-contained local build.
# ---------------------------------------------------------------------------
if [ -n "$BIN_DIR" ]; then
RELEASE_DIR="$BIN_DIR"
echo "==> Using prebuilt binaries from $RELEASE_DIR"
for bin in fips fipsctl fipstop fips-gateway; do
[ -f "$RELEASE_DIR/$bin" ] || {
echo "Error: prebuilt binary not found: $RELEASE_DIR/$bin" >&2
exit 1
}
done
else
if ! command -v cargo-zigbuild &>/dev/null; then
echo "Error: cargo-zigbuild not found." >&2
echo " Install: cargo install cargo-zigbuild" >&2
exit 1
fi
if ! rustup target list --installed | grep -q "^$RUST_TARGET$"; then
echo "==> Adding Rust target $RUST_TARGET..."
rustup target add "$RUST_TARGET"
fi
echo "==> Compiling..."
cd "$PROJECT_ROOT"
cargo zigbuild \
--release \
--target "$RUST_TARGET" \
--bin fips \
--bin fipsctl \
--bin fipstop \
--bin fips-gateway
RELEASE_DIR="$PROJECT_ROOT/target/$RUST_TARGET/release"
echo "==> Stripping binaries..."
STRIP="${LLVM_STRIP:-strip}"
for bin in fips fipsctl fipstop fips-gateway; do
"$STRIP" "$RELEASE_DIR/$bin" 2>/dev/null || true
done
fi
SIZE=$(du -sh "$RELEASE_DIR/fips" | cut -f1)
echo " fips: $SIZE"
# ---------------------------------------------------------------------------
# 2. Stage the installed filesystem tree (--files root for apk mkpkg)
# ---------------------------------------------------------------------------
# This block is the same payload as ../openwrt-ipk/build-ipk.sh; keep the two
# in sync. The CI apk structural check asserts every path below is present.
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
STAGE_DIR="$WORK_DIR/root" # becomes the package's filesystem
SCRIPTS_DIR="$WORK_DIR/scripts" # maintainer scripts (metadata, not payload)
mkdir -p "$STAGE_DIR" "$SCRIPTS_DIR"
install -d "$STAGE_DIR/usr/bin"
install -m 0755 "$RELEASE_DIR/fips" "$STAGE_DIR/usr/bin/fips"
install -m 0755 "$RELEASE_DIR/fipsctl" "$STAGE_DIR/usr/bin/fipsctl"
install -m 0755 "$RELEASE_DIR/fipstop" "$STAGE_DIR/usr/bin/fipstop"
install -m 0755 "$RELEASE_DIR/fips-gateway" "$STAGE_DIR/usr/bin/fips-gateway"
install -d "$STAGE_DIR/etc/init.d"
install -m 0755 "$FILES_DIR/etc/init.d/fips" "$STAGE_DIR/etc/init.d/fips"
install -m 0755 "$FILES_DIR/etc/init.d/fips-gateway" "$STAGE_DIR/etc/init.d/fips-gateway"
install -d "$STAGE_DIR/etc/fips"
install -m 0600 "$FILES_DIR/etc/fips/fips.yaml" "$STAGE_DIR/etc/fips/fips.yaml"
install -m 0755 "$FILES_DIR/etc/fips/firewall.sh" "$STAGE_DIR/etc/fips/firewall.sh"
# The shared fips.yaml ships ethernet.wan.interface: "eth0", the OpenWrt 24
# default. This .apk package targets OpenWrt 25+ (DSA), where the WAN port is
# named "wan", so ship "wan" as the default. Patching the staged copy keeps the
# as-installed config correct for the platform without maintaining a second copy
# of the file; operators can still edit /etc/fips/fips.yaml for non-standard boards.
sed -i 's|interface: "eth0"|interface: "wan"|' "$STAGE_DIR/etc/fips/fips.yaml"
install -d "$STAGE_DIR/etc/dnsmasq.d"
install -m 0644 "$FILES_DIR/etc/dnsmasq.d/fips.conf" "$STAGE_DIR/etc/dnsmasq.d/fips.conf"
install -d "$STAGE_DIR/etc/sysctl.d"
install -m 0644 "$FILES_DIR/etc/sysctl.d/fips-bridge.conf" "$STAGE_DIR/etc/sysctl.d/fips-bridge.conf"
install -m 0644 "$FILES_DIR/etc/sysctl.d/fips-gateway.conf" "$STAGE_DIR/etc/sysctl.d/fips-gateway.conf"
install -d "$STAGE_DIR/etc/hotplug.d/net"
install -m 0755 "$FILES_DIR/etc/hotplug.d/net/99-fips" "$STAGE_DIR/etc/hotplug.d/net/99-fips"
install -d "$STAGE_DIR/etc/uci-defaults"
install -m 0755 "$FILES_DIR/etc/uci-defaults/90-fips-setup" "$STAGE_DIR/etc/uci-defaults/90-fips-setup"
install -d "$STAGE_DIR/lib/upgrade/keep.d"
install -m 0644 "$FILES_DIR/lib/upgrade/keep.d/fips" "$STAGE_DIR/lib/upgrade/keep.d/fips"
# ---- conffiles ----
# apk mkpkg discovers config files from /lib/apk/packages/<name>.conffiles
# inside the --files tree (same mechanism OpenWrt's package-pack.mk uses).
# Listing fips.yaml here makes apk preserve user edits across upgrades, the
# apk equivalent of opkg's conffiles handling.
install -d "$STAGE_DIR/lib/apk/packages"
cat > "$STAGE_DIR/lib/apk/packages/${PKG_NAME}.conffiles" <<'EOF'
/etc/fips/fips.yaml
EOF
# ---- maintainer scripts ----
# Map our opkg maintainer scripts onto apk's lifecycle phases:
# opkg postinst -> apk post-install (enable + start services)
# opkg prerm -> apk pre-deinstall (stop + disable services)
cat > "$SCRIPTS_DIR/post-install" <<'EOF'
#!/bin/sh
# Run first-boot UCI setup (the script deletes itself when done).
if [ -x /etc/uci-defaults/90-fips-setup ]; then
/etc/uci-defaults/90-fips-setup && rm -f /etc/uci-defaults/90-fips-setup
fi
/etc/init.d/fips enable
/etc/init.d/fips start
/etc/init.d/fips-gateway enable
/etc/init.d/fips-gateway start
exit 0
EOF
cat > "$SCRIPTS_DIR/pre-deinstall" <<'EOF'
#!/bin/sh
/etc/init.d/fips-gateway stop 2>/dev/null || true
/etc/init.d/fips-gateway disable 2>/dev/null || true
/etc/init.d/fips stop 2>/dev/null || true
/etc/init.d/fips disable 2>/dev/null || true
exit 0
EOF
chmod 0755 "$SCRIPTS_DIR/post-install" "$SCRIPTS_DIR/pre-deinstall"
# ---------------------------------------------------------------------------
# 3. Assemble the .apk via apk mkpkg
# ---------------------------------------------------------------------------
# fakeroot makes the packaged files root-owned even though CI runs unprivileged.
DESCRIPTION="FIPS Mesh Network Daemon. Distributed, decentralized mesh networking over UDP, TCP, and raw Ethernet, with a TUN interface (fips0), ULA IPv6 addressing, and a .fips DNS responder."
DEPENDS="kmod-tun kmod-br-netfilter kmod-nft-nat kmod-nf-conntrack ip-full"
PKG_FILENAME="${PKG_NAME}_${PKG_VERSION}_${OPENWRT_ARCH}.apk"
mkdir -p "$DIST_DIR"
FAKEROOT=""
if command -v fakeroot >/dev/null 2>&1; then
FAKEROOT="fakeroot"
else
echo "Warning: fakeroot not found — packaged files will be owned by the build user." >&2
fi
$FAKEROOT "$APK_BIN" mkpkg \
--info "name:$PKG_NAME" \
--info "version:$APK_VERSION" \
--info "description:$DESCRIPTION" \
--info "arch:$OPENWRT_ARCH" \
--info "license:MIT" \
--info "origin:$PKG_NAME" \
--info "url:https://github.com/jmcorgan/fips" \
--info "maintainer:FIPS Network" \
--info "depends:$DEPENDS" \
--script "post-install:$SCRIPTS_DIR/post-install" \
--script "pre-deinstall:$SCRIPTS_DIR/pre-deinstall" \
--files "$STAGE_DIR" \
--output "$DIST_DIR/$PKG_FILENAME"
echo ""
echo "==> Done: dist/$PKG_FILENAME"
echo " $(du -sh "$DIST_DIR/$PKG_FILENAME" | cut -f1)"
echo ""
echo "Install on router (OpenWrt 25+, or 24.10 with apk enabled):"
echo " scp -O dist/$PKG_FILENAME root@192.168.1.1:/tmp/"
echo " ssh root@192.168.1.1 apk add --allow-untrusted /tmp/$PKG_FILENAME"
+4 -1
View File
@@ -132,7 +132,10 @@ The default config enables:
For Ethernet transport, uncomment the `ethernet:` section and set the correct
physical interface names for your router. **Always use physical port names
(`eth0`, `eth1`), never bridge names (`br-lan`).** See
(`eth0`, `eth1`, or DSA port names like `wan`/`lan1`), never bridge names
(`br-lan`).** The shipped default WAN port is `eth0` (OpenWrt 24); on OpenWrt
25 (DSA) boards the WAN port is named `wan` — the `.apk` package ships that
default. Run `ip link show` to confirm the names on your board. See
[`deploy/native/README.md`](../../deploy/native/README.md) for details.
## Service management
+47 -33
View File
@@ -27,11 +27,14 @@ set -euo pipefail
# ---------------------------------------------------------------------------
ARCH="aarch64"
BIN_DIR="" # if set, use prebuilt binaries from here instead of compiling
while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--arch=*) ARCH="${1#*=}"; shift ;;
--bin-dir) BIN_DIR="$2"; shift 2 ;;
--bin-dir=*) BIN_DIR="${1#*=}"; shift ;;
*) echo "Unknown argument: $1" >&2; exit 1 ;;
esac
done
@@ -86,44 +89,55 @@ PKG_VERSION="${PKG_VERSION:-$(cd "$PROJECT_ROOT" && git describe --tags --always
echo "==> Building $PKG_NAME $PKG_VERSION for $OPENWRT_ARCH ($RUST_TARGET)"
# ---------------------------------------------------------------------------
# Prerequisites
# 1. Obtain binaries
#
# Either use a directory of prebuilt binaries (--bin-dir; CI cross-compiles
# once in a shared job and hands them to both the .ipk and .apk packagers), or
# compile from source here for a self-contained local build.
# ---------------------------------------------------------------------------
if ! command -v cargo-zigbuild &>/dev/null; then
echo "Error: cargo-zigbuild not found." >&2
echo " Install: cargo install cargo-zigbuild" >&2
exit 1
if [ -n "$BIN_DIR" ]; then
RELEASE_DIR="$BIN_DIR"
echo "==> Using prebuilt binaries from $RELEASE_DIR"
for bin in fips fipsctl fipstop fips-gateway; do
[ -f "$RELEASE_DIR/$bin" ] || {
echo "Error: prebuilt binary not found: $RELEASE_DIR/$bin" >&2
exit 1
}
done
else
if ! command -v cargo-zigbuild &>/dev/null; then
echo "Error: cargo-zigbuild not found." >&2
echo " Install: cargo install cargo-zigbuild" >&2
exit 1
fi
if ! rustup target list --installed | grep -q "^$RUST_TARGET$"; then
echo "==> Adding Rust target $RUST_TARGET..."
rustup target add "$RUST_TARGET"
fi
echo "==> Compiling..."
cd "$PROJECT_ROOT"
cargo zigbuild \
--release \
--target "$RUST_TARGET" \
--bin fips \
--bin fipsctl \
--bin fipstop \
--bin fips-gateway
RELEASE_DIR="$PROJECT_ROOT/target/$RUST_TARGET/release"
echo "==> Stripping binaries..."
STRIP="${LLVM_STRIP:-strip}"
for bin in fips fipsctl fipstop fips-gateway; do
"$STRIP" "$RELEASE_DIR/$bin" 2>/dev/null || true
done
fi
if ! rustup target list --installed | grep -q "^$RUST_TARGET$"; then
echo "==> Adding Rust target $RUST_TARGET..."
rustup target add "$RUST_TARGET"
fi
# ---------------------------------------------------------------------------
# 1. Build
# ---------------------------------------------------------------------------
echo "==> Compiling..."
cd "$PROJECT_ROOT"
cargo zigbuild \
--release \
--target "$RUST_TARGET" \
--bin fips \
--bin fipsctl \
--bin fipstop \
--bin fips-gateway
RELEASE_DIR="$PROJECT_ROOT/target/$RUST_TARGET/release"
echo "==> Stripping binaries..."
STRIP="${LLVM_STRIP:-strip}"
for bin in fips fipsctl fipstop fips-gateway; do
"$STRIP" "$RELEASE_DIR/$bin" 2>/dev/null || true
done
SIZE=$(du -sh "$RELEASE_DIR/fips" | cut -f1)
echo " fips: $SIZE after strip"
echo " fips: $SIZE"
# ---------------------------------------------------------------------------
# 2. Assemble .ipk
@@ -12,6 +12,17 @@ start_service() {
# Ensure TUN module is loaded before starting the daemon.
modprobe tun 2>/dev/null || true
# Pre-create the control-socket runtime directory so the daemon binds the
# canonical /run/fips/control.sock instead of falling back to /tmp. This is
# the procd equivalent of the systemd unit's RuntimeDirectory=fips (and the
# fips.tmpfiles "d /run/fips 0750 root fips" entry); OpenWrt was the only
# platform missing it. Without it, fips-gateway — which creates /run/fips
# for its own gateway.sock — makes fipsctl/fipstop resolve a control socket
# under /run/fips that the daemon actually bound under /tmp.
mkdir -p /run/fips
chmod 0750 /run/fips
chgrp fips /run/fips 2>/dev/null || true
procd_open_instance
procd_set_param command "$PROG" --config "$CONFIG"
# Respawn: restart after 5 s, give up after 5 consecutive failures within
+3
View File
@@ -82,6 +82,8 @@ enum Commands {
enum StatsCommands {
/// List available history metrics
List,
/// Dump current counter values for every protocol metric family
Metrics,
/// List peers tracked in the stats history
Peers,
/// Fetch a time-series window for a metric
@@ -448,6 +450,7 @@ fn main() {
}
Commands::Stats { what } => match what {
StatsCommands::List => build_query("show_stats_list"),
StatsCommands::Metrics => build_query("show_metrics"),
StatsCommands::Peers => build_query("show_stats_peers"),
StatsCommands::History {
metric,
+323 -2
View File
@@ -2,7 +2,7 @@ use ratatui::widgets::TableState;
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Tab {
Node,
Peers,
@@ -113,6 +113,19 @@ impl Tab {
Tab::Peers | Tab::Sessions | Tab::Transports | Tab::Gateway
)
}
/// Number of focusable, independently-scrollable panes on this tab, for the
/// multi-pane focus/scroll model. Returns 0 for tabs that don't participate
/// (they use table selection or their own scroll instead). The Tree, Bloom
/// (Filters), and Routing tabs each lay out three stacked panes; the
/// Performance (Mmp) tab lays out two (Link MMP, Session MMP).
pub fn scroll_pane_count(&self) -> usize {
match self {
Tab::Tree | Tab::Bloom | Tab::Routing => 3,
Tab::Mmp => 2,
_ => 0,
}
}
}
#[derive(Clone)]
@@ -125,6 +138,17 @@ pub struct DetailView {
pub scroll: u16,
}
/// A pending Del-disconnect confirmation against a selected peer. Holds the
/// peer's npub (for the control command) and a human-readable label plus a
/// reconnect note tailored to the peer kind (or a generic line when the
/// connect-policy is not surfaced).
#[derive(Clone)]
pub struct ConfirmDisconnect {
pub npub: String,
pub display_name: String,
pub reconnect_note: String,
}
#[derive(Clone, Copy)]
pub enum SelectedTreeItem {
None,
@@ -132,6 +156,45 @@ pub enum SelectedTreeItem {
Link,
}
/// Per-view column-sort state: the active sort column index and direction.
/// `s` cycles the column; `S` toggles direction. Default is column 0 ascending,
/// which for the name-first column layouts is an alphabetical-by-name order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SortState {
pub col: usize,
pub descending: bool,
}
impl SortState {
/// Cycle to the next sort column (wrapping over `n` columns), resetting to
/// ascending on a column change so a fresh column starts predictably.
pub fn cycle_col(&mut self, n: usize) {
if n == 0 {
return;
}
self.col = (self.col + 1) % n;
self.descending = false;
}
/// Toggle the sort direction on the current column.
pub fn toggle_dir(&mut self) {
self.descending = !self.descending;
}
}
/// Sortable column labels for the Link MMP table (sort key order matches the
/// rendered column order). Column 0 is the peer name.
pub const MMP_LINK_SORT_LABELS: &[&str] = &["name", "srtt", "loss", "etx", "lqi", "gp"];
pub const MMP_LINK_SORT_COLS: usize = MMP_LINK_SORT_LABELS.len();
/// Sortable column labels for the Session MMP table.
pub const MMP_SESSION_SORT_LABELS: &[&str] = &["name", "srtt", "loss", "etx", "sqi", "mtu"];
pub const MMP_SESSION_SORT_COLS: usize = MMP_SESSION_SORT_LABELS.len();
/// Sortable column labels for the Graphs by-peer summary list.
pub const GRAPHS_PEER_SORT_LABELS: &[&str] = &["name", "min", "max", "last", "n"];
pub const GRAPHS_PEER_SORT_COLS: usize = GRAPHS_PEER_SORT_LABELS.len();
/// Options for the Graphs tab window selector.
pub const GRAPHS_WINDOWS: &[(&str, &str)] =
&[("1m", "1s"), ("10m", "1s"), ("1h", "1s"), ("24h", "1m")];
@@ -201,6 +264,18 @@ pub struct App {
pub data: HashMap<Tab, serde_json::Value>,
pub table_states: HashMap<Tab, TableState>,
pub detail_view: Option<DetailView>,
/// Whether the `?` help overlay is currently shown.
pub show_help: bool,
/// A pending Del-disconnect confirmation, if the modal is open.
pub confirm_disconnect: Option<ConfirmDisconnect>,
/// Per-tab focused pane index for multi-pane tabs, generalizing the
/// one-off peers `TableState`. Absent entry means pane 0. The accessors
/// below are the general focus/scroll model the interaction consumers
/// (multi-pane focus, Graphs by-peer) build on.
pub focused_pane: HashMap<Tab, usize>,
/// Per-(tab, pane) scroll offset (rows), generalizing the one-off detail
/// and graphs scroll state.
pub scroll_offsets: HashMap<(Tab, usize), u16>,
pub last_fetch: Instant,
pub last_error: Option<(Instant, String)>,
pub expanded_transports: HashSet<u64>,
@@ -227,6 +302,12 @@ pub struct App {
/// Cached peer list from `show_stats_peers`, populated when the
/// Graphs tab is active in a non-Node mode.
pub graphs_peers: Vec<GraphsPeer>,
/// Column-sort state for the Link MMP table.
pub mmp_link_sort: SortState,
/// Column-sort state for the Session MMP table.
pub mmp_session_sort: SortState,
/// Column-sort state for the Graphs by-peer summary list.
pub graphs_peer_sort: SortState,
}
impl App {
@@ -239,6 +320,10 @@ impl App {
data: HashMap::new(),
table_states: HashMap::new(),
detail_view: None,
show_help: false,
confirm_disconnect: None,
focused_pane: HashMap::new(),
scroll_offsets: HashMap::new(),
last_fetch: Instant::now(),
last_error: None,
expanded_transports: HashSet::new(),
@@ -253,13 +338,52 @@ impl App {
graphs_peer_metric_idx: 0,
graphs_peer_idx: 0,
graphs_peers: Vec::new(),
mmp_link_sort: SortState::default(),
mmp_session_sort: SortState::default(),
graphs_peer_sort: SortState::default(),
}
}
/// Cycle the Graphs-tab view mode.
/// Cycle the sort column for the active view (Link/Session MMP or Graphs
/// by-peer), passing the view's column count. On the Performance tab the
/// sort acts on the focused pane only (pane 0 Link MMP, pane 1 Session MMP),
/// so each pane keeps its own sort state.
pub fn cycle_sort_col(&mut self) {
match self.active_tab {
Tab::Mmp => {
if self.focused_pane() == 1 {
self.mmp_session_sort.cycle_col(MMP_SESSION_SORT_COLS);
} else {
self.mmp_link_sort.cycle_col(MMP_LINK_SORT_COLS);
}
}
Tab::Graphs => self.graphs_peer_sort.cycle_col(GRAPHS_PEER_SORT_COLS),
_ => {}
}
}
/// Toggle the sort direction for the active view (the focused pane on the
/// Performance tab).
pub fn toggle_sort_dir(&mut self) {
match self.active_tab {
Tab::Mmp => {
if self.focused_pane() == 1 {
self.mmp_session_sort.toggle_dir();
} else {
self.mmp_link_sort.toggle_dir();
}
}
Tab::Graphs => self.graphs_peer_sort.toggle_dir(),
_ => {}
}
}
/// Cycle the Graphs-tab view mode. Closes any open by-peer detail, which
/// only applies to the MetricByPeer mode.
pub fn graphs_next_mode(&mut self) {
self.graphs_mode = self.graphs_mode.next();
self.graphs_scroll = 0;
self.detail_view = None;
}
/// Advance the mode-specific selector (metric or peer).
@@ -310,6 +434,48 @@ impl App {
Some(&self.graphs_peers[idx])
}
/// Number of peers in the current Graphs by-peer (MetricByPeer) payload.
/// The MetricByPeer view lists one summary line per peer carried in the
/// `peers` array of the fetched `show_stats_history_all_peers` response.
pub fn graphs_metric_peer_count(&self) -> usize {
self.data
.get(&Tab::Graphs)
.and_then(|d| d.get("peers"))
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0)
}
/// Move the by-peer list / detail cursor to the next peer (wrapping).
/// Shared by the MetricByPeer summary list (Up/Down select) and the
/// open by-peer detail (Up/Down follow the selection, re-rendering the
/// plot for the newly selected peer).
pub fn graphs_peer_select_next(&mut self) {
let n = self.graphs_metric_peer_count();
if n > 0 {
self.graphs_peer_idx = (self.graphs_peer_idx + 1) % n;
}
}
/// Move the by-peer list / detail cursor to the previous peer (wrapping).
pub fn graphs_peer_select_prev(&mut self) {
let n = self.graphs_metric_peer_count();
if n > 0 {
self.graphs_peer_idx = (self.graphs_peer_idx + n - 1) % n;
}
}
/// Open the Graphs by-peer detail (full-pane btop plot) for the currently
/// selected peer. No-op unless the by-peer list has at least one peer.
pub fn graphs_open_peer_detail(&mut self) {
if self.graphs_metric_peer_count() > 0 {
if self.graphs_peer_idx >= self.graphs_metric_peer_count() {
self.graphs_peer_idx = 0;
}
self.detail_view = Some(DetailView { scroll: 0 });
}
}
/// Current Graphs-tab (window, granularity) pair.
pub fn graphs_window(&self) -> (&'static str, &'static str) {
GRAPHS_WINDOWS[self.graphs_window_idx % GRAPHS_WINDOWS.len()]
@@ -393,6 +559,161 @@ impl App {
self.detail_view = None;
}
/// Toggle the `?` help overlay.
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
/// Open a disconnect confirmation for the currently selected Peers row.
/// No-op unless the Peers tab is active with a selected row that carries an
/// npub. The reconnect note states that the peer stays disconnected until
/// it is manually reconnected; a manual disconnect suppresses
/// auto-reconnect for all peer kinds, so there is no per-direction
/// tailoring.
pub fn request_disconnect_confirm(&mut self) {
if self.active_tab != Tab::Peers {
return;
}
let Some(selected) = self
.table_states
.get(&Tab::Peers)
.and_then(|s| s.selected())
else {
return;
};
// The displayed order is the role-grouped sort (peers.rs); mirror it so
// the confirm names the same peer the cursor is on.
let mut peers = self
.data
.get(&Tab::Peers)
.and_then(|v| v.get("peers"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
peers.sort_by(|a, b| {
let rank = |p: &serde_json::Value| -> u8 {
let parent = p
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let child = p.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
if parent {
0
} else if child {
1
} else {
2
}
};
rank(a).cmp(&rank(b)).then_with(|| {
let lqi = |p: &serde_json::Value| {
p.get("mmp")
.and_then(|m| m.get("lqi"))
.and_then(|v| v.as_f64())
};
match (lqi(a), lqi(b)) {
(Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
})
});
let Some(peer) = peers.get(selected) else {
return;
};
let npub = peer.get("npub").and_then(|v| v.as_str()).unwrap_or("");
if npub.is_empty() {
return;
}
let display_name = peer
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or(npub)
.to_string();
let reconnect_note = "It stays disconnected until you manually reconnect it.".to_string();
self.confirm_disconnect = Some(ConfirmDisconnect {
npub: npub.to_string(),
display_name,
reconnect_note,
});
}
/// Cancel a pending disconnect confirmation.
pub fn cancel_disconnect(&mut self) {
self.confirm_disconnect = None;
}
/// Take the pending disconnect target, clearing the confirmation. Returns
/// the npub to disconnect when one was confirmed.
pub fn take_disconnect_target(&mut self) -> Option<String> {
self.confirm_disconnect.take().map(|c| c.npub)
}
/// Deselect the active tab's table row (return to the overview state).
/// No-op when the active tab has no selection.
pub fn deselect_row(&mut self) {
if let Some(state) = self.table_states.get_mut(&self.active_tab) {
state.select(None);
}
}
// The focus/scroll model below is the shared substrate the interaction
// consumers (multi-pane focus, Graphs by-peer detail) build on; some
// accessors land ahead of their first consumer, mirroring the test-kit's
// not-yet-used-helper allowance.
/// Currently focused pane index on the active tab (0 if unset). The general
/// focus model the multi-pane and Graphs-by-peer consumers read.
#[allow(dead_code)]
pub fn focused_pane(&self) -> usize {
self.focused_pane
.get(&self.active_tab)
.copied()
.unwrap_or(0)
}
/// Cycle pane focus forward across `pane_count` panes on the active tab.
#[allow(dead_code)]
pub fn focus_next_pane(&mut self, pane_count: usize) {
if pane_count == 0 {
return;
}
let cur = self.focused_pane();
self.focused_pane
.insert(self.active_tab, (cur + 1) % pane_count);
}
/// Scroll offset for a given pane on the active tab.
pub fn pane_scroll(&self, pane: usize) -> u16 {
self.scroll_offsets
.get(&(self.active_tab, pane))
.copied()
.unwrap_or(0)
}
/// Scroll the focused pane on the active tab by `delta` rows (saturating),
/// generalizing the one-off detail/graphs scroll counters.
#[allow(dead_code)]
pub fn scroll_focused_pane(&mut self, delta: i16) {
let pane = self.focused_pane();
let entry = self
.scroll_offsets
.entry((self.active_tab, pane))
.or_insert(0);
*entry = if delta >= 0 {
entry.saturating_add(delta as u16)
} else {
entry.saturating_sub((-delta) as u16)
};
}
/// Set the focused pane's scroll offset directly (used by Home/End). End
/// passes a large value the renderer clamps to the pane's content height.
pub fn set_focused_pane_scroll(&mut self, offset: u16) {
let pane = self.focused_pane();
self.scroll_offsets.insert((self.active_tab, pane), offset);
}
/// Scroll detail view down.
pub fn scroll_detail_down(&mut self) {
if let Some(ref mut dv) = self.detail_view {
+64 -20
View File
@@ -1,7 +1,9 @@
use ratatui::crossterm::event::{self, Event as CrosstermEvent, KeyEvent};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};
pub enum Event {
Key(KeyEvent),
@@ -9,45 +11,87 @@ pub enum Event {
Tick,
}
/// Upper bound on a single `event::poll` wait. Kept short (vs the full
/// tick interval) so [`EventHandler::stop`] can join the input thread
/// promptly at quit instead of blocking for up to a refresh interval.
const POLL_INTERVAL: Duration = Duration::from_millis(250);
pub struct EventHandler {
rx: mpsc::Receiver<Event>,
running: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
impl EventHandler {
pub fn new(tick_rate: Duration) -> Self {
let (tx, rx) = mpsc::channel();
let running = Arc::new(AtomicBool::new(true));
let thread_running = Arc::clone(&running);
thread::spawn(move || {
loop {
if event::poll(tick_rate).unwrap_or(false) {
if let Ok(evt) = event::read() {
match evt {
CrosstermEvent::Key(key) => {
if tx.send(Event::Key(key)).is_err() {
return;
}
let handle = thread::spawn(move || {
let mut last_tick = Instant::now();
while thread_running.load(Ordering::Relaxed) {
// Bound the poll by the time left until the next tick, but
// never longer than POLL_INTERVAL so the running flag is
// checked (and quit honored) promptly.
let timeout = tick_rate
.saturating_sub(last_tick.elapsed())
.min(POLL_INTERVAL);
match event::poll(timeout) {
Ok(true) => match event::read() {
Ok(CrosstermEvent::Key(key)) => {
if tx.send(Event::Key(key)).is_err() {
return;
}
CrosstermEvent::Resize(..) => {
if tx.send(Event::Resize).is_err() {
return;
}
}
_ => {}
}
}
} else {
// Poll timed out — send a tick
Ok(CrosstermEvent::Resize(..)) => {
if tx.send(Event::Resize).is_err() {
return;
}
}
Ok(_) => {}
Err(_) => return,
},
Ok(false) => {}
Err(_) => return,
}
if last_tick.elapsed() >= tick_rate {
if tx.send(Event::Tick).is_err() {
return;
}
last_tick = Instant::now();
}
}
});
Self { rx }
Self {
rx,
running,
handle: Some(handle),
}
}
pub fn next(&self) -> Result<Event, mpsc::RecvError> {
self.rx.recv()
}
/// Stop the input thread and wait for it to exit. Call this before
/// restoring the terminal so the thread is not still reading stdin
/// after raw mode is disabled — otherwise stray bytes (a keystroke or
/// a terminal query response) echo onto the restored screen, which is
/// especially visible over SSH/tmux.
pub fn stop(&mut self) {
self.running.store(false, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
impl Drop for EventHandler {
fn drop(&mut self) {
self.stop();
}
}
+162 -17
View File
@@ -223,10 +223,39 @@ fn fetch_data(
{
app.data.insert(Tab::Cache, data);
}
// The Tree and Filters views carry no parent/child role flags in their own
// daemon responses, so cross-fetch the peers view and join by node address
// to group their peer lists the same way the Peers tab does. Non-fatal: on
// error the grouping falls back to placing every peer under Other.
if (app.active_tab == Tab::Tree || app.active_tab == Tab::Bloom)
&& let Ok(data) = rt.block_on(client.query("show_peers"))
{
app.data.insert(Tab::Peers, data);
}
app.last_fetch = std::time::Instant::now();
}
/// Down-arrow behaviour on the Graphs tab. The by-peer detail follows the
/// selection (next peer); the by-peer list moves its cursor; the stacked
/// node/peer modes scroll the content.
fn graphs_down(app: &mut App) {
match app.graphs_mode {
crate::app::GraphsMode::MetricByPeer => app.graphs_peer_select_next(),
_ if app.detail_view.is_some() => app.scroll_detail_down(),
_ => app.graphs_scroll_down(),
}
}
/// Up-arrow behaviour on the Graphs tab (mirror of `graphs_down`).
fn graphs_up(app: &mut App) {
match app.graphs_mode {
crate::app::GraphsMode::MetricByPeer => app.graphs_peer_select_prev(),
_ if app.detail_view.is_some() => app.scroll_detail_up(),
_ => app.graphs_scroll_up(),
}
}
fn main() {
let cli = Cli::parse();
@@ -254,8 +283,14 @@ fn main() {
eprintln!("fipstop: failed to initialize terminal: {e}");
std::process::exit(1);
});
// Force a full repaint of a known-blank screen before the first draw.
// try_init enters the alternate screen but does not clear it, and the
// first draw only emits cells that differ from an assumed-blank buffer;
// on terminals that don't hand back a cleared alternate buffer (notably
// tmux, and over SSH) that leaves stale content showing through.
let _ = terminal.clear();
let mut app = App::new(refresh);
let events = EventHandler::new(refresh);
let mut events = EventHandler::new(refresh);
// Initial fetch
fetch_data(&rt, &client, &gateway_client, &mut app);
@@ -272,10 +307,65 @@ fn main() {
if key.kind != ratatui::crossterm::event::KeyEventKind::Press {
continue;
}
// The disconnect confirmation is modal: while open, only Y
// (confirm), N/Esc (cancel), and quit are honored.
if app.confirm_disconnect.is_some() {
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
app.should_quit = true;
}
(KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => {
if let Some(npub) = app.take_disconnect_target() {
let params = serde_json::json!({ "npub": npub });
if let Err(e) =
rt.block_on(client.query_with_params("disconnect", params))
{
app.last_error = Some((std::time::Instant::now(), e));
}
fetch_data(&rt, &client, &gateway_client, &mut app);
}
}
(KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) | (KeyCode::Esc, _) => {
app.cancel_disconnect();
}
_ => {}
}
if app.should_quit {
break;
}
continue;
}
// The `?` overlay is modal: while open, only `?`/Esc (close)
// and quit are honored, so navigation keys don't act behind it.
if app.show_help {
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
app.should_quit = true;
}
(KeyCode::Char('?'), _) | (KeyCode::Esc, _) => {
app.show_help = false;
}
_ => {}
}
if app.should_quit {
break;
}
continue;
}
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
app.should_quit = true;
}
(KeyCode::Char('?'), _) => {
app.toggle_help();
}
(KeyCode::Delete, _) => {
// Del on a selected Peers row opens the disconnect
// confirmation (the only state-mutating action).
if app.active_tab == Tab::Peers && app.detail_view.is_none() {
app.request_disconnect_confirm();
}
}
(KeyCode::Tab, KeyModifiers::NONE) => {
app.close_detail();
app.active_tab = app.active_tab.next();
@@ -287,25 +377,64 @@ fn main() {
fetch_data(&rt, &client, &gateway_client, &mut app);
}
(KeyCode::Down, _) => {
if app.detail_view.is_some() {
if app.active_tab == Tab::Graphs {
graphs_down(&mut app);
} else if app.detail_view.is_some() {
app.scroll_detail_down();
} else if app.active_tab == Tab::Graphs {
app.graphs_scroll_down();
} else if app.active_tab.has_table() {
app.select_next();
} else if app.active_tab.scroll_pane_count() > 0 {
app.scroll_focused_pane(1);
}
}
(KeyCode::Up, _) => {
if app.detail_view.is_some() {
if app.active_tab == Tab::Graphs {
graphs_up(&mut app);
} else if app.detail_view.is_some() {
app.scroll_detail_up();
} else if app.active_tab == Tab::Graphs {
app.graphs_scroll_up();
} else if app.active_tab.has_table() {
app.select_prev();
} else if app.active_tab.scroll_pane_count() > 0 {
app.scroll_focused_pane(-1);
}
}
(KeyCode::PageDown, _) => {
if app.active_tab.scroll_pane_count() > 0 {
app.scroll_focused_pane(10);
}
}
(KeyCode::PageUp, _) => {
if app.active_tab.scroll_pane_count() > 0 {
app.scroll_focused_pane(-10);
}
}
(KeyCode::Home, _) => {
if app.active_tab.scroll_pane_count() > 0 {
app.set_focused_pane_scroll(0);
}
}
(KeyCode::End, _) => {
if app.active_tab.scroll_pane_count() > 0 {
// A large offset the renderer clamps to content.
app.set_focused_pane_scroll(u16::MAX);
}
}
(KeyCode::Char('f'), KeyModifiers::NONE) => {
// Cycle pane focus on the multi-pane scrollable tabs.
let panes = app.active_tab.scroll_pane_count();
if panes > 0 {
app.focus_next_pane(panes);
}
}
(KeyCode::Enter, _) => {
if app.active_tab.has_table() && app.detail_view.is_none() {
if app.active_tab == Tab::Graphs
&& app.detail_view.is_none()
&& app.graphs_mode == crate::app::GraphsMode::MetricByPeer
{
// Expand the selected by-peer summary line into a
// full-pane btop plot.
app.graphs_open_peer_detail();
} else if app.active_tab.has_table() && app.detail_view.is_none() {
app.open_detail();
}
}
@@ -324,21 +453,20 @@ fn main() {
}
}
}
(KeyCode::Char('m'), KeyModifiers::NONE)
if app.active_tab == Tab::Graphs && app.detail_view.is_none() =>
{
(KeyCode::Char('m'), KeyModifiers::NONE) if app.active_tab == Tab::Graphs => {
// `m` cycles the broader Graphs mode, even from inside
// the by-peer detail (which then closes, since the
// detail only applies to the by-peer mode).
app.graphs_next_mode();
fetch_data(&rt, &client, &gateway_client, &mut app);
}
(KeyCode::Char('n'), KeyModifiers::NONE)
if app.active_tab == Tab::Graphs && app.detail_view.is_none() =>
{
(KeyCode::Char('n'), KeyModifiers::NONE) if app.active_tab == Tab::Graphs => {
// `n` switches the statistic, for both the by-peer list
// and the open by-peer detail (which re-renders).
app.graphs_next_selector();
fetch_data(&rt, &client, &gateway_client, &mut app);
}
(KeyCode::Char('N'), KeyModifiers::SHIFT)
if app.active_tab == Tab::Graphs && app.detail_view.is_none() =>
{
(KeyCode::Char('N'), KeyModifiers::SHIFT) if app.active_tab == Tab::Graphs => {
app.graphs_prev_selector();
fetch_data(&rt, &client, &gateway_client, &mut app);
}
@@ -354,8 +482,12 @@ fn main() {
}
}
(KeyCode::Esc, _) => {
// Priority: close an open detail first, otherwise
// deselect the active table row (return to overview).
if app.detail_view.is_some() {
app.close_detail();
} else if app.active_tab.has_table() {
app.deselect_row();
}
}
(KeyCode::Char('e'), KeyModifiers::NONE) => {
@@ -381,6 +513,15 @@ fn main() {
app.graphs_scroll = 0;
fetch_data(&rt, &client, &gateway_client, &mut app);
}
(KeyCode::Char('s'), KeyModifiers::NONE) => {
// `s` cycles the active sort column on the MMP and
// Graphs by-peer tables (no-op on other tabs).
app.cycle_sort_col();
}
(KeyCode::Char('S'), _) => {
// `S` toggles the sort direction on those same tables.
app.toggle_sort_dir();
}
_ => {}
}
}
@@ -400,5 +541,9 @@ fn main() {
}
}
// Stop the input thread before restoring the terminal so it is not
// still reading stdin once raw mode is disabled (stray bytes would
// otherwise echo onto the restored screen).
events.stop();
restore_terminal();
}
+145 -73
View File
@@ -2,7 +2,7 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use crate::app::{App, Tab};
@@ -20,47 +20,99 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
};
let chunks = Layout::vertical([
Constraint::Length(7), // Bloom Filter State
Constraint::Length(8), // Bloom Filter State
Constraint::Length(15), // Bloom Announce Stats
Constraint::Min(3), // Peer Filters
])
.split(area);
draw_state(frame, data, chunks[0]);
draw_stats(frame, data, chunks[1]);
draw_peer_filters(frame, data, chunks[2]);
let focused = app.focused_pane();
draw_state(
frame,
app,
data,
app.pane_scroll(0),
focused == 0,
chunks[0],
);
draw_stats(frame, data, app.pane_scroll(1), focused == 1, chunks[1]);
draw_peer_filters(
frame,
app,
data,
app.pane_scroll(2),
focused == 2,
chunks[2],
);
}
fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let lines = vec![
helpers::kv_line(
fn draw_state(
frame: &mut Frame,
app: &App,
data: &serde_json::Value,
scroll: u16,
focused: bool,
area: Rect,
) {
// is_root determines whether the uptree filter renders as "n/a (root)";
// read it from the dashboard (State) surface, which carries it.
let is_root = app
.data
.get(&Tab::Node)
.and_then(|d| d.get("is_root"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Uptree filter (what we last sent to the tree parent): "n/a (root)" for a
// root node, an em-dash before the first announce, else the value.
let uptree_fill = if is_root {
"n/a (root)".to_string()
} else {
match data.get("uptree_fill_ratio").and_then(|v| v.as_f64()) {
Some(r) => format!("{:.1}%", r * 100.0),
None => "\u{2014}".into(),
}
};
let subtree_est = if is_root {
"n/a (root)".to_string()
} else {
match data.get("uptree_estimated_count").and_then(|v| v.as_f64()) {
Some(n) => format!("{:.0}", n),
None => "\u{2014}".into(),
}
};
let lines = helpers::kv_lines(&[
(
"Node Addr",
&helpers::truncate_hex(helpers::str_field(data, "own_node_addr"), 16),
helpers::truncate_hex(helpers::str_field(data, "own_node_addr"), 16),
),
helpers::kv_line("Leaf Only", helpers::bool_field(data, "is_leaf_only")),
helpers::kv_line("Sequence", &helpers::u64_field(data, "sequence")),
helpers::kv_line(
(
"Leaf Only",
helpers::bool_field(data, "is_leaf_only").into(),
),
("Sequence", helpers::u64_field(data, "sequence")),
(
"Leaf Deps",
&helpers::u64_field(data, "leaf_dependent_count"),
helpers::u64_field(data, "leaf_dependent_count"),
),
];
("Fill (sent uptree)", uptree_fill),
("Subtree est", subtree_est),
]);
let block = Block::default()
.borders(Borders::ALL)
.title(" Bloom Filter State ");
let block = helpers::pane_block(" Bloom Filter State ", focused);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Paragraph::new(lines), inner);
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Bloom Announce Stats ");
fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused: bool, area: Rect) {
let block = helpers::pane_block(" Bloom Announce Stats ", focused);
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines = vec![
let lines = vec![
helpers::section_header("Inbound"),
helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")),
helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")),
@@ -88,13 +140,18 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
),
];
let max_lines = inner.height as usize;
lines.truncate(max_lines);
frame.render_widget(Paragraph::new(lines), inner);
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
fn draw_peer_filters(
frame: &mut Frame,
app: &App,
data: &serde_json::Value,
scroll: u16,
focused: bool,
area: Rect,
) {
let filters = data
.get("peer_filters")
.and_then(|v| v.as_array())
@@ -102,9 +159,7 @@ fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
.unwrap_or_default();
let count = filters.len();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Peer Filters ({count}) "));
let block = helpers::pane_block(&format!(" Peer Filters ({count}) "), focused);
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -114,48 +169,65 @@ fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
return;
}
let lines: Vec<Line> = filters
.iter()
.map(|f| {
let name = helpers::str_field(f, "display_name");
let seq = helpers::u64_field(f, "filter_sequence");
let has = f
.get("has_filter")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut spans = vec![
Span::styled(
format!(" {name:<16}"),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled("seq: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{seq:<6}")),
];
if has {
let fill = f
.get("fill_ratio")
.and_then(|v| v.as_f64())
.map(|r| format!("{:.1}%", r * 100.0))
.unwrap_or_else(|| "-".into());
let est = f
.get("estimated_count")
.and_then(|v| v.as_f64())
.map(|n| format!("{:.0}", n))
.unwrap_or_else(|| "-".into());
spans.push(Span::styled("fill: ", Style::default().fg(Color::DarkGray)));
spans.push(Span::raw(format!("{fill:<8}")));
spans.push(Span::styled("est: ", Style::default().fg(Color::DarkGray)));
spans.push(Span::raw(format!("{est:<6}")));
spans.push(Span::styled("ok", Style::default().fg(Color::Green)));
} else {
spans.push(Span::styled("none", Style::default().fg(Color::Red)));
}
Line::from(spans)
})
// The bloom response carries no role flags; recover them from the peers view
// (cross-fetched on this tab) by joining each filter's `peer` hex address,
// then group by tree role (parent -> STP children -> other) to match the
// Peers and Tree tabs so the same peer sits under the same heading.
let role_map = helpers::peer_role_map(app.data.get(&Tab::Peers));
let mut filters: Vec<serde_json::Value> = filters
.into_iter()
.map(|f| helpers::enrich_role(f, &role_map, "peer"))
.collect();
helpers::sort_by_group(&mut filters);
frame.render_widget(Paragraph::new(lines), inner);
let lines = helpers::grouped_peer_lines(&filters, peer_filter_line);
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
/// Render one Bloom-tab peer-filter line: name, filter sequence, and either the
/// fill/estimate columns (when the peer has a filter) or a "none" marker.
fn peer_filter_line(f: &serde_json::Value) -> Line<'static> {
let name = helpers::str_field(f, "display_name");
let seq = helpers::u64_field(f, "filter_sequence");
let has = f
.get("has_filter")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Right-justify each numeric into a fixed field wide enough for
// realistic data, with a guaranteed trailing separator so a
// wider-than-expected value can never touch the next label, and
// the digit columns line up across rows.
let mut spans = vec![
Span::styled(
format!(" {} ", helpers::truncate_name(name, 16)),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled("seq: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{seq:>9} ")),
];
if has {
let fill = f
.get("fill_ratio")
.and_then(|v| v.as_f64())
.map(|r| format!("{:.1}%", r * 100.0))
.unwrap_or_else(|| "-".into());
let est = f
.get("estimated_count")
.and_then(|v| v.as_f64())
.map(|n| format!("{:.0}", n))
.unwrap_or_else(|| "-".into());
spans.push(Span::styled("fill: ", Style::default().fg(Color::DarkGray)));
spans.push(Span::raw(format!("{fill:>6} ")));
spans.push(Span::styled("est: ", Style::default().fg(Color::DarkGray)));
spans.push(Span::raw(format!("{est:>6} ")));
spans.push(Span::styled("ok", Style::default().fg(Color::Green)));
} else {
spans.push(Span::styled("none", Style::default().fg(Color::Red)));
}
Line::from(spans)
}
+62 -2
View File
@@ -23,7 +23,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(7), // Runtime
Constraint::Length(7), // Identity
Constraint::Length(6), // State (sparkline row adds one line)
Constraint::Length(8), // State (root egg + transports + sparkline rows)
Constraint::Length(9), // Traffic + Listening on fips0 (side-by-side)
Constraint::Min(0), // remaining
])
@@ -95,6 +95,12 @@ fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let npub = helpers::str_field(data, "npub");
let node_addr = helpers::str_field(data, "node_addr");
let ipv6_addr = helpers::str_field(data, "ipv6_addr");
// Effective persistence: whether this identity survives a restart.
let mode = match data.get("persistent").and_then(|v| v.as_bool()) {
Some(true) => "persistent",
Some(false) => "ephemeral",
None => "-",
};
let label = Style::default().fg(Color::DarkGray);
@@ -111,6 +117,10 @@ fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
Span::styled(" ipv6: ", label),
Span::raw(ipv6_addr.to_string()),
]),
Line::from(vec![
Span::styled(" identity: ", label),
Span::raw(mode.to_string()),
]),
];
frame.render_widget(Paragraph::new(lines), inner);
@@ -148,6 +158,24 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
helpers::sparkline(&helpers::nested_f64_array(data, "sparklines", "peer_count"));
let spark_style = Style::default().fg(Color::DarkGray);
// Root: an Easter-egg marker when this node IS the root, otherwise the
// truncated root hex. The full root address + npub live on the Tree tab.
let is_root = data
.get("is_root")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let root_display = if is_root {
"I am the one who roots".to_string()
} else {
let root_hex = helpers::str_field(data, "root");
let head: String = root_hex.chars().take(16).collect();
format!("{head}\u{2026}")
};
// Configured transport types each with their peer count, e.g.
// "udp (5), tcp (2), tor (0)". Idle-but-configured types stay visible at 0.
let transports_by_type = format_transport_peer_counts(data);
let lines = vec![
Line::from(vec![
Span::styled(" state: ", label),
@@ -170,9 +198,22 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
Span::styled(transports, count),
Span::styled(" connections: ", label),
Span::styled(connections, count),
Span::styled(" mesh: ", label),
]),
// The mesh size is a bloom-cardinality estimate, not an exact count;
// it gets its own line so the longer "approx. mesh estimate:" label
// does not overflow the counts line at narrow widths.
Line::from(vec![
Span::styled(" approx. mesh estimate: ", label),
Span::styled(mesh_size, count),
]),
Line::from(vec![
Span::styled(" root: ", label),
Span::raw(root_display),
]),
Line::from(vec![
Span::styled(" transports: ", label),
Span::raw(transports_by_type),
]),
Line::from(vec![
Span::styled(" peers: ", label),
Span::styled(peer_spark, spark_style),
@@ -184,6 +225,25 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
frame.render_widget(Paragraph::new(lines), inner);
}
/// Format the `transport_peer_counts` map as `type (count)` joined with
/// commas, e.g. `udp (5), tcp (2), tor (0)`. Keys are rendered in sorted
/// order (the daemon emits a sorted map). Returns `-` when absent or empty.
fn format_transport_peer_counts(data: &serde_json::Value) -> String {
let Some(map) = data
.get("transport_peer_counts")
.and_then(|v| v.as_object())
else {
return "-".into();
};
if map.is_empty() {
return "-".into();
}
map.iter()
.map(|(ty, count)| format!("{ty} ({})", count.as_u64().unwrap_or(0)))
.collect::<Vec<_>>()
.join(", ")
}
fn draw_node_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default().borders(Borders::ALL).title(" Traffic ");
let inner = block.inner(area);
+280 -89
View File
@@ -18,7 +18,9 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use crate::app::{App, GRAPHS_METRICS, GraphsMode, PEER_GRAPHS_METRICS, Tab};
use crate::app::{
App, GRAPHS_METRICS, GRAPHS_PEER_SORT_LABELS, GraphsMode, PEER_GRAPHS_METRICS, SortState, Tab,
};
/// 5×5 braille lookup table indexed by (left fill 0..=4, right fill
/// 0..=4). Direct transcription of btop's `braille_up` glyph set.
@@ -78,10 +80,16 @@ fn draw_selector(frame: &mut Frame, app: &App, area: Rect) {
Span::styled(" scroll: ", label),
Span::styled(format!("{}", app.graphs_scroll), dim),
]);
let line2 = Line::from(Span::styled(
" [↑/↓] scroll [←/→] window [m] mode [n/N] cycle [g] graphs [q] quit",
label,
));
// The full keybinding reference lives in the status-bar footer (registry)
// and the `?` overlay; this in-pane line is a brief mode-specific reminder.
let line2_text = match app.graphs_mode {
GraphsMode::MetricByPeer if app.detail_view.is_some() => {
" [↑/↓] peer [n/N] stat [m] mode [Esc] back"
}
GraphsMode::MetricByPeer => " [↑/↓] select [Enter] expand [n/N] stat [m] mode",
_ => " [↑/↓] scroll [←/→] window [m] mode [n/N] cycle",
};
let line2 = Line::from(Span::styled(line2_text, label));
frame.render_widget(Paragraph::new(vec![line1, line2]), area);
}
@@ -187,21 +195,11 @@ fn draw_stacked(frame: &mut Frame, app: &mut App, inner: Rect) {
frame.render_widget(paragraph, inner);
}
fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) {
let data = match app.data.get(&Tab::Graphs) {
Some(d) => d,
None => {
frame.render_widget(
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray)),
inner,
);
return;
}
};
let metric_name = app.graphs_selected_peer_metric();
let peers = data.get("peers").and_then(|v| v.as_array());
let peer_series: Vec<(String, Vec<f64>)> = peers
/// Parse the by-peer payload (`peers` array of `{display_name, values}`) into
/// `[(name, values)]`, in payload order.
fn peer_series_from_data(data: &serde_json::Value) -> Vec<(String, Vec<f64>)> {
data.get("peers")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|p| {
@@ -219,7 +217,28 @@ fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) {
})
.collect()
})
.unwrap_or_default();
.unwrap_or_default()
}
/// By-peer mode: a scrollable summary list (one line per peer, with
/// min/max/last/n for the selected metric). Selecting a peer (Enter) swaps to a
/// full-pane btop plot via `draw_metric_by_peer_detail`. The cursor follows
/// `graphs_peer_idx`; Up/Down move it and the focus/scroll model keeps the
/// selection visible.
fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) {
let data = match app.data.get(&Tab::Graphs) {
Some(d) => d,
None => {
frame.render_widget(
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray)),
inner,
);
return;
}
};
let metric_name = app.graphs_selected_peer_metric();
let peer_series = peer_series_from_data(data);
if peer_series.is_empty() {
frame.render_widget(
@@ -230,94 +249,133 @@ fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) {
return;
}
// Pick a column count that keeps each cell wide enough for a
// readable braille plot. Each cell needs ~30 columns minimum.
let cols = if inner.width < 40 {
1
} else if inner.width < 100 {
2
} else {
3
};
let rows = peer_series.len().div_ceil(cols);
// Stack of cell-rows; each row is a horizontal split of cell cells.
let row_constraints: Vec<Constraint> = (0..rows)
.map(|_| Constraint::Length(METRIC_BLOCK_ROWS))
.collect();
let row_areas = Layout::vertical(row_constraints).split(inner);
for row_idx in 0..rows {
let col_constraints: Vec<Constraint> = (0..cols)
.map(|_| Constraint::Ratio(1, cols as u32))
.collect();
let col_areas = Layout::horizontal(col_constraints).split(row_areas[row_idx]);
for col_idx in 0..cols {
let peer_idx = row_idx * cols + col_idx;
if peer_idx >= peer_series.len() {
break;
}
let (peer_name, values) = &peer_series[peer_idx];
let cell_lines = render_metric_block_labeled(
metric_name,
peer_name,
values,
col_areas[col_idx].width,
);
frame.render_widget(Paragraph::new(cell_lines), col_areas[col_idx]);
}
// An open detail view swaps the whole pane for a full-width btop plot of
// the selected peer; Up/Down then flip peers rather than scroll the list.
if app.detail_view.is_some() {
draw_metric_by_peer_detail(frame, app, inner, metric_name, &peer_series);
return;
}
// The cursor tracks a payload-order peer index; build a display-order
// permutation per the active sort so re-sorting reorders the list while the
// cursor stays on the same logical peer (mapped to its new display row).
let order = sorted_order(&peer_series, app.graphs_peer_sort);
let sel_payload = app.graphs_peer_idx.min(peer_series.len() - 1);
let display_selected = order.iter().position(|&i| i == sel_payload).unwrap_or(0);
let unit = metric_unit(metric_name);
let cursor = Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let label = Style::default().fg(Color::DarkGray);
let name_style = Style::default().fg(Color::White);
let mut lines: Vec<Line<'static>> = vec![sort_header(app.graphs_peer_sort)];
for (display_row, &payload_idx) in order.iter().enumerate() {
let (peer_name, values) = &peer_series[payload_idx];
let (min, max, last, n) = summarize(values);
let is_sel = display_row == display_selected;
let marker = if is_sel { "\u{25b6} " } else { " " };
let nm = name_style;
// Right-justify the numeric columns into fixed-width fields so the
// min/max/last values and the sample count line up down the list
// regardless of magnitude.
let row = Line::from(vec![
Span::styled(
format!("{marker}{peer_name:<18}"),
if is_sel { cursor } else { nm },
),
Span::styled(format!(" [{unit}]"), label),
Span::styled(" min ", label),
Span::raw(format!("{:>8}", format_value(min))),
Span::styled(" max ", label),
Span::raw(format!("{:>8}", format_value(max))),
Span::styled(" last ", label),
Span::raw(format!("{:>8}", format_value(last))),
Span::styled(" n=", label),
Span::raw(format!("{n:>5}")),
]);
lines.push(row);
}
// Keep the selected row visible using the shared per-pane scroll model
// (Graphs by-peer list is pane 0). The +1 accounts for the sort header row.
let visible = inner.height as usize;
let selected = display_selected + 1;
let mut offset = app.pane_scroll(0) as usize;
if selected < offset {
offset = selected;
} else if visible > 0 && selected >= offset + visible {
offset = selected + 1 - visible;
}
let max_offset = lines.len().saturating_sub(visible);
offset = offset.min(max_offset);
app.scroll_offsets.insert((Tab::Graphs, 0), offset as u16);
let paragraph = Paragraph::new(lines).scroll((offset as u16, 0));
frame.render_widget(paragraph, inner);
}
/// Variant of `render_metric_block` that labels the block with the
/// peer name in addition to the metric. Used by the metric-by-peer grid.
fn render_metric_block_labeled(
/// Full-pane btop plot for the selected by-peer peer. The detail follows the
/// selection (Up/Down flip peers, n/N switch the statistic), re-rendering this
/// plot for the current `(peer, metric)`.
fn draw_metric_by_peer_detail(
frame: &mut Frame,
app: &App,
inner: Rect,
metric: &str,
peer_name: &str,
values: &[f64],
width: u16,
) -> Vec<Line<'static>> {
peer_series: &[(String, Vec<f64>)],
) {
let idx = app.graphs_peer_idx.min(peer_series.len() - 1);
let (peer_name, values) = &peer_series[idx];
let unit = metric_unit(metric);
let mut out: Vec<Line<'static>> = Vec::with_capacity(METRIC_BLOCK_ROWS as usize);
let (min, max, last, n) = summarize(values);
let title_style = Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD);
let label = Style::default().fg(Color::DarkGray);
let title = Line::from(vec![
let (min, max, last, n) = summarize(values);
let header = Line::from(vec![
Span::styled(format!(" {peer_name}"), title_style),
Span::styled(format!(" [{unit}]"), label),
Span::styled(" max ", label),
Span::styled(format!(" {metric} [{unit}]"), label),
Span::styled(" min ", label),
Span::raw(format_value(min)),
Span::styled(" max ", label),
Span::raw(format_value(max)),
Span::styled(" last ", label),
Span::raw(format_value(last)),
Span::styled(" n=", label),
Span::styled(" samples ", label),
Span::raw(format!("{n}")),
]);
out.push(title);
let mut lines: Vec<Line<'static>> = vec![header, Line::from("")];
let gutter = 2u16;
let plot_cols = width.saturating_sub(gutter) as usize;
let plot_cols = inner.width.saturating_sub(gutter) as usize;
// Reserve the header (1) + blank (1) + a footer hint line; the rest is plot.
let plot_rows = (inner.height as usize).saturating_sub(3).max(1);
if plot_cols == 0 || values.is_empty() {
for _ in 0..METRIC_PLOT_ROWS {
out.push(Line::from(Span::styled(
for _ in 0..plot_rows {
lines.push(Line::from(Span::styled(
" (no samples)",
Style::default().fg(Color::DarkGray),
)));
}
out.push(Line::from(""));
return out;
} else {
let sampled = resample(values, plot_cols * 2);
lines.extend(render_btop_graph(
&sampled,
plot_rows,
min,
max,
gutter as usize,
));
}
let sampled = resample(values, plot_cols * 2);
let rows = METRIC_PLOT_ROWS as usize;
let plot_lines = render_btop_graph(&sampled, rows, min, max, gutter as usize);
out.extend(plot_lines);
out.push(Line::from(""));
out
frame.render_widget(Paragraph::new(lines), inner);
}
/// Render a single metric's mini block: one title row, four plot rows,
@@ -337,7 +395,9 @@ fn render_metric_block(metric: &str, values: &[f64], width: u16) -> Vec<Line<'st
let title = Line::from(vec![
Span::styled(format!(" {metric}"), title_style),
Span::styled(format!(" [{unit}]"), label),
Span::styled(" max ", label),
Span::styled(" min ", label),
Span::raw(format_value(min)),
Span::styled(" max ", label),
Span::raw(format_value(max)),
Span::styled(" last ", label),
Span::raw(format_value(last)),
@@ -372,6 +432,69 @@ fn render_metric_block(metric: &str, values: &[f64], width: u16) -> Vec<Line<'st
out
}
/// Build a display-order permutation of `peer_series` indices per the sort
/// state. Column 0 sorts by name; columns 1..=4 sort by the corresponding
/// summary scalar (min/max/last/n). Descending reverses the order.
fn sorted_order(peer_series: &[(String, Vec<f64>)], sort: SortState) -> Vec<usize> {
let mut order: Vec<usize> = (0..peer_series.len()).collect();
order.sort_by(|&a, &b| {
let (na, va) = &peer_series[a];
let (nb, vb) = &peer_series[b];
let ord = if sort.col == 0 {
na.cmp(nb)
} else {
let key = |v: &[f64]| -> f64 {
let (min, max, last, n) = summarize(v);
match sort.col {
1 => min,
2 => max,
3 => last,
_ => n as f64,
}
};
let ka = key(va);
let kb = key(vb);
// NaN keys (e.g. an empty series' last) sort last under ascending.
match (ka.is_nan(), kb.is_nan()) {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal),
}
};
if sort.descending { ord.reverse() } else { ord }
});
order
}
/// Render the Graphs by-peer sort-column header: each column label with the
/// active sort column highlighted and carrying a direction arrow.
fn sort_header(sort: SortState) -> Line<'static> {
let active = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let idle = Style::default().fg(Color::DarkGray);
// Solid triangles mark the sort direction, distinct from the cursor and
// any plot glyphs.
let arrow = if sort.descending {
"\u{25bc}"
} else {
"\u{25b2}"
};
let mut spans: Vec<Span<'static>> = vec![Span::styled(" sort: ", idle)];
for (i, lbl) in GRAPHS_PEER_SORT_LABELS.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ", idle));
}
if i == sort.col {
spans.push(Span::styled(format!("{lbl}{arrow}"), active));
} else {
spans.push(Span::styled(lbl.to_string(), idle));
}
}
Line::from(spans)
}
fn summarize(values: &[f64]) -> (f64, f64, f64, usize) {
if values.is_empty() {
return (0.0, 0.0, 0.0, 0);
@@ -444,15 +567,23 @@ fn render_btop_graph(
}
let range = max - min;
// NaN samples pass through normalize as NaN so the cell loop below
// can blank them. Non-NaN samples are clamped into 0..=100.
// Flat series (range <= 0) carry no scale, so map them by their level:
// a genuine zero reading renders as an empty plot (NaN blanks every
// cell), while a steady non-zero reading rests on the baseline (0.0)
// rather than floating at mid-height. NaN samples always blank.
let flat = !range.is_finite() || range <= 0.0;
let flat_zero = flat && max == 0.0;
let normalized: Vec<f64> = values
.iter()
.map(|&v| {
if v.is_nan() {
if v.is_nan() || flat_zero {
// Blank cells: a NaN sample, or a genuine flat-zero series
// (rendered as an empty plot).
f64::NAN
} else if !range.is_finite() || range <= 0.0 {
50.0
} else if flat {
// A small positive level so the steady value rests as a
// row of dots on the baseline rather than an empty plot.
8.0
} else {
((v - min) / range * 100.0).clamp(0.0, 100.0)
}
@@ -618,6 +749,66 @@ mod tests {
assert_eq!(lines.len(), METRIC_BLOCK_ROWS as usize);
}
/// Collect the rendered braille plot rows (excluding the gutter) of a
/// metric block into one concatenated string for inspection.
fn plot_text(lines: &[Line<'static>]) -> String {
// The block is: title, METRIC_PLOT_ROWS plot rows, blank separator.
lines
.iter()
.skip(1)
.take(METRIC_PLOT_ROWS as usize)
.flat_map(|l| l.spans.iter())
.map(|s| s.content.to_string())
.collect::<String>()
}
#[test]
fn flat_zero_renders_empty_plot() {
// All-zero flat series: empty plot (no braille dots).
let lines = render_metric_block("loss_rate", &[0.0, 0.0, 0.0, 0.0], 40);
let plot = plot_text(&lines);
assert!(
plot.trim().is_empty(),
"flat-zero plot should have no dots, got {plot:?}"
);
}
#[test]
fn flat_nonzero_renders_baseline_dots() {
// Steady non-zero flat series: a baseline row of dots, not an empty
// plot and not floating at mid-height.
let lines = render_metric_block("mesh_size", &[7.0, 7.0, 7.0, 7.0], 40);
let plot = plot_text(&lines);
assert!(
!plot.trim().is_empty(),
"flat non-zero plot should rest on the baseline as dots"
);
}
#[test]
fn no_data_has_distinct_placeholder() {
// Empty input must be visibly distinct from a flat-zero empty plot.
let lines = render_metric_block("mesh_size", &[], 40);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.to_string())
.collect();
assert!(joined.contains("no samples"));
}
#[test]
fn title_row_includes_min() {
let lines = render_metric_block("mesh_size", &[2.0, 5.0, 9.0], 60);
let title: String = lines[0]
.spans
.iter()
.map(|s| s.content.to_string())
.collect();
assert!(title.contains("min "), "title should label a min field");
assert!(title.contains("max "), "title should still show max");
}
#[test]
fn gradient_spans_stops() {
if let Color::Rgb(r, g, _) = gradient_rgb(0.0) {
+367
View File
@@ -0,0 +1,367 @@
//! Declarative keybinding registry and the `?` help overlay.
//!
//! A single static table keyed by `(Tab, UiMode)` is the one source of truth
//! both the always-visible context footer (`draw_status_bar`) and the full `?`
//! overlay render from, so the two can never drift. Every key the dispatch
//! handles in a given context is registered here as a `(key, label)` pair; the
//! footer renders the contextual subset (with a width-aware truncation rule),
//! and the overlay renders the whole reference.
//!
//! A test (`registry_keys_exist_in_dispatch`) asserts every key string the
//! table mentions is one the `main.rs` dispatch actually recognizes, so a
//! stale or invented hint can't slip in.
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::app::{App, Tab};
/// The UI interaction mode the active tab is in, derived from existing `App`
/// fields. Selects which hint set the footer and overlay show. Order:
/// overview (nothing selected/open) is the base; a selected table row, an open
/// detail view, and (for multi-pane tabs) pane focus refine it.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum UiMode {
/// No row selected, no detail open — the tab's resting state.
Overview,
/// A table row is selected (Peers/Sessions/Transports/Gateway).
RowSelected,
/// A detail view is open over the active tab.
DetailOpen,
}
impl UiMode {
/// Derive the current mode from `App` state for the active tab.
pub fn of(app: &App) -> UiMode {
if app.detail_view.is_some() {
return UiMode::DetailOpen;
}
if app.active_tab.has_table()
&& app
.table_states
.get(&app.active_tab)
.and_then(|s| s.selected())
.is_some()
{
return UiMode::RowSelected;
}
UiMode::Overview
}
}
/// One keybinding hint: the key glyph shown in brackets and its action label.
#[derive(Clone, Copy)]
pub struct Hint {
pub key: &'static str,
pub label: &'static str,
}
const fn hint(key: &'static str, label: &'static str) -> Hint {
Hint { key, label }
}
/// Global hints available on (almost) every tab regardless of mode. These are
/// the lowest-priority footer candidates: when the bar overflows they drop
/// first, leaving the contextual hints and the always-present `[?] Help`.
pub const GLOBAL_HINTS: &[Hint] = &[hint("Tab", "next"), hint("g", "graphs"), hint("q", "quit")];
const DETAIL_HINTS: &[Hint] = &[hint("Esc", "close"), hint("\u{2191}\u{2193}", "scroll")];
const PEERS_SELECTED_HINTS: &[Hint] = &[
hint("Enter", "detail"),
hint("Del", "disconnect"),
hint("Esc", "deselect"),
];
const ROW_SELECTED_HINTS: &[Hint] = &[hint("Enter", "detail"), hint("Esc", "deselect")];
const TABLE_OVERVIEW_HINTS: &[Hint] = &[hint("\u{2191}\u{2193}", "select")];
const GRAPHS_OVERVIEW_HINTS: &[Hint] = &[
hint("Enter", "expand"),
hint("m", "mode"),
hint("n/N", "stat"),
hint("\u{2190}\u{2192}", "window"),
hint("s/S", "sort"),
];
/// The MMP (Performance) tab: `f` moves focus between the Link and Session MMP
/// panes, the arrows scroll the focused pane, and `s`/`S` sort the focused pane.
const MMP_HINTS: &[Hint] = &[
hint("f", "focus pane"),
hint("\u{2191}\u{2193}", "scroll"),
hint("s/S", "sort"),
];
/// The multi-pane scrollable tabs (Tree, Filters, Routing): `f` moves pane
/// focus and the arrow keys scroll the focused pane.
const PANE_SCROLL_HINTS: &[Hint] = &[hint("f", "focus pane"), hint("\u{2191}\u{2193}", "scroll")];
/// By-peer detail (full-pane plot) on the Graphs tab: Up/Down flip the peer the
/// plot follows, n/N switch the statistic, m cycles the mode, Esc returns to the
/// scrollable peer list.
const GRAPHS_DETAIL_HINTS: &[Hint] = &[
hint("\u{2191}\u{2193}", "peer"),
hint("n/N", "stat"),
hint("m", "mode"),
hint("Esc", "back"),
];
const NO_HINTS: &[Hint] = &[];
/// The contextual hints for a `(Tab, UiMode)`. Highest footer priority — these
/// describe what the current state's keys do and are kept when the bar is
/// narrow. The overlay shows these plus the globals plus `[?] Help`.
pub fn contextual_hints(tab: Tab, mode: UiMode) -> &'static [Hint] {
match (tab, mode) {
(Tab::Graphs, UiMode::DetailOpen) => GRAPHS_DETAIL_HINTS,
(_, UiMode::DetailOpen) => DETAIL_HINTS,
(Tab::Peers, UiMode::RowSelected) => PEERS_SELECTED_HINTS,
(_, UiMode::RowSelected) => ROW_SELECTED_HINTS,
(Tab::Peers | Tab::Sessions | Tab::Transports | Tab::Gateway, UiMode::Overview) => {
TABLE_OVERVIEW_HINTS
}
(Tab::Graphs, UiMode::Overview) => GRAPHS_OVERVIEW_HINTS,
(Tab::Mmp, UiMode::Overview) => MMP_HINTS,
(Tab::Tree | Tab::Bloom | Tab::Routing, UiMode::Overview) => PANE_SCROLL_HINTS,
_ => NO_HINTS,
}
}
/// Render a key hint as `[key] label` spans (key dim-bracketed, label plain).
fn hint_spans(h: &Hint) -> Vec<Span<'static>> {
vec![
Span::styled(format!("[{}] ", h.key), Style::default().fg(Color::Yellow)),
Span::styled(
format!("{} ", h.label),
Style::default().fg(Color::DarkGray),
),
]
}
/// Build the footer hint line for the active context, fitting `budget` columns.
///
/// Contextual hints come first and are kept; global hints fill remaining width
/// and drop when they don't fit; `[?] Help` is always appended last as the
/// overflow affordance. Returns the spans to append after the connection and
/// timing spans in the status bar.
pub fn footer_hint_spans(tab: Tab, mode: UiMode, budget: usize) -> Vec<Span<'static>> {
let help = Span::styled("[?] Help ", Style::default().fg(Color::DarkGray));
let help_w = "[?] Help ".len();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
// Reserve room for the always-present help affordance.
let avail = budget.saturating_sub(help_w);
let push_if_fits = |spans: &mut Vec<Span<'static>>, used: &mut usize, h: &Hint| -> bool {
let w = h.key.chars().count() + h.label.chars().count() + 4; // "[] " + " "
if *used + w <= avail {
spans.extend(hint_spans(h));
*used += w;
true
} else {
false
}
};
// Contextual first (highest priority).
for h in contextual_hints(tab, mode) {
push_if_fits(&mut spans, &mut used, h);
}
// Globals fill remaining space, dropping when they don't fit.
for h in GLOBAL_HINTS {
push_if_fits(&mut spans, &mut used, h);
}
spans.push(help);
spans
}
/// Render the full `?` help overlay: a centered modal listing every binding
/// for the active `(Tab, UiMode)` (contextual + global), drawn from the same
/// registry the footer reads.
pub fn draw_overlay(frame: &mut Frame, app: &App, area: Rect) {
let tab = app.active_tab;
let mode = UiMode::of(app);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
format!(" {}{:?}", tab.label(), mode),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Context",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
let ctx = contextual_hints(tab, mode);
if ctx.is_empty() {
lines.push(Line::from(Span::styled(
" (no context-specific keys)",
Style::default().fg(Color::DarkGray),
)));
} else {
for h in ctx {
lines.push(overlay_row(h));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Global",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
for h in GLOBAL_HINTS {
lines.push(overlay_row(h));
}
lines.push(overlay_row(&hint("BackTab", "previous tab")));
lines.push(overlay_row(&hint("?", "toggle this help")));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Press ? or Esc to close",
Style::default().fg(Color::DarkGray),
)));
let popup = centered_rect(60, 70, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" Help ")
.style(Style::default().bg(Color::Black));
let inner = block.inner(popup);
frame.render_widget(block, popup);
frame.render_widget(Paragraph::new(lines), inner);
}
/// Render the Del-disconnect confirmation modal: a centered Y/N prompt naming
/// the peer and showing a reconnect note tailored to its kind.
pub fn draw_disconnect_modal(frame: &mut Frame, app: &App, area: Rect) {
let Some(confirm) = &app.confirm_disconnect else {
return;
};
let lines = vec![
Line::from(Span::styled(
" Disconnect peer?",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(" Peer: ", Style::default().fg(Color::DarkGray)),
Span::styled(
confirm.display_name.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
]),
Line::from(Span::styled(
format!(" {}", confirm.reconnect_note),
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(vec![
Span::styled(" [Y] ", Style::default().fg(Color::Yellow)),
Span::raw("disconnect "),
Span::styled("[N/Esc] ", Style::default().fg(Color::Yellow)),
Span::raw("cancel"),
]),
];
let popup = centered_rect_lines(64, 8, area);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" Confirm ")
.style(Style::default().bg(Color::Black));
let inner = block.inner(popup);
frame.render_widget(block, popup);
frame.render_widget(Paragraph::new(lines), inner);
}
/// A centered rectangle of fixed `w`×`h` cells (clamped to `area`).
fn centered_rect_lines(w: u16, h: u16, area: Rect) -> Rect {
let w = w.min(area.width);
let h = h.min(area.height);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
Rect {
x,
y,
width: w,
height: h,
}
}
fn overlay_row(h: &Hint) -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {:<10}", format!("[{}]", h.key)),
Style::default().fg(Color::Yellow),
),
Span::raw(h.label.to_string()),
])
}
/// Compute a centered rectangle `pct_x`%×`pct_y`% of `area`.
fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let w = area.width * pct_x / 100;
let h = area.height * pct_y / 100;
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
Rect {
x,
y,
width: w,
height: h,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every key glyph the registry mentions must be one the `main.rs`
/// dispatch actually handles, so a stale or invented hint can't ship. The
/// dispatch key set is mirrored here; adding a binding to the registry
/// without wiring it (or vice versa) trips this.
#[test]
fn registry_keys_exist_in_dispatch() {
// The authoritative set of key glyphs the dispatch recognizes. Mirror
// of the match arms in `main.rs` (plus the arrow/Enter/Esc/Tab keys).
const DISPATCH_KEYS: &[&str] = &[
"q",
"Tab",
"BackTab",
"g",
"m",
"n/N",
"s/S",
"f",
"?",
"Del",
"Enter",
"Esc",
"\u{2191}\u{2193}", // up/down
"\u{2190}\u{2192}", // left/right
];
let mut all: Vec<Hint> = GLOBAL_HINTS.to_vec();
all.push(hint("BackTab", "previous tab"));
all.push(hint("?", "toggle this help"));
for &tab in &Tab::ALL {
for mode in [UiMode::Overview, UiMode::RowSelected, UiMode::DetailOpen] {
all.extend_from_slice(contextual_hints(tab, mode));
}
}
for h in all {
assert!(
DISPATCH_KEYS.contains(&h.key),
"registry key [{}] ({}) has no dispatch handler",
h.key,
h.label
);
}
}
}
+200
View File
@@ -1,7 +1,37 @@
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders};
use serde_json::Value;
/// A bordered pane block with a title that highlights its border (cyan, bold
/// title) when `focused`, so the multi-pane focus model has a clear visual
/// indicator of which pane the scroll keys act on.
pub fn pane_block(title: &str, focused: bool) -> Block<'static> {
let block = Block::default()
.borders(Borders::ALL)
.title(title.to_string());
if focused {
block
.border_style(Style::default().fg(Color::Cyan))
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
block
}
}
/// Clamp a desired scroll offset to a pane's content so an over-scroll (e.g.
/// from End, which passes `u16::MAX`) rests at the last full screen rather than
/// scrolling past the content. `content_rows` is the total rendered line count
/// and `visible_rows` the pane's inner height.
pub fn clamp_scroll(offset: u16, content_rows: usize, visible_rows: usize) -> u16 {
let max = content_rows.saturating_sub(visible_rows) as u16;
offset.min(max)
}
/// Extract a string field from JSON, returning "-" if missing.
pub fn str_field<'a>(data: &'a Value, key: &str) -> &'a str {
data.get(key).and_then(|v| v.as_str()).unwrap_or("-")
@@ -24,6 +54,23 @@ pub fn truncate_hex(s: &str, max_len: usize) -> String {
}
}
/// Truncate a display name to a fixed visible width, appending an ellipsis when
/// it overflows, then pad to exactly `width` columns. Unlike a bare `{:<width}`
/// format this guarantees the field never exceeds `width`, so a long npub-style
/// name can't push past its column and butt against the next label. Counts and
/// pads by `char`, which is correct for the ASCII/BMP names the daemon emits.
pub fn truncate_name(s: &str, width: usize) -> String {
let len = s.chars().count();
if len <= width {
format!("{s:<width$}")
} else if width <= 1 {
"\u{2026}".chars().take(width).collect()
} else {
let head: String = s.chars().take(width - 1).collect();
format!("{head}\u{2026}")
}
}
/// Format bytes-per-second with engineering units (B/s, KB/s, MB/s, GB/s) and 3 significant digits.
pub fn format_throughput(bytes_per_sec: f64) -> String {
if bytes_per_sec < 0.0 {
@@ -133,6 +180,17 @@ pub fn nested_f64_prefer(
.unwrap_or_else(|| "-".into())
}
/// Format an optional numeric field as a fixed-precision number, or an em-dash
/// placeholder when the value is JSON `null` or the key is absent. Used for
/// daemon-emitted `Option<f64>` fields (e.g. `effective_depth`) so an
/// unmeasured value renders distinctly from a real zero.
pub fn opt_f64_field(data: &Value, key: &str, decimals: usize) -> String {
match data.get(key).and_then(|v| v.as_f64()) {
Some(n) => format!("{:.prec$}", n, prec = decimals),
None => "\u{2014}".into(),
}
}
/// Extract a bool field from JSON, returning "yes"/"no" or "-" if missing.
pub fn bool_field(data: &Value, key: &str) -> &'static str {
data.get(key)
@@ -172,6 +230,148 @@ pub fn kv_line(key: &str, value: &str) -> Line<'static> {
])
}
/// Render a group of key-value pairs with the keys padded to a common
/// width so the values share a single left edge. Alignment is computed
/// once over the whole group rather than padded per call site, keeping
/// the convention (one aligned value column per stack) in one place.
pub fn kv_lines(pairs: &[(&str, String)]) -> Vec<Line<'static>> {
let key_width = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
pairs
.iter()
.map(|(key, value)| {
Line::from(vec![
Span::styled(
format!(" {key:<key_width$}: "),
Style::default().fg(Color::DarkGray),
),
Span::raw(value.clone()),
])
})
.collect()
}
/// Build a node-address -> (is_parent, is_child) map from the peers view's
/// `peers` array. Only the peers view carries the tree-role flags, so the Tree
/// and Bloom surfaces join their own peer lists against this map by node address
/// to recover each peer's role. A missing or malformed payload yields an empty
/// map (every peer then falls back to the Other group).
pub fn peer_role_map(
peers_data: Option<&Value>,
) -> std::collections::HashMap<String, (bool, bool)> {
let mut map = std::collections::HashMap::new();
if let Some(arr) = peers_data
.and_then(|d| d.get("peers"))
.and_then(|v| v.as_array())
{
for p in arr {
if let Some(addr) = p.get("node_addr").and_then(|v| v.as_str()) {
let is_parent = p
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_child = p.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
map.insert(addr.to_string(), (is_parent, is_child));
}
}
}
map
}
/// Enrich a tree/bloom peer Value with `is_parent`/`is_child` looked up in the
/// peers role map by `addr_key` (the peer's node-address field, which differs
/// per surface: `node_addr` on Tree, `peer` on Bloom). A peer not found in the
/// map is left without role flags, so `group_rank` places it under Other.
pub fn enrich_role(
mut peer: Value,
role_map: &std::collections::HashMap<String, (bool, bool)>,
addr_key: &str,
) -> Value {
let addr = peer
.get(addr_key)
.and_then(|v| v.as_str())
.map(String::from);
if let Some(addr) = addr
&& let Some(&(is_parent, is_child)) = role_map.get(&addr)
&& let Some(obj) = peer.as_object_mut()
{
obj.insert("is_parent".into(), Value::Bool(is_parent));
obj.insert("is_child".into(), Value::Bool(is_child));
}
peer
}
/// Tree-role group rank for a peer JSON object: parent first (0), then STP
/// children (1), then everything else (2). A node with no parent simply has
/// an empty group 0; a leaf with no children an empty group 1. Shared by the
/// Peers, Tree, and Bloom surfaces so they group peers identically.
pub fn group_rank(peer: &Value) -> u8 {
let is_parent = peer
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_child = peer
.get("is_child")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_parent {
0
} else if is_child {
1
} else {
2
}
}
/// The section label for a tree-role group rank, matching the Peers tab's
/// box-drawing labels so all three surfaces read consistently.
pub fn group_label(rank: u8) -> &'static str {
match rank {
0 => "\u{2500}\u{2500} Parent \u{2500}\u{2500}",
1 => "\u{2500}\u{2500} STP Children \u{2500}\u{2500}",
_ => "\u{2500}\u{2500} Other \u{2500}\u{2500}",
}
}
/// Stable-sort `peers` in place by tree-role group rank, preserving the input
/// order within each group. Callers that want a finer secondary key (e.g. LQI)
/// should sort by that key first, then call this for the group partition, or
/// supply their own comparator keyed off `group_rank`.
pub fn sort_by_group(peers: &mut [Value]) {
peers.sort_by_key(group_rank);
}
/// Render a group of peers as `Paragraph` lines: a styled section label before
/// each non-empty group (in parent -> children -> other order), a blank
/// separator between groups, and each peer rendered by `render_peer`. Empty
/// groups are omitted (no label). `peers` is expected to already be grouped by
/// `group_rank` (callers sort first). This is the Paragraph-of-Lines analogue
/// of the Peers tab's grouped table, shared by the Tree and Bloom peer lists.
pub fn grouped_peer_lines<F>(peers: &[Value], render_peer: F) -> Vec<Line<'static>>
where
F: Fn(&Value) -> Line<'static>,
{
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut last_group: Option<u8> = None;
for peer in peers {
let g = group_rank(peer);
if last_group != Some(g) {
if last_group.is_some() {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
format!(" {}", group_label(g)),
label_style,
)));
last_group = Some(g);
}
lines.push(render_peer(peer));
}
lines
}
/// Render a sequence of values as Unicode block characters.
///
/// Returns an empty string for empty input. Constant series render as a
+234 -106
View File
@@ -2,9 +2,9 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use crate::app::{App, Tab};
use crate::app::{App, MMP_LINK_SORT_LABELS, MMP_SESSION_SORT_LABELS, SortState, Tab};
use super::helpers;
@@ -22,21 +22,129 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let chunks =
Layout::vertical([Constraint::Percentage(60), Constraint::Percentage(40)]).split(area);
draw_link_mmp(frame, data, chunks[0]);
draw_session_mmp(frame, data, chunks[1]);
let focused = app.focused_pane();
draw_link_mmp(
frame,
data,
app.mmp_link_sort,
app.pane_scroll(0),
focused == 0,
chunks[0],
);
draw_session_mmp(
frame,
data,
app.mmp_session_sort,
app.pane_scroll(1),
focused == 1,
chunks[1],
);
}
fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let peers = data
/// A numeric sort key for a metric value from a layer object, with absent
/// values sorting last under an ascending sort by mapping them to +infinity.
fn metric_key(layer: Option<&serde_json::Value>, prefer: &str, fallback: Option<&str>) -> f64 {
layer
.and_then(|l| l.get(prefer).or_else(|| fallback.and_then(|f| l.get(f))))
.and_then(|v| v.as_f64())
.unwrap_or(f64::INFINITY)
}
/// Render the sortable-column header line: each column label, with the active
/// sort column highlighted and carrying a direction arrow.
fn sort_header(labels: &[&str], sort: SortState) -> Line<'static> {
let active = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let idle = Style::default().fg(Color::DarkGray);
// Solid triangles for the sort direction, distinct from the line-arrow
// glyphs the MMP trend columns use, so the two never collide visually or
// in tests.
let arrow = if sort.descending {
"\u{25bc}"
} else {
"\u{25b2}"
};
let mut spans: Vec<Span<'static>> = vec![Span::styled(" sort: ", idle)];
for (i, label) in labels.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ", idle));
}
if i == sort.col {
spans.push(Span::styled(format!("{label}{arrow}"), active));
} else {
spans.push(Span::styled(label.to_string(), idle));
}
}
Line::from(spans)
}
/// Apply the sort state to `peers` in place. Column 0 sorts by display name;
/// the remaining columns sort by the corresponding metric from `layer_key`
/// (the `link_layer` / `session_layer` object). Descending reverses the order.
fn sort_peers(peers: &mut [serde_json::Value], sort: SortState, layer_key: &str) {
peers.sort_by(|a, b| {
let ord = if sort.col == 0 {
let na = a.get("display_name").and_then(|v| v.as_str()).unwrap_or("");
let nb = b.get("display_name").and_then(|v| v.as_str()).unwrap_or("");
na.cmp(nb)
} else {
let la = a.get(layer_key);
let lb = b.get(layer_key);
let (ka, kb) = metric_pair(la, lb, layer_key, sort.col);
ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal)
};
if sort.descending { ord.reverse() } else { ord }
});
}
/// Compute the numeric sort keys for two peers on the given column, dispatching
/// to the correct metric for the Link vs Session layer.
fn metric_pair(
la: Option<&serde_json::Value>,
lb: Option<&serde_json::Value>,
layer_key: &str,
col: usize,
) -> (f64, f64) {
let (prefer, fallback): (&str, Option<&str>) = if layer_key == "link_layer" {
match col {
1 => ("srtt_ms", None),
2 => ("smoothed_loss", Some("loss_rate")),
3 => ("smoothed_etx", Some("etx")),
4 => ("lqi", None),
_ => ("goodput_bps", None),
}
} else {
match col {
1 => ("srtt_ms", None),
2 => ("smoothed_loss", Some("loss_rate")),
3 => ("smoothed_etx", Some("etx")),
4 => ("sqi", None),
_ => ("path_mtu", None),
}
};
(
metric_key(la, prefer, fallback),
metric_key(lb, prefer, fallback),
)
}
fn draw_link_mmp(
frame: &mut Frame,
data: &serde_json::Value,
sort: SortState,
scroll: u16,
focused: bool,
area: Rect,
) {
let mut peers = data
.get("peers")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let count = peers.len();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Link MMP ({count} peers) "));
let block = helpers::pane_block(&format!(" Link MMP ({count} peers) "), focused);
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -46,7 +154,9 @@ fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
return;
}
let mut lines: Vec<Line> = Vec::new();
sort_peers(&mut peers, sort, "link_layer");
let mut lines: Vec<Line> = vec![sort_header(MMP_LINK_SORT_LABELS, sort)];
for peer in &peers {
let name = helpers::str_field(peer, "display_name");
let ll = peer.get("link_layer");
@@ -77,73 +187,59 @@ fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
.map(helpers::format_throughput)
.unwrap_or_else(|| "-".into());
// Line 1: primary metrics
// Trend arrows sit inline, immediately after the value they
// describe: rtt -> srtt, loss -> loss, goodput -> gp. etx and lqi
// carry no trend; jitter has no numeric column and is dropped. Each
// tracked value reserves a fixed 1-char arrow slot (a space when
// stable) so the columns stay aligned regardless of trend state.
let label = Style::default().fg(Color::DarkGray);
let srtt_arrow = trend_arrow(ll, "rtt_trend", true);
let loss_arrow = trend_arrow(ll, "loss_trend", true);
let gp_arrow = trend_arrow(ll, "goodput_trend", false);
lines.push(Line::from(vec![
Span::styled(
format!(" {name:<16}"),
format!(" {} ", helpers::truncate_name(name, 16)),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled("srtt: ", Style::default().fg(Color::DarkGray)),
Span::styled("srtt: ", label),
Span::raw(format!("{srtt:<10}")),
Span::styled("loss: ", Style::default().fg(Color::DarkGray)),
srtt_arrow,
Span::styled(" loss: ", label),
Span::raw(format!("{loss:<8}")),
Span::styled("etx: ", Style::default().fg(Color::DarkGray)),
loss_arrow,
Span::styled(" etx: ", label),
Span::raw(format!("{etx:<6}")),
Span::styled("lqi: ", Style::default().fg(Color::DarkGray)),
Span::styled("lqi: ", label),
Span::raw(format!("{lqi:<8}")),
Span::styled("gp: ", Style::default().fg(Color::DarkGray)),
Span::styled("gp: ", label),
Span::raw(goodput),
gp_arrow,
]));
// Line 2: trends
if let Some(ll_val) = ll {
let mut trend_spans: Vec<Span> = vec![Span::raw(" ")];
let mut has_trends = false;
for (label, key, bad_rising) in [
("rtt", "rtt_trend", true),
("loss", "loss_trend", true),
("goodput", "goodput_trend", false),
("jitter", "jitter_trend", true),
] {
if let Some(trend) = ll_val.get(key).and_then(|v| v.as_str()) {
if has_trends {
trend_spans.push(Span::raw(" "));
}
trend_spans.push(Span::styled(
format!("{label}: "),
Style::default().fg(Color::DarkGray),
));
trend_spans.push(Span::styled(
trend.to_string(),
Style::default().fg(trend_color(trend, bad_rising)),
));
has_trends = true;
}
}
if has_trends {
lines.push(Line::from(trend_spans));
}
}
}
frame.render_widget(Paragraph::new(lines), inner);
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let sessions = data
fn draw_session_mmp(
frame: &mut Frame,
data: &serde_json::Value,
sort: SortState,
scroll: u16,
focused: bool,
area: Rect,
) {
let mut sessions = data
.get("sessions")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let count = sessions.len();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Session MMP ({count} sessions) "));
let block = helpers::pane_block(&format!(" Session MMP ({count} sessions) "), focused);
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -153,60 +249,92 @@ fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
return;
}
let lines: Vec<Line> = sessions
.iter()
.map(|s| {
let name = helpers::str_field(s, "display_name");
let sl = s.get("session_layer");
sort_peers(&mut sessions, sort, "session_layer");
let srtt = sl
.and_then(|l| l.get("srtt_ms"))
.and_then(|v| v.as_f64())
.map(|v| format!("{:.1}ms", v))
.unwrap_or_else(|| "-".into());
let loss = sl
.and_then(|l| l.get("smoothed_loss").or_else(|| l.get("loss_rate")))
.and_then(|v| v.as_f64())
.map(|v| format!("{:.4}", v))
.unwrap_or_else(|| "-".into());
let etx = sl
.and_then(|l| l.get("smoothed_etx").or_else(|| l.get("etx")))
.and_then(|v| v.as_f64())
.map(|v| format!("{:.2}", v))
.unwrap_or_else(|| "-".into());
let sqi = sl
.and_then(|l| l.get("sqi"))
.and_then(|v| v.as_f64())
.map(|v| format!("{:.2}", v))
.unwrap_or_else(|| "-".into());
let mtu = sl
.and_then(|l| l.get("path_mtu"))
.and_then(|v| v.as_u64())
.map(|v| v.to_string())
.unwrap_or_else(|| "-".into());
let mut lines: Vec<Line> = vec![sort_header(MMP_SESSION_SORT_LABELS, sort)];
lines.extend(sessions.iter().map(|s| {
let name = helpers::str_field(s, "display_name");
let sl = s.get("session_layer");
Line::from(vec![
Span::styled(
format!(" {name:<16}"),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled("srtt: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{srtt:<10}")),
Span::styled("loss: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{loss:<8}")),
Span::styled("etx: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{etx:<6}")),
Span::styled("sqi: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{sqi:<8}")),
Span::styled("mtu: ", Style::default().fg(Color::DarkGray)),
Span::raw(mtu),
])
})
.collect();
let srtt = sl
.and_then(|l| l.get("srtt_ms"))
.and_then(|v| v.as_f64())
.map(|v| format!("{:.1}ms", v))
.unwrap_or_else(|| "-".into());
let loss = sl
.and_then(|l| l.get("smoothed_loss").or_else(|| l.get("loss_rate")))
.and_then(|v| v.as_f64())
.map(|v| format!("{:.4}", v))
.unwrap_or_else(|| "-".into());
let etx = sl
.and_then(|l| l.get("smoothed_etx").or_else(|| l.get("etx")))
.and_then(|v| v.as_f64())
.map(|v| format!("{:.2}", v))
.unwrap_or_else(|| "-".into());
let sqi = sl
.and_then(|l| l.get("sqi"))
.and_then(|v| v.as_f64())
.map(|v| format!("{:.2}", v))
.unwrap_or_else(|| "-".into());
let mtu = sl
.and_then(|l| l.get("path_mtu"))
.and_then(|v| v.as_u64())
.map(|v| v.to_string())
.unwrap_or_else(|| "-".into());
frame.render_widget(Paragraph::new(lines), inner);
// Inline trend arrows mirror the Link MMP pane: srtt -> rtt_trend,
// loss -> loss_trend, etx -> etx_trend, each with a fixed 1-char
// slot (blank when stable) so the value columns stay aligned.
let label = Style::default().fg(Color::DarkGray);
let srtt_arrow = trend_arrow(sl, "rtt_trend", true);
let loss_arrow = trend_arrow(sl, "loss_trend", true);
let etx_arrow = trend_arrow(sl, "etx_trend", true);
Line::from(vec![
Span::styled(
format!(" {} ", helpers::truncate_name(name, 16)),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled("srtt: ", label),
Span::raw(format!("{srtt:<10}")),
srtt_arrow,
Span::styled(" loss: ", label),
Span::raw(format!("{loss:<8}")),
loss_arrow,
Span::styled(" etx: ", label),
Span::raw(format!("{etx:<6}")),
etx_arrow,
Span::styled(" sqi: ", label),
Span::raw(format!("{sqi:<8}")),
Span::styled("mtu: ", label),
Span::raw(mtu),
])
}));
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
/// Build the inline trend arrow span for a metric: a colored `↑`/`↓` for a
/// rising/falling trend, or a single blank space when stable or absent.
/// The slot is always one cell wide so value columns stay aligned. `layer`
/// is the `link_layer` / `session_layer` object carrying the `*_trend` key.
fn trend_arrow(layer: Option<&serde_json::Value>, key: &str, bad_rising: bool) -> Span<'static> {
let trend = layer.and_then(|l| l.get(key)).and_then(|v| v.as_str());
match trend {
Some("rising") => Span::styled(
"\u{2191}",
Style::default().fg(trend_color("rising", bad_rising)),
),
Some("falling") => Span::styled(
"\u{2193}",
Style::default().fg(trend_color("falling", bad_rising)),
),
// Stable or no trend: a blank reserved slot.
_ => Span::raw(" "),
}
}
/// Color a trend value based on whether "rising" is bad or good for this metric.
+24 -2
View File
@@ -2,12 +2,17 @@ mod bloom;
mod dashboard;
mod gateway;
mod graphs;
pub(crate) mod help;
mod helpers;
pub(crate) mod listening;
mod mmp;
mod peers;
mod routing;
mod sessions;
#[cfg(test)]
mod snapshots;
#[cfg(test)]
mod testkit;
mod transports;
mod tree;
@@ -30,6 +35,15 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
draw_tab_bar(frame, app, chunks[0]);
draw_content(frame, app, chunks[1]);
draw_status_bar(frame, app, chunks[2]);
// The `?` help overlay draws over everything when toggled on.
if app.show_help {
help::draw_overlay(frame, app, chunks[1]);
}
// The Del-disconnect confirmation modal draws over the content.
if app.confirm_disconnect.is_some() {
help::draw_disconnect_modal(frame, app, chunks[1]);
}
}
fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect) {
@@ -101,9 +115,17 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) {
elapsed.as_secs_f64()
));
let help = Span::styled("[?] Help ", Style::default().fg(Color::DarkGray));
// Context-aware hints fill the remaining width after the connection and
// timing spans, sourced from the shared keybinding registry so they can't
// drift from the `?` overlay.
let fixed_w = conn.width() + timing.width();
let budget = (area.width as usize).saturating_sub(fixed_w);
let mode = help::UiMode::of(app);
let hint_spans = help::footer_hint_spans(app.active_tab, mode, budget);
let line = Line::from(vec![conn, timing, help]);
let mut spans = vec![conn, timing];
spans.extend(hint_spans);
let line = Line::from(spans);
let bar = Paragraph::new(line);
frame.render_widget(bar, area);
}
+126 -80
View File
@@ -4,6 +4,7 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{
Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
TableState,
};
use crate::app::{App, Tab};
@@ -26,7 +27,9 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
}
}
/// Get peers sorted by LQI ascending (best first). Peers without LQI sort last.
/// Get peers grouped by role (parent -> STP children -> other), and within
/// each group sorted by LQI ascending (best first). Peers without LQI sort
/// last within their group.
fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
let mut peers = app
.data
@@ -37,20 +40,25 @@ fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
.unwrap_or_default();
peers.sort_by(|a, b| {
let lqi_a = a
.get("mmp")
.and_then(|m| m.get("lqi"))
.and_then(|v| v.as_f64());
let lqi_b = b
.get("mmp")
.and_then(|m| m.get("lqi"))
.and_then(|v| v.as_f64());
match (lqi_a, lqi_b) {
(Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
// Primary key: role group. Secondary key: LQI ascending.
helpers::group_rank(a)
.cmp(&helpers::group_rank(b))
.then_with(|| {
let lqi_a = a
.get("mmp")
.and_then(|m| m.get("lqi"))
.and_then(|v| v.as_f64());
let lqi_b = b
.get("mmp")
.and_then(|m| m.get("lqi"))
.and_then(|v| v.as_f64());
match (lqi_a, lqi_b) {
(Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
})
});
peers
@@ -71,6 +79,7 @@ fn draw_table(
Cell::from("SRTT"),
Cell::from("Loss"),
Cell::from("LQI"),
Cell::from("EffD"),
Cell::from("Goodput"),
Cell::from("Pkts Tx"),
Cell::from("Pkts Rx"),
@@ -81,90 +90,114 @@ fn draw_table(
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = peers
.iter()
.map(|peer| {
let name = helpers::str_field(peer, "display_name");
let npub = helpers::str_field(peer, "npub");
let is_parent = peer
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_child = peer
.get("is_child")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Build the grouped display: a styled label row before each non-empty
// group, the group's peer rows, and a blank separator before the next
// group. `peer_display_idx[p]` is the display-row index of sorted peer `p`,
// so the stored peer-index selection (used by detail + navigation) can be
// translated to the display row to highlight, and the cursor only ever
// lands on peer rows.
let mut rows: Vec<Row> = Vec::new();
let mut peer_display_idx: Vec<usize> = Vec::with_capacity(peers.len());
let mut last_group: Option<u8> = None;
let group_label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
for peer in peers.iter() {
let g = helpers::group_rank(peer);
if last_group != Some(g) {
if last_group.is_some() {
rows.push(Row::new(vec![Cell::from("")]));
}
rows.push(Row::new(vec![Cell::from(helpers::group_label(g))]).style(group_label_style));
last_group = Some(g);
}
// Transport: "type addr" (e.g., "udp 1.2.3.4:2121")
let transport = {
let t_type = peer
.get("transport_type")
.and_then(|v| v.as_str())
.unwrap_or("");
let t_addr = peer
.get("transport_addr")
.and_then(|v| v.as_str())
.unwrap_or("");
if t_type.is_empty() && t_addr.is_empty() {
"-".to_string()
} else if t_type.is_empty() {
t_addr.to_string()
} else if t_addr.is_empty() {
t_type.to_string()
} else {
format!("{t_type}/{t_addr}")
}
};
let name = helpers::str_field(peer, "display_name");
let npub = helpers::str_field(peer, "npub");
let is_parent = peer
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_child = peer
.get("is_child")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let dir = peer
.get("direction")
// Transport: "type addr" (e.g., "udp 1.2.3.4:2121")
let transport = {
let t_type = peer
.get("transport_type")
.and_then(|v| v.as_str())
.map(|d| match d {
"inbound" => "in",
"outbound" => "out",
other => other,
})
.unwrap_or("-");
let srtt = helpers::nested_f64(peer, "mmp", "srtt_ms", 1);
let loss = helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 3);
let lqi = helpers::nested_f64(peer, "mmp", "lqi", 2);
let goodput = helpers::nested_throughput(peer, "mmp", "goodput_bps");
let pkts_tx = helpers::nested_u64(peer, "stats", "packets_sent");
let pkts_rx = helpers::nested_u64(peer, "stats", "packets_recv");
// Tree role colorization
let row_style = if is_parent {
Style::default().fg(Color::Magenta)
} else if is_child {
Style::default().fg(Color::Cyan)
.unwrap_or("");
let t_addr = peer
.get("transport_addr")
.and_then(|v| v.as_str())
.unwrap_or("");
if t_type.is_empty() && t_addr.is_empty() {
"-".to_string()
} else if t_type.is_empty() {
t_addr.to_string()
} else if t_addr.is_empty() {
t_type.to_string()
} else {
Style::default()
};
format!("{t_type}/{t_addr}")
}
};
let dir = peer
.get("direction")
.and_then(|v| v.as_str())
.map(|d| match d {
"inbound" => "in",
"outbound" => "out",
other => other,
})
.unwrap_or("-");
let srtt = helpers::nested_f64(peer, "mmp", "srtt_ms", 1);
let loss = helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 3);
let lqi = helpers::nested_f64(peer, "mmp", "lqi", 2);
let eff_depth = helpers::opt_f64_field(peer, "effective_depth", 2);
let goodput = helpers::nested_throughput(peer, "mmp", "goodput_bps");
let pkts_tx = helpers::nested_u64(peer, "stats", "packets_sent");
let pkts_rx = helpers::nested_u64(peer, "stats", "packets_recv");
// Tree role colorization
let row_style = if is_parent {
Style::default().fg(Color::Magenta)
} else if is_child {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
peer_display_idx.push(rows.len());
rows.push(
Row::new(vec![
Cell::from(name.to_string()),
Cell::from(npub.to_string()),
Cell::from(helpers::truncate_hex(npub, 18)),
Cell::from(transport),
Cell::from(dir.to_string()),
Cell::from(srtt),
Cell::from(loss),
Cell::from(lqi),
Cell::from(eff_depth),
Cell::from(goodput),
Cell::from(pkts_tx),
Cell::from(pkts_rx),
])
.style(row_style)
})
.collect();
.style(row_style),
);
}
let widths = [
Constraint::Min(12), // Name
Constraint::Length(67), // Npub (full bech32)
Constraint::Min(20), // Name (wide enough for the group labels)
Constraint::Length(20), // Npub (truncated; full form in the detail view)
Constraint::Min(20), // Transport
Constraint::Length(4), // Dir
Constraint::Length(8), // SRTT
Constraint::Length(7), // Loss
Constraint::Length(6), // LQI
Constraint::Length(6), // EffD
Constraint::Length(10), // Goodput
Constraint::Length(9), // Pkts Tx
Constraint::Length(9), // Pkts Rx
@@ -184,12 +217,21 @@ fn draw_table(
)
.highlight_symbol("");
let state = app.table_states.entry(Tab::Peers).or_default();
frame.render_stateful_widget(table, area, state);
// The stored selection is the *peer* index (used by detail + navigation);
// translate it to the display row so the highlight lands on the peer's row
// and the cursor never sits on a label or blank separator.
let peer_sel = app.table_states.get(&Tab::Peers).and_then(|s| s.selected());
let mut display_state = TableState::default();
if let Some(p) = peer_sel
&& let Some(&disp) = peer_display_idx.get(p)
{
display_state.select(Some(disp));
}
frame.render_stateful_widget(table, area, &mut display_state);
// Scrollbar
// Scrollbar tracks the peer position within the peer count.
if row_count > 0 {
let selected = state.selected().unwrap_or(0);
let selected = peer_sel.unwrap_or(0);
let mut scrollbar_state = ScrollbarState::new(row_count).position(selected);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
@@ -329,6 +371,10 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
if let Some(depth) = peer.get("tree_depth").and_then(|v| v.as_u64()) {
lines.push(helpers::kv_line("Tree Depth", &depth.to_string()));
}
lines.push(helpers::kv_line(
"Effective Depth",
&helpers::opt_f64_field(peer, "effective_depth", 2),
));
lines.extend([
helpers::kv_line("Bloom Filter", if has_bloom { "yes" } else { "no" }),
helpers::kv_line("Filter Seq", &helpers::u64_field(peer, "filter_sequence")),
+252 -197
View File
@@ -2,7 +2,7 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use crate::app::{App, Tab};
@@ -26,45 +26,50 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
])
.split(area);
draw_routing_state(frame, data, chunks[0]);
draw_coord_cache(frame, app, chunks[1]);
draw_routing_stats(frame, data, chunks[2]);
let focused = app.focused_pane();
draw_routing_state(frame, data, app.pane_scroll(0), focused == 0, chunks[0]);
draw_coord_cache(frame, app, app.pane_scroll(1), focused == 1, chunks[1]);
draw_routing_stats(frame, data, app.pane_scroll(2), focused == 2, chunks[2]);
}
fn draw_routing_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let lines = vec![
helpers::kv_line(
fn draw_routing_state(
frame: &mut Frame,
data: &serde_json::Value,
scroll: u16,
focused: bool,
area: Rect,
) {
let lines = helpers::kv_lines(&[
(
"Coord Cache",
&helpers::u64_field(data, "coord_cache_entries"),
helpers::u64_field(data, "coord_cache_entries"),
),
helpers::kv_line(
(
"Identity Cache",
&helpers::u64_field(data, "identity_cache_entries"),
helpers::u64_field(data, "identity_cache_entries"),
),
helpers::kv_line(
(
"Pending Lookups",
&data
.get("pending_lookups")
data.get("pending_lookups")
.and_then(|v| v.as_array())
.map(|a| a.len().to_string())
.unwrap_or_else(|| "0".into()),
),
helpers::kv_line(
(
"Recent Requests",
&helpers::u64_field(data, "recent_requests"),
helpers::u64_field(data, "recent_requests"),
),
];
]);
let block = Block::default()
.borders(Borders::ALL)
.title(" Routing State ");
let block = helpers::pane_block(" Routing State ", focused);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Paragraph::new(lines), inner);
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
/// Format a forwarding counter as "N pkts (formatted_bytes)".
fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str) -> Line<'static> {
fn fwd_value(data: &serde_json::Value, pkt_key: &str, byte_key: &str) -> String {
let pkts = data
.get("forwarding")
.and_then(|f| f.get(pkt_key))
@@ -75,187 +80,238 @@ fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str
.and_then(|f| f.get(byte_key))
.and_then(|v| v.as_u64())
.unwrap_or(0);
helpers::kv_line(
label,
&format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)),
)
format!("{} pkts ({})", pkts, helpers::format_bytes(bytes))
}
fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Routing Statistics ");
/// Read a raw forwarding counter as a u64 (0 if missing), for arithmetic
/// (percentages, derived totals) that the string-returning helpers can't do.
fn fwd_count(data: &serde_json::Value, key: &str) -> u64 {
data.get("forwarding")
.and_then(|f| f.get(key))
.and_then(|v| v.as_u64())
.unwrap_or(0)
}
/// Total mesh egress = locally-originated + transit-forwarded, formatted as
/// "N pkts (B)". There is no single daemon counter for everything this node
/// transmits to peers, so it is derived from its two contributors.
fn mesh_tx_value(data: &serde_json::Value) -> String {
let pkts = fwd_count(data, "originated_packets") + fwd_count(data, "forwarded_packets");
let bytes = fwd_count(data, "originated_bytes") + fwd_count(data, "forwarded_bytes");
format!("{} pkts ({})", pkts, helpers::format_bytes(bytes))
}
/// Format a route-class count as "N (xx.x%)" where the percentage is the class's
/// share of total forwarded (transit) packets. Zero forwarded yields "0.0%".
fn route_class_value(count: u64, total_forwarded: u64) -> String {
let pct = if total_forwarded > 0 {
count as f64 / total_forwarded as f64 * 100.0
} else {
0.0
};
format!("{count} ({pct:.1}%)")
}
/// Build a section: a styled header line followed by the kv pairs rendered
/// through the group helper so the section's values share a left edge.
fn section(title: &str, pairs: &[(&str, String)]) -> Vec<Line<'static>> {
let mut out = vec![helpers::section_header(title)];
out.extend(helpers::kv_lines(pairs));
out
}
fn draw_routing_stats(
frame: &mut Frame,
data: &serde_json::Value,
scroll: u16,
focused: bool,
area: Rect,
) {
let block = helpers::pane_block(" Routing Statistics ", focused);
let inner = block.inner(area);
frame.render_widget(block, area);
let cols =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
// Left column: Forwarding + Discovery
let mut left = vec![
helpers::section_header("Forwarding"),
fwd_line(data, "Received", "received_packets", "received_bytes"),
fwd_line(data, "Delivered", "delivered_packets", "delivered_bytes"),
fwd_line(data, "Forwarded", "forwarded_packets", "forwarded_bytes"),
fwd_line(data, "Originated", "originated_packets", "originated_bytes"),
fwd_line(
data,
"Decode Error",
"decode_error_packets",
"decode_error_bytes",
),
fwd_line(
data,
"TTL Exhausted",
"ttl_exhausted_packets",
"ttl_exhausted_bytes",
),
fwd_line(
data,
"No Route",
"drop_no_route_packets",
"drop_no_route_bytes",
),
fwd_line(
data,
"MTU Exceeded",
"drop_mtu_exceeded_packets",
"drop_mtu_exceeded_bytes",
),
fwd_line(
data,
"Send Error",
"drop_send_error_packets",
"drop_send_error_bytes",
),
Line::from(""),
helpers::section_header("Discovery Requests"),
helpers::kv_line(
"Received",
&helpers::nested_u64(data, "discovery", "req_received"),
),
helpers::kv_line(
// Shorthand for a nested counter value (e.g. discovery.req_received).
let disc = |key: &str| helpers::nested_u64(data, "discovery", key);
let err = |key: &str| helpers::nested_u64(data, "error_signals", key);
let cong = |key: &str| helpers::nested_u64(data, "congestion", key);
// The node is an interface adapter between the local host stack and the
// mesh; the left column reads each side as a Transmitted/Received pair.
//
// Local Stack — traffic crossing the TUN / local-origination boundary:
// Transmitted is what the host injects into the mesh (originated), Received
// is what the mesh hands up to the host (delivered).
let mut left = section(
"Local Stack",
&[
(
"Transmitted",
fwd_value(data, "originated_packets", "originated_bytes"),
),
(
"Received",
fwd_value(data, "delivered_packets", "delivered_bytes"),
),
],
);
left.push(Line::from(""));
// Mesh — traffic crossing the peer-link boundary: Transmitted is everything
// this node puts on the wire (originated + forwarded, derived), Received is
// the ingress aggregate from peers (own-delivered + transit + drops).
left.extend(section(
"Mesh",
&[
("Transmitted", mesh_tx_value(data)),
(
"Received",
fwd_value(data, "received_packets", "received_bytes"),
),
],
));
left.push(Line::from(""));
left.extend(section(
"Discovery Requests",
&[
("Received", disc("req_received")),
("Forwarded", disc("req_forwarded")),
("Initiated", disc("req_initiated")),
("Deduplicated", disc("req_deduplicated")),
("Target Is Us", disc("req_target_is_us")),
("Duplicate", disc("req_duplicate")),
("Bloom Miss", disc("req_bloom_miss")),
("Backoff Suppressed", disc("req_backoff_suppressed")),
("Fwd Rate Limited", disc("req_forward_rate_limited")),
("TTL Exhausted", disc("req_ttl_exhausted")),
("Decode Error", disc("req_decode_error")),
],
));
left.push(Line::from(""));
left.extend(section(
"Discovery Responses",
&[
("Received", disc("resp_received")),
("Accepted", disc("resp_accepted")),
("Forwarded", disc("resp_forwarded")),
("Timed Out", disc("resp_timed_out")),
("Identity Miss", disc("resp_identity_miss")),
("Proof Failed", disc("resp_proof_failed")),
("Decode Error", disc("resp_decode_error")),
],
));
// Right column — "Forwarded" (transit / routed through this node).
// Forwarded total, then the route-class breakdown (a percentage partition
// of the total), then the transit-path drop reasons.
let fwd_total = fwd_count(data, "forwarded_packets");
let mut right = section(
"Forwarded",
&[(
"Forwarded",
&helpers::nested_u64(data, "discovery", "req_forwarded"),
),
helpers::kv_line(
"Initiated",
&helpers::nested_u64(data, "discovery", "req_initiated"),
),
helpers::kv_line(
"Deduplicated",
&helpers::nested_u64(data, "discovery", "req_deduplicated"),
),
helpers::kv_line(
"Target Is Us",
&helpers::nested_u64(data, "discovery", "req_target_is_us"),
),
helpers::kv_line(
"Duplicate",
&helpers::nested_u64(data, "discovery", "req_duplicate"),
),
helpers::kv_line(
"Bloom Miss",
&helpers::nested_u64(data, "discovery", "req_bloom_miss"),
),
helpers::kv_line(
"Backoff Suppressed",
&helpers::nested_u64(data, "discovery", "req_backoff_suppressed"),
),
helpers::kv_line(
"Fwd Rate Limited",
&helpers::nested_u64(data, "discovery", "req_forward_rate_limited"),
),
helpers::kv_line(
"TTL Exhausted",
&helpers::nested_u64(data, "discovery", "req_ttl_exhausted"),
),
helpers::kv_line(
"Decode Error",
&helpers::nested_u64(data, "discovery", "req_decode_error"),
),
Line::from(""),
helpers::section_header("Discovery Responses"),
helpers::kv_line(
"Received",
&helpers::nested_u64(data, "discovery", "resp_received"),
),
helpers::kv_line(
"Accepted",
&helpers::nested_u64(data, "discovery", "resp_accepted"),
),
helpers::kv_line(
"Forwarded",
&helpers::nested_u64(data, "discovery", "resp_forwarded"),
),
helpers::kv_line(
"Timed Out",
&helpers::nested_u64(data, "discovery", "resp_timed_out"),
),
helpers::kv_line(
"Identity Miss",
&helpers::nested_u64(data, "discovery", "resp_identity_miss"),
),
helpers::kv_line(
"Proof Failed",
&helpers::nested_u64(data, "discovery", "resp_proof_failed"),
),
helpers::kv_line(
"Decode Error",
&helpers::nested_u64(data, "discovery", "resp_decode_error"),
),
];
fwd_value(data, "forwarded_packets", "forwarded_bytes"),
)],
);
// Blank separator after the Forwarded total, matching the spacing between
// every other section pair; the total and its route-class breakdown read
// as two distinct groups.
right.push(Line::from(""));
// Route-class breakdown: a partition of Forwarded, each line annotated with
// its share of the total. Tree-down cross — the dive-to-tree-child
// cut-through — is the last class; Tree-down + Tree-down cross sum to the
// pre-split tree-down total.
right.extend(section(
"Route Class",
&[
(
"Direct Peer",
route_class_value(fwd_count(data, "route_direct_peer"), fwd_total),
),
(
"Tree-down",
route_class_value(fwd_count(data, "route_tree_down"), fwd_total),
),
(
"Tree-up",
route_class_value(fwd_count(data, "route_tree_up"), fwd_total),
),
(
"Cross-link descend",
route_class_value(fwd_count(data, "route_crosslink_descend"), fwd_total),
),
(
"Cross-link ascend",
route_class_value(fwd_count(data, "route_crosslink_ascend"), fwd_total),
),
(
"Tree-down cross",
route_class_value(fwd_count(data, "route_tree_down_cross"), fwd_total),
),
],
));
right.push(Line::from(""));
right.extend(section(
"Dropped",
&[
(
"No Route",
fwd_value(data, "drop_no_route_packets", "drop_no_route_bytes"),
),
(
"TTL Exhausted",
fwd_value(data, "ttl_exhausted_packets", "ttl_exhausted_bytes"),
),
(
"Decode Error",
fwd_value(data, "decode_error_packets", "decode_error_bytes"),
),
(
"MTU Exceeded",
fwd_value(data, "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"),
),
(
"Send Error",
fwd_value(data, "drop_send_error_packets", "drop_send_error_bytes"),
),
],
));
right.push(Line::from(""));
right.extend(section(
"Error Signals",
&[
("Coords Required", err("coords_required")),
("Path Broken", err("path_broken")),
("MTU Exceeded", err("mtu_exceeded")),
],
));
right.push(Line::from(""));
right.extend(section(
"Congestion",
&[
("CE Forwarded", cong("ce_forwarded")),
("CE Received", cong("ce_received")),
("Congestion Detected", cong("congestion_detected")),
("Kernel Drops", cong("kernel_drop_events")),
],
));
// Right column: Error Signals + Congestion
let mut right = vec![
helpers::section_header("Error Signals"),
helpers::kv_line(
"Coords Required",
&helpers::nested_u64(data, "error_signals", "coords_required"),
),
helpers::kv_line(
"Path Broken",
&helpers::nested_u64(data, "error_signals", "path_broken"),
),
helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(data, "error_signals", "mtu_exceeded"),
),
Line::from(""),
helpers::section_header("Congestion"),
helpers::kv_line(
"CE Forwarded",
&helpers::nested_u64(data, "congestion", "ce_forwarded"),
),
helpers::kv_line(
"CE Received",
&helpers::nested_u64(data, "congestion", "ce_received"),
),
helpers::kv_line(
"Congestion Detected",
&helpers::nested_u64(data, "congestion", "congestion_detected"),
),
helpers::kv_line(
"Kernel Drops",
&helpers::nested_u64(data, "congestion", "kernel_drop_events"),
),
];
// Both columns scroll together under the focused-pane offset, clamped to
// the taller column so neither over-scrolls past its content.
let visible = cols[0].height as usize;
let content = left.len().max(right.len());
let scroll = helpers::clamp_scroll(scroll, content, visible);
let max_lines = cols[0].height as usize;
left.truncate(max_lines);
right.truncate(max_lines);
frame.render_widget(Paragraph::new(left), cols[0]);
frame.render_widget(Paragraph::new(right), cols[1]);
frame.render_widget(Paragraph::new(left).scroll((scroll, 0)), cols[0]);
frame.render_widget(Paragraph::new(right).scroll((scroll, 0)), cols[1]);
}
fn draw_coord_cache(frame: &mut Frame, app: &App, area: Rect) {
fn draw_coord_cache(frame: &mut Frame, app: &App, scroll: u16, focused: bool, area: Rect) {
let data = match app.data.get(&Tab::Cache) {
Some(d) => d,
None => {
let block = Block::default()
.borders(Borders::ALL)
.title(" Coordinate Cache ");
let block = helpers::pane_block(" Coordinate Cache ", focused);
let inner = block.inner(area);
frame.render_widget(block, area);
let msg =
@@ -284,18 +340,17 @@ fn draw_coord_cache(frame: &mut Frame, app: &App, area: Rect) {
.map(helpers::format_duration_ms)
.unwrap_or_else(|| "-".into());
let lines = vec![
helpers::kv_line("Entries", &format!("{entries} / {max_entries}")),
helpers::kv_line("Fill Ratio", &fill_pct),
helpers::kv_line("Default TTL", &ttl),
helpers::kv_line("Expired", &expired),
helpers::kv_line("Avg Age", &avg_age),
];
let lines = helpers::kv_lines(&[
("Entries", format!("{entries} / {max_entries}")),
("Fill Ratio", fill_pct),
("Default TTL", ttl),
("Expired", expired),
("Avg Age", avg_age),
]);
let block = Block::default()
.borders(Borders::ALL)
.title(" Coordinate Cache ");
let block = helpers::pane_block(" Coordinate Cache ", focused);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Paragraph::new(lines), inner);
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
//! Render-snapshot test harness for fipstop's `ui::draw_*` functions.
//!
//! Renders a draw function into an in-memory `ratatui` `TestBackend`
//! `Buffer` from a fixed area and canned JSON, then exposes the result as
//! a text grid plus per-cell style lookups. This makes layout, columns,
//! alignment, labels, grouping order, and per-cell colour machine-checkable
//! under `cargo test`, with no operator eyes.
#![cfg(test)]
// This is a test toolkit: some accessors (grid/find/fg_at) are consumed by
// snapshot cases added as render items land, so allow not-yet-used helpers.
#![allow(dead_code)]
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use crate::app::{App, Tab};
/// Build an `App` with `data` registered under `tab` and that tab active.
pub fn app_with(tab: Tab, data: serde_json::Value) -> App {
let mut app = App::new(std::time::Duration::from_secs(2));
app.active_tab = tab;
app.connection_state = crate::app::ConnectionState::Connected;
app.data.insert(tab, data);
app
}
/// Render a draw closure into a `w`x`h` `TestBackend` and return the buffer.
pub fn render<F>(w: u16, h: u16, draw: F) -> Buffer
where
F: FnOnce(&mut ratatui::Frame, Rect),
{
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|frame| {
let area = frame.area();
draw(frame, area);
})
.expect("draw");
terminal.backend().buffer().clone()
}
/// Dump a buffer as a vector of trimmed-right text rows.
pub fn lines(buf: &Buffer) -> Vec<String> {
let w = buf.area.width as usize;
let h = buf.area.height as usize;
let mut out = Vec::with_capacity(h);
for y in 0..h {
let mut row = String::new();
for x in 0..w {
if let Some(cell) = buf.cell((x as u16, y as u16)) {
row.push_str(cell.symbol());
}
}
out.push(row.trim_end().to_string());
}
out
}
/// The full buffer as a single newline-joined string (for eyeball diffs).
pub fn grid(buf: &Buffer) -> String {
lines(buf).join("\n")
}
/// Return true if any row, after trimming, contains `needle`.
pub fn contains_row(buf: &Buffer, needle: &str) -> bool {
lines(buf).iter().any(|r| r.contains(needle))
}
/// Find the first (x, y) of `needle` in the rendered grid, if present.
///
/// The returned `x` is the cell column (not a byte offset), so it can be
/// used directly with `buf.cell`. `needle` is matched against the running
/// concatenation of cell symbols; the column reported is the cell at which
/// the match begins.
pub fn find(buf: &Buffer, needle: &str) -> Option<(u16, u16)> {
let w = buf.area.width as usize;
let h = buf.area.height as usize;
for y in 0..h {
// Per-cell symbols paired with their column, so a byte match maps
// back to the originating cell column even with multibyte glyphs.
let mut row = String::new();
let mut starts: Vec<usize> = Vec::new();
for x in 0..w {
if let Some(cell) = buf.cell((x as u16, y as u16)) {
starts.push(row.len());
row.push_str(cell.symbol());
}
}
if let Some(byte_off) = row.find(needle) {
// Map the byte offset back to the cell column.
let col = starts
.iter()
.position(|&s| s == byte_off)
.unwrap_or(byte_off);
return Some((col as u16, y as u16));
}
}
None
}
/// Foreground colour of the cell at the first column where `needle` starts.
pub fn fg_at(buf: &Buffer, needle: &str) -> Option<Color> {
let (x, y) = find(buf, needle)?;
buf.cell((x, y)).map(|c| c.fg)
}
+24 -1
View File
@@ -211,7 +211,10 @@ fn draw_table(
"Inbound" => "In",
other => other,
};
let addr = helpers::truncate_hex(helpers::str_field(link, "remote_addr"), 16);
// Wide enough to render a full MAC (~17) or `hci0/MAC`
// (~22) without chopping mid-octet; the link detail view
// shows the untruncated address.
let addr = helpers::truncate_hex(helpers::str_field(link, "remote_addr"), 24);
let label = format!(" {tree_char} {dir_short} {addr}");
let state = helpers::str_field(link, "state");
@@ -478,6 +481,26 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso
&helpers::nested_u64(t, "stats", "connect_refused"),
));
}
"nym" => {
lines.push(helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(t, "stats", "mtu_exceeded"),
));
lines.push(helpers::kv_line(
"SOCKS5 Errors",
&helpers::nested_u64(t, "stats", "socks5_errors"),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Connections"));
lines.push(helpers::kv_line(
"Established",
&helpers::nested_u64(t, "stats", "connections_established"),
));
lines.push(helpers::kv_line(
"Timeouts",
&helpers::nested_u64(t, "stats", "connect_timeouts"),
));
}
"ethernet" => {
lines.push(Line::from(""));
lines.push(helpers::section_header("Beacons"));
+124 -68
View File
@@ -2,7 +2,7 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use crate::app::{App, Tab};
@@ -26,12 +26,44 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
])
.split(area);
draw_position(frame, data, chunks[0]);
draw_stats(frame, data, chunks[1]);
draw_peers(frame, data, chunks[2]);
let focused = app.focused_pane();
draw_position(frame, data, app.pane_scroll(0), focused == 0, chunks[0]);
draw_stats(frame, data, app.pane_scroll(1), focused == 1, chunks[1]);
draw_peers(
frame,
app,
data,
app.pane_scroll(2),
focused == 2,
chunks[2],
);
}
fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
/// Look up a peer's daemon-computed `effective_depth` from the Peers tab data
/// by node_addr, formatted, or an em-dash when unavailable/unmeasured. The
/// value is a single daemon derivation (`show_peers`); the Tree tab reads it
/// back rather than recomputing, so the surfaces cannot drift.
fn peer_effective_depth(app: &App, node_addr: &str) -> String {
app.data
.get(&Tab::Peers)
.and_then(|v| v.get("peers"))
.and_then(|v| v.as_array())
.and_then(|peers| {
peers
.iter()
.find(|p| p.get("node_addr").and_then(|v| v.as_str()) == Some(node_addr))
})
.map(|p| helpers::opt_f64_field(p, "effective_depth", 2))
.unwrap_or_else(|| "\u{2014}".into())
}
fn draw_position(
frame: &mut Frame,
data: &serde_json::Value,
scroll: u16,
focused: bool,
area: Rect,
) {
let root_hex = helpers::str_field(data, "root");
let is_root = data
.get("is_root")
@@ -42,10 +74,18 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let decl_seq = helpers::u64_field(data, "declaration_sequence");
let decl_signed = helpers::bool_field(data, "declaration_signed");
// Full root hex (no truncation) so it can be correlated against logs and
// configs; the npub line below resolves the root's identity when known.
let root_display = if is_root {
format!("{} (self)", helpers::truncate_hex(root_hex, 16))
format!("{root_hex} (self)")
} else {
helpers::truncate_hex(root_hex, 16)
root_hex.to_string()
};
let root_npub = helpers::str_field(data, "root_npub");
let npub_display = if root_npub == "-" {
"<unknown>".to_string()
} else {
root_npub.to_string()
};
let parent_display = if is_root {
@@ -56,6 +96,7 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let mut lines = vec![
helpers::kv_line("Root", &root_display),
helpers::kv_line("Npub", &npub_display),
helpers::kv_line("Depth", &depth),
helpers::kv_line("Parent", &parent_display),
helpers::kv_line("Declaration", &format!("seq {decl_seq}, {decl_signed}")),
@@ -100,22 +141,19 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
lines.push(Line::from(path_parts));
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Tree Position ");
let block = helpers::pane_block(" Tree Position ", focused);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Paragraph::new(lines), inner);
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Tree Announce Stats ");
fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused: bool, area: Rect) {
let block = helpers::pane_block(" Tree Announce Stats ", focused);
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines = vec![
let lines = vec![
helpers::section_header("Inbound"),
helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")),
helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")),
@@ -136,10 +174,6 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
&helpers::nested_u64(data, "stats", "sig_failed"),
),
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
helpers::kv_line(
"Parent Switched",
&helpers::nested_u64(data, "stats", "parent_switched"),
),
helpers::kv_line(
"Loop Detected",
&helpers::nested_u64(data, "stats", "loop_detected"),
@@ -175,25 +209,29 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
),
];
// Trim to fit available height
let max_lines = inner.height as usize;
lines.truncate(max_lines);
frame.render_widget(Paragraph::new(lines), inner);
// Apply the focused-pane scroll instead of truncating, so over-full stats
// can be revealed by scrolling.
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
fn draw_peers(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
fn draw_peers(
frame: &mut Frame,
app: &App,
data: &serde_json::Value,
scroll: u16,
focused: bool,
area: Rect,
) {
let peers = data
.get("peers")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let my_root = helpers::str_field(data, "root");
let my_root = helpers::str_field(data, "root").to_string();
let count = peers.len();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Tree Peers ({count}) "));
let block = helpers::pane_block(&format!(" Tree Peers ({count}) "), focused);
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -203,44 +241,62 @@ fn draw_peers(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
return;
}
let lines: Vec<Line> = peers
.iter()
.map(|p| {
let name = helpers::str_field(p, "display_name");
let has_depth = p.get("depth").is_some();
if !has_depth {
return Line::from(vec![
Span::styled(
format!(" {name:<16}"),
Style::default().fg(Color::DarkGray),
),
Span::styled("(no position)", Style::default().fg(Color::DarkGray)),
]);
}
let depth = helpers::u64_field(p, "depth");
let dist = helpers::u64_field(p, "distance_to_us");
let peer_root = helpers::str_field(p, "root");
let (root_ind, root_color) = if peer_root == my_root {
("same root", Color::Green)
} else {
("diff root", Color::Red)
};
Line::from(vec![
Span::styled(
format!(" {name:<16}"),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled("depth: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{depth:<4}")),
Span::styled("dist: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{dist:<4}")),
Span::styled(root_ind, Style::default().fg(root_color)),
])
})
// The tree response carries no role flags; recover them from the peers view
// (cross-fetched on this tab) by joining on the hex node address, then group
// by tree role (parent -> STP children -> other) like the Peers tab so the
// same peer sits under the same heading on every surface.
let role_map = helpers::peer_role_map(app.data.get(&Tab::Peers));
let mut peers: Vec<serde_json::Value> = peers
.into_iter()
.map(|p| helpers::enrich_role(p, &role_map, "node_addr"))
.collect();
helpers::sort_by_group(&mut peers);
frame.render_widget(Paragraph::new(lines), inner);
let lines = helpers::grouped_peer_lines(&peers, |p| tree_peer_line(app, &my_root, p));
let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize);
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
/// Render one Tree-tab peer line: name plus depth/dist/eff columns and a
/// same-root/diff-root indicator, or a "(no position)" note for a peer with no
/// tree depth yet.
fn tree_peer_line(app: &App, my_root: &str, p: &serde_json::Value) -> Line<'static> {
let name = helpers::str_field(p, "display_name");
let has_depth = p.get("depth").is_some();
if !has_depth {
return Line::from(vec![
Span::styled(
format!(" {} ", helpers::truncate_name(name, 16)),
Style::default().fg(Color::DarkGray),
),
Span::styled("(no position)", Style::default().fg(Color::DarkGray)),
]);
}
let depth = helpers::u64_field(p, "depth");
let dist = helpers::u64_field(p, "distance_to_us");
let peer_root = helpers::str_field(p, "root");
let node_addr = helpers::str_field(p, "node_addr");
let eff = peer_effective_depth(app, node_addr);
let (root_ind, root_color) = if peer_root == my_root {
("same root", Color::Green)
} else {
("diff root", Color::Red)
};
Line::from(vec![
Span::styled(
format!(" {} ", helpers::truncate_name(name, 16)),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled("depth: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{depth:<4}")),
Span::styled("dist: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{dist:<4}")),
Span::styled("eff: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{eff:<7}")),
Span::styled(root_ind, Style::default().fg(root_color)),
])
}
+19 -9
View File
@@ -69,16 +69,18 @@ impl BloomFilter {
/// Insert a NodeAddr into the filter.
pub fn insert(&mut self, node_addr: &NodeAddr) {
let (h1, h2) = Self::base_hashes(node_addr.as_bytes());
for i in 0..self.hash_count {
let bit_index = self.hash(node_addr.as_bytes(), i);
let bit_index = self.bit_index(h1, h2, i);
self.set_bit(bit_index);
}
}
/// Insert raw bytes into the filter.
pub fn insert_bytes(&mut self, data: &[u8]) {
let (h1, h2) = Self::base_hashes(data);
for i in 0..self.hash_count {
let bit_index = self.hash(data, i);
let bit_index = self.bit_index(h1, h2, i);
self.set_bit(bit_index);
}
}
@@ -93,8 +95,9 @@ impl BloomFilter {
/// Check if the filter might contain raw bytes.
pub fn contains_bytes(&self, data: &[u8]) -> bool {
let (h1, h2) = Self::base_hashes(data);
for i in 0..self.hash_count {
let bit_index = self.hash(data, i);
let bit_index = self.bit_index(h1, h2, i);
if !self.get_bit(bit_index) {
return false;
}
@@ -196,21 +199,28 @@ impl BloomFilter {
self.hash_count
}
/// Compute a hash index for the given data and hash function number.
/// Compute the two base hashes for `data` with a single SHA-256 digest.
///
/// Uses double hashing: h(x,i) = (h1(x) + i*h2(x)) mod m
fn hash(&self, data: &[u8], k: u8) -> usize {
// Use first 16 bytes of SHA-256 for h1 and h2
/// Double hashing derives the k hash functions from two base hashes:
/// h(x,i) = (h1(x) + i*h2(x)) mod m. Computing the digest once here and
/// reusing `(h1, h2)` across all k functions avoids re-hashing per k.
fn base_hashes(data: &[u8]) -> (u64, u64) {
// Use first 16 bytes of SHA-256 for h1 and h2.
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(data);
let hash = hasher.finalize();
// h1 from first 8 bytes
// h1 from first 8 bytes, h2 from next 8 bytes (little-endian).
let h1 = u64::from_le_bytes(hash[0..8].try_into().unwrap());
// h2 from next 8 bytes
let h2 = u64::from_le_bytes(hash[8..16].try_into().unwrap());
(h1, h2)
}
/// Derive the bit index for hash function `k` from the base hashes.
///
/// Uses double hashing: h(x,k) = (h1(x) + k*h2(x)) mod m.
fn bit_index(&self, h1: u64, h2: u64, k: u8) -> usize {
let combined = h1.wrapping_add((k as u64).wrapping_mul(h2));
(combined as usize) % self.num_bits
}
+75 -7
View File
@@ -144,6 +144,16 @@ impl BloomState {
self.last_sent_filters.insert(peer_id, filter);
}
/// Read back the last outgoing filter actually sent to a peer, if any.
///
/// Returns the filter recorded by [`record_sent_filter`](Self::record_sent_filter)
/// — i.e. what the peer currently holds for us — or `None` when no announce
/// has been sent to that peer yet (or the node is root, with no parent to
/// send to).
pub fn last_sent_filter(&self, peer_id: &NodeAddr) -> Option<&BloomFilter> {
self.last_sent_filters.get(peer_id)
}
/// Remove stored filter state for a peer that was removed.
pub fn remove_peer_state(&mut self, peer_id: &NodeAddr) {
self.last_sent_filters.remove(peer_id);
@@ -162,21 +172,79 @@ impl BloomState {
peer_addrs: &[NodeAddr],
peer_filters: &HashMap<NodeAddr, BloomFilter>,
) {
for peer_addr in peer_addrs {
if peer_addr == exclude_from {
continue;
}
let new_filter = self.compute_outgoing_filter(peer_addr, peer_filters);
let changed = match self.last_sent_filters.get(peer_addr) {
let targets: Vec<NodeAddr> = peer_addrs
.iter()
.filter(|addr| *addr != exclude_from)
.copied()
.collect();
for (peer_addr, new_filter) in self.compute_outgoing_filters(&targets, peer_filters) {
let changed = match self.last_sent_filters.get(&peer_addr) {
Some(last) => *last != new_filter,
None => true, // never sent → must send
};
if changed {
self.pending_updates.insert(*peer_addr);
self.pending_updates.insert(peer_addr);
}
}
}
/// Compute the outgoing filter for many peers in one pass.
///
/// Equivalent to calling [`compute_outgoing_filter`](Self::compute_outgoing_filter)
/// once per target, but linear in the number of contributing peer
/// filters instead of quadratic. The per-peer call rebuilds the whole
/// union from scratch, so computing it for every peer costs
/// O(targets × filters) 1 KB merges; announce fan-out on a
/// large node does exactly that, once per tick and again on every
/// inbound announce.
///
/// The split-horizon exclusion is the only thing that differs between
/// targets, so the union of "everything except peer i" is assembled
/// from a running prefix union and a precomputed suffix union. Merging
/// is a bytewise OR, which is commutative and associative, so the
/// result is bit-identical to the per-peer computation.
pub fn compute_outgoing_filters(
&self,
targets: &[NodeAddr],
peer_filters: &HashMap<NodeAddr, BloomFilter>,
) -> HashMap<NodeAddr, BloomFilter> {
let base = self.base_filter();
let keys: Vec<NodeAddr> = peer_filters.keys().copied().collect();
let n = keys.len();
// suffix[i] = union of peer_filters[keys[i..]]; suffix[n] is empty.
let mut suffix = vec![BloomFilter::new(); n + 1];
for i in (0..n).rev() {
let mut acc = suffix[i + 1].clone();
// Size mismatches are skipped, exactly as in the per-peer path.
let _ = acc.merge(&peer_filters[&keys[i]]);
suffix[i] = acc;
}
// Filter for a target that contributes nothing: everything merged.
let mut all = base.clone();
let _ = all.merge(&suffix[0]);
let mut per_key: HashMap<NodeAddr, BloomFilter> = HashMap::with_capacity(n);
let mut prefix = BloomFilter::new();
for i in 0..n {
let mut outgoing = base.clone();
let _ = outgoing.merge(&prefix);
let _ = outgoing.merge(&suffix[i + 1]);
per_key.insert(keys[i], outgoing);
let _ = prefix.merge(&peer_filters[&keys[i]]);
}
targets
.iter()
.map(|target| {
let filter = per_key.get(target).cloned().unwrap_or_else(|| all.clone());
(*target, filter)
})
.collect()
}
/// Compute the outgoing filter for a specific peer.
///
/// The filter includes:
+145
View File
@@ -228,6 +228,84 @@ fn test_bloom_filter_insert_bytes_contains_bytes() {
assert!(filter.contains_bytes(data2));
}
#[test]
fn test_bloom_filter_bit_indices_match_double_hashing_formula() {
use sha2::{Digest, Sha256};
// Independently recompute the documented double-hashing bit indices:
// one SHA-256 digest of the input, h1 = bytes[0..8] LE, h2 = bytes[8..16]
// LE, then for k in 0..hash_count: (h1 + k*h2) mod num_bits. This pins
// bit-identical behavior regardless of the internal implementation.
fn expected_indices(data: &[u8], num_bits: usize, hash_count: u8) -> Vec<usize> {
let digest = Sha256::digest(data);
let h1 = u64::from_le_bytes(digest[0..8].try_into().unwrap());
let h2 = u64::from_le_bytes(digest[8..16].try_into().unwrap());
(0..hash_count)
.map(|k| {
let combined = h1.wrapping_add((k as u64).wrapping_mul(h2));
(combined as usize) % num_bits
})
.collect()
}
fn bit_is_set(filter: &BloomFilter, index: usize) -> bool {
let byte = filter.as_bytes()[index / 8];
(byte >> (index % 8)) & 1 == 1
}
let configs = [(1024usize, 5u8), (8192usize, 7u8)];
let inputs: [&[u8]; 4] = [b"", b"alpha", b"the quick brown fox", &[0u8, 1, 2, 3, 255]];
for (num_bits, hash_count) in configs {
for data in inputs {
let mut filter = BloomFilter::with_params(num_bits, hash_count).unwrap();
let expected = expected_indices(data, num_bits, hash_count);
filter.insert_bytes(data);
// Every expected bit is set.
for &idx in &expected {
assert!(
bit_is_set(&filter, idx),
"expected bit {} set for input {:?} (num_bits={}, k={})",
idx,
data,
num_bits,
hash_count
);
}
// No unexpected bits are set: the set-bit count never exceeds the
// number of distinct expected indices.
use std::collections::HashSet;
let distinct: HashSet<usize> = expected.iter().copied().collect();
assert_eq!(
filter.count_ones(),
distinct.len(),
"unexpected bits set for input {:?}",
data
);
// contains reports the inserted item as present.
assert!(filter.contains_bytes(data));
}
}
// NodeAddr path uses the same formula over its byte view.
let node = make_node_addr(7);
let mut filter = BloomFilter::with_params(1024, 5).unwrap();
let expected = expected_indices(node.as_bytes(), 1024, 5);
filter.insert(&node);
for &idx in &expected {
assert!(bit_is_set(&filter, idx));
}
assert!(filter.contains(&node));
// Spot-check a definitely-absent item is reported absent.
let absent = make_node_addr(200);
assert!(!filter.contains(&absent));
}
#[test]
fn test_bloom_filter_estimated_count_saturated() {
// Create a small filter with all bits set
@@ -606,3 +684,70 @@ fn test_bloom_state_mark_changed_peers_excludes_source() {
assert!(!state.needs_update(&peer1));
}
#[test]
fn test_compute_outgoing_filters_matches_per_peer() {
let node = make_node_addr(0);
let mut state = BloomState::new(node);
state.add_leaf_dependent(make_node_addr(200));
state.add_leaf_dependent(make_node_addr(201));
// Six contributing peers with overlapping content, plus one whose
// filter is a different size and must be skipped by both paths.
let mut peer_filters = HashMap::new();
for i in 1u8..=6 {
let mut filter = BloomFilter::new();
for j in 0..5u8 {
filter.insert(&make_node_addr(i.wrapping_mul(7).wrapping_add(j)));
}
peer_filters.insert(make_node_addr(i), filter);
}
let odd_peer = make_node_addr(7);
let mut odd = BloomFilter::with_params(4096, DEFAULT_HASH_COUNT).unwrap();
odd.insert(&make_node_addr(99));
peer_filters.insert(odd_peer, odd);
// Targets: every contributing peer, the odd-sized one, and two peers
// that contribute nothing (non-tree peers get announces too).
let mut targets: Vec<NodeAddr> = (1u8..=7).map(make_node_addr).collect();
targets.push(make_node_addr(120));
targets.push(make_node_addr(121));
let batch = state.compute_outgoing_filters(&targets, &peer_filters);
assert_eq!(batch.len(), targets.len());
for target in &targets {
let expected = state.compute_outgoing_filter(target, &peer_filters);
assert_eq!(
batch.get(target),
Some(&expected),
"batch filter for {:?} differs from per-peer computation",
target
);
}
// Split horizon is real, not vacuous: a contributing peer's own
// entries must be absent from its own outgoing filter, and present
// in another peer's.
let peer3 = make_node_addr(3);
let own_entry = make_node_addr(3u8.wrapping_mul(7));
assert!(!batch[&peer3].contains(&own_entry));
assert!(batch[&make_node_addr(1)].contains(&own_entry));
}
#[test]
fn test_compute_outgoing_filters_empty_inputs() {
let node = make_node_addr(0);
let state = BloomState::new(node);
let peer_filters = HashMap::new();
assert!(
state
.compute_outgoing_filters(&[], &peer_filters)
.is_empty()
);
let target = make_node_addr(1);
let batch = state.compute_outgoing_filters(&[target], &peer_filters);
assert_eq!(batch[&target], state.base_filter());
}
+131
View File
@@ -205,6 +205,40 @@ impl CoordCache {
self.entries.clear();
}
/// Drop entries whose cached destination ancestry contains the given
/// `NodeAddr`.
///
/// Used at parent-position-change sites: when our own position in the
/// tree changes, destinations downstream of us (whose cached coordinates
/// embed our previous prefix) have stale path information and must be
/// re-learned. Entries whose ancestry does not include `node_addr` are
/// unaffected by the local position change and are retained.
///
/// Returns the count of entries removed.
pub fn invalidate_via_node(&mut self, node_addr: &NodeAddr) -> usize {
let len_before = self.entries.len();
self.entries
.retain(|_, entry| !entry.coords().contains(node_addr));
len_before - self.entries.len()
}
/// Drop entries whose cached destination `root_id` differs from
/// `current_root`.
///
/// Used at root-change sites (become_root, root handover via
/// TreeAnnounce). `find_next_hop` returns `None` for any destination
/// whose root does not match the local root, so entries from a stale
/// root cannot route and would otherwise occupy cache slots until
/// TTL expiry.
///
/// Returns the count of entries removed.
pub fn invalidate_other_roots(&mut self, current_root: &NodeAddr) -> usize {
let len_before = self.entries.len();
self.entries
.retain(|_, entry| entry.coords().root_id() == current_root);
len_before - self.entries.len()
}
/// Evict one entry (expired first, then LRU).
fn evict_one(&mut self, current_time_ms: u64) {
// First try to evict an expired entry
@@ -507,4 +541,101 @@ mod tests {
assert_eq!(stats.expired, 0);
assert_eq!(stats.avg_age_ms, 0);
}
// ===== Surgical invalidation tests =====
#[test]
fn test_invalidate_via_node_at_self_depth() {
// Entry whose own NodeAddr (depth 0) is the invalidation target.
let mut cache = CoordCache::new(100, 1000);
let target = make_node_addr(1);
cache.insert(target, make_coords(&[1, 0]), 0);
assert_eq!(cache.len(), 1);
let removed = cache.invalidate_via_node(&target);
assert_eq!(removed, 1);
assert_eq!(cache.len(), 0);
}
#[test]
fn test_invalidate_via_node_interior() {
// Entry whose ancestry contains the target in the interior of the path.
let mut cache = CoordCache::new(100, 1000);
let dest = make_node_addr(5);
// Path: 5 -> 3 -> 1 -> 0 (root). Target 3 appears at depth 1.
cache.insert(dest, make_coords(&[5, 3, 1, 0]), 0);
let removed = cache.invalidate_via_node(&make_node_addr(3));
assert_eq!(removed, 1);
assert_eq!(cache.len(), 0);
}
#[test]
fn test_invalidate_via_node_absent() {
// Entry whose ancestry does NOT contain the target must be retained.
let mut cache = CoordCache::new(100, 1000);
let dest = make_node_addr(5);
cache.insert(dest, make_coords(&[5, 3, 1, 0]), 0);
let removed = cache.invalidate_via_node(&make_node_addr(99));
assert_eq!(removed, 0);
assert_eq!(cache.len(), 1);
assert!(cache.contains(&dest, 0));
}
#[test]
fn test_invalidate_via_node_empty_cache() {
let mut cache = CoordCache::new(100, 1000);
let removed = cache.invalidate_via_node(&make_node_addr(1));
assert_eq!(removed, 0);
assert_eq!(cache.len(), 0);
}
#[test]
fn test_invalidate_other_roots_current_root_kept() {
let mut cache = CoordCache::new(100, 1000);
// Entries rooted at addr(0)
cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0);
cache.insert(make_node_addr(2), make_coords(&[2, 0]), 0);
let removed = cache.invalidate_other_roots(&make_node_addr(0));
assert_eq!(removed, 0);
assert_eq!(cache.len(), 2);
}
#[test]
fn test_invalidate_other_roots_different_root_dropped() {
let mut cache = CoordCache::new(100, 1000);
// Three entries rooted at addr(0), one rooted at addr(9)
cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0);
cache.insert(make_node_addr(2), make_coords(&[2, 0]), 0);
cache.insert(make_node_addr(3), make_coords(&[3, 0]), 0);
cache.insert(make_node_addr(4), make_coords(&[4, 9]), 0);
let removed = cache.invalidate_other_roots(&make_node_addr(0));
assert_eq!(removed, 1);
assert_eq!(cache.len(), 3);
assert!(!cache.contains(&make_node_addr(4), 0));
assert!(cache.contains(&make_node_addr(1), 0));
}
#[test]
fn test_invalidate_other_roots_all_match() {
let mut cache = CoordCache::new(100, 1000);
cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0);
cache.insert(make_node_addr(2), make_coords(&[2, 0]), 0);
let removed = cache.invalidate_other_roots(&make_node_addr(0));
assert_eq!(removed, 0);
assert_eq!(cache.len(), 2);
}
#[test]
fn test_invalidate_other_roots_empty_cache() {
let mut cache = CoordCache::new(100, 1000);
let removed = cache.invalidate_other_roots(&make_node_addr(0));
assert_eq!(removed, 0);
assert_eq!(cache.len(), 0);
}
}
+109 -2
View File
@@ -24,6 +24,7 @@ mod node;
mod peer;
mod transport;
use crate::node::REKEY_JITTER_SECS;
use crate::upper::config::{DnsConfig, TunConfig};
use crate::{Identity, IdentityError};
use serde::{Deserialize, Serialize};
@@ -39,8 +40,8 @@ pub use node::{
};
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
pub use transport::{
BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
TransportsConfig, UdpConfig,
BleConfig, DirectoryServiceConfig, EthernetConfig, NymConfig, TcpConfig, TorConfig,
TransportInstances, TransportsConfig, UdpConfig,
};
/// Default config filename.
@@ -686,6 +687,32 @@ impl Config {
}
}
// Reject rekey triggers that fire immediately and forever. Both
// arms are checked regardless of `node.rekey.enabled` so that
// turning rekey on later cannot surface a config error at a
// surprising moment. There is deliberately no upper bound:
// u64::MAX is the idiom for disabling one arm of the trigger.
let rekey = &self.node.rekey;
if rekey.after_messages == 0 {
return Err(ConfigError::Validation(
"`node.rekey.after_messages` must be at least 1; 0 fires the message-count trigger on every poll instead of disabling it. \
Use a very large value to effectively disable the message-count trigger."
.to_string(),
));
}
let jitter_secs = REKEY_JITTER_SECS.unsigned_abs();
if rekey.after_secs <= jitter_secs {
return Err(ConfigError::Validation(format!(
"`node.rekey.after_secs` is {}, but must be greater than the per-session rekey jitter of {jitter_secs}s; \
each session offsets the interval by a random value in [-{jitter_secs}, +{jitter_secs}] seconds, so a smaller interval saturates to zero \
and rekeys on sight for roughly half of sessions. \
Use a very large value to effectively disable the timer trigger.",
rekey.after_secs
)));
}
Ok(())
}
@@ -1459,6 +1486,86 @@ peers:
.expect("outbound_only should be exempt from the loopback check");
}
#[test]
fn test_validate_default_rekey_settings_ok() {
Config::default()
.validate()
.expect("shipped default rekey settings must validate");
}
#[test]
fn test_validate_rekey_after_messages_zero_rejected() {
let mut config = Config::default();
config.node.rekey.after_messages = 0;
let err = config.validate().expect_err("validation should fail");
let msg = err.to_string();
assert!(msg.contains("after_messages"), "got: {msg}");
}
#[test]
fn test_validate_rekey_after_messages_one_accepted() {
let mut config = Config::default();
config.node.rekey.after_messages = 1;
config
.validate()
.expect("after_messages = 1 rekeys every message, which is wasteful but well defined");
}
#[test]
fn test_validate_rekey_after_secs_at_or_below_jitter_rejected() {
let jitter = REKEY_JITTER_SECS.unsigned_abs();
for after_secs in [0, 1, jitter - 1, jitter] {
let mut config = Config::default();
config.node.rekey.after_secs = after_secs;
match config.validate() {
Err(e) => assert!(e.to_string().contains("after_secs"), "got: {e}"),
Ok(()) => panic!("after_secs = {after_secs} should be rejected"),
}
}
}
#[test]
fn test_validate_rekey_after_secs_just_above_jitter_accepted() {
let mut config = Config::default();
config.node.rekey.after_secs = REKEY_JITTER_SECS.unsigned_abs() + 1;
config
.validate()
.expect("one second above the jitter bound leaves a non-zero effective interval");
}
#[test]
fn test_validate_rekey_unbounded_values_accepted() {
let mut config = Config::default();
config.node.rekey.after_secs = u64::MAX;
config.node.rekey.after_messages = u64::MAX;
config
.validate()
.expect("u64::MAX disables an arm of the trigger and must stay legal");
}
#[test]
fn test_validate_rekey_checked_even_when_disabled() {
let mut config = Config::default();
config.node.rekey.enabled = false;
config.node.rekey.after_messages = 0;
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("after_messages"));
let mut config = Config::default();
config.node.rekey.enabled = false;
config.node.rekey.after_secs = REKEY_JITTER_SECS.unsigned_abs();
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("after_secs"));
}
#[test]
fn test_outbound_only_forces_ephemeral_bind() {
let cfg = UdpConfig {
+19 -6
View File
@@ -219,6 +219,12 @@ pub struct DiscoveryConfig {
/// Nostr-mediated overlay endpoint discovery.
#[serde(default = "DiscoveryConfig::default_nostr")]
pub nostr: NostrDiscoveryConfig,
/// mDNS / DNS-SD peer discovery on the local link. Identity surface
/// is a strict subset of what `nostr.advertise` already publishes
/// publicly, so there's no marginal privacy cost; the latency win
/// for same-LAN peers is large (sub-second pairing, no relay).
#[serde(default = "DiscoveryConfig::default_lan")]
pub lan: crate::discovery::lan::LanDiscoveryConfig,
}
impl Default for DiscoveryConfig {
@@ -231,6 +237,7 @@ impl Default for DiscoveryConfig {
backoff_max_secs: 0,
forward_min_interval_secs: 2,
nostr: NostrDiscoveryConfig::default(),
lan: crate::discovery::lan::LanDiscoveryConfig::default(),
}
}
}
@@ -257,6 +264,9 @@ impl DiscoveryConfig {
fn default_nostr() -> NostrDiscoveryConfig {
NostrDiscoveryConfig::default()
}
fn default_lan() -> crate::discovery::lan::LanDiscoveryConfig {
crate::discovery::lan::LanDiscoveryConfig::default()
}
}
/// Nostr advert discovery policy.
@@ -625,9 +635,12 @@ pub struct BloomConfig {
pub update_debounce_ms: u64,
/// Antipoison cap: reject inbound FilterAnnounce whose FPR exceeds
/// this value (`node.bloom.max_inbound_fpr`). Valid range `(0.0, 1.0)`.
/// Default `0.05` ≈ fill 0.549 at k=5 ≈ ~3,200 entries on the 1KB
/// filter. Conceptually distinct from future autoscaling hysteresis
/// setpoints — same unit, different knobs.
/// Default `0.20` ≈ fill 0.7248 at k=5 ≈ ~2,114 entries on the 1 KB
/// filter (SwamidassBaldi). Raised from 0.10 so aggregates that are
/// legitimately near their operating ceiling are not rejected before
/// the network reaches the fixed-filter capacity limit; conceptually
/// distinct from future autoscaling hysteresis setpoints — same unit,
/// different knobs.
#[serde(default = "BloomConfig::default_max_inbound_fpr")]
pub max_inbound_fpr: f64,
}
@@ -635,8 +648,8 @@ pub struct BloomConfig {
impl Default for BloomConfig {
fn default() -> Self {
Self {
update_debounce_ms: 500,
max_inbound_fpr: 0.05,
update_debounce_ms: Self::default_update_debounce_ms(),
max_inbound_fpr: Self::default_max_inbound_fpr(),
}
}
}
@@ -646,7 +659,7 @@ impl BloomConfig {
500
}
fn default_max_inbound_fpr() -> f64 {
0.05
0.20
}
}
+29
View File
@@ -45,8 +45,28 @@ pub struct PeerAddress {
/// are tried first.
#[serde(default = "default_priority")]
pub priority: u8,
/// Wall-clock observation timestamp (Unix ms) for ranking by recency.
///
/// `None` means "no freshness signal", typically an operator-edited
/// static config. The dialer sorts candidates by this field descending
/// so freshly observed overlay or runtime hints can be tried before stale
/// static addresses. This field is runtime-only and is ignored when
/// comparing peer-address lists for config changes.
#[serde(default, skip_serializing_if = "Option::is_none", skip_deserializing)]
pub seen_at_ms: Option<u64>,
}
impl PartialEq for PeerAddress {
fn eq(&self, other: &Self) -> bool {
self.transport == other.transport
&& self.addr == other.addr
&& self.priority == other.priority
}
}
impl Eq for PeerAddress {}
fn default_priority() -> u8 {
100
}
@@ -62,6 +82,7 @@ impl PeerAddress {
transport: transport.into(),
addr: addr.into(),
priority: default_priority(),
seen_at_ms: None,
}
}
@@ -75,8 +96,16 @@ impl PeerAddress {
transport: transport.into(),
addr: addr.into(),
priority,
seen_at_ms: None,
}
}
/// Tag this address with a freshness timestamp for source-agnostic
/// candidate ranking.
pub fn with_seen_at_ms(mut self, seen_at_ms: u64) -> Self {
self.seen_at_ms = Some(seen_at_ms);
self
}
}
/// Configuration for a known peer.

Some files were not shown because too many files have changed in this diff Show More