Commit Graph
1041 Commits
Author SHA1 Message Date
Johnathan Corgan cc51efef6e Merge branch 'master' into next 2026-08-11 12:56:45 +00:00
Johnathan Corgan ba161eadb7 Merge branch 'maint' 2026-08-11 12:56:20 +00:00
Johnathan Corgan 54a794c215 Refresh the dependency lockfile past the nostr advisories
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.
2026-08-11 12:53:31 +00:00
Johnathan Corgan 70ac24a167 Cover the next-only work in the changelog, and drop a duplicated entry
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.
2026-08-11 07:23:51 +00:00
Johnathan Corgan 55c71f1311 Merge branch 'master' into next
# Conflicts:
#	CHANGELOG.md
2026-08-11 07:21:10 +00:00
Johnathan Corgan 26f1710587 Cover the master-only work since 0.4.0 in the changelog
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.
2026-08-11 07:09:04 +00:00
Johnathan Corgan 283f2b9a6e Merge branch 'maint'
# Conflicts:
#	CHANGELOG.md
#	src/bin/fips.rs
2026-08-11 07:03:22 +00:00
Johnathan Corgan ecda392501 Cover the rest of the 0.4.2 cycle in the changelog
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.
2026-08-11 06:57:44 +00:00
Johnathan Corgan 7d225602ec Stop a failed log write from panicking a gateway task
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.
2026-08-11 06:43:59 +00:00
Johnathan Corgan e1c4ed6e20 Stop a failed log write from panicking the thread that logged
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.
2026-08-11 06:41:26 +00:00
Johnathan Corgan f5b01370fa Release the session index and link at every XX handshake reject arm
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.
2026-08-11 06:21:31 +00:00
Johnathan Corgan facc852722 Cover the routing view adapter, where every forwarding decision passes
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.
2026-08-11 06:21:31 +00:00
Johnathan Corgan ffc1f8cf23 Verify the zig download before extracting it
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.
2026-08-11 03:58:12 +00:00
Johnathan Corgan c7e6d00333 Merge branch 'master' into next
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.
2026-08-10 02:28:38 +00:00
Martti MalmiandJohnathan Corgan 53d80e6983 Avoid allocations in routing next-hop selection
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.
2026-08-10 01:58:13 +00:00
Johnathan Corgan c80c93f89e Merge branch 'master' into next
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.
2026-08-10 01:52:49 +00:00
Johnathan Corgan 0c458b380a Give the UDP io module and the connected-socket fast path one home each
`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.
2026-08-10 01:42:16 +00:00
Martti Malmi fa736b19a8 Batch macOS connected UDP receives
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.
2026-08-09 22:59:10 +03:00
Johnathan Corgan 3cdd5290de Merge branch 'master' into next
# Conflicts:
#	CHANGELOG.md
2026-08-09 14:01:38 +00:00
fr34akyandJohnathan Corgan d0dcb40958 FreeBSD support: daemon, TUN datapath, .fips DNS, and native pkg packaging
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>
2026-08-09 13:51:20 +00:00
Johnathan Corgan c2f0fafd1c Merge branch 'master' into next 2026-08-09 13:41:09 +00:00
Johnathan Corgan feb8a91fdd Merge branch 'maint' 2026-08-09 13:41:03 +00:00
redshiftandJohnathan Corgan cdb03c76b0 Follow the macOS install layout for config, ACL, and identity paths
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>
2026-08-09 13:40:56 +00:00
Johnathan Corgan fd094e7899 Merge branch 'master' into next
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.
2026-08-09 11:33:37 +00:00
Johnathan Corgan c7b57974fb Merge branch 'maint'
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.
2026-08-09 11:22:56 +00:00
Johnathan Corgan 0155345984 Bind the FSP session address to the authenticated peer key, on both the initial and rekey paths
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.
2026-08-09 06:48:01 +00:00
Johnathan Corgan afac31e7f2 Merge branch 'master' into next 2026-08-02 14:48:02 +00:00
Johnathan Corgan 6ebee33eb1 Merge branch 'maint' 2026-08-02 14:48:02 +00:00
Johnathan Corgan f40cc16bdb Close the three holes in the test-image scoping guard
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.
2026-08-02 14:47:54 +00:00
Johnathan Corgan 1dee7b24b6 Merge branch 'master' into next
# Conflicts:
#	docs/reference/configuration.md
#	src/node/handlers/handshake.rs
#	src/node/tests/unit.rs
2026-08-02 01:34:43 +00:00
Johnathan Corgan aca89bbab8 Meter established-link msg1 separately from stranger admission
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.
2026-08-01 22:37:30 +00:00
Johnathan Corgan 4c10f5d8b2 Merge branch 'maint'
# Conflicts:
#	src/transport/udp/connected_peer.rs
#	src/transport/udp/sockopts_macos.rs
2026-08-01 17:09:16 +00:00
Johnathan Corgan df2b25198c Describe what the connected-UDP modules are instead of forecasting their wiring
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.
2026-07-30 23:11:19 +00:00
Johnathan Corgan 047afc3467 Say why the Darwin socket tuning module allows dead code
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.
2026-07-30 23:07:17 +00:00
Johnathan Corgan fdc1e7dfca Describe what the connected-UDP modules are instead of forecasting their wiring
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.
2026-07-30 23:07:17 +00:00
Johnathan Corgan ceddd5df3c Merge branch 'maint' into master
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.
2026-07-29 23:27:04 +00:00
Johnathan Corgan f82ee44f70 Log the transport id and local address when adopting a traversal socket
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.
2026-07-29 23:22:52 +00:00
Johnathan Corgan 472665ba2f Merge branch 'maint'
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.
2026-07-29 18:03:02 +00:00
Johnathan Corgan 931bc5a9dd Say which syscall failed when a connected UDP socket cannot open
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.
2026-07-29 17:10:47 +00:00
Johnathan Corgan f5be8ec07d Meter established-link msg1 separately from stranger admission
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.
2026-07-29 17:10:29 +00:00
Johnathan Corgan 069d450983 Merge branch 'master' into next 2026-07-29 02:24:38 +00:00
Johnathan Corgan 83c4e800a5 Merge branch 'maint'
# Conflicts:
#	src/node/retry.rs
2026-07-29 02:17:34 +00:00
Johnathan Corgan 13a98ae702 Make a half-scoped reap impossible in ci-cleanup.sh
The reap runs two selectors, one on the CI label and one on the
compose project, and each flag narrows only its own. So --run-id
alone leaves the project sweep broad and --project-prefix alone
leaves the label sweep broad, and either way the reap still destroys
every concurrent run on the host. Both single-flag forms look scoped
and are not.

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

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

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

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

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

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

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

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

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

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

Tests cover both constructors, that the cached npub matches the
identity, and that the display name still tracks an alias change. The
memoization itself is asserted by pointer stability rather than by
timing, so it is deterministic under load. Three deliberate breaks
were each caught by exactly one test: re-deriving instead of
memoizing, populating one constructor's cache from the wrong source,
and reordering the display-name fallback so it stops honoring aliases.
2026-07-29 01:32:48 +00:00
Johnathan Corgan 2045b51f5d Watch the Nostr loops that never return, not the one that does
Every node reported state: degraded permanently. The liveness probe
polled connect_task, which wraps a single Client::connect() call. That
call only spawns a per-relay background connection task and returns,
so the handle finished moments after start on a perfectly healthy
node, the supervisor saw a child exit, and the node latched Degraded
for the rest of its life.

Nothing behaved differently, because every consumer of NodeState
treats Degraded the same as Running. What was lost is the signal: a
genuine degradation was indistinguishable from the permanent false
one.

Watch the three service loops that cannot return by design instead —
the inbound notify loop, the advert publisher, and the refresh ticker.
Each is an unconditional loop, so a finished handle means a panic or
an abort, which is unrecoverable and matches the one-way ChildExited
latch in the supervisor. connect_task and relay_startup_task stay
deliberately unwatched, and the doc comment now says why, since
watching either reproduces this bug exactly.

The tests install task handles directly and check both directions:
that a finished connect_task alongside three live loops reports
healthy, which is the production configuration a few hundred
milliseconds after start, and that each loop dying on its own reports
degraded. Reverting to the old predicate reds four of five; replacing
the predicate with a constant false also reds four of five, so the
fix cannot pass by never reporting degraded at all.
2026-07-29 01:31:13 +00:00
Johnathan Corgan d6457fa74f Stop awaiting the advert refetch on the retry tick
process_pending_retries runs inline on the node's 1-second rx-loop
tick. For each due peer it awaited a Nostr relay fetch carrying a
2-second timeout, and discarded the result. With up to sixteen due
peers in one tick body, the timeouts stack: field profiling measured
single 2.00 s stalls as the common case and a worst tick of 12.4 s
against a 1 s period, with every other rx-loop arm delayed behind it
by as much as 4.2 s.

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

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

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

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

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

Two smaller fixes ride along. The responder now resolves its relays
before binding a socket and running STUN, instead of spending a STUN
round trip and holding an offer slot only to discover it has nowhere
to answer. And it gained the empty-list guard the initiator already
had, which gives BootstrapError::MissingRelays a condition it can
reach for the first time -- it was unreachable, since the merge always
appended our own DM relays.
2026-07-29 00:31:36 +00:00