144 Commits
Author SHA1 Message Date
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
231 changed files with 36321 additions and 3427 deletions
+23 -1
View File
@@ -1,2 +1,24 @@
[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.
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 }}"
+110 -22
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,6 +24,28 @@ 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.
#
# Granularity-only differences (same coverage, different matrix shape —
# NOT a divergence):
# deb-install — split here into per-distro legs (debian12/debian13/
# ubuntu22/ubuntu24/ubuntu26) for parallelism; local runs the
# same distro set in one suite.
# dns-resolver — single leg here; runs all scenarios (same as local).
# ─────────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# Job 1 Build matrix
#
@@ -30,24 +56,28 @@ jobs:
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 +102,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 +124,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 +159,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 +180,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 +189,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 +236,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 +271,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 +309,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,6 +361,10 @@ jobs:
- suite: rekey-outbound-only
type: rekey-outbound-only
topology: rekey-outbound-only
# ── Inbound max_peers admission-cap test ───────────────────────
- suite: admission-cap
type: admission-cap
topology: mesh
- suite: acl-allowlist
type: acl-allowlist
# ── Firewall baseline (fips0 nftables default-deny) ────────────
@@ -408,6 +454,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 +478,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
@@ -616,7 +668,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/
@@ -719,6 +771,42 @@ jobs:
docker compose -f testing/static/docker-compose.yml \
--profile gateway down --volumes --remove-orphans
# ── Inbound max_peers admission-cap integration test ────────────────
# Lowers node.max_peers on one mesh node and asserts the inbound cap
# holds under sustained retry pressure: denied peers keep retrying but
# are never promoted to an active session. The admission-cap-test.sh
# assertions are tailored per link-layer handshake variant; the leg
# itself is uniform. Static-style harness on the shared mesh profile.
- name: Generate configs (admission-cap)
if: matrix.type == 'admission-cap'
run: bash testing/static/scripts/generate-configs.sh mesh
- name: Inject admission-cap config (admission-cap)
if: matrix.type == 'admission-cap'
run: bash testing/static/scripts/admission-cap-test.sh inject-config
- name: Start containers (admission-cap)
if: matrix.type == 'admission-cap'
run: |
docker compose -f testing/static/docker-compose.yml \
--profile mesh up -d
- name: Run admission-cap test
if: matrix.type == 'admission-cap'
run: bash testing/static/scripts/admission-cap-test.sh
- name: Collect logs on failure (admission-cap)
if: matrix.type == 'admission-cap' && failure()
run: |
docker compose -f testing/static/docker-compose.yml \
--profile mesh logs --no-color | tail -300
- name: Stop containers (admission-cap)
if: matrix.type == 'admission-cap' && always()
run: |
docker compose -f testing/static/docker-compose.yml \
--profile mesh down --volumes --remove-orphans
# ── Real-deb install integration ────────────────────────────────────
# The deb-install harness builds its own .deb from source in a
# cargo-deb builder image; the pre-built Linux binary from the
+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
+790
View File
@@ -5,6 +5,796 @@ 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
### Fixed
## [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.0"
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.
+47 -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.0-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,14 @@ 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.0**. 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 builds on the v0.3.0 testing-and-polishing
track, adding 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 +235,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
+264 -707
View File
File diff suppressed because it is too large Load Diff
+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.10`); 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.10`). 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.10` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.10` corresponds to fill 0.631 at k=5 (≈1,630 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.10 # 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-21 (provisional)
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.
+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 -64
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")),
@@ -175,25 +213,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 +245,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)),
])
}
+10
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);
+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);
}
}
+2 -2
View File
@@ -39,8 +39,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.
+18 -5
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.10` ≈ fill 0.631 at k=5 ≈ ~1,630 entries on the 1 KB
/// filter (SwamidassBaldi). Raised from 0.05 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,
}
@@ -636,7 +649,7 @@ impl Default for BloomConfig {
fn default() -> Self {
Self {
update_debounce_ms: 500,
max_inbound_fpr: 0.05,
max_inbound_fpr: 0.10,
}
}
}
@@ -646,7 +659,7 @@ impl BloomConfig {
500
}
fn default_max_inbound_fpr() -> f64 {
0.05
0.10
}
}
+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.
+79
View File
@@ -801,6 +801,77 @@ impl BleConfig {
}
}
// ============================================================================
// Nym Transport Configuration
// ============================================================================
/// Default Nym SOCKS5 proxy address (nym-socks5-client).
const DEFAULT_NYM_SOCKS5_ADDR: &str = "127.0.0.1:1080";
/// Default Nym connect timeout in milliseconds (300s — Nym mixnet
/// SOCKS5 connections require multiple round-trips through 3 mix nodes
/// with timing obfuscation, which can take several minutes).
const DEFAULT_NYM_CONNECT_TIMEOUT_MS: u64 = 300_000;
/// Default Nym MTU (same as TCP).
const DEFAULT_NYM_MTU: u16 = 1400;
/// Default Nym startup timeout in seconds (time to wait for
/// nym-socks5-client to become ready before giving up).
const DEFAULT_NYM_STARTUP_TIMEOUT_SECS: u64 = 120;
/// Nym transport instance configuration.
///
/// Outbound-only connections through a nym-socks5-client SOCKS5 proxy.
/// The nym-socks5-client must be running separately (e.g., as a sidecar
/// process or container).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NymConfig {
/// SOCKS5 proxy address (host:port). Defaults to "127.0.0.1:1080".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub socks5_addr: Option<String>,
/// Outbound connect timeout in milliseconds. Defaults to 300000 (300s).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_timeout_ms: Option<u64>,
/// Default MTU for Nym connections. Defaults to 1400.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtu: Option<u16>,
/// Seconds to wait for nym-socks5-client to become ready at startup.
/// Defaults to 120.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup_timeout_secs: Option<u64>,
}
impl NymConfig {
/// Get the SOCKS5 proxy address. Default: "127.0.0.1:1080".
pub fn socks5_addr(&self) -> &str {
self.socks5_addr
.as_deref()
.unwrap_or(DEFAULT_NYM_SOCKS5_ADDR)
}
/// Get the connect timeout in milliseconds. Default: 300000.
pub fn connect_timeout_ms(&self) -> u64 {
self.connect_timeout_ms
.unwrap_or(DEFAULT_NYM_CONNECT_TIMEOUT_MS)
}
/// Get the default MTU. Default: 1400.
pub fn mtu(&self) -> u16 {
self.mtu.unwrap_or(DEFAULT_NYM_MTU)
}
/// Get the startup timeout in seconds. Default: 120.
pub fn startup_timeout_secs(&self) -> u64 {
self.startup_timeout_secs
.unwrap_or(DEFAULT_NYM_STARTUP_TIMEOUT_SECS)
}
}
// ============================================================================
// TransportsConfig
// ============================================================================
@@ -827,6 +898,10 @@ pub struct TransportsConfig {
#[serde(default, skip_serializing_if = "is_transport_empty")]
pub tor: TransportInstances<TorConfig>,
/// Nym transport instances.
#[serde(default, skip_serializing_if = "is_transport_empty")]
pub nym: TransportInstances<NymConfig>,
/// BLE transport instances.
#[serde(default, skip_serializing_if = "is_transport_empty")]
pub ble: TransportInstances<BleConfig>,
@@ -844,6 +919,7 @@ impl TransportsConfig {
&& self.ethernet.is_empty()
&& self.tcp.is_empty()
&& self.tor.is_empty()
&& self.nym.is_empty()
&& self.ble.is_empty()
}
@@ -863,6 +939,9 @@ impl TransportsConfig {
if !other.tor.is_empty() {
self.tor = other.tor;
}
if !other.nym.is_empty() {
self.nym = other.nym;
}
if !other.ble.is_empty() {
self.ble = other.ble;
}
+3 -3
View File
@@ -12,7 +12,7 @@ use tracing::debug;
pub async fn dispatch(node: &mut Node, command: &str, params: Option<&Value>) -> Response {
match command {
"connect" => connect(node, params).await,
"disconnect" => disconnect(node, params),
"disconnect" => disconnect(node, params).await,
_ => Response::error(format!("unknown command: {command}")),
}
}
@@ -49,7 +49,7 @@ async fn connect(node: &mut Node, params: Option<&Value>) -> Response {
/// Disconnect a peer.
///
/// Params: `{"npub": "npub1..."}`
fn disconnect(node: &mut Node, params: Option<&Value>) -> Response {
async fn disconnect(node: &mut Node, params: Option<&Value>) -> Response {
let Some(params) = params else {
return Response::error("missing params for disconnect");
};
@@ -61,7 +61,7 @@ fn disconnect(node: &mut Node, params: Option<&Value>) -> Response {
debug!(npub = %npub, "API disconnect requested");
match node.api_disconnect(npub) {
match node.api_disconnect(npub).await {
Ok(data) => Response::ok(data),
Err(msg) => Response::error(msg),
}
+2
View File
@@ -31,6 +31,8 @@
//! and the ICMPv6-echo-request accept are not classified — they
//! don't pertain to listening TCP/UDP ports the operator binds.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use serde_json::Value;
use crate::control::listening::Proto;
+3 -1
View File
@@ -15,7 +15,9 @@
//! See `docs/design/fips-security.md` for the operator-side narrative
//! that motivates the panel.
use std::net::{IpAddr, Ipv6Addr};
#[cfg(target_os = "linux")]
use std::net::IpAddr;
use std::net::Ipv6Addr;
/// Transport protocol of a listening socket.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

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