Files
fips/RELEASE-NOTES.md
T
Johnathan Corgan 6a564e26ac Prepare the v0.5.0 release content
Everything the release needs except the version number, which stays at
0.5.0-dev until the tag.

The changelog entry covers only the work that is new on this line. The
point release's forty-six entries arrived under their own heading with the
forward merge and are left alone; the twenty that remained are regrouped by
topic and eight more added for changes no entry covered. Three of those
eight matter to someone upgrading. Five root modules and four re-exports
left the public library surface and Node::connections narrowed, none of it
recorded anywhere; the entry names what to use instead and distinguishes
the removed connection-phase enum from the Noise type of the same name,
which is a different type that still exists. Tracing targets moved, so an
existing RUST_LOG filter stops matching rather than erroring. And the
handshake resend interval key no longer governs the first resend, which is
now a constant, though it still governs later ones.

Seven more entries cover the work that landed after the first content pass
was written: the experimental native datagram API, the fipsctl probe
diagnostic, per-instance transport addressing, the app-owned UDP socket
seam, and the connect, disconnect and path-MTU fixes. The four bug fixes
among them all reach the deployed line, so the release notes no longer
claim this release carries exactly one fix for a shipped bug; it carries
four.

There is no security section, because after the split every security entry
belongs to the point release. The release notes say so plainly rather than
leaving a reader upgrading across both releases to conclude this one
carries no security work.

The notes are organized by audience, since the release spans OpenWrt
routers, embedders, FreeBSD, and the existing platforms, and a single list
serves none of them. The native datagram API is given a section of its own
rather than folded into the embedding seam: it is a client-facing API
rather than a way to host a node, and its one rule with no Berkeley-socket
counterpart, that the v1 wire carries no half-close, needs to be somewhere
a client author will read it. FreeBSD is advertised as supported on x86_64
only, stated wherever the platform appears. Android is advertised as an
embedding seam and not as a supported platform: a compile-gated library
surface with no artifact and no host application guide.

The configuration table rename is carried through every shipped file that
taught the old spelling: nine documentation files, the OpenWrt sample
config and a test generator, twenty-two sites in all. Guides written this
same cycle were among them, which is how the omission was found. The
documentation that arrived with the native API was checked for the same
omission and was already clean. The compatibility tests keep the old
spelling deliberately, since they exist to test the fold.

The changelog section is the fold of master's [Unreleased], not a snapshot
of it. An earlier version of this commit took a copy that then drifted, so
each section ended up holding a bullet the other did not and re-folding
them would have picked a winner silently. Both causes were fixed on master
instead — the NixOS module had never been recorded there, and the
pre-release batch of fixes was new — so [Unreleased] is a strict superset
and this is a copy rather than a merge. [0.5.0] carries all forty-six
bullets byte for byte, [Unreleased] is empty, and [0.4.2] is untouched,
checked by hashing it against master's copy.

The BLE work landed after the content pass and gets one summary entry in
the changelog and one section in the release notes rather than nine
bullets: the ble_available gate replacing target_os = "linux",
packet-boundary recovery for stream-oriented backends, peer recognition by
node identity instead of a rotating link address, the L2CAP PSM moving
into the backend seam and onto the advertisement, the embedder-supplied
Android radio, bounded probe retry, and inbound handshakes moved off the
accept loop.

The two release-notes copies no longer share their link paths. Relative
links resolve from one directory only, so the seven written for
docs/releases/ all 404ed from the root copy. The root copy now uses paths
from the repository root and the versioned copy keeps the ../ form; both
sets were resolved against the tree. The same two links are broken the
same way in the v0.4.0 through v0.4.2 notes, left as shipped history.

The contributor tallies are re-derived against maint..HEAD rather than
adjusted: twenty commits from outside the project and 171 from me, with
Arjen at fifteen and fr34aky at two. An earlier count of twelve and 138
was carried from a measurement taken three days before this content was
written, and the BLE branch widened the gap after it. Arjen's NixOS flake
module, the UDP sin6_scope_id fix and most of the BLE rework were
uncredited, as was fr34aky's L2CAP PSM seam. They want one last re-derive
at tag time if anything lands before the tag.

A sweep of all 99 tracked markdown files against the tree corrected
fifty-three of them. Four told the reader to run a build.sh that does not
exist; the only harness builder is testing/scripts/build.sh. The BLE build
prerequisites were described as optional on the strength of a probe that
build.rs does not perform, and bluez was named a build prerequisite when
libdbus-sys asks only for libdbus-1-dev and pkg-config and bluez is the
runtime daemon. Link cost is the primary sort key in next-hop ranking, not
reserved for future use; Ethernet runs on macOS as well as Linux; the BLE
MTU is the L2CAP CoC MTU rather than a negotiated ATT_MTU; effective
Ethernet MTU is 1497; the LAN discovery subsystem is src/mdns and eight
citations still named a src/discovery that never existed here. The
connectivity states in three tutorials were invented, and their jq filters
matched nothing including healthy peers. One command filtered on a literal
fd97: address prefix, which only the first byte of fixes, so it returned
empty for all but one reader in 256 and every later step using the
variable failed silently. transports.tor.advertise_on_nostr was
undocumented despite being validated against node.rendezvous.nostr.enabled.

The transport design document gains the BLE section it never had, written
from the source: the backend cascade and its compile_error tripwire, the
platform gate, the PSM advertisement wire layout and the byte budget that
forces a 16-bit service-data key, and the probe and admission bounds.

Three source files carried the same class of staleness and are corrected
with the documentation: the OpenWrt ipk usage line and Makefile error text
both named a packaging/openwrt that does not exist, and chaos.sh parsed
--subnet without listing it.

Folded in with the content commit, having been prepared alongside it:

The three GitHub Action pins that had gone stale. Every third-party
action is pinned to a commit SHA, nothing reports that a pin has aged,
and re-resolving all ten against their tags found dorny/test-reporter@v2,
taiki-e/install-action@v2 and vmactions/freebsd-vm@v1 had moved. The
three install-action@nextest references stay unpinned, since that action
reads the tool to install from the ref name. check-action-pins.sh passes
at 75 references and all nine workflow files parse.

The lockfile refresh, which is the mutating half of the dependency sweep.
Thirty-six packages move to their latest semver-compatible versions and
every one is transitive; nothing declared in Cargo.toml changes version.
No advisory forces any of them. It was taken before the validation
battery, because a gate run against a lockfile that later moves proves
nothing about what ships.

The sha2 0.10 to 0.11, hkdf 0.12 to 0.13 and bech32 0.11 to 0.12 majors,
three of the four deferred at v0.4.0 for change surface rather than
security. All three land with no source change. sha2 and hkdf must move
together, since both depend on digest 0.11, and neither changes an
algorithm. That matters because the chaining-key KDF in the Noise
handshake is built on Hkdf::<Sha256>, where an output change would be a
wire break rather than a compile error; no known-answer vectors exist for
that path, so the wire-compatibility gate is what covers it. secp256k1
0.31 is deliberately absent, since nostr's own requirement would leave
two copies of the ECC library in the tree.

The README support matrix, rebuilt as one feature table broken out by
Linux variety. A single Linux column hid that Debian, Ubuntu, Arch and
NixOS are one glibc build differing in packaging, that OpenWrt is musl
and drops BLE, and that Android is not a daemon platform. Transport rows
sort by how many platforms carry them. A Native API row reads its
platform set from the cfg gates. The installer row becomes a package
format row naming the artifact, and only the .deb is exercised per
release.

Four changelog and release-note gaps the BLE re-walk found: a Bluetooth
LE bullet stranded inside the released 0.4.2 section, a missing Fixed
entry for the scan and probe loop counting a pool-refused connection as
an established link, the unnamed embedder call that installs an
application-owned radio, and the fact that stopping the transport now
stops scanning as well as advertising.

Three release-document gaps found walking the unsurveyed commits: the UDP
reuse-flag fix stated in the direction opposite to the one it was made,
with the silent second-daemon bind it prevents left unsaid; the corrected
native-API socket paragraph carried into both release-note copies, which
still named SOCK_SEQPACKET on FreeBSD and two kernels where three are
handled; and the coordinate-cache hardening, which shipped with no text
anywhere despite adding four operator-visible status fields. That last
entry states plainly that the checks are mitigations and not a closure,
since the coordinate is still not authenticated.

Also folded in, the documentation pass that followed the content commit:

A stage-pipeline diagram for the probe, embedded in the fipsctl
reference under the five-stage list. It draws the five stages left to
right with each stage's failure reasons below it, and the bypass that
skips both lookup stages when the coordinates are cached or the target
is a direct peer. Its branches come from the probe state machine rather
than from the report, so the path stage is drawn as the one failure that
does not stop the probe.

A rewrite of the README's "What FIPS does" section. It now opens with
what a machine running FIPS gets, rather than with the two deployment
modes, and gives the self-organizing and permissionless property its own
paragraph since it holds for both modes.

A regrouping of the README's feature list into the mesh, getting traffic
onto it, and running a node, with a bullet added for the native datagram
API, which had none despite sitting in the support matrix. The Quick
start now leads with the released packages rather than a source build.
It also fixes a real defect: the package enables fips.service and
fips-dns.service and starts neither on a fresh install, so .fips name
resolution was silently dead until the next reboot and neither page said
to start the service.

A rewrite of the release notes. They opened with seven subsections of
upgrade caveats and reached the first feature two hundred lines in; they
now open with a summary of the release and elaborate below it in the
same order. Android is stated as supported through an embedded crate
rather than as a standalone daemon, consistently across all three
documents. The OpenWrt pair is corrected: it is 802.11s between routers
with FIPS supplying encryption, authentication and routing, plus a
convention of an open !FIPS SSID a client joins over WiFi, not meshing
over a router's own radios. The probe's path output is described as the
least-common-ancestor walk, which is the worst-case fallback route
rather than the route a packet takes. Detail that did not change what a
reader does was cut from the notes and kept in the changelog.
2026-08-30 10:42:59 +00:00

40 KiB

FIPS v0.5.0

Released: 2026-08-23 (provisional: the date is confirmed when the release is tagged)

v0.5.0 is a feature release, the first since v0.4.0. It adds two new supported platforms, a new way for applications to use the mesh, and a set of smaller additions across transports, diagnostics and packaging. It also renames part of the configuration surface, carries four fixes for bugs that shipped in earlier releases, and adds four security fixes specific to this line.

Upgrading. Nodes upgrade in any order. A v0.5.0 node and a v0.4.x node peer, rekey and route normally, so there is no flag day and no coordination. If you are coming from v0.4.1 or earlier, read the v0.4.2 notes too: v0.5.0 contains that release, and most of this cycle's security work is in it.

Platform support

A port to FreeBSD, on x86_64, has been made, with a native package. The daemon, fipsctl and fipstop build and run there; fips-gateway stays Linux-only. One thing this paves the way for is to create FIPS native support in FreeBSD-based firewall appliances, such as OPNsense and pfSense.

Android support is officially provided in this release, but for use as an embedded crate in other applications rather than as a standalone daemon. Android applications own the TUN adapter, and the FIPS crate provides a Rust API to send and receive packets over the mesh. The Bluetooth LE transport is now supported on Android, and is interoperable with Linux.

Native API for datagrams

The FIPS mesh sends datagrams between cryptographic endpoints, and the main way this has been used up until now has been to emulate an IPv6 network adapter and tunnel IPv6 packets over it. This has allowed existing IPv6 applications to use the mesh without any changes.

The native datagram API lets an application written to it communicate directly over the mesh, addressing a peer by public key and exchanging datagrams on a file descriptor. It bypasses the local IP stack and the emulated IPv6 adapter entirely. How nodes peer with each other does not change, and nothing on the wire changes.

Other changes

OpenWrt gains an 802.11s mesh between routers and the open !FIPS client SSID, both opt-in. The Bluetooth LE transport is refactored so Linux and Android share one implementation with a backend for each. A peer address may name which instance of a transport it belongs to, so a node running several listeners of one type can be dialled on the right one.

fipsctl gains two subcommands: probe, which reports in five stages whether one target is reachable and where the attempt stopped, and address, which derives a node's mesh address from a key file with no daemon running. Shutdown now drains before it closes, and an optional profiler measures where the maintenance tick spends its time.

The Nix flake gains a NixOS module and an overlay, so a flake consumer enables the daemon with one line rather than hand-rolling a systemd unit.

FreeBSD details

The .pkg is published on the release page. No aarch64 artifact is produced and that combination is not verified here.

FreeBSD follows the macOS install layout: /usr/local/etc/fips for config, hosts, peers.allow, peers.deny and fipsctl keygen output, and /var/run/fips for the control socket. Packaging under packaging/freebsd/ builds through make freebsd and ships rc.d services, a fips control-socket group, service stop and restart across pkg upgrade, and .fips DNS integration for local_unbound, unbound and dnsmasq. mDNS LAN discovery works, by way of an mdns-sd bump to 0.20 that picks up the first socket-pktinfo release building on FreeBSD.

Two changes here reach every platform. That mdns-sd bump applies to all targets, and the daemon now disables ANSI color in its logs when stdout is not a terminal.

Contributed by @fr34aky (#129).

The native datagram API

The API is off by default and its surface may still change, so it ships for client authors to build against and report back on.

The wire needs no change and gets none. Every FSP data packet has carried a port pair inside its AEAD envelope since v0.2.0, and port 256 is simply the IPv6 shim, so what was missing was a way for a program to ask for a port of its own and be handed the traffic.

The x-only public key is the address, and an npub is that key written in bech32, so converting between them is a local encoding rather than a lookup or a name service. The 16-byte node address that travels on the wire is a truncated hash of the key, does not invert, and appears nowhere a client can see.

The API is a direct, best-effort interface to FSP, the session protocol the mesh already speaks. FSP authenticates and encrypts end to end, and it delivers datagrams on a best-effort basis: no acknowledgement, no retransmission, no ordering guarantee and no flow control between the two ends. A program that needs any of those builds them itself, or runs an ordinary reliable protocol over the fips0 adapter instead.

The interface style is deliberately close to Berkeley sockets. A program calls connect for a flow to a public key and a port, or bind for a port to receive flows on, and from there uses ordinary socket calls on the descriptor it holds.

A listener is a descriptor. The daemon writes one message per arrival to it, carrying the new flow's descriptor and the peer's address, so poll, select and epoll work on a listener and accepting is a recvmsg. There is no accept command and no reject command: refusing a flow is closing the descriptor you were handed.

The Rust surface mirrors std::net, with FipsStream::connect, FipsListener::bind, incoming, accept, io::Result and an errno mapping rather than a bespoke error type, plus set_nonblocking, AsFd and four deadline methods under the names and signatures std::net uses for the same jobs.

One rule has no counterpart in Berkeley sockets and a client author must know it: the v1 wire carries no half-close. Nothing peer-driven ever closes a flow, so a server written to read until the flow ends waits for a signal that cannot arrive.

The listener uses SOCK_SEQPACKET on Linux and SOCK_DGRAM on macOS and FreeBSD; both keep the message boundaries the API's contract with its clients rests on. macOS does not implement SOCK_SEQPACKET for AF_UNIX at all. FreeBSD accepts the constant and returns a socket that is not an atomic-record socket, so consecutive messages coalesce and a zero-length message is dropped rather than delivered; both were measured on the FreeBSD 15.1 image rather than reasoned about. The three kernels signal a closed peer differently and were measured too, so the receive path treats a Darwin or FreeBSD ECONNRESET as end of file alongside the POLLHUP and zero-byte read that Linux gives.

Contributed by @jmcorgan (Johnathan Corgan).

Start with the walkthrough, then how to write a client; the reference carries the full surface.

OpenWrt

Two capabilities land together here, and the second is stacked on the first. Both arrive as opt-in helpers: a package must not commandeer a router's radios on install, so neither runs unless you run it.

802.11s mesh backhaul

FIPS can now be the encryption and routing layer over router-to-router radio links. The mesh runs open, with mesh_fwding 0: SAE would duplicate the Noise layer and force ath10k into raw mode, and the FIPS spanning tree is the routing layer, so the 802.11s link is deliberately left as a bare L2 neighbor link and FIPS provides all encryption, authentication and routing over it.

fips-mesh-setup is a UCI helper that creates a mesh point per radio: radio0 becomes fips-mesh0, radio1 becomes fips-mesh1, with a free-index fallback and a collision guard. A dual-band router gets one instance per radio, and FIPS treats the two paths as failover rather than multipath: cross-connection resolution keeps one active link per peer and the second band stands by, re-establishing after keepalive timeout.

The shipped fips.yaml carries the matching mesh0 and mesh1 Ethernet-transport entries commented out, so a stock install that never creates the interfaces logs no per-boot interface-missing warning. The helper uncomments the block when it creates the interface and re-comments it on remove. Two silent non-peering causes found in the field are surfaced by the helper's warnings: a radio left on auto channel, and a sta interface dragging the radio to its upstream access point's channel.

Contributed by @Origami74 (#123). The full procedure is in the 802.11s backhaul how-to.

The open !FIPS access SSID

Stacked on that backhaul, every FIPS router can broadcast the same open !FIPS SSID, forming one standard ESS that phones and laptops save once and roam between natively, with the Noise IK handshake as the only security layer. The leading ! sorts it to the top of alphabetically ordered network pickers. The encryption type must be uniform across routers or clients treat the ESS as separate saved networks.

fips-ap-setup creates the fips-ap0 open access point on an isolated network. IPv6 is a static ULA /64 announced by router advertisement, so addressing is stateless SLAAC with DHCPv6 off; IPv4 is a DHCPv4 lease out of a fixed 10.21.<N>.0/24, deliberately identical on every router so a roaming client's lease stays valid. Both sit behind a locked-down fips_ap firewall zone with no path to br-lan or the WAN, passing only DHCPv4, ICMPv6, mDNS and the FIPS transports. There is no internet by design, so phones keep cellular as their default route, and the addressing is what an Android client's connectivity check needs to stay associated. The helper also uncomments the node.rendezvous.lan block, since a phone app cannot open raw Ethernet sockets and DNS-SD is how it finds the daemon.

Contributed by @Origami74 (#126). The full procedure is in the open access SSID how-to.

Bluetooth LE peering

The BLE transport has been refactored so the code common to Linux and Android is implemented once, with a separate backend for each platform. Most of the work is contributed. The transport compiles on every glibc Linux target and on Android, and is excluded on musl; the gate is ble_available, and a platform with no concrete backend now fails the build rather than compiling a transport that starts, reports itself up and never peers.

A peer is recognised by node identity rather than by its link address. Resolvable private addresses rotate continually and modern phones use them by default, so every rotation presented as a brand-new device and none of the already-connected guards could tell.

The L2CAP PSM is now decided by the backend. BlueZ is the exception in letting an application choose the PSM it binds: Android and macOS both return an OS-assigned one that cannot be requested, and before a connection exists there is no channel on which to be told it. So listen reports the PSM it actually bound, the advertisement carries it alongside the 128-bit FIPS service UUID, and a dialer takes it from the scan, falling back to the configured value for a peer that advertises none.

Probe retry is bounded. A discovered address that failed to connect was re-dialled every cooldown for the life of the process, and because BLE hardware caps concurrent connections at roughly four to ten, a handful of unreachable peers starved discovery of everything behind them. Failing addresses now back off by powers of two and the retry book itself is capped, so rotating private addresses cannot grow it without bound. Each connect outcome has its own counter and structured log line carrying the role, the outcome, the PSM dialled and how long the peer took to conclude, which is what distinguishes a peer out of range from one being dialled at the wrong PSM.

Inbound handshakes run off the accept loop, eight in flight, aborting the oldest at the bound. The exchange previously ran inline, so a peer that connected and then said nothing held the loop for the full 5-second deadline and the effective inbound concurrency was one.

Node and transport control

Shutdown now drains before it closes

On the shutdown signal the node broadcasts Disconnect to all peers and then keeps serving for a bounded window, exiting early once all peers are gone. The window is the new node.drain_timeout_secs, default 2 seconds. Teardown was previously immediate.

Under systemd or launchd this shows up as a stop taking up to two seconds longer than it used to. If your service manager has a short stop timeout, or you have tooling that expects the process to be gone immediately, that is the thing to check. Setting node.drain_timeout_secs: 0 restores the old behaviour. The immediate stop path used by non-daemon callers is unchanged.

A peer address may name a transport instance

A peer address may name which instance of a transport it belongs to, as transport: "udp/aware", where the part after the slash is the key the transport was configured under. A node running several instances of one type could not be told them apart by a dialer: both bind wildcard sockets, so the address-family test matched either and selection fell through to the lowest transport id. One socket carried every dial and the other never carried traffic. A bare type is unqualified and matches any instance, which is what every existing configuration and caller produces, so nothing changes for a node that does not use the syntax. A qualified name is never substituted with a different instance, since that is the wrong-lane dial the syntax exists to prevent, and the configuration validator rejects a name that no configured transport answers to rather than letting the address be skipped invisibly at every dial.

Diagnostics

fipsctl probe

For one target, where it sits in the spanning tree relative to this node and whether this node can actually reach it. Five stages report separately, bloom, discovery, path, session and rtt, because one verdict covering several findings is what sends an operator to the source: "no peer's filter claims this address" says the mesh has never heard of the target, while "a filter claimed it and nothing answered" says the opposite. The probe opens an FSP session, waits for one MMP receiver report to yield a round-trip time, and tears down only what it opened; a session that existed before the probe started is left alone. The path it prints is the least-common-ancestor walk computed from the two sets of coordinates. That is the worst-case fallback route, not necessarily the route a packet takes: a cut-through between peers can deliver in fewer hops, so the tree distance is an upper bound. Nothing here changes the wire format. --json emits exactly one document at the end, so a script parsing the report does not have to skip past progress output.

fipsctl address

fipsctl address [npub|hostname] prints a node's fd00::/8 mesh address and nothing else, without contacting the daemon. With no argument it derives the local node's address from fips.key in the default key directory, falling back to the world-readable fips.pub beside it; --key PATH names a key or public key file elsewhere. This lets an installer or an image build write a mesh address into a config file at a point where no node is running and none can be, and keeps the derivation in one place rather than reimplemented by whatever needs it.

Maintenance tick profiling

The rx-loop tick arm runs twenty-five unconditional housekeeping steps on one runtime thread and is polled last, so anything slow in it holds inbound packets, TUN traffic and control commands behind it. A new tick-body profiler measures that, on a live node, with no restart.

It lives behind the new profiling Cargo feature and is off by default. With the feature off, the instrumentation macro is a pure pass-through, so a default build carries no timing code on the tick path. With it on, fipsctl profile tick on [--dir PATH], off and status start and stop a capture at runtime. Each capture writes one tab-separated file, by default under /var/log/fips and capped at 32 MB, carrying per ten-second interval the exact count, max and total for every step, the whole-tick span, and gauges for ticks, peer count, the gap between successive tick-arm entries and the resulting arm-starvation delay.

Getting an instrumented build installed is supported directly: packaging/debian/build-deb.sh --features <list> builds the .deb with a Cargo feature list, and the auto-derived dev Version gains a matching +<features> marker so a feature build and a default build of the same commit are no longer indistinguishable. The marker sorts above the unmarked build, so installing a feature build is an upgrade and reverting to the default build is a downgrade: revert with dpkg -i, not apt install. The packaged systemd units gained LogsDirectory=fips so the capture directory is created and cleaned up declaratively.

Packaging and deployment

The NixOS module and overlay

The flake now exposes a NixOS module and an overlay, so a flake consumer enables the daemon with one line instead of hand-rolling a systemd unit. overlays.default adds pkgs.fips; nixosModules.default provides services.fips.* with enable, package, configFile, openFirewall (UDP 2121 and TCP 8443) and dns.enable, which routes .fips to [::1]:5354 through systemd-resolved declaratively rather than with setup and teardown scripts. packaging/nixos/README.md carries a full consumer flake.nix. Contributed by @Origami74 (Arjen).

For app and embedding developers

FIPS can now be embedded in an application, and that is how Android is supported: as an embedded crate rather than as a standalone daemon. There is no Android daemon artifact and no host-app integration guide. What ships is a library surface that compiles for Android and a pair of entry points for an app that owns its own tunnel.

The daemon's desktop transports and TUN operations are now gated by target_os rather than by Cargo features, so a plain cargo build compiles for every target with no flags, and Android self-excludes the raw Ethernet transport exactly as Windows already did. No Cargo features are introduced and desktop builds are unchanged.

Node::enable_app_owned_tun() gives an embedder that owns the TUN file descriptor, an Android VpnService for instance, a channel pair for exchanging IPv6 packet bytes with FIPS, instead of FIPS creating a system TUN device. start() then performs no system-TUN and no CAP_NET_ADMIN operations. Packets entering this way bypass handle_tun_packet, so the embedder must push only fd00::/8 destined packets and must clamp TCP MSS on outbound SYNs.

Node::dns_local_addr() is the DNS companion. An embedder whose resolver is pointed into the tunnel has no system socket aimed at the built-in .fips responder, so the accessor reports the address read back off the bound socket: dns.port = 0 therefore yields the kernel-assigned port. It returns Some only while the responder is up. Read it once, after start() returns and before the node is moved into a background task; it is not a liveness feed.

Both contributed by @Origami74 (#127, #136). CI cross-compiles the library for aarch64-linux-android and runs clippy against it, which is a compile gate. Nothing executes on Android in CI.

Node::enable_app_owned_udp_fd() is a third such entry point. Some hosts associate a socket with one interface or network and steer inbound traffic by that association rather than by destination address, and the socket option that corrects it depends on host state FIPS has no basis to reason about, so the descriptor goes to whoever does. One descriptor arrives per UDP transport that binds, labelled with the instance name it was configured under, so an embedder running several listeners can tell them apart. FIPS keeps owning the socket. Unix only, since the Windows UDP backend has no descriptor.

Upgrade notes

This section is the operator-actionable list. Everything in it applies to every platform.

A node with no working transport now fails to start

Node health is determined once startup completes, instead of every node unconditionally reaching a single running state. Zero transports up is now fatal: the node tears down cleanly and the daemon exits with an error. Previously such a node came up, reported itself running, and served nothing.

fipsctl show transports

On the running v0.4.x node, that lists every transport instance with its state. If it lists none, or lists none in an up state, that node will fail to start on v0.5.0 and the fix is a working transport, not a rollback. The common causes are a transports: block where every entry is commented out, and an Ethernet transport naming an interface that does not exist on the box, which logs an interface-missing warning and does not come up.

A node with at least one transport up, and some other configured child that failed, comes up degraded and serving, with a warning naming what failed. That covers a second or later transport, Nostr, mDNS, TUN, DNS, and the worker pools. A child you never asked the node to run does not count against it.

Three new node states are visible through the control socket

Degraded, Failed and Draining join the published node state and show up in control queries. Degraded is operational, Failed is not. If you have a monitor that matches the node state string exactly, teach it the three new values before you upgrade.

Exit detection also re-evaluates health at runtime for the DNS task, the two TUN threads and mDNS, so a child that dies after a healthy start now shows as degraded rather than staying green.

The node.discovery.* config table is split

node.discovery.* carried two unrelated things: the scalars that govern mesh lookup, and the settings that govern peer rendezvous. They are now separate tables.

  • node.lookup.* takes the mesh-lookup scalars: ttl, attempt_timeouts_secs, recent_expiry_secs, backoff_base_secs, backoff_max_secs, forward_min_interval_secs.
  • node.rendezvous.* takes peer rendezvous: nostr.* and lan.*.

A deployed node.discovery: block still loads. It is folded into the new tables at startup and behaves identically, with a one-time deprecation warning on the fips::config target naming the moves. The legacy block will be removed at the v2 cutover, so migrate your fips.yaml rather than leaving it.

Two of these keys are ones you may have adopted only one release ago. If you are coming from v0.4.2, then node.discovery.nostr.max_concurrent_offers_per_npub and node.discovery.nostr.signal_ttl_secs are now node.rendezvous.nostr.max_concurrent_offers_per_npub and node.rendezvous.nostr.signal_ttl_secs.

One further rename, in the same vocabulary: the Ethernet transport's per-interface discovery flag is now listen, pairing with the existing announce flag as receive and transmit. The old discovery: key is still accepted through a serde alias, so deployed configs load unchanged, but a config the daemon re-emits will carry listen:.

Every shipped sample, guide and reference now teaches the new spelling. One exception is worth knowing about on OpenWrt: /etc/fips/fips.yaml is an opkg conffile there, so upgrading a router keeps its existing copy and the new sample is never installed. A router upgraded from an earlier release will still show the old commented discovery: examples in its config file. Nothing breaks, since the old key parses, but the file on the router is not the file in the package.

Tracing targets moved, so RUST_LOG filters go blind rather than error

The internal restructuring moved modules, and tracing targets follow module paths, so the targets moved with them:

  • fips::discovery::nostr::* is now fips::nostr::*
  • mDNS is now fips::mdns::*
  • fips::tree is now fips::proto::stp
  • fips::bloom is now fips::proto::bloom
  • fips::protocol is now fips::proto::*
  • the mesh-lookup subsystem moves from fips::discovery to fips::proto::lookup

An existing RUST_LOG filter naming an old target still parses. It simply stops matching. The symptom is missing log lines rather than an error, and a filter that has gone blind looks exactly like a subsystem that has gone quiet, so update RUST_LOG settings, journal-watch recipes and log-scraping alerts as part of the upgrade. Four targets are named explicitly in the source rather than derived from a module path and are unaffected: fips::config, fips::instr, fips::node::handlers::handshake and fips::node::handlers::rekey.

The discovery metric family is now lookup

The mesh-lookup control-metrics family is emitted under the key lookup in fipsctl stats metrics and show routing. The former discovery key is still emitted as a deprecated alias carrying identical counters during the migration window, and will be removed. Point dashboards and alerts at lookup.*.

fipstop's Routing State pane follows: its Discovery Requests and Discovery Responses sections are now Lookup Requests and Lookup Responses. The counters are unchanged, so an operator who knows the pane by its old labels is reading the same numbers under new names.

The first handshake resend no longer follows its config key

node.rate_limit.handshake_resend_interval_ms no longer governs the first outbound handshake resend, which is now armed from a hardcoded 1000 ms constant in the peer state machine. The key still governs the second and later resends, alongside node.rate_limit.handshake_resend_backoff and node.rate_limit.handshake_max_resends. The constant equals the shipped default of 1000, so a deployment that never overrode the key sees no change. A deployment that raised or lowered it will find the first resend still firing at 1000 ms.

For library consumers

These changes are source-breaking for code that depends on the fips crate. Nothing about the behaviour of the shipped binaries changes, nothing on the wire changes, and an operator who runs the packaged daemon and tools is unaffected. If you do not build against the crate, skip this section.

The protocol layers were restructured into sans-IO cores with the I/O kept in a thin shell. The consequence for the public surface:

  • The crate-root modules bloom, discovery, mmp, protocol and tree are gone. The protocol cores moved into an internal proto module and are reached through crate-root re-exports: tree types through proto::stp, bloom types through proto::bloom, and the FSP, STP, lookup, routing and FMP wire types through their matching proto::* submodules. PromotionResult and cross_connection_winner come from proto::fmp rather than from peer.
  • The crate-root HandshakeState, PeerConnection, PeerSlot and ProtocolError re-exports are removed. The HandshakeState removed here is the peer connection-phase enum, not the Noise handshake type of the same name, which is untouched and still lives at fips::noise::HandshakeState.
  • ProtocolError is replaced by fips::Error. Its Malformed variant now carries a &'static str rather than a String, and it gained BadSizeClass, BadCoord and BadBloom variants, so the diagnostic text changed with it.
  • PeerSlot and the PeerConnection resend API were unused and are deleted.
  • Node::connections() is now pub(crate) and yields the internal peer machine rather than a PeerConnection. A consumer that walked links through it should use Node::peers(), Node::get_peer() and Node::peer_count() over ActivePeer, all of which remain public.

Two new crate-root modules, nostr and mdns, own peer rendezvous and LAN discovery, and the crate root gains the is_punch_packet helper and the CoordError, MtuExceeded, COORDS_REQUIRED_SIZE and MTU_EXCEEDED_SIZE exports.

Data plane and diagnostics

  • Batched macOS receives on connected UDP peer drains. The connected UDP path now uses recvmsg_x(2), matching the wildcard UDP receive path instead of issuing one recv(2) syscall per queued datagram. Contributed by Martti Malmi (@mmalmi, #135).
  • Allocation-free next-hop selection. Routing next-hop selection visits borrowed peers and coordinates instead of allocating candidate snapshots for each forwarded packet. Contributed by Martti Malmi (@mmalmi, #134).
  • A connected UDP socket that cannot open now names the syscall and the address. The local address for bind, the peer address for connect. Both paths previously returned a bare OS error that the caller wrapped identically, so a field report of Address already in use could not be attributed to either, and the two have entirely different causes. A node at roughly 245 peers was emitting this three times a second across nine peers with no way to diagnose it.
  • The sub-floor path-MTU refusal warning carries its correlator. The warning raised when a lookup response carries a path MTU below the actionable floor now names the request it refused, as a request_id field on the log line. Only the log line changes: the response is still accepted, the coordinates are still cached, the sub-floor value is still discarded, and the same counter is still charged.

Notable bug fixes

This release carries four fixes for bugs that shipped in previous releases. Every other fix in the range either shipped in v0.4.2 or repairs something that was introduced and corrected within this development cycle, and never reached a released version. The CHANGELOG has the complete list.

  • The macOS control socket lands in /var/run/fips, not /tmp. The packaged macOS daemon now recreates and binds its control socket at /var/run/fips/control.sock. A privileged macOS process selects that private runtime path before its leaf exists, so bind creates it, and clients follow once it is there. Socket setup now changes ownership and mode only for a private parent directory it creates or recognizes as a canonical FIPS runtime directory. Previously the packaged daemon fell through to the shared /tmp/fips-control.sock path after every boot, and because socket setup changed the parent directory unconditionally, the root daemon also took group ownership of /tmp itself. Contributed by @erskingardner (#138).

  • fipsctl disconnect now closes the transport connection, not only the peer. It notified the peer and freed every node-side structure, sessions, indices, links, address mapping, tree and bloom state, and never touched the transport, so on a connection-oriented transport (TCP, Tor, Nym, BLE) the pool entry, the socket and its inbound-slot accounting outlived the peer the node had just forgotten, until the far end closed or the receive loop errored. An operator who disconnected a peer to free a slot did not free the slot. UDP, Ethernet and loopback are unaffected, their close_connection being the connectionless no-op. Still not addressed: disconnect reports peer not found for an identity that is only mid-handshake.

  • fipsctl connect now tries the address it was given for a peer the node is already connected to, instead of reporting success without doing anything. The command built an ephemeral peer configuration and handed it to the ordinary dial path, which returns success the moment the peer is already held, so an operator moving a peer onto a freshly provisioned link had no way to make the node use it: the peer stayed where it first authenticated until that path died. The address is now tried as an alternate path alongside the live one, so promotion happens only after the alternate handshake authenticates and a wrong address cannot displace a healthy link. The response gains an additive refreshed field. connect stays ephemeral: the peer is not written to configuration and gets no auto-reconnect.

  • A path MTU measured on one link no longer clamps a peer that has moved to another. Every writer of the per-destination path-MTU cache keeps the smaller of the existing and incoming value, which is right while a peer stays put, but the entry was keyed by destination alone. A peer first reached over a narrow link stayed clamped to that link's ceiling for the lifetime of the process: when it later became reachable over a wider transport, the re-seed saw a tighter existing value and declined, and traffic kept running at the old ceiling with nothing reporting it, because the clamp was doing exactly what it was told. The node now records which transport last seeded each destination and treats a seed from a different one as authoritative.

Security

Most of this cycle's security content shipped in v0.4.2, which v0.5.0 contains: session and handshake authentication hardening, path MTU bounding, routing-signal gating, private key material protection and clearing, gateway DNS answer validation, the supply-chain work, and the nineteen further fixes from two security reports received during the release cycle. If you are upgrading from v0.4.1 or earlier, all of that arrives with this release, and the [0.4.2] section of the CHANGELOG is where it is enumerated.

Four items are specific to this line, because the code they touch exists only here.

An inbound onion connection no longer leaks its inbound slot. The Tor accept loop spawned the per-connection receive task before inserting the pool entry and bumping the counter, so a remote that reset immediately let the receive task reach its cleanup first: the removal found nothing, the decrement never fired, and the increment landed with nothing left to undo it. Enough of those and max_inbound rejected every further onion connection while the pool was visibly empty. The readiness barrier the TCP accept loop already used is now applied here too.

The --dir given to profile tick on is confined to /var/log/fips when the daemon runs as root. The control socket is reachable by the fips group, which the security model treats as strictly weaker than root, and the directory travelled from the socket into a root create_dir_all with no validation. This affects only a --features profiling build; the subcommand is absent from a stock package. The capture sink also no longer writes over whatever is already at its path, and capture files are created private to their owner.

Two further defects were found while merging the v0.4.2 security work up into this line, and they were already present here in a different shape than on the maintenance line: the socket-bind policy, which this line had centralized across three sockets rather than one, and a shared per-address rate limiter that swept its whole map on every admission with no ceiling. Fixing them here reaches further than the original fixes did.

One piece of supply-chain hygiene does belong to this release. The workflow files and composite actions that exist only on this line are now pinned to full commit SHAs, so the whole .github tree is pinned or explicitly justified: 75 action references, 71 pinned to a 40-character commit SHA with the mandatory version comment, and 4 left on mutable tags by explicit allowance. Nine of those were pinned here, in files that arrived through the merge on mutable tags because the original pinning sweep was authored on a branch that never carried them.

Security reports have a private channel; see SECURITY.md.

Known limitations

A zero-length datagram before a close is reported as the close

This affects the experimental native datagram API only.

A peer that closes its half of a flow leaves POLLHUP latched, and the flag stays set while its messages are still queued. The receive path therefore asks FIONREAD as well: bytes still queued prove a further message is waiting, so a client that sends an empty datagram, then a message, then closes has both delivered.

One case has no answer. A zero-length datagram that is the last message before a close is indistinguishable from the close itself. Reading it drains the queue, and a zero-length message contributes no bytes for FIONREAD to report. Measured on Linux 6.8: a socket in that state is identical to a drained one in revents, in FIONREAD, under MSG_PEEK and in the recvmsg return.

Do not give a zero-length payload a meaning of its own on this API. Carry a one-byte discriminator, and let the zero-byte read mean end of file. Separating the two needs a payload that is never zero bytes on the wire, which is a protocol change and is not in this release.

Getting v0.5.0

  • Linux x86_64 / aarch64: .deb and tarball at the v0.5.0 release page.
  • Arch Linux: fips from the AUR.
  • macOS: .pkg at the v0.5.0 release page.
  • Windows: ZIP at the v0.5.0 release page.
  • FreeBSD (x86_64): .pkg at the v0.5.0 release page. New this release; see the FreeBSD section of packaging/README.md.
  • OpenWrt: .ipk (OpenWrt 24.x and earlier) or .apk (OpenWrt 25+) at the v0.5.0 release page. Both carry the fips-mesh-setup and fips-ap-setup helpers.
  • From source: cargo build --release from a checkout of the v0.5.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.5.0 tag builds the binaries from source with the pinned toolchain and no manual prerequisites (see the Nix section of packaging/README.md).

There is no Android daemon artifact. Android is supported as an embedded crate, described above.

The full per-commit changelog lives in CHANGELOG.md. Issues and discussion at github.com/jmcorgan/fips.

Contributors

Thanks to everyone who contributed code, packaging work, bug reports, or reviews to this release. Twenty of this release's commits came from outside the project, and they carry several of the capabilities an operator meets first.

  • @Origami74 (Arjen): the OpenWrt 802.11s mesh backhaul (#123), the open !FIPS access SSID (#126), the Android-ready core with the app-owned TUN interface (#127), and dns_local_addr() for embedders (#136). Also the per-instance transport addressing that lets a peer address name which listener it belongs to, the app-owned UDP socket interface beside it, and the connect, disconnect and path-MTU fixes, all carried in through the platform integration branch rather than a numbered pull request. Also the NixOS flake module and overlay, the UDP sin6_scope_id receive fix, and most of the Bluetooth LE rework: packet-boundary recovery, identity-based peer recognition, the bounded probe retry and the embedder-supplied Android radio backend, with the build gate that decides where the transport exists. Fifteen commits, and the two largest new operator capabilities in the release.
  • Martti Malmi (@mmalmi): allocation-free routing next-hop selection (#134) and batched macOS connected-UDP receives (#135). Two commits.
  • @fr34aky: FreeBSD support, covering the daemon, the TUN datapath, .fips DNS integration and native pkg packaging (#129), and the L2CAP PSM interface for Bluetooth LE with its BlueZ implementation. Two commits, and a new supported platform.
  • @erskingardner (Jeff Gardner): the control-socket runtime directory fix (#138). One commit, and a first contribution to FIPS.
  • @jmcorgan (Johnathan Corgan): release shepherd; the sans-IO protocol restructuring, the per-peer control machine, the peering reconciler, node lifecycle, health and drain, the tick profiler, the lookup and rendezvous naming split, and the integration and review of the contributed work above, plus the native datagram API and the fipsctl probe diagnostic. 171 commits.