The pinning sweep was authored on the maintenance branch, so it only ever saw
that branch's workflow files. This line carries package-freebsd.yml, which does
not exist there, and a ci.yml job block that does not either, so nine references
came through the merge still on mutable tags. The guard that landed with the
sweep then did exactly what it is for and failed the branch.
Those nine are now pinned in the same form, including the third-party FreeBSD VM
action that executes the whole build inside an image it controls. Each SHA was
resolved from the upstream peeled tag and checked back against it.
The lesson is worth keeping with the guard rather than in a commit message: a
checker authored on the earliest branch is only as complete as that branch's
file set, and merging it upward gates files it has never swept.
Carries the four security fixes and the master-side resolutions forward. One
conflict needed judgment rather than a side.
This line and master both grew a module-scope pair of test frame builders, so
that the transport modules sharing the frame reader can build wire-shaped frames
in their own tests. The merge resolved that by taking master's copies, which
write master's frame header version nibble. The frame length would have been
right either way, since the builder sizes itself from this branch's own
MSG1_WIRE_SIZE constant, but the header byte would not: master writes 0x01 and
0x00 where this branch writes 0x11 and 0x10, and a doc comment came with them
claiming 114 bytes where this branch's msg1 is 41. Both builders now carry this
branch's header bytes, and the duplicate copies inside the test module are
removed in favour of the module-scope pair.
Carries the four security fixes forward. Three resolutions were not mechanical,
because this line has moved under them:
The handshake reaper is async here too, so it can close the transport
connection it used to forget. That meant porting the change onto the sans-IO
refactor rather than taking maint's text: check_timeouts, cleanup_stale_connection
and drive_handshake_timeouts all become async, and the call sites in the rx loop's
instrumented tick body and in the supervisor's stale sweep gain their awaits.
maint's lifecycle.rs no longer exists on this line; its call site is in
lifecycle/mod.rs.
The keygen guard keeps both changes: this line's note about keys stranded at the
old macOS and FreeBSD default directory, and maint's switch from exists() to
symlink_metadata() so a dangling symlink cannot slip past the overwrite refusal.
The onion transport's first-frame deadline is NOT carried. maint reads its frames
inline, while this line has moved Tor and Nym onto a shared proxied receive loop,
so the fix would have to be implemented against that shared loop and would touch
Nym with it. Doing that inside a merge resolution, on a security deadline, with no
review, is the wrong place for it. The TCP half of the deadline is present and
covers the listener that carries the 256-slot default; the onion listener with its
64-slot cap is left as filed follow-up work on this line and next.
The test frame builder lives in transport::framing here rather than in
transport::tcp::stream.
The dependency refresh landed with its own entry; the gateway DNS validation,
the key-write hardening, the inbound TCP slot deadline and the action pinning
did not. Each entry states what the defect was, what closed it, and where a
behaviour changed: the DNS rcode relay, the fips.pub mode policy, and the fact
that the TCP deadline covers the first frame only and leaves a peer that sends
one frame then goes silent still holding a slot.
Not one action reference in this repository was pinned. Every uses: line named
a mutable tag, and one named a branch. That includes the jobs holding the AUR
deploy key, the jobs with release write scope, and the packaging jobs that run
with a signing key in the environment, so whoever controls one of those action
repositories could repoint a tag into a job holding our credentials.
Sixty-two of the sixty-six references are now full commit SHAs with the
original tag kept as a trailing comment, so a reader can still tell which
release a pin is. Each SHA was resolved from the upstream peeled tag. Four
references are left unpinned and justified in one place rather than silently:
two actions select the tool they install from the ref name itself, so a bare
SHA hands them a hex string where a toolchain name belongs and the step fails.
Pinning those means moving the selection into with:, which changes what
resolves, and that is a separate decision from pinning.
A guard enforces the form on every sweep, wired into the parity job and the
local runner beside the existing checkers. It accepts only owner/repo@40-hex
with a mandatory trailing comment, treats an unreadable tree as exit 2 rather
than as a pass, and its header names what it does not cover: the actions that
pinned actions themselves invoke, the pip and cargo installs that are version
pinned at best, and anything fetched at run time.
The sharper hole was not the tags. The OpenWrt packaging workflow fetched a
helper binary straight from a release URL with no verification, in two jobs
that hold a signing key, which is code execution from a third-party host into a
credentialed job and needs nobody to retag anything. That download now goes
through a shared script with per-architecture pinned SHA-256 constants,
modelled on the zig block already in that workflow. Upstream publishes no
checksum document, so the provenance comment records the asset URL and the date
the hashes were taken by downloading rather than pretending they were verified
against a published sum.
An accepted TCP socket took an inbound slot before a single byte was read. The
cap is tested at accept, the pool insert and the counter bump follow with no
read in between, and the frame reader's two read_exact calls carry no deadline.
So an unauthenticated remote held a slot by connecting and sending nothing, and
since pool keys are ip:port, N sockets from one address are N slots rather than
one. At the 256 default that locks out inbound peering for as long as the
attacker keeps the sockets open.
The first frame on an inbound connection now has a deadline. It is a module
constant rather than a config key: this branch takes no new operator-facing
surface, and a knob is not needed to fix a missing bound. The onion listener
has the identical accept-then-count ordering and gets the same treatment; it
was not in the original report.
The second half is that nothing reclaimed a slot once taken. The node-layer
handshake reaper tore down session state but never closed the transport
connection, so a peer that sent a real msg1 and then stalled was forgotten by
the node while its socket, its pool entry and its slot lived on. The reaper now
closes the transport connection too. Closing twice is safe: every
close_connection implementation guards on removing the entry from its pool, and
the connectionless ones are no-ops, so the handshake paths that already close
and then drop a link are undisturbed.
What this does not close, and it should be said plainly rather than discovered
later: the deadline covers the first frame only. A peer that sends one
well-formed frame and then goes silent still holds its slot, and so does one
that completes msg1 and stalls beyond the reaper's reach. Closing those needs a
rolling idle deadline, which interacts with heartbeats being per peer rather
than per link and is a larger decision than this change.
The gateway's DNS forwarder accepted whatever datagram arrived on its upstream
socket as the answer. It reused the client's own transaction ID in the upstream
query, bound that socket to a wildcard address, received with a call that
discards the sender, never compared the response ID or the question section
against what it asked, and never checked that the returned address was inside
the mesh prefix. The address it extracted is installed as a DNAT rule that
carries no interface constraint, so a forged answer redirected traffic rather
than merely poisoning a lookup.
Four changes close it. The upstream query now carries a freshly drawn random
transaction ID rather than the client's. The upstream socket is connected to
the resolver before use, so the kernel drops datagrams from anyone else. A
parsed response must be a response, carry the same ID, carry exactly one
question matching the qname and qclass that were sent, and be for AAAA;
anything else is discarded and the receive continues against the original
deadline instead of accepting the first datagram to arrive. The extracted
address goes through the validating address parser rather than a comment
asserting the prefix byte, and a non-mesh answer is refused before any pool
allocation, so no mapping event is emitted and no rule is installed.
The validation sits before the rcode check, which changes one behaviour worth
naming: an upstream that answers FORMERR or REFUSED with an empty question
section no longer has that rcode relayed to the client and gets SERVFAIL
instead. Checking after the rcode would let a forged NXDOMAIN through, so the
placement is deliberate.
Connecting the socket also fixes the dead-upstream half of the availability
problem in the same loop, since a connected socket surfaces ECONNREFUSED
immediately instead of stalling to the five second timeout. The serve loop
still handles one query at a time; that half is untouched here.
The tests drive real queries through a fake upstream: a foreign source
injecting a well-formed answer, a wrong transaction ID, a wrong question, a
non-mesh address, and the healthy path as an over-rejection guard. Each was
checked by reverting the corresponding fix and confirming the intended test
reds alone.
Every private key in the tree is written by one function, and it opened the
path with create+truncate and no O_NOFOLLOW, so a symlink pre-planted at the
key path was followed and its target overwritten. The mode was supplied only
through open(2)'s mode argument, which the kernel applies when it creates the
file and ignores otherwise, so a fips.key that already existed at 0644 stayed
0644 after every rewrite. That half needs no attacker: one chmod, or a restore
that did not preserve modes, leaves the key readable forever.
Both writers now go through a shared open helper that carries O_NOFOLLOW and,
for the private key only, applies the mode to the open descriptor before any
secret bytes are written. The public key keeps its create-time mode instead,
because forcing it would reopen an operator-tightened fips.pub on every start.
A refused open is classified by inspecting the path rather than the errno,
since O_NOFOLLOW reports a symlinked final component differently across the
Unix variants this module compiles for.
Six write results that were discarded now report. The sharpest was in
persistent mode: a failed key write fell through to an ephemeral identity with
no message at all, so a node could silently change npub on every start while
its config asked for a stable one. An ephemeral start over an existing key file
now warns before it overwrites, naming the path and the setting that would have
preserved the identity, which is the warning the key generation tool has always
given and the daemon never did. Existence is tested with symlink_metadata
rather than exists, because a dangling symlink reports false from the latter
while still being a file the write acts on.
The persistent read path warns when it finds a key file whose mode is looser
than 0600 or which is a symlink. It does not repair either: the daemon does not
own a file it did not create.
Windows is a named coverage gap in both writers' docs. There is no mode to
enforce and no O_NOFOLLOW; the file inherits the parent directory's ACLs.
The lockfile pinned nostr 0.44.3 and nostr-relay-pool 0.44.1, both yanked
and both carrying advisories that reach this code. The relay-pool ones are
the reason this is worth doing promptly: RUSTSEC-2026-0224 and -0232 are a
verification-cache bypass and the processing of unverified relay events,
and the path they land on is how a node learns peer adverts, which it
consumes without verifying anything itself. RUSTSEC-2026-0231 sits on the
same path, and -0216 and -0227 reach NIP-44 decryption of relay-supplied
content. The other advisories in the set cover APIs this code never calls.
nostr moves to 0.44.8 and nostr-relay-pool to 0.44.3. Both fixed floors are
inside 0.44, so the existing requirements already admitted them and nothing
in the source changed. The refresh is taken over the whole lockfile rather
than the two crates alone, because a targeted bump leaves RUSTSEC-2026-0204
in crossbeam-epoch open and four yanked crates in the tree for no gain.
cargo audit reports no vulnerability now, against twelve before. The four
remaining warnings cannot be closed by a version move: instant and paste are
unmaintained, lru 0.16.4 is unsound, and nostr-relay-pool is itself marked
unmaintained as of RUSTSEC-2026-0243, which the dependency strategy needs to
answer separately.
The XX line had accumulated work with no entry: the explicit rekey
declaration in msg3, which is a wire change and belongs under Breaking;
node.rekey.enabled narrowing to mean initiate-only, which is the one an
operator can be bitten by, since a pair configured true on one end and
false on the other carried no traffic until the link-dead timer; the leaf
node that self-elected as root and partitioned the mesh; the session index
and link leaks at the reject arms; and the peer static verification, which
closes an on-path identity substitution on both handshake paths.
Also removes the msg1 metering entry that merged up from maint. The XX
line has its own version of it a hundred lines above, keyed on promotion
state rather than on an address-map entry, and the merged copy described
behaviour this branch does not have.
Everything the maint line contributed is already here by merge, so this
adds what only master carries: the 802.11s mesh backhaul and the open
!FIPS access SSID for OpenWrt, the Android-ready core with its app-owned
TUN seam, the bounded shutdown drain and its new node.drain_timeout_secs,
the node health states with start-time and runtime child-exit detection,
and the connected UDP socket errors that now name the failing syscall.
Zero transports up becoming fatal is the entry an upgrader most needs:
a node that previously came up serving nothing now exits.
The sans-IO migration and the module relocations are deliberately absent.
They are the bulk of the cycle by commit count and none of them changes
behaviour, so the config key renames they produced are the only part a
reader can observe, and those were already recorded.
Bug fixes for defects introduced on master since 0.4.0 are left out as
well, since no released version ever carried them: the permanent Degraded
latch from watching the wrong Nostr handle, and the open-discovery sweep
log lost in the peering refactor.
The Unreleased section carried the msg1 metering, the macOS path fixes,
the traversal clock and the hop-limit change, but not the work that
landed alongside them. Adds the rekey config validation, the three tick
body performance changes, the richer socket adoption log line, the
traversal relay filtering, the log write panic, and a Security entry for
the FSP session address binding, which is the one an operator most needs
to see before deciding when to upgrade.
Left out deliberately: the CI and test harness commits, which are the
bulk of the cycle by count and none of which changes shipped behaviour,
and the zig download integrity fix, which hardens how the OpenWrt
packages are built rather than what they contain. The 0.4.1 section sets
that precedent by carrying no CI entries.
fips-gateway builds its subscriber the same way the daemon did, with the
default internal-error reporting that sends a failed log write to
`eprintln!`. Both shipped units set StandardOutput=journal and
StandardError=journal, so one full disk fails both sinks together, which
is the same precondition as on the daemon side.
What dies here is a spawned task rather than a thread. The DNS resolver,
the control accept loop and the pool tick are spawned and then not looked
at again: their handles are first touched at shutdown, as `let _ =
task.await`, which discards the JoinError. A task lost this way leaves
the process running and reporting healthy with mesh name resolution or
lease expiry and NAT cleanup stopped, and nothing recording that it
stopped.
Whether each task reaches a log site while the disk is full is not
enumerated here; the resolver logs on exactly the error paths a full disk
makes likely.
The subscriber is built with the default internal-error reporting, which
sends a failed log write to `eprintln!`. That macro panics when stderr also
fails, so on a full disk a single WARN can unwind whichever thread emitted
it. The shipped macOS plist points StandardOutPath and StandardErrorPath at
the same unrotated file and systemd routes both to journald, so the two
sinks fail together rather than independently.
A crypto worker is the case that matters. It logs a WARN on send
backpressure, and a worker that dies takes its share of the peer space with
it permanently: dispatch keeps hashing peers onto a channel nobody is
reading. The panic message is discarded along the same broken path, so
nothing records why.
Turning off internal-error reporting closes that path without touching
dispatch or the deliberate blocking backpressure on a full worker channel.
It does not make worker death survivable, which is a separate concern.
The msg2 self-connect drop and the msg3 reject arms disposed a leg without
returning what that leg had allocated. Each site now releases the session
index and the link it holds, and captures the index before disposal, since
reading it afterwards yields None.
One arm is not like the others and must not be fixed like them. Its index
comes from the receiver field of the incoming header, which a peer supplies,
so freeing it unconditionally would let a hostile peer release an index
belonging to an unrelated live session: a memory leak traded for a remote
session teardown. That arm now frees only after establishing that the index
is not claimed elsewhere, by a predicate that scans the pending maps, the
peer machines and the active peers.
The predicate is deliberately transport-blind. Scoping it to the transport
the packet arrived on would let an orphaned entry on one transport free an
index live on another, and that regression was invisible to the whole suite
until the test added here: with the scoping applied, 1806 tests passed.
Each of the predicate's limbs is now decided by exactly one test, checked by
mutating the production code and confirming the intended test fails alone.
The outbound ACL-reject arm also regains the reschedule call its dial-gate
sibling makes, so a configured peer no longer drops off the dial schedule.
The six methods connecting the live peer map to the routing core had no
tests. Replacing the bloom predicate with one returning true, which disables
filtering on the forwarding path entirely, left the suite green at 1718
passed, and the two tests whose names promise to cover it passed as well.
The new tests assert on the real adapter through find_next_hop, which builds
it internally and cannot be handed a mock. Two fixtures make them
discriminate: one places two peers at equal distance and equal cost so the
tie-break is the only thing separating them, and one places three peers at
distinct distances so the winner depends on the coordinate values returned
rather than merely on their presence.
Each of the six methods was broken in turn and the intended test confirmed to
fail. The break that motivated this now fails three tests where it failed
none.
The two misleading tests keep their assertions and gain a note saying what
they do not discriminate and which test does. One of them cannot be made to
discriminate without a production change: under the broken predicate it
returns the same peer by a different branch, and the caller sees no branch.
The OpenWrt cross-compile fetched zig through `curl | sudo tar xJ`, so a
short read reached tar as a truncated archive and failed the build with
"Unexpected EOF in archive". A pipe leaves nowhere to check the bytes, and
curl's own --retry does not cover it: exit 18 is not in its transient set.
Download to a staging directory first, verify a pinned SHA-256, then
extract. Each architecture now sets its hash on the same case branch that
sets its name, so an architecture cannot be added without one, and a guard
fails with the jq recipe for deriving it if the hash is ever empty. Three
attempts with 10s and 20s backoff, matching the retry idiom already in this
workflow, and an early exit when two attempts return identical bytes, since
a stable mismatch is a wrong pin rather than a bad transfer.
The step also gains `set -euo pipefail` and a trap that removes the staging
directory on every exit path. It previously ran under the default shell
without pipefail, so a failure inside the pipe could be masked by tar.
The hashes come from ziglang.org's download index and were checked against
the bytes of both tarballs. That is integrity, not authenticity: index and
archive share an origin, and upstream publishes no detached sums.
Carries the routing next-hop allocation removal up from master.
This one needed a real resolution rather than a side. Master rewrote the
selector to enumerate borrowed peers and deleted the candidate-assembly
stage outright, while this branch had added a Full-profile narrowing to
that same stage: only Full peers carry transit bloom filters, so
candidates were `may_reach && is_full`. Taking master's side would have
silently dropped that filter and let a leaf or non-routing peer be
chosen as a transit next hop on the strength of a bloom filter it does
not maintain.
So `peer_is_full` moves to the borrowed-peer signature alongside the
rest of the seam, and its filter joins the fused predicate in
`select_best_candidate`. The three predicates are unchanged as a set:
a peer must may_reach, be Full, and be able to send.
Both shell implementations follow: the node's, which now reads the
profile off the borrowed peer instead of a second map lookup, and the
test mock's.
Added `candidate_selection_excludes_non_full_peers`, because the test
that used to cover this asserted on the deleted assembly function and
could not survive. It pins both halves: a cheaper, closer, sendable
non-Full peer must lose to an expensive Full one, and a view containing
only non-Full peers must yield no bloom hop at all. Break-checked —
removing the `is_full` term from the predicate reds this test and
nothing else.
Quartet green on the merged tree at 1798 lib tests.
Routing currently collects peer addresses and eligible candidates into temporary vectors before selecting the best next hop.
Visit borrowed peers and fuse eligibility checks with the cost, distance, and address comparison so the hot path no longer allocates or clones coordinates.
Carries the macOS batched connected-peer receive and the UDP io
reorganization up from master.
Only CHANGELOG.md conflicted, both sides having added to the head of
`### Changed`; resolved as the union, master's entry first.
The restructure looked risky to merge and was not: at `3cdd529` this
branch was byte-identical to the merge base on every file it touches
(`io.rs`, `sockopts_macos.rs`, the whole of `peer/connected_udp/`,
`peer/mod.rs`, `transport/udp/mod.rs`), so master's side carries with
nothing here to lose. `peer/active.rs` is the one file where both lines
had moved; it auto-merged, and the result carries all five of master's
path rewrites alongside this branch's own XX-side changes.
Quartet green on the merged tree at 1794 lib tests.
`io.rs` had grown to 1322 lines in which almost nothing was actually
generic: two mutually exclusive `platform` modules, plus a third module
for the connected-socket fast path, with Linux and macOS diverging
repeatedly inside the Unix one rather than at any module boundary. The
only genuinely shared items were the doc header, four imports and the
re-exports.
Turn it into a directory that says which is which. `io/unix.rs` holds
everything both Unix targets share: socket creation and adoption, buffer
sizing, the synchronous calls, and the `AsyncFd` wrapper. The three
points where behaviour really differs move to `io/linux.rs` and
`io/macos.rs` behind identical signatures, so the shared file selects a
`sys` module once at the top and its bodies carry no `cfg` at all:
- `enable_drop_counting` — `SO_RXQ_OVFL` on Linux, nothing on Darwin
- `CMSG_BUF_SIZE` / `parse_drops` — the ancillary control buffer and its
reader, which only Linux populates
- `recv_batch` — `recvmmsg` against `recvmsg_x`
`io/unix_other.rs` supplies the same three for the Unix targets that are
neither: FreeBSD, and Android, which is `target_os = "android"` and is
the one of the two that CI lints. Naming them is the point — this arm
was previously spelled `not(target_os = "linux")` and was only correct
because of a `cfg` two files away.
`io/windows.rs` stays a whole separate backend over
`tokio::net::UdpSocket`. It shares no implementation with the Unix side,
so it exports the same two type names and nothing else.
The connected-socket fast path becomes `io/connected/`, and this is the
part that was split across two trees. Constructing the fd lived in
`io.rs`; the handle that adopts it and the drain thread that must
accompany it lived under `peer/`, which they had no reason to — that
module imported nothing from `peer`, reached only into
`transport::udp`, and pointed at it three times in its own docs to
explain itself. All three pieces are now siblings: `fd.rs`, `socket.rs`
and `drain.rs`. The four platform differences in fd construction — the
`socket(2)` type flags, the follow-up fd-flag call, the service-type
tuning and the buffer-size strategy — join the same `sys` seam as the
receive code, which is what makes this the right parent for them.
`sockopts_macos.rs` moves in beside them as `io/macos_sockopts.rs`: only
this module ever consumed it, so it drops from `pub(crate)` to a private
child, and its file-wide `dead_code` allowance for the unreferenced
service-type table stays confined to the constants it was written for.
No behaviour change. Every call sequence, error path and syscall order
is preserved, including the order in which the connected socket applies
its options and which of those calls propagate errors rather than being
best-effort. The crate-facing names are unchanged except for the two
handle types, which move with the module and are re-exported as
`transport::udp::{ConnectedPeerSocket, PeerRecvDrain}` beside the
`open_connected_fd` that was already there.
Connected peer drains currently issue one recv syscall for each queued datagram on macOS even though the wildcard UDP path already uses recvmsg_x.
Reuse the Darwin batch ABI for connected sockets, preserving partial-batch and EINTR behavior while requesting up to 32 datagrams per receive call. Add a connected-socket burst test that crosses the batch boundary.
Adds FreeBSD as a supported platform. The daemon, fipsctl, TUN datapath and
DNS integration build and run there, with a native pkg and an rc.d service.
The one piece of genuinely new datapath logic is the TUN framing. FreeBSD's
tun rejects every non-IPv4 packet with EAFNOSUPPORT unless TUNSIFHEAD is set,
so nothing IPv6 can be sent at all; with it set, every frame carries a 4-byte
network-order address-family prefix the way macOS utun does. The ioctl is
issued at device creation and the prefix is stripped on read, which gives
callers the same raw-IP contract as Linux and macOS. A frame carrying only
the header reads as zero bytes and the reader loops treat it as nothing to
do. The address family is now taken from libc rather than hardcoded, because
AF_INET6 is 30 on Darwin and 28 on FreeBSD.
The reader shutdown path, the writer's address-family header and the
supervisor's shutdown pipe were all macOS-only and are now shared with
FreeBSD, since neither platform wakes a blocked read when the interface goes
down. Linux continues to rely on interface deletion.
mdns-sd moves from 0.19 to 0.20 for socket-pktinfo 0.4.1, the first release
that builds on FreeBSD, which uses IP_RECVDSTADDR and IP_RECVIF instead of
Linux-style IP_PKTINFO. This is the only change here that affects every
platform rather than just the new one.
The config, ACL, hosts and keygen path constants now treat FreeBSD the same
as macOS, since both install under /usr/local/etc/fips. Those constants
arrived separately on maint and are merged here rather than duplicated: the
predicates widen to cover FreeBSD, the platform-gated tests widen with them,
and keygen keeps reading the shared SYSTEM_CONFIG_DIR constant rather than
reintroducing a literal.
Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
The macOS packaging installs config under /usr/local/etc/fips, wired through
the launchd plist and build-pkg.sh, but the default-path constants and the
config search path were hardcoded to /etc/fips for all Unix. On macOS the
daemon and fipsctl therefore looked in a directory that does not exist: the
ACL and host-map loaders hit their NotFound no-op arm and returned empty
state, so a populated peers.deny reported effective_mode "default_open" with
enforcement inactive, and host-file aliases went unloaded, with no error.
The peers.allow, peers.deny and hosts defaults now follow the platform's
packaging, and fipsctl keygen writes its identity there too. The config
search path keeps probing /etc/fips first and adds /usr/local/etc/fips after
it, so an existing install keeps working across the upgrade and the packaged
file still wins over a stale leftover. Both the macOS search-path entry and
the keygen output directory read one SYSTEM_CONFIG_DIR constant, so they
cannot drift apart. At startup the daemon warns once about hosts, peers.allow
or peers.deny stranded at the old location; the config file is deliberately
excluded, since both directories stay on the search path and a config left
behind is still read.
The control-socket snapshot tests repoint the ACL reloader at non-existent
paths under the temp dir, so the snapshot no longer reflects whatever ACL
files happen to exist on the machine running the tests.
Platform-gated unit tests pin both layouts, so a future refactor cannot
silently drift either one. Linux and Windows behavior is unchanged.
Adding a second system config directory moves the directory the daemon
derives the identity key path from, since that comes from whichever config
file loaded last. A host carrying fips.yaml at both locations would have
resolved fips.key to the new directory, found none, and under persistent
generated a fresh identity, silently changing its npub, routing address and
mesh IPv6 with no migration path. The daemon now adopts a key stranded at the
legacy path and warns to move it rather than generating one. The fallback is
confined to keys resolved from the system config directory, so a run using
./fips.yaml or a user config is never redirected to a system key.
Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
Carries up the FSP session address-to-key binding fix.
The production guard merged cleanly but its tests did not: they were written
against master's Noise XK handshake, and FSP runs XX here. The XK-specific
constructors and message writers do not exist on this branch, and the
initiator no longer takes the responder's static key up front, so the test
module did not compile as merged.
Resolved by driving the exchange through the XX constructors, and renaming
the helper and the guard's own debug message to match the pattern actually
in use. The guard itself needed no change: XX also delivers the initiator's
static key to the responder in msg3, which is where the check reads it.
Carries up the FSP session address-to-key binding fix.
The only conflict was the test module's import line: master reads the FSP
message types from proto::fsp and proto::link, where maint still reads them
from protocol. Resolved to master's paths, with SessionMsg3 taken from
proto::fsp alongside SessionAck.
The responder recorded a session under the source address carried in the
datagram without ever checking that address against the static key the
Noise handshake had just authenticated. A peer could therefore complete a
genuine handshake while claiming another node's address, and the identity
cache, the session map and the address the IPv6 shim reconstructs on
delivery would all attribute its traffic to the node it named.
Derive the address from the authenticated key at the point the key first
becomes available in msg3, and reject the handshake when it does not match
the claimed source. The entry has already been removed by that point, so
returning drops the half-open session and neither the identity nor the
session is recorded.
The rekey responder path needed its own check rather than inheriting that
one. It returns before the initial path's code is reached, and it never
read the peer's static key at all, so a rekey could complete under an
established session with a different key than the one that opened it. It
now requires the key to be unchanged, which is the stronger comparison
available there, and abandons the rekey while keeping the existing session
intact on mismatch. Tearing the session down instead would have handed an
attacker a way to kill established sessions.
Both comparisons are on x-only keys. A stored peer key may carry a
synthesized even parity because npubs encode no parity, while the
handshake learns the true point, so comparing full keys would reject
roughly half of legitimate peers on every rekey.
Both rejections are counted separately in the session reject statistics.
The tests drive real Noise handshakes through the datagram entry point and
construct the mismatch rather than asserting the comparison exists.
The guard that stops a consumer naming the shared mutable test image had
three ways through it, so a regression could walk back in past something
that looked like it was watching.
It matched only the literal fips-test:latest, but docker resolves a bare
untagged fips-test to exactly that, so image: fips-test reintroduced the
defect verbatim and passed. The match now catches the untagged form while
still allowing the run-scoped tag and the documented FIPS_TEST_IMAGE
fallback.
It did not look at the build context at all, so reverting a build.context
to the shared testing/docker directory passed. That half of the original
defect is now guarded the same way as the image name, matching a path
whose last component is docker rather than exempting bind mounts and
per-run directories by name.
Its allowlist was whole-file, and the comment claiming none of those files
was reachable from a CI run was false for two of them. Entries are now
scoped to the specific reference that is justified, so a new shared-tag
reference anywhere in a formerly exempt file is still caught, and the
comment says plainly which files CI does reach and why their particular
references are safe. The guard's own exemption is bounded by asserting it
starts no docker command.
git ls-files failure is now checked rather than discarded, so a sweep that
could not run exits 2 instead of reporting a clean tree.
Each hole was reproduced against both the old and new guard: all three are
red now and were green before. The healthy tree still passes, and the
legitimate forms do not false-positive.
The msg1 rate limiter ran one bucket for everything, so a rekey or
restart msg1 from an established peer competed for admission with
strangers. Under inbound pressure the maintenance traffic lost, and an
established session could not re-handshake until the stranger flood
eased.
Classify the source before the rate-limit decision and give established
links their own bucket. Classification costs two map lookups and no
crypto, so it runs first.
The predicate cannot be the one the master line uses. There, IK has
learned the peer identity by the time msg1 is handled, so a hit in the
address-to-link map is enough to prove an established peer. Under XX no
identity is known at msg1, and an in-flight handshake populates that map
before the peer is promoted, so the same predicate would let a stranger
draw on the established bucket for the whole life of its handshake.
This version requires a promoted peer, which is the property actually
being asserted.
Two tests cover the difference and both fail if the predicate is
replaced with the master-line one: a pending inbound stranger must keep
drawing on the stranger bucket for its whole lifetime, and the
established gate must not admit on a bare map hit.
The four handshake reject arms that leak a session index are untouched;
every hunk here sits at or above the end of the msg1 handler.
Master-line half of the same correction made on the maintenance line. The header
comments on the connected-socket module inside the UDP io layer and on the
per-peer drain thread both said the fast path was not yet wired into the
encrypt-worker dispatch site, and that a follow-up would do it. It has been
wired at both ends for some time: the rx loop calls
activate_connected_udp_sessions, which spawns PeerRecvDrain. A reader deciding
whether the connected-UDP send path is live got the wrong answer from the first
comment they met.
Both modules already document themselves, so the forecast comments are deleted
rather than reworded, and the dead_code allowances they justified go with them.
The bare allowance on the connected-socket handle, which carried no comment at
all, goes too. None of the three was suppressing anything: clippy with
-D warnings passes with all of them removed, so besides being unjustified they
would have hidden whatever went dead next in those modules. The item-level
allowance on local_addr is a different attribute and is left alone.
Its header said the tuning was dormant, and that the module was kept visible on
Linux so clippy would not lose track of it. Both claims are false:
apply_udp_socket_tuning is called from the connected-peer socket opener under
cfg(macos), and the module declaration is itself gated to macOS, so it is not
compiled on Linux at all.
Unlike the two sibling modules, this allowance stays, because here it is doing
real work: the module defines the whole NET_SERVICE_TYPE table for reference and
selects only OAM, which leaves eight constants deliberately unreferenced. The
comment now says so, which is the justification the attribute was missing.
The header comments on the connected-peer socket and on its drain thread both
said the fast path was not yet wired into the node tick, and that a follow-up
would do it. It has been wired at both ends for some time: the rx loop calls
activate_connected_udp_sessions, which spawns PeerRecvDrain. A reader deciding
whether the connected-UDP send path is live got the wrong answer from the first
comment they met.
Both files already carry module docs describing what they are, so the forecast
comments are deleted rather than reworded. The dead_code allowances they
justified go with them: clippy with -D warnings passes without either, so they
were suppressing nothing, and while they sat there they would also have hidden
whatever went dead next in those modules.
The traversal adoption log line moved from src/node/lifecycle.rs to
src/node/lifecycle/mod.rs on this line, which git saw as a modify/delete
rather than a rename, so the change was re-applied by hand at the new
path and the stray maint-path file dropped. Both fields it reads are
present on this line's BootstrapHandoffResult in src/nostr/handoff.rs.
The "Adopted NAT traversal socket" line carried only the peer npub, which
left two questions unanswerable from a capture.
An operator cannot join a host socket table against our adoption events
without the local address, so there was no way to confirm which socket a
stuck connected-UDP activation was contending with. And an adopted
transport inherits accept_connections from the configured UDP transport,
so it can admit peers beyond the one it was punched for: without the
transport id, several peers sharing one adopted transport and several
separate adopted transports produce identical log output.
Both fields already existed on BootstrapHandoffResult, so this only binds
the value the match arm was discarding.
The established-link msg1 metering is re-authored against this line's
handshake handler rather than taken from the merge. The two versions of
src/node/handlers/handshake.rs differ by roughly 1437 lines from the
earlier decomposition, so the conflict hunks spanned whole divergent
bodies, including this line's restructured dual-initiation handling,
and resolving them hunk by hunk would have spliced the two structures
together. The file was taken from this line and the change re-applied
by hand; the resulting diff against the pre-merge tip is the metering
change and nothing else.
The rate limiter itself needed no adaptation: src/node/rate_limit.rs
was byte-identical on both lines, so the guard, the second bucket and
the derivation merged in unchanged.
Eighteen explicit pending-slot releases in handle_msg1 are replaced by
a single guard held for the function's scope. Every one of the
eighteen either preceded a return or ended a match arm, and the
success path released at the end of the function, so the guard is
behaviour-equivalent. One ordering difference: the slot is now
released at function exit rather than immediately before each arm's
reject-stat bump. Nothing outside the handler reads the pending count
between those two points, so it is unobservable in tree, but it is an
ordering change rather than a pure refactor.
Four tests are ported from the other line, one with its wire-module
import path adjusted for this line's layout.
Known coverage gap: no test distinguishes the guard being held from
the guard being released at acquire time. Rebinding `_slot` to a bare
`_` leaves the whole suite green and draws no clippy warning, so the
invariant rests on the binding name. Noted at the site.
open_connected_fd returned a bare last_os_error() from both the bind
and the connect path, and its caller wrapped both identically, so a
field report of "Address already in use" could not be attributed to
either. The two have entirely different causes: on Linux a UDP
connect(2) to a 4-tuple another socket already holds returns
EADDRINUSE, which is not the same fault as bind refusing the local
address. A node at roughly 245 peers is emitting this three times a
second across nine peers and the report cannot be diagnosed as it
stands.
Name the syscall and the address it was operating on in each error,
the local address for bind and the peer address for connect. The
address is what makes the next step possible: the identity that can
collide is the resolved SocketAddr, not the configured transport
address, and nothing in the log carried it on the failure path.
This is diagnosis only and fixes nothing. The unbounded per-tick
retry, the process-global failure counter that hides it, and the
sockets that outlive their peers are separate changes.
Note that the resolved peer address now appears at warn level on the
failure path, alongside the peer's node address, where previously it
appeared only in the success-path debug line.
raw_os_error() on the returned error is now None, since wrapping
produces a custom error. No caller reads it: the sole consumer
flattens the error to a string, and the errno text survives in the
message. Recorded as a known gap rather than a defect.
The msg1 rate limiter ran before the established-peer carve-out, so a
rekey or restart msg1 arriving on an existing link was refused on
exactly the same terms as a stranger's first packet and the carve-out
below it never applied to the traffic it was written for. On a node
with many peers this refuses a large share of ordinary maintenance
traffic: a field node at roughly 245 peers refused 8753 msg1 in 25
minutes, and 159 of the 201 distinct sources were peers it already
held sessions with.
Classify the source before metering it, and give established-link msg1
its own token bucket instead of a bypass. A bypass was rejected
deliberately: the limiter is global precisely because UDP sources are
spoofable, and an established-peer exemption is by construction keyed
on source address, so metering the exempted class keeps that property
where bypassing discards it.
The bucket is derived from settings the operator already sets, burst
from max_peers and rate from max_peers, the rekey interval and the
resend budget, so raising the peer limit sizes it automatically rather
than leaving a constant nobody revisits. Both parameters can be set
explicitly; an explicit zero burst or non-positive rate is rejected at
config validation, because it would refuse every rekey msg1 from an
established peer rather than disabling the limit.
Split out the established-link test as its own predicate so the
rate-limit classifier and the accept_connections gate cannot drift,
and convert the limiter's pending slot to a guard released on drop.
The slot was previously acquired in one place and released explicitly
at eighteen exit paths; once some paths stop acquiring one, any path
that still released one would have freed a slot belonging to a
different in-flight handshake, lifting effective concurrency above the
configured maximum with no counter moving and no log firing.
The "Msg1 rate limited" line now reports which limb refused, the
pending count or the token bucket, which it did not distinguish
before.
Classification costs an O(peers) scan on every inbound msg1 including
refused ones, where the previous order refused at O(1). The scan is
only needed because addr_to_link is keyed on the unresolved dial
address; correcting that keying reduces this to a single O(1) lookup.
Recorded at the predicate.