Merge branch 'maint'

# Conflicts:
#	CHANGELOG.md
#	src/bin/fips.rs
This commit is contained in:
Johnathan Corgan
2026-08-11 07:03:22 +00:00
4 changed files with 199 additions and 5 deletions
+81 -4
View File
@@ -138,15 +138,92 @@ jobs:
run: cargo install cargo-zigbuild --version 0.19.8 --locked
- name: Install zig (required by cargo-zigbuild)
shell: bash
run: |
set -euo pipefail
ZIG_VERSION="0.13.0"
# Each arch carries the expected SHA-256 of its upstream tarball,
# taken from ziglang.org's own https://ziglang.org/download/index.json,
# field .["<version>"]["<arch>-linux"].shasum:
# jq -r '.["0.13.0"]["x86_64-linux"].shasum' index.json
# Upstream publishes no .sha256 sidecar and no SHA256SUMS, so
# index.json is the only checksum document offered, and it lists only
# recent releases: once a version ages out of it the pin can no longer
# be re-derived upstream. Bumping ZIG_VERSION means replacing every
# hash below, and adding an arch means adding its hash here too.
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) ZIG_ARCH="x86_64" ;;
aarch64|arm64) ZIG_ARCH="aarch64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
x86_64|amd64)
ZIG_ARCH="x86_64"
ZIG_SHA256="d45312e61ebcc48032b77bc4cf7fd6915c11fa16e4aad116b66c9468211230ea"
;;
aarch64|arm64)
ZIG_ARCH="aarch64"
ZIG_SHA256="041ac42323837eb5624068acd8b00cd5777dac4cf91179e8dad7a7e90dd0c556"
;;
*)
echo "Unsupported architecture: $ARCH"
exit 1
;;
esac
curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" | sudo tar xJ -C /opt
if [ -z "${ZIG_SHA256:-}" ]; then
echo "No SHA-256 pinned for zig ${ZIG_VERSION} on ${ZIG_ARCH}."
echo "Add one to the case above, from https://ziglang.org/download/index.json:"
echo " jq -r '.[\"${ZIG_VERSION}\"][\"${ZIG_ARCH}-linux\"].shasum'"
exit 1
fi
NAME="zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz"
URL="https://ziglang.org/download/${ZIG_VERSION}/${NAME}"
# Stage outside the checkout so a failed attempt cannot leave a stray
# tarball in the working tree.
ZIG_TMP="$(mktemp -d)"
trap 'rm -rf "$ZIG_TMP"' EXIT
TARBALL="${ZIG_TMP}/${NAME}"
# Download to a file and check it before anything consumes it: piping
# curl straight into tar let a truncated transfer reach the extractor,
# which is how this step failed. curl's own --retry does not cover a
# short read (exit 18), and a checksum mismatch needs a fresh download
# anyway, so the retry is an explicit bounded loop.
verified=""
previous=""
for attempt in 1 2 3; do
rm -f "$TARBALL"
if curl -fsSL -o "$TARBALL" "$URL" && [ -s "$TARBALL" ]; then
actual="$(sha256sum < "$TARBALL" | cut -d' ' -f1)"
if [ "$actual" = "$ZIG_SHA256" ]; then
echo "zig tarball matches its pinned SHA-256 (${actual})"
verified=yes
break
fi
echo "zig tarball failed its checksum on attempt ${attempt}:"
echo " expected ${ZIG_SHA256}"
echo " actual ${actual}"
echo " size $(wc -c < "$TARBALL") bytes"
if [ "$actual" = "$previous" ]; then
echo "Two attempts fetched byte-identical content, so retrying is not"
echo "going to help: the pin is stale, upstream re-published, or the"
echo "source is serving the same bad file every time."
break
fi
previous="$actual"
else
echo "zig tarball download failed on attempt ${attempt}"
fi
if [ "$attempt" -lt 3 ]; then
sleep $((attempt * 10))
fi
done
if [ -z "$verified" ]; then
echo "zig ${ZIG_VERSION} (${ZIG_ARCH}) did not download with its pinned"
echo "SHA-256 after ${attempt} attempt(s). Refusing to extract a tarball"
echo "that does not match the pin; failing the build."
exit 1
fi
sudo tar xJ -C /opt -f "$TARBALL"
sudo ln -sf /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
zig version
+101
View File
@@ -86,6 +86,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
folded into the new tables with a one-time deprecation warning; migrate your
`fips.yaml` to the new keys.
- Config validation now rejects two `node.rekey` settings that appear to
disable the trigger and in fact fire it continuously. `after_messages` of
zero makes the message-count arm true on every poll, because the trigger
compares the counter with greater-or-equal. `after_secs` at or below the
per-session jitter bound is the same trap on the timer arm: each session
offsets the interval by a random value within plus or minus that bound, so a
smaller interval saturates to zero on a negative draw and rekeys on sight,
for roughly half of sessions. Both are checked whether or not rekey is
enabled, so switching it on later cannot surface the error at a surprising
moment, and neither gains an upper bound — a very large value remains the
supported way to disable one arm. A config carrying either setting now fails
to load instead of starting a node that rekeys constantly.
- Peer bloom filters are computed for every recipient in one prefix and suffix
union sweep rather than rebuilt per recipient. Announcing to R peers
previously did R full map builds and R by T merges; at 240 peers that was
20.6 ms per tick, roughly half the tick body, with a median per-interval
maximum of 34.5 ms. The result is exactly equal rather than approximately:
merging is a bytewise OR, so regrouping the unions cannot change it. The
trade-off, measured rather than assumed, is that the sweep does its full work
regardless of how many peers are ready, so a tick announcing to one or two
peers now costs about twice what it did; break-even is around three ready
peers. Cadence, the debounce, the sequence rule and the fill-ratio cap are
unchanged.
- Each peer's npub is derived once at construction instead of once per tick.
The per-tick stats snapshot ran a bech32 encode for every tracked peer, and a
second one for the common peer with no hosts-file entry and no alias, since
the display-name fallback bottoms out in the same encode: 14.1 ms per tick at
240 peers. The display name itself is deliberately not cached, because the
alias map and the host map both mutate at runtime.
- The peer-retry tick no longer awaits the Nostr advert refetch. It ran inline
on the 1-second rx-loop tick, awaiting a fetch with a 2-second timeout for
each due peer and discarding the result; with up to sixteen due peers the
timeouts stacked, and 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, delaying every
other rx-loop arm by as much as 4.2 s. The refetch is now spawned, so a dial
uses the advert cached at that moment and the refreshed one lands for that
peer's next retry.
- The `Adopted NAT traversal socket` log line now carries the transport id and
the local address alongside the peer npub. Without the local address an
operator cannot join a host socket table against adoption events, and without
the transport id several peers sharing one adopted transport are
indistinguishable from several separate adopted transports.
- Inbound msg1 is classified before it is rate limited, and rekey or restart
msg1 arriving on an established link now draws on its own token bucket
instead of competing with stranger admission for a single shared one. On a
@@ -116,6 +163,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Nostr NAT traversal signals are now sent only to relays the client pool
actually holds. A signal is addressed to the merge of the peer's NIP-17 inbox
relays, the relays its advert nominates for signaling, and our own DM relays,
but the pool is built once at startup from the configured relays and the send
is rejected outright, before anything is contacted, if any single URL in that
list is outside it. One unconfigured relay anywhere in the merge therefore
killed the whole attempt, including the sends to relays both sides shared. On
a public node in open mode this made discovery non-functional: 309 traversal
attempts, 290 explicit failures, zero successes, every failure on `relay not
found`. Configured peers were unaffected, since they run a matching relay
set. Comparison is on the normalized relay URL rather than the raw string, so
a configured relay spelled with a trailing slash or different host case is
not discarded. Two smaller fixes ride along: the responder resolves its
relays before binding a socket and running STUN, rather than spending a STUN
round trip and holding an offer slot only to find it has nowhere to answer,
and it gained the empty-relay-list guard the initiator already had.
- A failed log write can no longer panic the thread or task that logged. The
subscriber was built with the default internal-error reporting, which sends a
failed write to `eprintln!`, and that macro panics when stderr has also
failed. The shipped supervisor configurations make that a single condition
rather than two: the macOS plist points both standard streams at one
unrotated file, and the systemd units route both to journald, so one full
disk fails both sinks together. In the daemon a crypto worker was the case
that mattered — it logs a warning on send backpressure, and a worker that
dies takes its share of the peer space with it permanently, while the panic
message is discarded along the same broken path. In `fips-gateway`, which
built its subscriber the same way, the casualty is a spawned task: the DNS
resolver, the control accept loop or the pool tick, none of which is observed
until shutdown, so the process would keep running and reporting healthy with
mesh name resolution or lease expiry and NAT cleanup silently stopped.
- macOS: `peers.allow`, `peers.deny`, and the `hosts` file are now read
from `/usr/local/etc/fips/`, matching the install layout the macOS
packaging ships (`packaging/macos/`). The default-path constants were
@@ -193,6 +272,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
counter now charges at the node that makes the decision rather than at the
hop after it.
### Security
- The FSP session address is now bound to the peer key the Noise handshake
authenticated, on both the initial and the rekey path. The responder recorded
a session under the source address carried in the datagram without ever
checking that address against the static key it had just authenticated, so a
peer could 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. The address is now derived from the authenticated key at the point it
first becomes available in msg3, and a mismatch drops the half-open session
without recording either the identity or the session. The rekey responder
needed its own check: it returns before that code is reached and 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 and abandons the rekey while leaving the existing session
intact, rather than tearing the session down, which would have handed an
attacker a way to kill established sessions. Both comparisons are on x-only
keys, because a stored key may carry a synthesized parity while the handshake
learns the true point. The two rejections are counted separately in the
session reject statistics.
## [0.4.1] - 2026-07-19
### Changed
+10 -1
View File
@@ -67,7 +67,16 @@ async fn main() {
)
.from_env_lossy();
fmt().with_env_filter(filter).with_target(true).init();
// As in the daemon: a failed log write must not panic whoever logged. The
// default reports write failures with `eprintln!`, which panics when stderr
// fails too, and both units send stdout and stderr to journald. Here the
// casualty is a spawned task — the DNS resolver or the pool tick — whose
// handle nothing observes until shutdown.
fmt()
.with_env_filter(filter)
.with_target(true)
.log_internal_errors(false)
.init();
info!("fips-gateway {} starting", version::short_version());
+7
View File
@@ -101,10 +101,17 @@ async fn run_daemon(
// ANSI color only when stdout is a terminal — under a supervisor
// (daemon(8), systemd) escape codes would litter the log file.
//
// Never let a failed log write panic the thread that logged. The default
// is to report a write failure with `eprintln!`, which itself panics when
// stderr fails too — and the shipped supervisor configs point stdout and
// stderr at the same place, so one full disk satisfies both. A worker
// thread killed that way takes its share of the peer space with it.
fmt()
.with_env_filter(filter)
.with_target(true)
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout()))
.log_internal_errors(false)
.init();
info!("FIPS {} starting", version::short_version());