mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
d4687e5d3081d13e0ab68543e3e3a3d964415fb4
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d4687e5d30 |
node: gate outbound connection initiation on max_peers
node.limits.max_peers was honored only on inbound msg1 admission (handshake.rs handle_msg1 returns PeerLimitExceeded when peers.len is at the cap). Four outbound initiation paths proceeded unconditionally at capacity: auto-reconnect retries (process_pending_retries), Nostr-mediated discovery's BootstrapEvent::Established adoption (poll_nostr_discovery), NAT-traversal punch initiation (the outgoing side of the offer/answer/punch sequence in the Nostr discovery runtime), and NAT-traversal punch response (the incoming side of the same sequence). A saturated node burned CPU, UDP probes, STUN observations, and Nostr relay traffic on connections that the inbound gate would reject the moment they reached msg1. Introduce Node::outbound_admission_check (peers.len < max_peers, or true when max_peers == 0 as the no-cap sentinel) and gate the four paths. The discovery runtime lives in a separate task and does not hold a Node reference; bridge via an Arc<AtomicBool> the runtime reads and Node refreshes once per tick from outbound_admission_check. The atomic granularity is intentionally loose: one-tick lag is acceptable because the inbound msg1 gate continues to be the authoritative cap, and in-flight handshakes started below the cap are allowed to complete. Inbound gate at handshake.rs is unchanged. |
||
|
|
df43ac79b9 |
node: skip parent explicitly in compute_mesh_size children loop
The mesh-size estimator's children loop relied on the cached peer_declaration(parent_id).parent_id() != my_addr check to exclude the parent. That cached view briefly disagrees with our own latest my_declaration().parent_id() during the window between a local parent-switch and the new parent's next inbound TreeAnnounce: the peer-declaration cache still names us as the parent's parent, so the parent is iterated as if it were a child and its (typically dominant) bloom cardinality is added a second time. Symptom: estimated mesh size displayed in fipsctl show status and fipstop nearly-but-not-exactly doubles during tree rebalancing. Make the invariant structural with an explicit peer_addr == parent_id skip at the head of the children loop. Per-peer 500 ms rate-limiter and overall recompute cadence are unchanged. Adds a regression test that constructs the stale-peer-declaration scenario directly and asserts the parent is not double-counted. |
||
|
|
ffd78440a8 |
node: periodically re-broadcast TreeAnnounce on no-change in check_periodic_parent_reeval
Closes the eventually-consistent gap in spanning-tree state distribution. Every existing send_tree_announce_to_all call site gates on a local state-change event (parent switch, self-root promotion, ancestry change, peer promotion, parent loss). Once a partition latches — for example a parent-switch announce stranded in the brief cross-init handshake swap window, where the announce arrives on a session-index whose decrypt-worker entry has been unregistered — neither side's state changes again, so neither side ever re-broadcasts. The existing 60 s check_periodic_parent_reeval was a re-evaluation, not a re-broadcast: it short-circuited silently on no-change. Production-side healing depended on incidental link churn; lab harnesses with stable docker-bridge links had no equivalent path. Add a final else branch that fires send_tree_announce_to_all unconditionally on the no-change path, alongside the existing switch and self-promote arms. Receivers coalesce by sequence comparison (ParentDeclaration::is_fresher_than) and short-circuit at the `if !updated` gate in handle_tree_announce; same-sequence repeats drop silently with no cascade. The per-peer 500 ms rate-limiter is well below this 60 s cadence and does not suppress the heartbeat broadcast. The fix is a general protocol-robustness improvement: it addresses any in-flight TreeAnnounce loss class, not only the specific cross-init swap-window drop site. testing/static/scripts/rekey-test.sh BASELINE_CONVERGENCE_TIMEOUT 60 -> 65 so a partition healed by the periodic broadcast at T+60 lands inside the convergence window. wait_for_full_baseline early-exits on PASS, so successful reps see no extra wall-clock. |
||
|
|
4d5380604a |
node: don't drive connect-on-send from the rx_loop tick path
The tick body's per-peer check_* loops (heartbeats, bloom announces, MMP reports, tree announces) called transport.send for every active peer, which on TCP/Tor fell through to a 5 s connect-on-send wait for any peer whose pool entry was not yet established. That wedged the entire tick body for the full connect_timeout_ms per unreachable peer; under post-restart convergence on a high-peer mesh, this cascaded into multi- second tick stalls. On master, the same mechanism also starved the per-tick control-snapshot republish and pushed fipsctl queries onto an mpsc fallback that was itself queued behind the wedged rx_loop, producing the 5-second fipsctl head-of-line pattern operators observed on loaded nodes. Gate send_encrypted_link_message_with_ce on transport.connection_state before the send: proceed only when Connected; on None, kick off a non-blocking background connect (idempotent — TransportHandle::connect dedupes against the connecting pool and spawns the timeout-bounded TcpStream::connect inside its own tokio task) and fail this send fast with a clear "transport connection not ready" error. A subsequent tick retries once the pool has an entry. The reconnect lifecycle (check_link_heartbeats, process_pending_retries, poll_pending_connects) is unchanged. The connect-on-send branch in transport.send_async itself remains in place for code paths that legitimately need synchronous connect (e.g., explicit operator-driven fipsctl connect). |
||
|
|
f396d71826 |
node: deterministic tie-breaker for cross-init NAT traversal adoption
When both peers' Nostr-mediated UDP punches complete within the same scheduling window, each side's `BootstrapEvent::Established` event arrives with `is_connecting_to_peer` already true: each side received an inbound msg1 from the peer's pre-punch outbound attempt, which created a connecting-state record. The deduplication skip then fires on both sides, neither installs the fresh traversal socket as canonical, and the peer-adoption budget (45 s) expires. Cross-node wall-clock alignment of the skip log line in observed failures was within ~1 ms — simultaneous dual- fire under contention, the dual-initiation pattern. Apply the deterministic NodeAddr tie-breaker already used at `handlers/handshake.rs:269` for rekey dual-initiation and in `peer::cross_connection_winner` for cross-connection resolution. Smaller NodeAddr wins as adopter: enumerate the in-flight connections whose `expected_identity` points at this peer, tear them down via the canonical `cleanup_stale_connection` helper, and fall through to `adopt_established_traversal`. Larger NodeAddr loses and keeps the existing `continue` semantics; the loser's in-flight outbound is reconciled by `handle_msg1`'s cross- connection logic when the winner's fresh msg1 arrives over the adopted socket. `cleanup_stale_connection` visibility bumped from module-private to `pub(in crate::node)` so it is callable from `lifecycle.rs`. The defensive re-check inside `adopt_established_traversal` itself is left as-is — after the outer cleanup the winner reaches it with `is_connecting_to_peer == false`, so the inner skip won't trip. The `BootstrapEvent::Failed` arm is unchanged: there is no winning outcome on dual failure, and the existing skip + retry-schedule semantics are correct. |
||
|
|
6e5cb8965f |
Make FSP session rekey hitless under packet loss and reordering
An FSP session rekey could leave the two endpoints holding different key sets for a brief window: if a handshake message was lost in transit, one side rotated to the new keys while the other did not. Traffic sealed in one key epoch then reached a peer still on the other epoch and failed to decrypt, producing bursts of AEAD decryption failures and dropped connectivity until a later rekey cycle reconverged the pair. Choreographing the cutover order cannot close this window: any fixed ordering still leaves a skew that packet reordering widens. Make rekey correctness independent of cutover timing by overlapping the key epochs on the receive path. During a rekey transition the receiver trial-decrypts each frame against every live session it holds: current, the not-yet-promoted pending session, and the draining previous session. The K-bit becomes a hint that orders the trial-decrypt cascade rather than a hard gate, and a frame that authenticates against the pending session is itself the cutover signal. No rotation ordering and no packet reordering can then cause a decryption failure. The pre-rekey Noise session is held in the `previous` slot until the peer has demonstrably moved off it. Its drain deadline is anchored on the most recent frame the peer authenticated against that slot, refreshed each time the trial-decrypt cascade lands there, rather than on a fixed wall-clock timer started unilaterally at the local cutover. A peer that never received the new keys keeps authenticating against `previous` and the slot stays live; without this, a fixed timer would erase the only key set that could decrypt the peer's frames, producing a permanent silent decrypt failure on a live data path. A peer that never catches up is handled by the existing FSP session liveness path rather than by silent decrypt failure. The lost-handshake liveness gap is closed separately by retransmitting the third rekey handshake message until the peer is confirmed on the new keys, with a bounded retry budget after which the rekey cycle is cleanly abandoned and retried on the next timer. Adds unit tests covering the trial-decrypt cascade (epoch selection, promotion on pending decrypt, reordered old-epoch stragglers after cutover, per-slot replay-window integrity), the msg3 retransmission lifecycle, and the peer-progress-aware drain retirement. |
||
|
|
66020bc318 |
changelog: document the macOS package-integrity fix
Commit
|
||
|
|
57a089f6c3 |
macos package: derive package arch from the build target
The published v0.3.0 macOS installer is a structurally corrupt xar archive: pkgutil and xar reject it even though its SHA-256 matches the published checksum. build-pkg.sh derived the architecture suffix in the .pkg filename from `uname -m`. On the Apple-silicon macOS runner that always reports arm64, so the cross-compiled x86_64 build also named its output fips-<version>-macos-arm64.pkg. The release job downloads both build artifacts with merge-multiple into one directory, where the two identically named files collide and tear into a malformed result. The x86_64 package never reaches the release at all. Derive the package architecture from the Rust target triple, which is authoritative for cross-compiles, instead of from the build host. Each matrix leg now produces a distinctly named, arch-correct package, so the two artifacts no longer collide. Add a SHA-256 integrity chain so a corrupt or mismatched asset cannot be published again: - Capture the .pkg SHA-256 on the macOS runner, after the on-runner structural verification, into a sidecar file carried in the artifact. - Add a verify-handoff job that runs on every trigger and asserts each downloaded .pkg still matches its macOS-runner SHA-256. - Gate the release job on verify-handoff and repeat the check on the exact bytes about to be published. The build step now asserts it produced the expected arch-named package so a regression in the naming fails loudly rather than as a silent collision. Relates to #102. The published v0.3.0 macOS assets still need to be rebuilt and reuploaded separately. |
||
|
|
79ae430725 |
docs: add PR-REVIEW.md checklist and link from CONTRIBUTING
Publish the 13-criteria PR review checklist the maintainer runs on every incoming PR so contributors (and their coding agents) can run the same pass before opening, surfacing problems before the review round trip. CONTRIBUTING.md gets a new 'Self-review against the project review checklist' subsection under 'Submitting pull requests' and a Further Reading entry. CHANGELOG [Unreleased] gets an Added entry. |
||
|
|
2bc9dd557a |
changelog: backfill surgical coord-cache invalidation + rekey-test ping retry
Two Fixed entries appended to [Unreleased]: - The coord cache surgical invalidation ( |
||
|
|
306e455513 |
rekey-test: retry strict-ping asserts on failure
The Phase 1, Phase 3, and Phase 5 strict asserts each fire a single ping per directed pair. Under low-level packet loss (e.g. 1% i.i.d. per-direction loss from a CI runner under pressure), a single-shot round-trip fails at ~2% per pair, so a 20-pair strict assert misses with probability 1 - (0.98)^20 = ~33% per phase from ICMP noise alone, well above the routing-state signal the asserts are meant to catch. ping_one gains a max_attempts parameter (default 1, preserving existing call sites). On failure it retries up to MAX_PING_ATTEMPTS-1 additional times with PING_RETRY_DELAY seconds between attempts. Per-pair worst case under the defaults (4 attempts, 1 s spacing, 5 s ping6 -W timeout) is 4*5 + 3 = 23 s; per-rep worst case scales with the failing-pair count. Successful retries log "OK (RTT, attempt N)"; exhausted retries log "FAIL (after N attempts)". The retry budget is wired into all three strict asserts: - Phase 1 final ping_all (after wait_for_full_baseline converges) - Phase 3 ping_all (post-first-rekey) - Phase 5 ping_all (post-second-rekey) The wait_for_full_baseline convergence loop itself stays single-shot. Its job is to detect when the mesh first sees a fully clean 20-pair batch, and retries inside the loop would conflate transient ping loss with still-converging routing state. No daemon code changes. |
||
|
|
49bd210480 |
cache: scope coord cache invalidation to entries actually affected by topology change
Replaces the unconditional `CoordCache::clear()` calls at parent-switch, become-root, and loop-detection sites with two targeted invalidation methods scoped to what actually makes an entry stale: - `invalidate_via_node(node_addr)`: drop entries whose cached destination ancestry contains `node_addr`. Used at parent-position- change sites — our prefix changed, so destinations downstream of us have stale-prefix coords. - `invalidate_other_roots(current_root)`: drop entries rooted under a different root than the current one. Used at root-change sites. Under the previous global flush, parent switches blanked the cache across the board, leaving `find_next_hop` returning `None` for every non-direct-peer destination until the cache passively re-warmed via incoming TreeAnnounces / SessionSetup. Surgical invalidation preserves entries that remain correct after the topology change. The cached coord describes a destination's tree position; that position only goes stale relative to our own routing decisions when our own prefix changes (entries we are downstream of) or the root changes (entries in a different tree). Peer removal does not invalidate cached coords: `Node::find_next_hop` recomputes the next-hop decision on every call against the current peer set, bloom filters, and tree state, and Discovery already triggers on `no route to destination` errors when a destination becomes unroutable through us. The peer-removal site retains the original "no cache invalidation" behavior. Each method returns the count of entries removed for observability. Unit tests cover each method against the cases enumerated in the acceptance criterion. |
||
|
|
ab1e248ff4 |
changelog: add acl-allowlist + AUR-publish coverage, merge CI entries
Bring [Unreleased] into sync with all maint commits since v0.3.0: - Add a Fixed entry for the acl-allowlist test-script poll-assertion conversion (commit |
||
|
|
7f518731c8 |
ci: cancel in-progress runs on same-ref pushes
Add a top-level concurrency block to ci.yml keyed on (workflow, ref) with cancel-in-progress: true. Pushes (including force-pushes) to the same ref now retire any in-flight run for that ref rather than letting the superseded and current-tip runs both burn runner minutes. Motivated by a 2026-05-14 force-push experience where two CI runs ran concurrently against a feature branch — the original push at c76ec99 continued for ~17 minutes alongside the amended push at 927ef47, despite only the latter being the live tip. Scope deliberately limited to ci.yml. Tag-triggered release-build workflows (package-*.yml, aur-publish-*.yml) are untouched — they operate on per-tag refs that already form distinct concurrency groups and release artifact builds generally should not be cancellable by unrelated activity. |
||
|
|
80fb086071 |
test(rekey): add Phase 5 settle window for post-second-rekey convergence
Phase 5's per-pair connectivity check ran immediately after the second rekey cycle, with no settle for routing reconvergence. Under GitHub-runner CPU contention, post-rekey parent-switches and coord-cache flushes can take longer than the per-ping 5s timeout for a small fraction of pairs (1-3 of 20 typically), even though the rekey mechanism itself completes cleanly. Phase 6 log analysis stays all-green on these failed runs; the failure is purely connectivity timing. Mirror Phase 3's existing 12-second settle pattern: reuse REKEY_SETTLE and emit the same banner shape. Two-line change. Cost on the success path is a fixed 12s per suite run. |
||
|
|
e9dd3167f2 |
test(acl-allowlist): poll log assertion to absorb XX-handshake timing race
Convert assert_log_contains from a one-shot grep snapshot into a bounded poll that retries until the pattern appears or the timeout elapses (default 15s). Same wait-with-timeout shape as wait_for_peers_exact above it in the file. The pre-existing flake on next-branch CI is structural: under XX handshake, the cross-connection tie-breaker selects which side reaches its ACL-check point first. When container-a wins the tie-breaker on the first attempt against c and d, only the outbound-handshake-context rejection fires immediately, and the inbound-handshake-context rejection only emits on a later retry when c or d's msg1 lands while a has no pending outbound. On one 2026-05-14 run the inbound rejection appeared 63ms after the test had given up. The race window is small but real. Polling the log instead of one-shot reading absorbs the millisecond-to-second variance without slowing the success path (the helper returns as soon as the pattern appears). |
||
|
|
4f3d2f8471 |
rekey: apply symmetric jitter to desynchronize dual-initiation
Add a per-session signed jitter offset (uniform [-15, +15] seconds) to the rekey timer triggers in check_rekey (FMP) and check_session_rekey (FSP). The configured `node.rekey.after_secs` becomes the nominal interval rather than a floor; mean is preserved. Desynchronizes both endpoints in symmetric-start meshes so the dual-initiation race stops occurring rather than being resolved after the fact by the smaller-NodeAddr tie-breaker. Per-session storage means each rekey cutover reconstructs the session and redraws the jitter naturally — successive cycles get independent offsets, preventing drift back into sync. |
||
|
|
7bd8d3b7a0 |
Update CHANGELOG for unreleased work on maint
Three entries under [Unreleased] for the three commits since the v0.3.0 release tag: - Sidecar example: FIPS_UDP_MTU env override (commit |
||
|
|
538ce077df |
docs: rewrite CONTRIBUTING.md, add docs/branching.md
The previous CONTRIBUTING.md read like a stock Rust contributing template that mentioned FIPS. Replace it with an entry-point doc that gives new contributors the FIPS-specific mental model they need to make a useful first PR: the FMP/FSP layering and why mesh-level changes need multi-node testing, the three-branch release model and how to choose a target branch, structured bug reporting expectations, and PR submission requirements (scope discipline, the local-CI ladder, separate requirements for feature PRs vs bug-fix PRs, squash-merge mechanics). Add a contributor-facing AI coding assistant policy: use is welcome, but the contributor must do a thorough manual review and editorial pass before submission. The agent is a tool; the contributor is accountable for the submission. Review effort scales with submission effort -- unreviewed agent output will receive an agent reply in turn, without human review. Add docs/branching.md as the long-form companion covering the release workflow, version conventions, and merge-direction rationale. The new CONTRIBUTING.md is the day-to-day entry point; docs/branching.md is the reference. All cross-references resolve against current HEAD. Previous stale links to fips-intro.md, fips-wire-formats.md, and fips-configuration.md are gone; the doc points at the current docs/design/ layout, docs/getting-started.md, docs/tutorials/join-the-test-mesh.md, and testing/README.md. No code changes. |
||
|
|
6533276eda |
test(rekey): bump Phase 1 baseline-convergence headroom 36s → 60s
The Phase 1 pre-rekey baseline in `wait_for_full_baseline` occasionally times out on GitHub-hosted runners with one ping pair failing to converge inside the BASELINE_CONVERGENCE_TIMEOUT window. Phases 2–6 always pass cleanly when this happens — the rekey itself is fine, the mesh just hasn't finished spanning-tree + bloom-filter convergence by the time Phase 1 starts pinging. The wait loop returns as soon as all 20 pairs converge, so the cost on the success path is unchanged (typical local CI returns well under the old 36s). The bump only adds headroom on the failure path. Operators previously worked around this with `gh run rerun --failed`; this aims to retire that workaround for the IK/XK lines. Distinct from the next-branch XX rekey dual-init race, which is a real protocol bug tracked separately. |
||
|
|
32a3b58d1f |
docs(sidecar): make UDP MTU env-overridable end-to-end
The sidecar entrypoint hardcoded `udp.mtu: 1472`, the Docker-bridge IPv4 maximum (1500 MTU - 8 UDP - 20 IPv4 header). Promote it to `FIPS_UDP_MTU` (defaulting to 1472, preserving behavior) so non-Docker reuses of the example can override without editing the script. Plumb the env var through `docker-compose.yml` so a host-level setting reaches the container, and add a row to the README's env-var table. Also annotate the static-CI node template with a comment explaining the same 1472 rationale and the daemon's 1280 default. The template keeps 1472 as the literal value since the CI suite runs on a Docker bridge where that's correct. No behavior change unless the host explicitly sets FIPS_UDP_MTU. |
||
|
|
627fd3627b |
chore: open v0.3.1-dev cycle on maint
Reset maint to v0.3.0 to retire the v0.2.x maintenance window and open a new tracking branch for v0.3.1-dev bug-fix work. - Cargo: 0.3.0 → 0.3.1-dev - CHANGELOG: fresh [Unreleased] block above [0.3.0] - README: badge v0.3.0 → v0.3.1--dev |
||
|
|
1617f6ec1c |
Release v0.3.0
Bump Cargo.toml version 0.3.0-dev -> 0.3.0 and resync Cargo.lock, move the CHANGELOG [Unreleased] block under [0.3.0] - 2026-05-11, update the README status badge and prose to v0.3.0, and add the release notes at docs/releases/release-notes-v0.3.0.md with a mirrored copy at the repo root as RELEASE-NOTES.md. |
||
|
|
025ab49d26 |
Merge maint into master after v0.2.1 release
Uses the 'ours' merge strategy to keep all of master's tree verbatim with one carve-out: the v0.2.1 release notes archive at docs/releases/release-notes-v0.2.1.md is retained as a permanent docs artifact. Discarded from maint's release-prep commit: Cargo.toml / Cargo.lock version bumps (master stays on v0.3.0-dev), README v0.2.1 badge and prose, and the RELEASE-NOTES.md root mirror (master's root mirror will eventually carry the v0.3.0 release notes when v0.3.0 ships from this line). CHANGELOG: the v0.2.1 release section is integrated between [Unreleased] (v0.3.0-dev work) and the [0.2.0] historical section. Items that landed on maint during the v0.2.1 cycle are removed from [Unreleased] so [0.2.1] is the canonical home for those changes (Linux release artifact and AUR workflows; bloom fill-ratio validation; seven maint-line bug fixes). |
||
|
|
d72e619c51 |
Release v0.2.1
Bump Cargo.toml version 0.2.1-dev -> 0.2.1 and resync Cargo.lock, move the CHANGELOG [Unreleased] block under [0.2.1] - 2026-05-11, update the README status badge and prose to v0.2.1, and add the release notes at docs/releases/release-notes-v0.2.1.md with a mirrored copy at the repo root as RELEASE-NOTES.md. |
||
|
|
0e57216d98 |
testing: rename vps-chi to test-us01 in tor socks5-outbound scaffold
The host (217.77.8.91:443) was renamed to test-us01 some time ago (canonical name shared with packaging/common/hosts and the docs); the test scaffold's alias labels and narrative comments hadn't been updated. Pure naming cleanup, no behavior change. The continued dependency on the live external host is tracked separately. |
||
|
|
77fdd52fe0 |
docs: reviewer feedback pass on Nostr-discovery surface
Walk through reviewer feedback on the Nostr-discovery docs and land 18 items. Bulk patterns: - `external_addr` / `public: true` semantics consistently misdescribed. The advert path is gated on `cfg.is_public()`; inside that branch the daemon picks an address by precedence (`external_addr`, non-wildcard `bind_addr`, STUN). The docs treated `public: true` and `external_addr` as alternatives when they are stacked: `public: true` is the master switch and `external_addr` populates the address inside it. Reconciled across `enable-nostr-discovery.md` and `advertise-your-node.md`: add `public: true` to the `external_addr` examples; replace "STUN as a logging cross-check" with "STUN is skipped entirely"; fix "neither flag is needed" for direct public bind (both flags still required); make the publish-tutorial Step 3 conditional on the chosen Step 2 path (STUN runs only on the `public: true` path); rewrite the troubleshooting "wrong public IP advertised" bullet with two coherent fixes. - `udp:nat` overpromised as a symmetric-NAT solution. Symmetric NAT on either side typically defeats the punch. Reframe `udp:nat` as best-effort hole-punching for nodes without a directly reachable UDP endpoint in the how-to, the publish tutorial (intro, callout, section heading rewrite from "If you're behind symmetric NAT" to "If your direct UDP advert isn't reachable"), the consume tutorial's "What's next" pointer, and `tutorials/README.md`. Promote reachability over named NAT classes: STUN can confirm the public IP but not that the listener-port mapping is open. - YAML "silently ignores unknown keys" is wrong. Config parser rejects unknown fields via `serde(deny_unknown_fields)` on the per-section structs; misspelled fields refuse the daemon's start with a parse-error line in the journal. Fixed in the publish tutorial's troubleshooting and the open-discovery tutorial's `policy` typo bullet. Mechanical fixes: - Repoint stale anchors. `getting-started.md` and `configuration.md` linked to `#installation` / `#inspect` on the README; the README has no such headings. Repoint to `#quick-start` and `cli-fipsctl.md`. Two stale anchors in the publish tutorial pointing at non-existent sub-scenarios in the how-to (`#sub-scenario-2c-...`, `#sub-scenario-2b-tor-onion-node`) repointed to the correct anchors. - Drop the `fipsctl show status` claim from the open-discovery troubleshooting bullet (`show_status` doesn't include `discovery.nostr.policy`). Replace with daemon startup logs. - Fix the `advertise: false` parenthetical in the consume-only tutorial (`default_advertise()` returns `true`; we set `false` explicitly for the consume-only path). - Drop the "supplies a relay list" overstatement in two activation paragraphs (the how-to and the design doc). Default relay / STUN-server lists ship in the config; both are optional overrides. - Add the missing `transports.udp.public` entry to the open-discovery tutorial's prerequisites checklist. Tutorial users coming out of advertise-your-node could be on either the direct-UDP (`public: true`) or `udp:nat` (`public: false`) path; list both. Files: docs/getting-started.md, docs/reference/configuration.md, docs/how-to/enable-nostr-discovery.md, docs/tutorials/README.md, docs/tutorials/advertise-your-node.md, docs/tutorials/resolve-peers-via-nostr.md, docs/tutorials/open-discovery.md, docs/design/fips-nostr-discovery.md. |
||
|
|
42b88c9bb8 |
docs: refresh README, CONTRIBUTING, and examples for v0.3.0
Four short prose corrections folded together to align operator-facing
docs with current v0.3.0 reality:
- README Rust prerequisite reconciled with the toolchain pin (1.94.1,
was 1.85+).
- CONTRIBUTING.md bumps the Rust prerequisite and adds the squash-merge
policy note.
- examples/sidecar-nostr-relay/Dockerfile drops stale --features tui
and the paired --no-default-features; the tui/ble/gateway cargo
features were replaced by platform cfg gates in
|
||
|
|
eaba693b18 |
packaging: bring systemd tarball to feature parity with .deb / AUR
The generic systemd install tarball is the catch-all install path
for systemd Linux distros that don't have a per-format package
(Fedora, RHEL/CentOS, openSUSE, Alpine, etc.). It had drifted
behind the .deb and AUR packages and was missing fips-gateway, the
mesh-interface firewall baseline, and the multi-backend DNS helper
in the shipped tarball. Bring it to parity:
- New `packaging/systemd/fips-gateway.service` (clone of the .deb
unit; ExecStart pointed at `/usr/local/bin/fips-gateway`). Not
enabled at install time; operator opt-in.
- New `packaging/systemd/fips-firewall.service` (clone of the .deb
unit; nft path unchanged at `/usr/sbin/nft`). Not enabled at
install time; operator opt-in.
- `build-tarball.sh` now bundles the `fips-gateway` binary, the two
new units, the `fips.nft` baseline conffile, and the
`fips-dns-setup` / `fips-dns-teardown` multi-backend helpers from
`packaging/common/`.
- `install.sh` now installs `fips-gateway` to `/usr/local/bin/`,
installs both new units to `/etc/systemd/system/` (without
enabling them), preserves `/etc/fips/fips.nft` on upgrade like
`fips.yaml`, and creates the `/etc/fips/fips.d/` operator drop-in
directory. Post-install messaging mentions both opt-in services.
- `uninstall.sh` stops and disables the optional services in
dependency order (firewall, gateway, dns, daemon), removes the
new unit files, and removes the gateway binary. `--purge` already
handles `/etc/fips/` removal which covers `fips.nft` and
`fips.d/`.
- `README.install.md` documents all of the above: expanded
"What Gets Installed" table, new sections covering the firewall
baseline and the LAN gateway, refreshed DNS section reflecting
the multi-backend setup helper (systemd dns-delegate /
systemd-resolved drop-in / per-link resolvectl / dnsmasq /
NetworkManager-dnsmasq), and updated Service Management.
Also fixes a latent packaging bug: `install.sh` previously
referenced `${SCRIPT_DIR}/../common/fips-dns-setup`, a path that
exists only in the source-repo layout and not in the extracted
tarball. The script now resolves the helper from the staging
directory first (the tarball case), falling back to the source-repo
relative path. Bug latent since the multi-backend DNS helpers
landed.
CHANGELOG `[Unreleased]` documents the parity bump under Changed
and the path-resolution fix under Fixed.
Closes the longest-standing parity gap for non-Debian / non-Arch
systemd Linux distros installing from the release-distribution
tarball.
|
||
|
|
d52d7debb7 |
aur: bring AUR packaging to .deb parity
Three changes folded together close the AUR-side parity gap with the .deb packaging: - PKGBUILD now ships fips.nft baseline and fips-firewall.service, with fips.nft marked as backup so operator edits survive upgrades. - PKGBUILD-git mirrors the release PKGBUILD: installs fips-gateway (was missing entirely), ships fips.nft and fips-firewall.service, and tracks the same backup() set. - packaging/aur/README.md documents the gateway, firewall service, and nft baseline that ship as of this revision. AUR users now receive the same artifact set as .deb users. |
||
|
|
77ecfda1a1 |
changelog: document ring ChaCha20-Poly1305 backend swap
The Noise-session AEAD swap landed as |
||
|
|
8094a51a82 |
nostr: fix subscription startup race losing relay REQ replays
Freshly-restarted nodes with policy: open silently lost the historical
event replay that relays send in response to subscribe(). The
broadcast::Receiver was created INSIDE spawn_notify_loop, which the
tokio runtime starts at some indeterminate point after subscribe()
returns. tokio's broadcast channel only delivers messages sent after
the receiver is created; messages dispatched in the gap between
subscribe() issuing the REQ and the spawned task calling
client.notifications() were dropped by external_notification_sender.send
returning Err(SendError) with no subscribers attached.
Symptom on a node with policy: open: non-configured peers were not
discovered until they next re-published their advert (default
advert_refresh_secs = 1800s = 30 min). Configured peers were unaffected
because fetch_advert (relay-fetch path) caches them at startup-sweep
time. The bug has been latent since
|
||
|
|
0cc3de3daa |
changelog: update [Unreleased]
Three Changed entries for the rx-path performance work (Linux UDP recvmmsg batched receive, run_rx_loop drain batching, and eager pubkey_full precompute on PeerIdentity construction) and five Fixed entries: adopted NAT-traversed UDP transports inheriting the primary listener's MTU and buffer config, TreeAnnounce ancestry on self-root transitions, unconditional overlay-advert refetch before each retry, stale overlay-advert eviction on NoTransportForType, and scheduled retry on startup peer-init failure. Pure CHANGELOG addition (+117 lines, no edits to existing entries). Bullets are wrapped at 80 columns and attribute external contributions to the originating PR and author. |
||
|
|
e471807239 |
testing/firewall: avoid SIGPIPE in drop-counter probe
The drop-counter sanity check piped `nft list table inet fips`
through `awk '/counter packets/ {print $3; exit}'`. Awk's `exit`
on first match closes the pipe, the upstream `nft list` SIGPIPEs
on its next write, `set -o pipefail` makes the pipeline return
141, and the surrounding command-substitution aborts the script
before it can assign DROP_PKTS or print the section header.
Replaces the early-exit pattern with `/counter packets/ && !seen
{ print $3; seen=1 }` — same first-match output, but awk reads
the full input so nft never SIGPIPEs.
The original form had been latent for as long as the test has
existed; recent CI runs at master tip
|
||
|
|
c412646498 |
testing: pre-create /run/fips/ in the unified test image
Mirrors systemd's RuntimeDirectory=fips so the daemon's resolve_default_socket() picks /run/fips/control.sock inside containers, matching production layout and the path that the chaos sim harness (testing/chaos/sim/control.py) probes. Without this, the resolver falls through to /tmp/fips-control.sock (no /run/fips, no XDG_RUNTIME_DIR), the daemon binds there, and the harness's hardcoded /run/fips/control.sock probe returns FileNotFoundError on every node. chaos-bloom-storm then fails its bloom_send_rate assertion with start=0 nodes, end=0 nodes; other chaos scenarios pass only because their tolerances absorb the empty samples. Production hosts always have /run/fips materialized by the fips.service unit's RuntimeDirectory directive before the daemon starts, which is why the regression hit only the containerized test path. Verified locally with chaos-bloom-storm: max per-node delta 27 <= ceiling 30 over the trailing 30s window. |
||
|
|
53ad528f7d |
fipstop: add "Listening on fips0" panel to Node tab
Surfaces local services reachable from the mesh, paired with their
current `inet fips` baseline filter classification. Lands to the
right of the existing TUN section in the Traffic block.
A new daemon control query `show_listening_sockets` returns IPv6
listeners bound to either `::` (wildcard) or the node's fd00::/8
address, each classified as Accept / Drop / Unknown / NoFirewall
against the running inbound chain. fipstop renders the result as a
table beside the Traffic counters: Accept rows in default White,
Drop / Unknown in DarkGray, a yellow banner above the table when
`fips-firewall.service` is inactive, and a trailing `*` on
wildcard binds to remind the operator the bind is not
fips0-specific.
Daemon side:
- `src/control/listening.rs` walks `/proc/net/tcp6` and
`/proc/net/udp6` via the procfs crate (LISTEN state for TCP,
wildcard remote for UDP), filters to fips0-reachable binds, and
resolves inodes to PID / comm via `/proc/<pid>/fd`.
- `src/control/firewall_state.rs` shells out to
`nft -j list table inet fips` and walks the inbound chain.
Recognises canonical accepts (`tcp/udp dport N accept`,
`dport { ... } accept`, `dport A-B accept`), the iifname-scoping
line, conntrack and icmpv6 lines (skipped). Any rule with
unrecognised matchers (saddr filters, jumps, daddr filters) or
non-terminal verdicts forces Unknown classification for the
ports it references. Eleven unit tests cover the classification
logic; the listening enumerator carries a /proc-parsing test of
its own.
- `show_listening_sockets` emits
`{fips0_addr, firewall_active, sockets[]}` with per-row
`{proto, local_addr, port, pid, process, filter, wildcard_bind}`.
fipstop side:
- `src/bin/fipstop/ui/dashboard.rs` splits the Traffic block into
a 50/50 horizontal layout; the existing TUN + Forwarded panel
occupies the left half.
- `src/bin/fipstop/ui/listening.rs` renders the right half.
- `main.rs` fetches the new query each tick when the Node tab is
active. Errors are non-fatal: an old daemon without the query
leaves the payload at None and the panel renders "loading...".
`Cargo.toml` gains `procfs = "0.18"` on the Linux target. IPv4
listeners are not enumerated — fips0 is IPv6-only.
Folded in: revert the default-socket lookup from writability-probe
back to existence-based selection. The previous tempfile-probe on
`/run/fips` silently steered fipstop / fipsctl onto an XDG path
the daemon never bound for any user in the `fips` group whose
shell session had not yet picked up the supplementary group (no
re-login after `usermod -aG`). `XDG_RUNTIME_DIR` is set on every
modern systemd-managed user session, so this hit the common case.
The kernel checks actual group membership at `connect(2)`, so a
user who genuinely cannot connect now gets a clear `EACCES`
rather than a silent path mismatch. Drops the now-unused
`is_writable_dir` helper. `XDG_RUNTIME_DIR` existence validation
is preserved.
Documentation:
- `docs/reference/cli-fipstop.md` — Node-tab row updated, new
"Listening on fips0 panel" section.
- `docs/reference/control-socket.md` — `show_listening_sockets`
added to the read-only queries table.
- `docs/how-to/enable-mesh-firewall.md` — new "Verify with
fipstop" section.
- `docs/tutorials/host-a-service.md` — fipstop callouts at
Steps 3, 5, 6 + Troubleshooting bullet + wildcard-bind reminder
under "What you've learned".
- `CHANGELOG.md` — new bullet under `Added / Operator Tooling`,
resolver `Fixed` entry rewritten to describe the
existence-based final shape.
|
||
|
|
b3a1fb464f |
testing: add bloom-storm chaos scenario
Six-node depth-4 mesh with an induced upstream parent flap. Asserts a
trailing-window ceiling on per-node `stats.bloom.sent` and a sanity
floor on parent-switch count over a ~3-4 min observation window.
Guards against the regression class where a spanning-tree update that
changes only an internal path edge (no root or depth delta) fails to
be properly contained and instead propagates to leaves as a sustained
bloom-traffic oscillation, visible only at fleet scale and only after
several minutes of uptime.
Adds a new chaos primitive (`link_swap`) for deterministic asymmetric
link-cost flapping and a post-run assertion framework with two
checks:
- `bloom_send_rate.max_per_node`: trailing-window ceiling on the
`show_bloom` stats counter delta. Calibrated against the
post-mortem reproduction harness data (per-variant counter table
against pre-fix vs post-fix binaries).
- `min_parent_switches.min_total`: sanity guard against a
misconfigured harness where the flap inducer fires but the
topology never produces a real parent-switch event (e.g., wrong
root election from a different seed). Without this, the
bloom-rate assertion would trivially pass on any binary
including a regressed one.
The runner exits 3 on assertion failure (alongside 0 success and 2
panic-detected). Threshold derivation is documented in the scenario
README; the seed pin is also documented there since smallest-NodeAddr
root election is sensitive to the pubkey hash ordering.
Wired into ci-local.sh's chaos pool and the GitHub CI chaos matrix.
|
||
|
|
6807a3213b |
deps: bump windows-service 0.7 to 0.8.1
Routine refresh; raises MSRV to 1.71 (non-issue for our 2024-edition toolchain) and updates windows-sys to 0.61. FIPS uses define_windows_service!, service_main, the Error type, and Error::Winapi - all stable across 0.7 -> 0.8. Windows CI matrix is the verification gate; no live Windows nodes. |
||
|
|
b547dd70f5 |
deps: bump rtnetlink 0.20.0 to 0.21.0
Pulls netlink-packet-route 0.30.0, which adds DEVCONF_FORCE_FORWARDING to Inet6DevConf for kernel 6.17+. Closes the IFLA_INET6_CONF WARN observed on kernel-6.17 hosts during fips startup. Zero source edits: FIPS does not use the deprecated link_local_address API or the renamed StablePrivacy display path. Live-host WARN-absence verification on a kernel-6.17 host is scheduled for a separate deploy. |
||
|
|
1d7d0d2522 |
deps: refresh bump-safe batch
Manifest pin widening: - clap 4.5 -> 4.6 (env-var rebuild correctness, derive hygiene) - tun 0.8.5 -> 0.8.7 (Linux ioctl-type fix) Lockfile-only refreshes within existing pins: - tokio -> 1.52.3 - tracing-subscriber -> 0.3.23 - socket2 -> 0.6.3 - futures -> 0.3.32 - libc -> 0.2.186 - tempfile -> 3.27.0 - bytes (transitive) -> 1.11.1+ (clears RUSTSEC-2026-0007 BytesMut::reserve overflow) All additive; no source-level migration required. |
||
|
|
67e660d813 |
deps: bump rand 0.10.0 to 0.10.1
Closes RUSTSEC-2026-0097 (unsoundness with custom logger calling rand::rng() from the log handler). Fix is the upstream deprecation of the `log` feature; no API change for our pin. |
||
|
|
c255e3f4a2 |
session: drop dead SessionSetup/SessionAck variants
Both variants of SessionMessageType were never emitted anywhere in src/, and the production from_byte dispatch sites lacked Some-arms for them — any 0x00/0x01 byte that reached either dispatcher would log "Unknown..." and drop. The matching rustdoc tables described an Offset 0 msg_type byte that the encode() path has never written; the actual wire format is the FSP common prefix [ver_phase][flags][payload_len:2 LE] with body keyed by phase nibble, as documented in docs/reference/wire-formats.md. Drop the variants, drop their from_byte/to_byte/Display arms, fix the two stale rustdoc tables to describe the real wire shape, and trim the variant-iteration unit test that enumerated them. Zero on-wire behaviour change. |
||
|
|
f32bc83034 |
docs: correct Ethernet MTU framing rustdoc
The Ethernet data frame format is `[type:1][length:2 LE][payload]`, so the per-link payload MTU is the interface MTU minus 3 bytes, not minus 1. The 2-byte length field is required to trim NIC minimum-frame padding before AEAD verification. The implementation in src/transport/ethernet/mod.rs already uses saturating_sub(3) correctly; only the rustdoc on the effective_mtu field and the EthernetConfig.mtu field's documentation lagged behind. No behaviour change. |
||
|
|
e4f37082c2 |
docs: gateway tutorial review feedback
Two small improvements to the OpenWrt gateway deploy tutorial: - Add a router-side ping step at the top of Step 4 (post-gateway-start client test). Confirms the router itself reaches the mesh before bringing the LAN segment into the diagnosis: if this fails the troubleshooting target is the daemon / mesh side; if it succeeds and the LAN-client test below fails, the target is the LAN segment (proxy_ndp, RA pool route, or DNS forwarding through dnsmasq). - Mark the inbound port-forward section heading as Optional. The outbound half is the steady-state use of a gateway and applies to every deployment; the inbound port-forward half is a per-service opt-in that many operators won't need. |
||
|
|
7daca6bcf1 |
packaging: refresh OpenWrt ipk README
Bring the packaging README into agreement with what the ipk actually installs and the CLI surface fipsctl exposes today: - Package contents table now lists /usr/bin/fips-gateway, /etc/init.d/fips-gateway, and /etc/sysctl.d/fips-gateway.conf alongside the daemon. These have been part of the install block but were missing from the README. - fipsctl examples updated to the current command form (fipsctl show peers / show links / show sessions in place of the removed shorthands), with a pointer to the canonical CLI reference. - Service management section gains a short subsection covering the optional gateway service, including the enable/start incantation and a link to the deploy-fips-gateway tutorial. |
||
|
|
0fcf0f6f8f |
gateway: change dns.listen default to [::1]:5353
The gateway is designed for systems already serving DHCP and DNS to a LAN segment (canonically an OpenWrt AP). On those systems port 53 is already taken by the existing resolver, so the prior `[::]:53` default conflicted with the gateway's intended deployment target out of the box. The OpenWrt ipk previously overrode this in its packaged config as a workaround; matching the source default to what the canonical deployment actually wants makes the override redundant and removes a foot-gun for fresh manual Linux-host installs. The redundant `dns.listen` line in `packaging/openwrt-ipk/files/etc/fips/fips.yaml` is dropped along with this change. Operators on a host without a pre-existing resolver on port 53 can opt back into the wildcard bind by setting `dns.listen: "[::]:53"` explicitly. The new default binds IPv6 loopback only — Linux IPv6 sockets bound to explicit `::1` do not accept v4-mapped traffic, so forwarders that reach the gateway over IPv4 loopback need to be pointed at an explicit IPv4 listen address instead. Touches the gateway config struct and its default-value test, the commented-out gateway example in the Debian common fips.yaml, the OpenWrt ipk config (override removed), the gateway reference / how-to / design / tutorial / troubleshoot docs, and a CHANGELOG entry under [Unreleased] -> Changed. |
||
|
|
9112c8f7f0 |
changelog: prep [Unreleased] for v0.3.0
- Add a Documentation entry covering the docs/ reorganisation, top-level getting-started.md, per-section landing pages, source-accuracy pass, and gateway feature-set rewrite. - Add a Fixed entry covering propagation of spanning-tree updates whose changes are confined to internal path edges (no root or depth delta). - Add a single rolled-up entry covering expanded test coverage across the new-feature surface plus CI hardening. - Drop a tree-ancestry test-determinism bullet that did not change user-visible behaviour. |
||
|
|
db5b6b10bd |
config: unify default control-socket path resolution
Daemon and client tools previously evaluated the same three locations (`/run/fips`, `XDG_RUNTIME_DIR`, `/tmp`) in different orders, allowing fipsctl/fipstop to connect to a socket the daemon never bound when neither side set `node.control.socket_path` explicitly. Collapse the three call sites (`default_control_path`, `default_gateway_path`, `ControlConfig::default_socket_path`) into a shared `resolve_default_socket` helper. Canonical order is `/run/fips` -> `$XDG_RUNTIME_DIR/fips/` -> `/tmp/fips-<name>`. Two hardening fixes folded in: writability is probed via tempfile create rather than mode bits (ACL- and group-aware), and `XDG_RUNTIME_DIR` is validated as an existing directory before being used (avoids stale post-logout values). The deployed fleet is unaffected -- packaged configs set `node.control.socket_path` explicitly. The fix surfaces for dev runs and the binary-install getting-started path. |
||
|
|
18019bb1b5 |
docs: rewrite top-level README for v0.3.0-dev
- Status badge v0.2.0 → v0.3.0-dev. - Lede rewritten around the two equally-supported deployment modes (overlay on existing IP networks; ground-up over raw Ethernet, WiFi, Bluetooth) matching docs/README.md and docs/getting-started.md. - Features list refreshed: Nostr-mediated discovery and UDP NAT traversal called out, LAN gateway described as both halves (outbound + inbound port forwarding), peer ACL and control-socket-per-binary noted. - Quick start trimmed to the Debian inline path + pointer at docs/getting-started.md for the multi-platform walkthrough; transport-by-platform matrix retained. - Documentation section reorganised around the four-section docs/ tree (tutorials, how-to, reference, design) with one entry-point pointer per section. - Stale doc links fixed (docs/design/fips-intro.md → docs/design/fips-concepts.md; docs/design/fips-configuration.md no longer linked). - Status & roadmap rewritten for the v0.3.0-dev release-line scope (no new wire-format changes; FMP swap deferred to the next-branch post-v0.3.0 line). 422 → 235 lines. |
||
|
|
5abf9a9325 |
docs: four-section /docs/ restructure with new-user content, accuracy pass, and gateway feature-set rewrite
Restructures /docs/ by reader purpose (tutorials, how-to, reference, design), adds the new-user-progression and operator-recipe content the prior layout lacked, runs an accuracy pass against current source across the pre-existing design docs, and rewrites the gateway feature-set documentation end-to-end around its actual operational profile (a niche feature designed for systems already serving DHCP/DNS to a LAN, with two independent halves — outbound LAN→mesh, inbound mesh→LAN — sharing one nftables table, one binary, and one control socket). Top-level README and getting-started rewritten around two equally-weighted deployment modes (overlay on existing IP networks; ground-up over non-IP transports). ## Additions - 11 new tutorials in docs/tutorials/: an 8-step new-user progression from single-daemon test-mesh peering through to a ground-up two-device mesh, an IPv6-adapter side-trip walkthrough, an Advanced Tutorials index, and a hand-held OpenWrt walk-through for fips-gateway deployment that exercises both halves of the feature. - 12 new how-tos in docs/how-to/: firewall activation, Nostr discovery (resolve / advertise / open across five scenarios), Tor onion (directory + control_port modes), UDP buffer tuning, unprivileged-user setup, persistent identity, host aliases, Bluetooth LE peering, MTU diagnostics, manual Linux-host gateway deployment (covers both halves), gateway troubleshooting (organised by half), and a section index. - 9 new reference docs in docs/reference/: configuration, wire formats, control-socket protocol, four CLI references (fips, fipsctl, fipstop, fips-gateway), security posture matrix, and Nostr events catalog. Configuration and wire-formats are renamed-and-extended from prior design/ versions; the other seven are net-new. - 6 new design docs: fips-concepts, fips-architecture, and fips-prior-work split out of the deleted fips-intro.md; consolidated fips-mmp and fips-mtu aggregations; and a new generic port-advertisement-and-nat-traversal doc (Nostr-signaled port advertisement plus UDP NAT-traversal protocol, FIPS as an example implementation, suitable for eventual NIP submission). - Top-level docs/getting-started.md walking through the binary-installer-only Install story. - packaging/common/hosts pre-populated with the eight public test-mesh nodes so shortnames resolve out of the box on every fresh install. ## Changes - 23 wire-format diagrams relocated to reference/diagrams/ alongside the wire-formats move. - 4 design diagrams corrected against source code (fips-protocol-stack, fips-identity-derivation, fips-coordinate-discovery, fips-routing-decision). - 10 pre-existing design docs reconciled with current source. Numeric corrections: stale link-MMP report bounds (now [1s, 5s] with 200 ms cold-start floor); UDP default MTU (now 1280, IPv6 minimum); node_addr formula (SHA-256(pubkey)[..16]); Noise patterns (IK at link, XK at session); peer-ACL semantics (strict allowlist requires ALL in peers.deny); daemon DNS upstream ([::1]:5354); on-the-wire bloom-filter size (1,071 bytes); obsolete Cargo-feature references (PR #79 dropped them) removed. - Transport framing tightened across the docs: TCP is for UDP-filtered networks (not NAT traversal); Tor is a deployment mode (not failover); WebSocket dropped (not a shipped FIPS transport); WiFi promoted to Implemented via Ethernet in infrastructure mode; classic-Bluetooth row removed (BLE is the only Bluetooth-mode transport). - docs/design/fips-gateway.md rewritten end-to-end to lead with the niche-feature framing and the two-halves structure. Title moved from "FIPS Outbound LAN Gateway" to "FIPS Gateway"; architecture section describes the common machinery (the fips-gateway service, the nftables table, the control socket) before splitting into separate "Outbound Half" and "Inbound Half" sections of equal weight; security considerations split per-half; no Future Work section (speculative directions live in the project tracker, not in protocol design docs). Inbound port forwarding is a first-class half rather than a buried "Implemented Extensions" subsection. - Gateway terminology unified across all gateway docs as a separate Linux service running alongside the fips daemon (its own systemd unit / OpenWrt init script). Container- pattern terms (sidecar) are reserved for the Docker/Kubernetes sidecar deployment examples — the testing/sidecar/ tree, examples/k8s-sidecar/, examples/sidecar-nostr-relay/, examples/wireguard-sidecar-macos/, and the related CHANGELOG / top-level README entries — where the term carries its standard container meaning. - Net-new design body content: rekey section in fips-mesh-layer (Noise IK msg1/msg2 over the established link, K-bit cutover, drain window, smaller-NodeAddr-wins tie-breaker on dual-init); Mesh Size Estimation and Antipoison FPR Cap sections in fips-bloom-filters; Mesh-Interface Query Filter subsection in fips-ipv6-adapter; failure-suppression knobs and clock- skew tolerance in fips-nostr-discovery; loop-rejection and mid-chain ancestor swap added to spanning-tree propagation / stability rules; Priority Chain in fips-mesh-operation renumbered to match the routing-decision diagram. - Top-level README: dropped the stale nostr-discovery cargo-feature parenthetical. docs/README.md and the four section READMEs (tutorials, how-to, reference, design) refreshed for the new structure; index rows reflect both halves of the gateway feature and the new fips-gateway CLI reference. - Cargo.toml [package.metadata.deb] assets path updated for the fips-security.md move; .gitignore /reference/ rule anchored to repo root so docs/reference/ is trackable. - packaging/openwrt-ipk/files/etc/fips/fips.yaml configuration-doc URL updated to the new docs/reference/configuration.md location. ## Deletions - docs/design/fips-intro.md (split into the three new intro design docs). - docs/design/document-relationships.svg (orphan, no longer referenced). - docs/proposals/ tree removed; the only proposal it contained (the Nostr UDP hole-punch protocol) was rewritten as the new generic design/port-advertisement-and-nat-traversal.md. |
||
|
|
4cdf382038 |
tree: propagate mid-chain ancestor swaps to leaves
A leaf node's my_coords could go stale after an upstream mid-chain ancestor swap, leaving non-parent destinations with 100% loss until either the parent or the depth also changed. The broadcast gate in handle_tree_announce's `else if !is_root && parent_id == from` branch compared only (root, depth). A swap that altered an interior ancestor without changing root or depth (e.g. A->B->C reorganizing to A->D->C while keeping (A, depth=2)) was silently dropped one hop below the swap node. Downstream nodes' coords paths then drifted from the real tree topology, defeating greedy distance routing for any destination whose path crossed the unrepresented section. Widen the gate to compare the full my_coords.node_addrs() so mid-chain swaps propagate to leaves the same way root/depth changes already did. The gate body's bloom-marking is adjusted in step so the wider gate doesn't generate empty/redundant FilterAnnounces to every peer on every mid-chain swap propagation: mark_changed_peers replaces mark_all_updates_needed in the gate body (parent_id is unchanged in this branch, so outgoing filter content is typically unchanged, and mark_changed_peers correctly marks zero peers in that case), and the unconditional mark_update_needed(*from) at the top of handle_tree_announce is removed (bloom exchange initiation is already handled at handshake completion, and ongoing content changes are picked up naturally by mark_changed_peers in handle_filter_announce when peers send their next filter). Required surface change: peer_inbound_filters in src/node/bloom.rs upgraded from private to pub(super) so the gate body can call it. Verified in a 6-node depth-4 docker reproduction under tc/netem-induced parent flapping: a depth-4 leaf's ancestry_changed counter advances with upstream parent switches while bloom_sent stays at zero matching the pre-change steady-state baseline. |
||
|
|
a62a0a6cf4 |
nostr: suppress retraversal of cross-FMP-version peers
Open-discovery NAT traversal succeeds at the UDP layer regardless of what FMP-protocol version the peer speaks. When the daemon discovers a peer running a different FMP version (e.g. a v0/v1 mix during a mid-rollout window, or a misconfigured peer in the same advert namespace), the punch sequence completes, the socket is adopted via `Node::adopt_established_traversal`, and we initiate an FMP handshake. The peer drops our msg1 at its own version-gate and we drop their msg1/msg2 at `Unknown FMP version, dropping`. Neither side advances the handshake. Today the bootstrap transport sits idle until the 31s stale- handshake timeout, drops, and the open-discovery sweep ~30s later fires the full STUN+offer+answer+punch sequence again — every minute, indefinitely, against peers the handshake literally cannot complete with. Add a `Node::bootstrap_transport_npubs` map populated alongside `bootstrap_transports` at adopt time. The rx loop reverse-maps the transport_id → npub on version-mismatch and bumps the discovery layer's `failure_state` to a long structural cooldown via the new `NostrDiscovery::record_protocol_mismatch` API. The next sweep skips the npub for `protocol_mismatch_cooldown_secs` (default 86400 = 24h, separate from the 30-min transient-failure `extended_cooldown_secs`). One-shot WARN per fresh observation. Repeat mismatches inside the cooldown window are silent (the failure_state method returns false when an existing comparable cooldown is already in place). The handshake/transport teardown chain is unchanged — the fix is specifically about preventing the *next* sweep cycle from re-traversing. Cleared on `cleanup_bootstrap_transport_if_unused` and on the adopt-failure rollback path so completed handshakes don't leave stale entries behind. Four new unit tests in `failure_state.rs` cover fresh-entry signaling, repeat-suppression inside the window, streak-pin behavior for `show_peers` rendering, and post-cooldown re-arming. |
||
|
|
7fc890b7a2 |
session: mirror proactive PathMtuNotification into path_mtu_lookup
The TUN-side TCP MSS clamp consults `path_mtu_lookup` (FipsAddress- keyed) when sizing outbound TCP flows. Until now, only the reactive `MtuExceeded` handler mirrored the bottleneck MTU into that store; the proactive end-to-end `PathMtuNotification` echoed by the destination updated only `MmpSessionState.path_mtu`, leaving the TUN mirror stale. On stable long-lived paths, the proactive echo can tighten the session-canonical MTU well before any transit router fires a `MtuExceeded` for those flows (since all current traffic is already sized by the tighter session value). New TCP flows opened during that window get clamped by the discovery-time value rather than the session-canonical one, leading to PMTU-D loss until the reactive path eventually fires. Mirror the post-apply MTU into `path_mtu_lookup` whenever `apply_notification` returns true, with the same tighter-only semantics as the reactive mirror — never loosen the clamp. Gated on the bool return so spurious writes don't happen on rejected increases or no-op same-value notifications. Four new unit tests exercise the empty-lookup write, tighten- existing, keep-tighter-existing, and no-session-no-op paths, parallel to the existing reactive-mirror test trio. |
||
|
|
a78f670a6a |
CHANGELOG: restructure and complete [Unreleased] for v0.3.0
- Reorganize Added into 11 subsections ordered by importance and protocol layer: Mesh Layer (FMP), Platform Support, Mesh Peer Transports, Security, LAN Gateway, IPv6 Adapter, Operator Tooling, Packaging and Deployment, Examples, Documentation. - Add entries for peer ACL enforcement (#50), MIPS portable_atomic (#62), inbound mesh port forwarding, and historical node and per-peer statistics with btop-style graphs (#64). - Add a Fixed entry covering the TCP-over-FIPS reliability work (transport_mtu determinism, per-destination TCP MSS clamp at the TUN boundary, reactive MtuExceeded mirror, Windows TUN reader plumbing). - Recast the multi-backend DNS configuration entry as a systemd-host overhaul under IPv6 Adapter (default ::1 bind, global drop-in backend, five-distro test harness); fold the Ubuntu 22 Fixed entry into it. - Expand the cargo feature flag rationalization Changed entry to cover PR #79's full scope: tui, ble, gateway, and nostr-discovery all dropped. - Drop the rekey msg1 Fixed entry; all cases require new-in-release functionality. - Collapse the BLE transport bullets into one consolidated bullet under Mesh Peer Transports and scrub stale cargo-feature wording from the overlay-discovery and BLE bullets. |
||
|
|
2f95929862 |
nostr: public-IP discovery for UDP/TCP advert publication
When a UDP transport had `advertise_on_nostr: true` + `public: true` + `bind_addr: 0.0.0.0:NNNN`, the advert builder previously read the kernel's `local_addr()`, found `0.0.0.0`, filtered it out (correctly — wildcard isn't a valid advertised endpoint), and silently emitted no UDP endpoint in the published advert. Operators on AWS EIP / GCP / Azure setups (where binding to the public IP directly is impossible because 1:1 NAT does the address translation off-host) had no way to advertise UDP without binding to a specific local IP — and no log explaining what was happening. TCP had the same shape, with no `public: true` precondition. Three pieces, layered. UDP gets zero-config autodiscovery via STUN; both UDP and TCP get an explicit operator-supplied override; the fall-through path now logs loudly instead of silently skipping. UDP public-IP autodiscovery (STUN) ---------------------------------- In the UDP `is_public()` + wildcard-bind branch, run a one-shot STUN observation against an ephemeral UDP socket on the daemon's configured `stun_servers`. Take the reflexive IPv4 (the STUN-reported port is the ephemeral source port and is discarded), combine with the configured listener port for the advert (`udp:<reflexive-ip>:<port>`). Works on AWS EIP / GCP / Azure 1:1-NAT setups because STUN sees the public-Internet egress IP and the bind port is preserved through 1:1 NAT. Result is cached per-transport on a new `public_udp_addr_cache` field on `NostrDiscovery` (keyed by `TransportId.as_u32()`). Asymmetric cache TTL: a successful observation is cached for `advert_refresh_secs` (default 30 min) so we don't STUN every refresh tick. A failed observation is cached for only 60s (`PUBLIC_UDP_ADDR_FAILURE_TTL`) so a transient STUN flake at startup retries within ~a minute and the advert grows its UDP endpoint as soon as STUN starts working — rather than waiting the full 30-min cycle. The shared `observe_traversal_addresses` STUN helper had a hard-coded 2s per-server response wait, right for the per-traversal flow (latency-sensitive — 3 STUN servers worst-case = 6s) but too short for the one-shot advert-publish startup discovery. Parameterized `per_server_timeout` on the helper, with two named constants in `stun.rs`: `TRAVERSAL_STUN_TIMEOUT = 2s` (existing call sites) and `ADVERT_STUN_TIMEOUT = 5s` (new public-UDP discovery path). Both use `tokio::time::timeout_at` under the hood, so success returns immediately — the timeout is only the worst case. `external_addr` override (UDP + TCP) ------------------------------------ New `external_addr: Option<String>` field on `transports.udp.*` and `transports.tcp.*` for explicit advertise-as override. Takes precedence over both the bound `local_addr` and (for UDP) the STUN-derived autodiscovery. Required for TCP on cloud-NAT setups (AWS EIP, GCP/Azure external IPs) where binding to the public IP directly fails with `EADDRNOTAVAIL` because the public IP isn't on a host interface — the network fabric does 1:1 NAT off-host. Without this field the operator's only TCP path was "leave advert off" or "find a way to make the public IP locally bindable." For UDP, `external_addr` is optional but useful as a deterministic alternative to STUN. Operators who want to skip STUN egress, whose STUN servers are blocked, or who want the daemon to not depend on external services for advert content can specify it explicitly. The accessor parses two shapes: - Bare IP (`"54.183.70.180"` or `"2001:db8::1"`): combines with the configured `bind_addr` port. - Full host:port (`"54.183.70.180:8443"` or `"[2001:db8::1]:443"`): used verbatim — useful for port-forward setups where the externally-visible port differs from the bind port. Final precedence in `Node::build_overlay_advert` (now async, only caller `refresh_overlay_advert` was already async): - UDP: `external_addr` → non-wildcard `local_addr` → STUN → loud warn - TCP: `external_addr` → non-wildcard `local_addr` → loud warn Loud warns instead of silent skips ---------------------------------- The wildcard-bind fall-through paths now log a `warn!` pointing at the operator-side fixes: - UDP: "set transports.udp.external_addr, bind to a specific public IP, or ensure node.discovery.nostr.stun_servers is reachable" - TCP: "Either set external_addr to the public IP (recommended for cloud 1:1-NAT setups) or bind explicitly to the public IP" Replaces the silent skip that previously cost operators a debugging session when the advert mysteriously contained only the Tor onion endpoint. Tests ----- 11 new unit tests in `src/config/transport.rs` covering the parser (IPv4/IPv6, bare/full, malformed) and the accessor (UDP with default bind, UDP with explicit port override, UDP unset, TCP without bind_addr, TCP with bind_addr, TCP with full socket-addr override, parse_bind_port for IPv4/IPv6/malformed). The 38-test nostr suite still passes. CHANGELOG entries under `[Unreleased]` Fixed. |
||
|
|
bcc9c525d3 |
nostr: per-peer NAT-traversal failure suppression and clock-skew handling
Public-test daemons with populous open-discovery caches generate
sustained NAT-traversal-failure WARN volume (~140/hour, ~3500/day)
against cache-learned peers that have gone offline — their adverts
are absent from major Nostr relays but cached entries persist until
their advertised `valid_until` expires. The daemon kept publishing
offers indefinitely under exponential backoff with no per-peer
suppression, drowning operator signal and hammering relays. A
parallel concern: the strict freshness check at signal.rs silently
rejected offers under modest clock skew (now_ms() anchors to
SystemTime once at startup, so post-startup NTP step adjustments
don't propagate on long-uptime daemons), indistinguishable from
"peer is offline."
Six independent improvements layered on the existing retry logic.
Per-npub WARN log rate-limit
----------------------------
New `FailureState` struct on `NostrDiscovery` records per-npub
`last_warn_at_ms`. Subsequent failures inside `warn_log_interval_secs`
(default 5 min) emit DEBUG instead of WARN. Each WARN now also
carries `consecutive_failures` and remaining `cooldown_secs` so
operators can read the trajectory without grepping multiple lines.
Per-npub consecutive-failure counter + extended cooldown
--------------------------------------------------------
After `failure_streak_threshold` (default 5) consecutive failures
against a peer, the next `extended_cooldown_secs` (default 1800)
of attempts are suppressed by pushing
`retry_pending[npub].retry_after_ms` past the cooldown wall. The
open-discovery sweep also consults `cooldown_until` and increments
a new `skipped_cooldown` counter so a peer whose `retry_pending`
was cleared by max_retries doesn't get re-enqueued during the
cooldown window. Caps offer-publish rate per dead peer regardless
of how often the sweep tries to re-enqueue.
Stale-advert eviction on streak-threshold transition
----------------------------------------------------
On the threshold-crossing transition (one-shot, not every
subsequent failure), `tokio::spawn` an active re-fetch of the
peer's Kind 37195 advert from `advert_relays`. Three outcomes:
- absent on relays → cache evicted; sweep won't re-enqueue
(peer is genuinely gone).
- newer `created_at` → cache refreshed + streak reset
(peer republished; allowed to retry immediately).
- same → cache untouched; cooldown stands.
Cost: ~one fetch per dead peer per 30-min cooldown cycle, vs
hundreds of offer publishes/hour today.
Clock-skew tolerance on freshness check
---------------------------------------
`signal.rs` `validate_offer_freshness` and
`validate_traversal_answer_for_offer` now allow ±60s grace beyond
strict TTL. Both return a new `FreshnessOutcome` enum so callers
can DEBUG-log when an offer/answer was only accepted via the grace
window. `FRESHNESS_SKEW_TOLERANCE_MS` is hard-coded — loosening
this past minutes erodes the freshness/replay security boundary
and operators tend to tune in the wrong direction.
NTP-style skew estimate (offer_received_at echo)
------------------------------------------------
Added optional `offerReceivedAt: Option<u64>` field to
`TraversalAnswer` payload. Responder fills it with `now_ms()` at
offer-receipt time. Initiator computes the standard NTP offset
formula `((T2-T1) + (T3-T4)) / 2` against the round-trip and
DEBUG-logs when `|skew| ≥ 30s`. Skew is also stashed in
`FailureState` and surfaced in `show_peers`. Non-breaking — older
responders that don't fill the field still produce valid answers,
and `estimate_clock_skew` returns `None`.
Per-peer state in `show_peers` JSON
-----------------------------------
Each peer entry in `show_peers` now carries:
"nostr_traversal": {
"consecutive_failures": <u32>,
"in_cooldown": <bool>,
"cooldown_until_ms": <u64 | null>,
"last_observed_skew_ms": <i64 | null>
}
Always emitted (schema-stable); values populated when discovery is
enabled and the npub has a recorded entry. Required a new public
`Node::nostr_discovery_handle()` accessor and refactored
`FailureState`'s internal Mutex from `tokio::sync` to `std::sync`
(operations never hold across await), which lets the synchronous
`show_peers` handler call `snapshot()` directly without the
dispatcher becoming async.
New config knobs (under `node.discovery.nostr`)
-----------------------------------------------
failure_streak_threshold: 5
extended_cooldown_secs: 1800
warn_log_interval_secs: 300
failure_state_max_entries: 4096
Tests
-----
12 new unit tests:
- 5 in `tests.rs` covering freshness strict / tolerated / rejected
outcomes, NTP skew estimation, and the backward-compat None case
when the responder didn't fill `offer_received_at`.
- 7 in `failure_state.rs` covering streak/warn-rate-limit state
transitions, cooldown active vs expired semantics,
success-resets-streak, observed-skew records, and size-cap
eviction by oldest `last_failure_at`.
CHANGELOG entries added under `[Unreleased]` Fixed.
|
||
|
|
f66be793b8 |
Fix snapshot-test CRLF mismatch on Windows runners
The control-query snapshot tests panicked on the Windows GitHub runner because git's default core.autocrlf=true converted fixture files (src/control/snapshots/*.json) to CRLF on checkout, while the in-memory JSON output is LF. trim_end() only strips trailing newlines, not interior \r, so every snapshot comparison mismatched. Two defenses: 1. .gitattributes: pin src/control/snapshots/*.json to text eol=lf so future Windows checkouts keep the fixtures LF-only regardless of local git config. 2. src/control/queries.rs (assert_snapshot): replace \r\n with \n in the expected text before comparison, so any future re-introduction of CRLF (a contributor with non-LF editor settings, a different runner, etc.) doesn't surface as a snapshot mismatch. |
||
|
|
5c92fffa2b |
Extend shellcheck excludes for OpenWrt rc.common and ash conventions
The package-openwrt.yml shellcheck step was failing on warnings that
are false positives for OpenWrt scripts:
SC2034 — USE_PROCD, START, STOP in /etc/init.d scripts are read by
rc.common, not by the script itself; standard shellcheck
cannot see the indirection.
SC3043 — `local` is undefined in strict POSIX sh, but OpenWrt uses
ash which supports it. The init scripts use `local`
extensively for parameter scoping.
SC2086, SC2089, SC2090 — firewall.sh constructs nft match clauses
with literal quotes that need to survive variable expansion
(`match='iifname "$TUN"'` then `nft ... $match accept`). The
lack of double-quoting around `$match` is intentional so
word-splitting yields separate nft arguments.
Adding these to the exclude list. SC2317 (unreachable code) and
SC1008 (rc.common shebang form) were already excluded.
|
||
|
|
43639fecb9 |
Add stun-faults integration suite covering STUN failure/recovery
Cover the previously untested STUN client behavior under server
unreachable, response timeout, and packet loss. The 3 NAT scenarios
test happy paths only; if the STUN client mishandled a fault (panic,
hang, missing log signal), it would silently degrade NAT traversal
without surfacing in CI.
testing/nat/scripts/stun-faults-test.sh (new, 244 lines):
Phase 1 (drop, ~12s): tc prio + netem loss 100% band + u32 filter on
dst 172.31.10.40 udp 3478. Falls back to iptables -j DROP if netem
isn't available. Asserts daemon process alive, no panic, log line
matching stun.*(timed?out|fail|fallback|unreachable|no address)
within the phase window.
Phase 2 (delay then clear, ~17s): tc qdisc add dev eth0 root netem
delay 5000ms for 7s, then deleted. 10s settle. Asserts process alive,
no panic, AND "STUN observation succeeded" log line after clear
(recovery proof).
Phase 3 (kill, ~12s): docker stop fips-nat-stun. Asserts process
alive, no panic, fault evidence in logs.
testing/nat/docker-compose.yml: stun-faults profile adds two
services. stun-fault-node is fips-test:latest on shared-lan at
172.31.10.50. stun-fault-shim is fips-test:latest sharing the
daemon's network namespace via network_mode: service:stun-fault-
node, with cap_add NET_ADMIN, NET_RAW; entrypoint sleep infinity so
the script can docker exec into it. Reuses existing stun
(172.31.10.40:3478) and relay (172.31.10.30:7777) services.
testing/nat/scripts/generate-configs.sh: 3-hunk update so the
generator accepts the new scenario and points its peer config at the
existing relay/STUN. The peer is configured for connect_peer() so
the daemon retries traversal on a loop, repeatedly invoking
observe_traversal_addresses() — which is the fault-injection target.
testing/ci-local.sh: STUN_FAULTS_SUITES=(stun-faults) array,
run_stun_faults runner, list/integration-loop/--only-dispatch hooks.
.github/workflows/ci.yml: matrix row {suite: stun-faults, type:
stun-faults} + 3 steps gated on matrix.type == 'stun-faults' between
nostr-publish-consume and any chaos suite. Reuses fips-linux
artifact + fips-test:latest image.
Approach: script-driven via docker exec stun-fault-shim. Sharing
network namespace means tc rules on the shim's eth0 affect daemon
egress. No timing logic in the shim itself.
|
||
|
|
33a2063672 |
Add firewall integration suite covering fips0 default-deny baseline
End-to-end exercise the v0.3.0 nftables firewall baseline so the
security claim — "services on fips0 are not exposed by default" — is
validated in CI rather than by operator opt-in. The packaging postinst
state is pinned by the deb-install matrix; this suite pins the actual
ruleset behavior.
testing/firewall/ — new directory mirroring the acl-allowlist
precedent (kept in its own directory, not extending testing/static):
docker-compose.yml (52 lines): two FIPS containers on bridge
172.32.0.0/24, peered over UDP/2121. node-b mounts
packaging/common/fips.nft RO at /etc/fips/fips.nft and a generated
drop-in services.nft at /etc/fips/fips.d/.
test.sh (247 lines): four-case asserter
(a) curl from node-a to node-b:8000 → DROP (port not allowlisted;
terminal counter drop fires)
(b) curl from node-b to node-a:8000 → 200 OK (reply traverses
node-b's ct state established,related accept)
(c) ping6 a→b → success (icmpv6 echo-request accept)
(d) nc -z a→b:22 → success (drop-in tcp dport 22 accept honored
via include "/etc/fips/fips.d/*.nft")
Plus drop-counter check after case (a) confirms the dropped
connection actually hit the chain's terminal counter drop.
generate-configs.sh (111 lines): mirrors acl-allowlist generator,
produces the drop-in services.nft + fips configs.
README.md (111 lines): how to run, expected output, design notes.
.gitignore: ignores generated-configs/.
testing/ci-local.sh: FIREWALL_SUITES=(firewall) array + run_firewall
runner; dispatch in run_integration and run_suite mirroring
run_acl_allowlist.
.github/workflows/ci.yml: matrix row {suite: firewall, type:
firewall} + 3 steps gated on matrix.type == 'firewall' between
acl-allowlist and gateway. Reuses fips-linux artifact + fips-test:
latest image.
Activation note: the unified test image does not run systemd, so
test.sh invokes the fips-firewall.service ExecStart
(/usr/sbin/nft -f /etc/fips/fips.nft) directly. The systemd-unit
enablement path is covered by the deb-install matrix; this suite
exercises what the unit configures, not how it gets started.
|
||
|
|
5611e976ad |
Add nostr-publish-consume integration suite
Cover the previously untested overlay advert publish/relay/consume
round-trip. The bilateral publish/subscribe path was a v0.3.0 release
gap: malformed adverts could panic consumers, broken signatures could
go undetected, and reverse-direction subscription was unverified.
Adds testing/nat/scripts/nostr-relay-test.sh (290 lines):
Phase 1+2 (combined): wait_for_peers on both nodes; pass on
bidirectional advert publish/subscribe round-trip + dial completed;
ping6 both directions confirms TUN-level reachability.
Phase 3 (malformed advert resilience): stdlib-only Python WebSocket
client publishes a syntactically valid Schnorr-signed Kind-37195
event whose `content` is gibberish (cannot deserialize as
OverlayAdvert). The relay enforces BIP-340 signature validity, so the
event reaches the consumers (rather than being dropped at the relay)
— a trivially-junk content payload is the right adversarial input.
Required ~80 lines of stdlib-only secp256k1 + BIP-340 in the script
(no new container deps). Asserts pidof fips on both nodes after the
publish, scans logs for panic markers, re-pings to prove the existing
peer link survives.
testing/nat/docker-compose.yml: new profile nostr-publish-consume
with two daemon services (nostr-pub-a 172.31.10.20, nostr-pub-b
172.31.10.21) on shared-lan, reusing the existing strfry relay
(172.31.10.30:7777) and STUN service (172.31.10.40:3478).
testing/nat/scripts/generate-configs.sh: 2-line allowlist update so
the new scenario flows through the existing config generator (rather
than forking a parallel one). Generated node-{a,b}.yaml + npubs.env
smoke-tested cleanly.
testing/ci-local.sh: NOSTR_RELAY_SUITES=(nostr-publish-consume)
array, run_nostr_publish_consume runner, dispatch in run_integration
and run_suite. Mirrors existing run_nat shape.
.github/workflows/ci.yml: one matrix row + 3 steps in the integration
job, gated on matrix.type == 'nostr-publish-consume'. Consumes the
same fips-linux artifact and fips-test:latest image as the existing
NAT suites.
Tor/TCP transport variants kept out of v0.3.0 scope; the structure
leaves room for nostr-publish-consume-tcp/-tor siblings later without
disturbing this baseline.
|
||
|
|
d822ee8b3c |
Validate packaging/common/fips.nft syntax in CI build phase
Add nft -c -f packaging/common/fips.nft syntax-check to both testing/ci-local.sh and .github/workflows/ci.yml so a regression in the 128-line firewall ruleset surfaces in the build gate rather than when an operator activates fips-firewall.service. testing/ci-local.sh: first step inside run_build(), before cargo build --release. Uses command -v nft for prereq detection mirroring the existing cargo-nextest pattern; records as nft-syntax in RESULTS. Operator-facing message points at apt install nftables when nft is absent. .github/workflows/ci.yml: nftables added to the build job's existing Linux apt-install step; new Validate fips.nft syntax (Linux only) step gated on runner.os == 'Linux' (skips macOS/Windows matrix slots, runs on ubuntu-latest and ubuntu-24.04-arm). Note: nft -c -f requires netlink cache initialization on modern nftables even in check mode, so both invocations use sudo (safe in CI's passwordless sudo, and operator's typical local sudo). Without sudo, nft fails with "cache initialization failed: Operation not permitted" before reaching ruleset parse. |
||
|
|
ff40966832 |
Add PowerShell lint job for packaging/windows scripts
Add windows-lint job to .github/workflows/ci.yml running
PSScriptAnalyzer against the three operator-facing PowerShell scripts
(build-zip.ps1, install-service.ps1, uninstall-service.ps1) on the
windows-latest runner. Job runs in parallel with build/test, no
needs:, no tag gating.
Also add packaging/windows/PSScriptAnalyzerSettings.psd1 with two
project-appropriate suppressions:
PSAvoidUsingWriteHost — operator-facing progress in all three
scripts is intentional; Write-Output would conflate progress
with return value.
PSAvoidUsingPositionalParameters — Copy-Item src dst / New-Item
-Path style positional usage is widespread for cp/mv-like
readability.
Severity = @('Error', 'Warning') gates CI on Warnings+ only,
skipping Information-only findings.
The lint job lives in ci.yml rather than package-windows.yml: keeps
the packaging workflow focused, avoids interleaving with the
structural-verification steps in package-windows.yml, and the lint
runs on every push (not just tags).
|
||
|
|
b8b1bb03a0 |
Cover UDP forward, multi-forward, and multi-client paths in gateway-test
Extend the gateway integration suite with three previously unexercised runtime paths. All three share testing/static/scripts/gateway-test.sh and testing/static/docker-compose.yml so they land as one commit. 6A — UDP port forwarding runtime path. Add udp 18081 -> [fd02::20]:8081 to inject_gateway_config() and a phase-7 case where gw-client runs an inline Python UDP echo server bound [::]:8081 and gw-server sends a UDP probe to [GW_MESH_IP]:18081 via inline python3, asserting the echoed payload prefix. The config layer already accepted proto: udp (test_port_forwards_same_port_different_proto_ok) but the UDP NAT rule shape and conntrack handling differ from TCP and were unverified. Uses Python rather than socat because fips-test:latest does not ship socat; nc -u IPv6 round-trip semantics are messier than a Python one-liner. 6B — Second simultaneous TCP forward. Add tcp 18082 -> [fd02::20]:8081 alongside the existing 18080 forward. Phase 7 now greps the daemon's nft DNAT table for all three rules (18080, 18082, 18081) and runs HTTP fetches through both TCP forwards with distinct backend payloads (inbound-forward-ok vs inbound-forward-ok-2) so a misrouted response fails the assertion. 11A — Concurrent multi-client flows. Add gw-client-2 service to docker-compose mirroring gw-client (IPv6 fd02::21, IPv4 172.20.1.21). Phase 3 sets the fd01::/112 route on both. Phase 4 issues DNS lookups from both, asserts they receive distinct virtual IPs, and queries the gateway control socket (show_mappings) to confirm exactly 2 active mappings (5-attempt retry loop tolerates snapshot-publish lag). Phase 5 launches both curl requests concurrently as background processes, asserts each response. Validates concurrent NAT mappings, pool contention, proxy NDP under simultaneous LAN-client traffic — all real-world deployment shape that was not pinned. Phase 8 reclamation timing unchanged (TTL=5s, grace=5s, 25s wait covers both mapping ticks generously even with a slight stagger). |
||
|
|
616010f8c8 |
Verify package structural correctness across macOS, Windows, and OpenWrt builds
Add post-build structural verification steps to the three platform packaging workflows so structural defects abort before checksum/upload. The .pkg/.zip/.ipk builds run on tag (or every push for OpenWrt) but their output layouts were unverified — silent drift could ship missing binaries, broken plists, or malformed package containers that would only surface at install time. Field operators rely on manual validation; CI now catches packaging regressions before release. macOS (.github/workflows/package-macos.yml): The pkg is a pkgbuild component flat package; pkgutil --expand yields the structure but actual files live inside Payload (cpio.gz). The verification step extracts that with gzip -dc | cpio -i, then asserts: - usr/local/bin/fips, fipsctl, fipstop all present - Library/LaunchDaemons/com.fips.daemon.plist present - plutil -lint on the plist returns zero Each check prints PASS/FAIL; verifier dumps the full extracted tree on failure for diagnosis. Codesigning verification deferred (pkgbuild invocation has no --sign — nothing to verify yet). Windows (.github/workflows/package-windows.yml): build-zip.ps1 produces a flat archive (no wrapper directory) via Compress-Archive -Path "$StagingDir\*". The verifier extracts and asserts each expected file at the extract root: fips.exe, fipsctl.exe, fipstop.exe (binaries) fips.yaml, hosts (config from packaging/common/) install-service.ps1, uninstall-service.ps1 (from packaging/windows/) README.txt (generated inline by build script) Each check prints PASS/FAIL; verifier dumps the full extracted listing on failure for diagnosis. LICENSE intentionally not asserted: the build script does not currently ship one, and asserting on it would false-fail every build. OpenWrt (.github/workflows/package-openwrt.yml): Add four post-build verification steps between Build .ipk and SHA-256 hashes. The ipk build runs every push but its contents, shell-script lint state, and sysctl drop-in syntax were unverified. Steps: 1. Install shellcheck (conditional — pre-installed on ubuntu-latest; apt-get install fallback only if missing). 2. Lint init/uci/hotplug/firewall scripts: run shellcheck --shell=sh --exclude=SC1008,SC2317 against the 5 shipped #!/bin/sh scripts (fips, fips-gateway, firewall.sh, 99-fips hotplug, 90-fips-setup uci-default). SC1008 silences the rc.common shebang form; SC2317 silences "unreachable" warnings for externally-invoked rc.common hooks. 3. Sysctl drop-in syntax: per-line regex check against both fips-gateway.conf and fips-bridge.conf. 4. Verify ipk structural integrity: extract via tar -xzf (OpenWrt's actual format despite the misleading "ar archive" comment in build-ipk.sh), assert all three top-level entries (control.tar.gz, data.tar.gz, debian-binary), assert 14 file paths in data.tar.gz (binaries, init scripts, configs, sysctl drop-ins, hotplug + uci-defaults + sysupgrade keep), and assert the 4 control-tarball entries plus debian-binary content "2.0". Full ipk install/runtime test deferred past v0.3.0 scope. Operator-side note: testing/ci-local.sh NOT modified for the OpenWrt verifier (operators don't typically have the cross-compile toolchain locally). |
||
|
|
1d8e698b57 |
Assert nftables firewall baseline postinst state in deb-install matrix
Extend testing/deb-install/test.sh per-distro container test loop with 5 new PASS assertions covering the v0.3.0 nftables firewall baseline that ships installed-but-disabled (operator opt-in): 1. fips-firewall.service unit present at /lib/systemd/system/ 2. fips-firewall.service disabled by default (is-enabled = 'disabled') 3. /etc/fips/fips.nft exists AND is registered as a dpkg conffile 4. /etc/fips/fips.d/ drop-in directory present with mode 755 root:root 5. fips.nft includes the drop-in glob /etc/fips/fips.d/*.nft Assertions slot in between the existing fips-dns service-state check and the simulated-boot service-start block. Each runs against every distro in the existing matrix (debian12 / ubuntu24 / ubuntu26). The security claim — services on fips0 are not exposed by default — remains end-to-end-validated separately by the testing/firewall/ suite. This commit specifically pins the static install state so packaging regressions surface in the per-push deb-install gate rather than at operator opt-in time. |
||
|
|
e08f42e3cc |
Pin discovery state machine: open-discovery sweep + per-attempt lookup timeout
Cover two adjacent runtime behaviors in the discovery state machine
that were previously unpinned at the test level.
1. Open-discovery startup sweep iterate-filter-queue contract.
Cover the runtime sweep behavior: iterate advert cache, apply
skip-filters (own-pubkey, already-connected peers), queue eligible
entries to retry_pending. The config layer was tested but the sweep's
own filtering logic was unpinned.
src/discovery/nostr/runtime.rs: add #[cfg(test)] impl block with
three pub(crate) helpers — new_for_test() builds a minimal
NostrDiscovery with empty cache and no relays/background tasks (uses
fresh nostr::Keys signer + Client::builder().autoconnect(false));
cached_advert_for_test() wraps an OverlayEndpointAdvert into a
CachedOverlayAdvert valid for 1h; insert_advert_for_test() writes
direct to the advert_cache RwLock. All three vanish from release
builds via cfg-gating.
src/node/lifecycle.rs: visibility-only widen on
run_open_discovery_sweep from private async fn to
pub(in crate::node) async fn so the in-tree test can drive it
directly. Same pattern as already-pub(in crate::node) handlers in
src/node/handlers/.
src/node/tests/discovery.rs: add #[tokio::test]
test_open_discovery_sweep_queues_eligible_skips_filtered. Builds
Node + Arc<NostrDiscovery>, injects 3 adverts (eligible, already-
connected peer, own-pubkey), invokes the sweep, asserts retry_pending
contains exactly the eligible entry with matching peer_config npub
and the two filtered entries do NOT appear.
2. Per-attempt timeout state machine in check_pending_lookups.
Cover the central new behavior of
|
||
|
|
81c0547bdf |
Pin consecutive decrypt-failure counter and threshold-20 force removal
Cover the security-relevant defensive signal: sustained decrypt failures indicate key drift or active probing; threshold-trip force- removes the peer. src/peer/active.rs (+43): two unit tests on the counter struct itself - test_increment_decrypt_failures_monotonic asserts each increment_decrypt_failures() call returns count+1 for at least 25 iterations - test_reset_decrypt_failures_zeroes_counter asserts the reset helper zeroes a non-zero counter and is idempotent src/node/tests/decrypt_failure.rs (new, 93 lines): end-to-end test - Builds a Node + connected peer via existing make_completed_connection / add_connection / promote_connection harness so peers_by_index is exercised, not just peers - Drives the peer to threshold-20 by calling handle_decrypt_failure 20 times; asserts iterations 1..20 leave the peer registered with monotonically increasing counter, then iteration 20 evicts from both peers and peers_by_index src/node/handlers/encrypted.rs: visibility-only widen on handle_decrypt_failure from private to pub(in crate::node) so the in-tree test can drive the threshold logic without re-implementing it. Same pattern as the already-pub(in crate::node) handle_encrypted_frame in the same file. Threshold pinned: DECRYPT_FAILURE_THRESHOLD = 20 at src/node/handlers/encrypted.rs:11. |
||
|
|
5ed2d36464 |
Snapshot-pin all 18 control query handlers as v0.3.0 schema baseline
Add hand-rolled JSON snapshot harness in src/control/queries.rs to detect silent schema drift in operator-facing control-socket responses. Builds a Node with deterministic identity (Identity::from_secret_bytes(&[0xAB; 32])), invokes each of the 18 show_* handlers, redacts 17 volatile fields (version, pid, exe_path, control_socket, tun_name, allow_file, deny_file, *_ms / *_secs_ago / uptime_secs), sorts object keys recursively, and compares against a fixture in src/control/snapshots/. First run writes snapshots and passes; subsequent runs enforce. Future schema changes show as a snapshot diff that operators update intentionally — not a stability contract, just a tripwire so drift is never silent. A 19th meta-test dispatch_covers_all_snapshotted_handlers walks every name through dispatch() to confirm each returns status: ok and trips if a 19th handler is added without a matching snapshot. No new dependencies (insta deliberately not added; Cargo.toml [dev-dependencies] keeps tempfile + criterion only). 18 fixture files added, ~544 lines combined; harness is 367 added lines, all inside #[cfg(test)] mod tests. |
||
|
|
9204888a54 |
Pin macOS utun AF prefix and BPF frame parsing at unit level
Add #[cfg(target_os = "macos")] unit tests catching macOS-specific regressions before they reach the macos-latest GitHub runner. src/upper/tun.rs: surgical refactor extracts the inline AF_INET6_HEADER constant into module-scope helpers utun_af_inet6_header() (encode) and parse_utun_af_prefix() (decode inverse for round-trip testability). TunWriter::run now calls the helper instead of the inline const; behavior unchanged. Six new tests pin the AF=30 constant matching Darwin, big-endian byte order, encode/parse round-trip, short-buffer rejection, minimum header acceptance with trailing payload, and no-panic on garbage bytes. src/transport/ethernet/socket_macos.rs: existing test mod already covered bpf_wordalign and 5 parse_next_frame cases. Three new tests fill genuine gaps: struct layout pin against kernel ABI, caplen- overrun rejection, full Ethernet header round-trip via parse. Existing tests pre-exist; only adding to the same #[cfg(test)] block. |
||
|
|
00f4a4c7af |
Pin bloom-not-closer-than-tree-parent fall-through to greedy tree
Add test_routing_bloom_hit_not_closer_falls_through_to_tree to
src/node/tests/routing.rs covering the regression class fixed in
|
||
|
|
9c96c9193d |
Pin node.log_level parser string-to-tracing::Level mapping
Add table-driven unit test test_log_level_parser to src/config/node.rs covering all 5 explicit match arms (trace, debug, warn|warning, error), the implicit None-and-unknown → INFO default, case-insensitivity via to_lowercase (TRACE / Debug / Warning / WARN / ERROR / INFO), and edge cases (empty string, "verbose"). Pins observed behavior: there is no explicit "info" arm — it falls through the wildcard to INFO, identical to unknown strings. |
||
|
|
c86dc32197 |
Pin STUN binding-success parser malformed-response behavior
Add 6 negative-input unit tests to src/discovery/nostr/stun.rs covering truncated header (all lengths 0..20), bad magic cookie, unknown attribute type (skip-not-error), truncated XOR-MAPPED-ADDRESS, length-overflow attribute, and transaction-ID mismatch. The happy path was exercised by the 3 NAT scenarios but the parser had no negative-input coverage. Tests pin observed behavior: parse_stun_binding_success returns Option<SocketAddr>, so all malformed-response cases assert None rather than an error variant. Unknown TLVs are silently skipped via the loop's default arm; length-overflow triggers the value_end > packet.len() guard and breaks out of the loop without panicking. |
||
|
|
037a965a93 |
Mirror reactive MtuExceeded into path_mtu_lookup
When a transit forwarder drops an oversized data packet and reports the bottleneck back via MtuExceeded, the receive-side handler already updates per-session MmpSessionState::path_mtu (used by PTB synthesis to feed kernel TCP). It did not, however, update path_mtu_lookup — the per-destination map the TUN reader/writer consult at TCP MSS clamp time. So forward-path-asymmetry flows kept clamping at the discovery reverse-path value (too generous for the actual forward-path budget) on every subsequent SYN. Add the missing write at the same point apply_notification runs. Keep the tighter of existing-or-new — the clamp must never loosen. Same write-shape as seed_path_mtu_for_link_peer. Tests: - Three focused unit tests on handle_mtu_exceeded for the empty, tighten, and keep-tighter cases. - Extended test_multihop_pmtud_heterogeneous_mtu to assert the lookup tightens after the wire-level MtuExceeded propagation, alongside its existing PathMtuState assertion. Adds two #[cfg(test)] accessors on Node (path_mtu_lookup_get / path_mtu_lookup_insert) and bumps handle_mtu_exceeded to pub(in crate::node) for direct test invocation. |
||
|
|
953137ede7 |
Plumb path_mtu_lookup into Windows run_tun_reader
The B3 path_mtu_lookup plumbing landed without updating the windows_tun::run_tun_reader signature or its inner handle_tun_packet call, breaking the Windows build. Linux/macOS variants and TunWriter (Windows) were already plumbed; this brings the Windows reader into line. No behavioral change on any platform. |
||
|
|
ae607431eb |
Per-destination TCP MSS clamping at the TUN boundary
Adds source-side TCP MSS clamping informed by per-destination path MTU learned via discovery, with a conservative IPv6-minimum-derived ceiling for cold flows where discovery has not yet completed. Closes the multi-hop default-config TCP wedges observed in production where a sender's local-floor MSS exceeds what some intermediate forwarder hop is willing to carry: silent drops, no PTB feedback through the userspace TUN to the kernel TCP stack, retransmits at the same too- large MSS, application connection times out. ## Architecture A new `Arc<RwLock<HashMap<FipsAddress, u16>>>` field `path_mtu_lookup` on Node mirrors the per-destination path MTU in a form accessible from sync TUN reader/writer threads. A new `per_flow_max_mss` helper in `src/upper/tun.rs` reads the lookup at SYN-clamp time and returns the appropriate ceiling for the flow. Three write sites populate `path_mtu_lookup`: 1. **Discovery originator branch** of `handle_lookup_response`: the path MTU bottleneck accumulated through the reverse path lands here when a LookupResponse arrives at the originator. Same value also lands in `coord_cache` per the existing `insert_with_path_mtu` API. 2. **FMP peer-promotion seed** (`seed_path_mtu_for_link_peer`): when an FMP link-layer peer is promoted to active, the local outgoing-link MTU on the peer's transport seeds the lookup. Tighter existing values (learned via discovery) are preserved; the seed only writes when no entry exists or the existing value is looser than the link MTU. Without this seed, directly-configured peers (auto_connect / static peer config) would leave `path_mtu_lookup` empty for their FipsAddress because the FSP session establishes without ever issuing a LookupRequest. 3. **Target-edge fold at `send_lookup_response`**: when a node is the discovery target, it folds its own outgoing-link MTU to the response's next-hop into `path_mtu` before sending. Without this fold, the response leaves the target with `path_mtu = u16::MAX` and only intermediate transits min-fold; the target's first reverse-path hop is never represented in the bottleneck calculation. Refactored the existing transit- side min-fold into a shared `apply_outgoing_link_mtu_to_response` helper called from both sites. ## Read-side: per_flow_max_mss Two TUN call sites consume the lookup: - Outbound `handle_tun_packet` clamps SYN MSS using packet[24..40] (IPv6 destination) as the lookup key. - Inbound `TunWriter::run` clamps SYN-ACK MSS using packet[8..24] (IPv6 source). When the lookup contains a learned value, the helper computes `min(global_max_mss, effective_ipv6_mtu(path_mtu) - 60)` where 60 is IPv6 (40) + TCP (20) headers and `effective_ipv6_mtu` accounts for the FIPS encapsulation overhead. When the lookup is empty for a destination — the cold-flow case — the helper returns `min(global_max_mss, IPv6-minimum-derived ceiling)`. RFC 8200 mandates every IPv6 path accept ≥1280-byte packets, so the IPv6-minimum-derived MSS (1280 - 77 - 60 = 1143) fits any compliant path. Without this conservative ceiling, the first SYN to a destination with no learned path MTU exits the TUN at the kernel-natural MSS (TUN MTU - 60), and the application connection wedges silently before discovery completes for a corrected second SYN to fire. The fix is provably safe: the ceiling is taken with `min` against the local global so operators with even tighter local floors are never loosened upward. Subsequent flows pick up the actual learned per-destination value once discovery (or the FMP-promotion seed for direct peers) populates the lookup. ## Diagnostic logging All write and read sites emit instrumentation suitable for operators bisecting a wedged path: - `debug!` log on every `path_mtu_lookup` write (discovery originator path and FMP-promotion seed path), showing the FipsAddress, written value, prior value, and post-write map size. `warn!` on poisoned-lock failure path. - `trace!` log per `per_flow_max_mss` call covering every fall-through branch (wrong addr_bytes length, non-fd::/8 prefix, lookup poisoned, no entry for destination, empty-lookup conservative ceiling) and the success path. trace level filters out under normal log settings; capture with `RUST_LOG=info,fips::node::handlers::discovery=debug,fips::upper::tun=trace`. ## Tests 15 new unit tests across 3 files: - `per_flow_max_mss` (8 tests in `src/upper/tun.rs::tests`): empty-lookup conservative ceiling, empty-lookup global-smaller floor, learned-value-overrides-conservative, per-destination smaller, per-destination larger capped by global, non-fips addr, short addr slice, per-destination independence. - `seed_path_mtu_for_link_peer` (4 tests in `src/node/tests/unit.rs`): seed when empty, keep tighter existing, tighten looser existing, no-op for unknown transport. - Discovery integration (3 tests in `src/node/tests/discovery.rs`): apply_outgoing_link_mtu_to_response on unknown peer no-op, two-node target-edge fold (path_mtu reflects target-edge link), three-node chain transit min-fold (existing test, updated for target-edge inclusion). Two pre-existing discovery tests had assertions updated to account for the target-edge fold: - `test_response_path_mtu_two_node`: previously asserted `u16::MAX` (no transit to min-fold); now asserts 1280 (the test transport MTU, folded in by send_lookup_response). - `test_response_path_mtu_four_node_chain`: previously asserted 1350 (transit MTUs only); now asserts 1280 (target-edge MTU is the bottleneck). - `test_transit_forwards_when_mtu_sufficient`: previously asserted 1400 (transit MTU only); now asserts 1280 (target- edge MTU is the bottleneck). ## Verification Local CI on this commit: 29/29 suites pass, 1105 lib tests pass, clippy --all-targets --all-features -D warnings clean, cargo fmt clean. Production deploy verified via trace capture across the managed fleet: cold-flow conservative ceiling branch fires on first SYN, learned-lookup branch takes over once discovery completes, both behaviors observable end-to-end at the SYN MSS on the wire. No wire-format change. No config-format change. |
||
|
|
996a591001 |
Add clippy to rust-toolchain.toml components
The new GitHub Actions Clippy job introduced in
|
||
|
|
da5d23ccb7 |
Document nostr-nat ephemeral UDP transport MTU choice
Adopted ephemeral UDP transports created by adopt_established_traversal() default to UdpConfig::default() (MTU=1280, IPv6 minimum) when the bootstrap runtime hands a socket without an explicit transport_config override. This is by design: NAT-traversal middlebox MTU is unpredictable and the IPv6 minimum is the only value guaranteed by spec to survive arbitrary paths. Add an explanatory comment at the call site so future readers find the rationale without spelunking through ISSUE-2026-0013, and so any future change to the inheritance behavior is a deliberate decision rather than an accidental refactor. No behavior change. |
||
|
|
8448e38510 |
Make Node::transport_mtu() deterministic across restarts (TCP black hole fix)
Default-config TCP flows between fips peers were stalling completely (cwnd-pinned, 0 bps for 10s+) on a non-trivial fraction of restarts. Reproducible with iperf3 between any two peers. Root cause: `Node::transport_mtu()` iterated `self.transports.values()` (HashMap with default RandomState hasher) and returned `handle.mtu()` of the first one whose `is_operational()` returned true. Two stacked sources of non-determinism stacked on each other: HashMap iteration order is randomized per-process via RandomState, and async transport `.start()` completion order races each daemon restart. The returned value drives the TCP MSS clamp ceiling computed once at TUN init (src/upper/tun.rs:501-524) and stored as immutable max_mss in the reader/writer thread state. When the picker landed on a transport with MTU > 1357 (any non-UDP-1280 in the standard fleet defaults), `max_mss > 1220` (kernel's natural fips0-MTU-derived MSS), the daemon's clamp was silently a no-op, and the kernel emitted 1220-byte segments. Those wrap into 1280-byte IPv6 → 1357-byte fips datagrams that exceed UDP-1280 transports at any forwarding hop, causing silent drops with no PTB feedback to the kernel TCP stack. Fix: return min across operational transports instead of first-iterated. With UDP-1280 in the configured set (the common case), `transport_mtu = 1280`, `max_mss = 1143 < 1220`, daemon's clamp engages, MSS=1143 reaches the wire, packets fit, throughput recovers. Empirical green light from a single-UDP-config end-to-end test: iperf3-without-`-M` recovered to ~21 Mbps with no operator-side nft TCPMSS rules. Adds three unit tests: - transport_mtu_returns_min_across_operational: pin selection to smallest MTU when multiple operational transports differ. - transport_mtu_fallback_when_no_operational_transports: 1280 fallback. - transport_mtu_min_with_single_operational: trivial single-transport case. The `effective_ipv6_mtu` field reported by `fipsctl show status` was also racy (consequence of the same bug); fixed by this change as a side effect. |
||
|
|
a41f80a776 |
Tighten clippy gate to --all-targets --all-features and clean up
The local ci-local.sh and the GitHub CI clippy invocations both used `cargo clippy --all -- -D warnings`, which only checks lib + bin targets. Test code, integration tests, and benches were not lint-gated. Three pre-existing clippy errors lurked in test modules as a result (two field_reassign_with_default in config tests, one items_after_test_module in stun.rs). Tighten both invocations to `cargo clippy --all-targets --all-features -- -D warnings` so the gate covers everything cargo can build, and fix the three exposed errors: - src/config/mod.rs: rewrite two test-only `Config::default()` + field-reassign sites to struct-update syntax. - src/discovery/nostr/stun.rs: move helper `random_txn_id` above the `#[cfg(test)] mod tests` block. Also adds a dedicated Clippy job to the GitHub CI workflow so the strict gate runs on every PR (the workflow had no clippy job before; clippy ran only via testing/ci-local.sh on operator machines). No behavior changes; lint hygiene + CI hardening only. |
||
|
|
ab2edec2c6 |
Add Nostr open-discovery startup sweep with diagnostic logging
Under `node.discovery.nostr.policy: open`, the per-tick auto-dial in `queue_open_discovery_retries` was supposed to pick up adverts cached from the relay subscription backlog at startup, but in practice only adverts arriving live (after the daemon was up) were being dialed. Backlog adverts sat in the in-memory cache until they aged out. Adds a one-shot startup sweep that runs once per daemon start, gated identically to the per-tick sweep (`enabled` && `policy == open`), after a configurable settle delay so the relay subscription backlog has time to populate the advert cache. The sweep iterates the cache with the same skip-filters as the per-tick path (statically-configured peers, already-connected, retry-pending, connecting) plus a tighter age filter: only adverts whose `created_at` is within `startup_sweep_max_age_secs` of now are queued. Two new config fields under `node.discovery.nostr`: - `startup_sweep_delay_secs` (default 5) - `startup_sweep_max_age_secs` (default 3600 = one hour) Both are only consulted when `policy == open`; under any other policy the sweep is a no-op. Adds diagnostic logging to the open-discovery sweep so operators can verify what the auto-dial path is doing on each daemon bring-up: info-level on each retry-queued enqueue (with peer short-npub and advert age), and a one-line summary on every startup sweep and on any per-tick sweep that queues at least one retry. The summary breaks down skipped candidates by reason (age, configured, self, already-connected, retry-pending, connecting, no-endpoints, invalid-npub) — currently the path was silent so there was no operator-visible signal that the cache iteration was running. Refactors the existing `queue_open_discovery_retries` body into a shared `run_open_discovery_sweep(max_age_secs, caller)` helper so the per-tick and startup paths share filter/queue logic and only differ in the age filter and log label. Surfaces `created_at` from `NostrDiscovery::cached_open_discovery_candidates` (return tuple extended) so the age filter has the data it needs. Three new unit tests in `config::node::tests` cover the new defaults, YAML override round-trip, and partial-YAML default fallback. |
||
|
|
239cbdc4ba |
Fix Tor onion adverts missing port in Nostr overlay discovery
The Nostr overlay advert publisher serialized `transport: tor` endpoints as a bare `<onion>.onion` hostname with no port. The Tor address parser requires `<host>:<port>` form and rejected the bare shape with `expected host:port`. Any peer receiving a Tor-only advert went into a persistent retry-fail loop on jittered backoff until the advert aged out of the discovery cache. The bug had been latent for as long as Tor adverts have been published on Nostr, and was masked in deployments where every node also advertised a non-Tor transport (peers fell through to the working endpoint). Surfaced first on a deployment where Tor was the only advert path. Publisher now emits `<onion>.onion:<port>` using a new `transports.tor.advertised_port` config field that defaults to 443, matching the Tor `HiddenServicePort 443 127.0.0.1:<bind_port>` convention. Operators whose torrc uses a non-default virtual port can override. Adds a unit test that pins the publisher/parser contract: formats the advert exactly as the publisher does and asserts `parse_tor_addr` accepts the result; asserts the bare-onion form (the bug) does not parse, catching any future regression that drops the port again. Parser is unchanged (already correct). |
||
|
|
96c6b7dea8 |
Admit rekey msg1 from established peers when addr forms differ
Companion to the ethernet `accept_connections: false` rekey-deadlock fix from earlier this release: the same dual-init failure mode shows up over UDP when peers register by hostname, and the existing addr_to_link-only carve-out in `should_admit_msg1` doesn't cover it. The carve-out's first predicate keys `addr_to_link` by the literal `TransportAddr` that `initiate_connection` inserted, which is the hostname-form when a peer config carries a hostname (e.g., `core-vm.tail65015.ts.net:2121`). Inbound packets always arrive with numeric source addrs because `udp_receive_loop` builds the `TransportAddr` from the `SocketAddr` the kernel reports via `recvfrom`. `TransportAddr` equality is byte-exact, so the two forms don't match and the lookup misses. With `udp.accept_connections: false` (or `udp.outbound_only: true`, which forces it false) the gate then rejects the rekey msg1 from an established peer. The dual-init tie-breaker stalls because the loser side never produces msg2; both sides retry indefinitely and the winner side keeps logging "Dual rekey initiation: we win, dropping their msg1" at 1Hz. The earlier ethernet fix didn't generalize to this variant because ethernet TransportAddrs are always numeric MAC bytes — both the config-time form and the inbound-arrival form match identically. Add a second predicate to `should_admit_msg1`: an active peer's `current_addr()` matching `(transport_id, remote_addr)`. `current_addr` is updated and refreshed from inbound encrypted-frame source addrs (`handlers/encrypted.rs`), which are always numeric `SocketAddr`-form, so this catches the established peer regardless of how its `addr_to_link` key was originally inserted. The fast `addr_to_link` check stays first; the iteration over peers is bounded by peer count and only runs when the first predicate misses. Regression coverage in this commit: - Unit test `test_should_admit_msg1_admits_rekey_when_addr_form_differs` in `src/node/tests/handshake.rs`. Constructs the failing scenario in-process: `addr_to_link` populated with hostname-form key, peer's `current_addr` at the resolved numeric form, query with numeric form. Without the new predicate this fails immediately. - New integration topology `rekey-outbound-only` plus matching docker-compose profile. Same 5-node mesh shape as `rekey-accept-off` but `inject-config` sets `udp.outbound_only: true` on node-b and rewrites node-b's peer-c address from the numeric docker IP to the docker hostname (`node-c:2121`), reproducing the production hostname-vs-numeric mismatch. The test asserts no sustained "Dual rekey initiation: we win" log lines on any node (>10 = bug) and the existing rekey health checks catch the connectivity loss the loop produces. - `testing/ci-local.sh` and `.github/workflows/ci.yml` extended to run the new variant in the local sweep and the GitHub CI integration matrix alongside `rekey` and `rekey-accept-off`. Verified locally: full `bash testing/ci-local.sh` sweep passes 29/29 suites (23m 12s) with the new variant green; 1084 unit tests pass. |
||
|
|
e641eb5b5f |
Drop the nostr-discovery cargo feature flag
Make Nostr-mediated overlay discovery unconditional, mirroring the philosophy of PR #79's collapse of the tui/ble/gateway features in favor of platform cfg gates. nostr / nostr-sdk are pure Rust over WebSockets/TCP, so they build cleanly on every FIPS-supported platform — there is no need for the parallel feature gate. The flag was already in `default = [...]`, so no behavior change for anyone using `cargo build` without `--no-default-features`. Operators who explicitly disabled the feature will now find Nostr code present in the binary; the runtime check `node.discovery.nostr.enabled` still controls whether the runtime starts. Cargo.toml: - Remove the `[features]` table entirely. - Drop `optional = true` from `nostr` and `nostr-sdk`. Source: 27 cfg sites collapsed across 5 files — `src/discovery.rs`, `src/discovery/nostr/mod.rs`, `src/node/handlers/rx_loop.rs`, `src/node/lifecycle.rs`, `src/node/mod.rs`. Two `#[cfg(not(feature = "nostr-discovery"))]` fallback blocks (the udp:nat-without-runtime debug-log path and the "feature not compiled in" warning at startup) were removed as dead code; the always-on path already handles the missing-runtime case via `nostr_discovery: Option<NostrDiscovery>`. Packaging and tooling: - `packaging/openwrt-ipk/Makefile`: drop a stale `--features gateway` flag (the `gateway` feature was already removed in PR #79; this was a leftover that the build path tolerated only because cargo ignored unknown feature names). - `testing/scripts/build.sh`: drop `DEFAULT_CARGO_BUILD_ARGS=(--features nostr-discovery)`; defaults are empty. - `packaging/common/fips.yaml`: drop the "requires the nostr-discovery feature" comment from the discovery section. Bundled cleanup: - Apply `cargo clippy --fix` against three pre-existing warnings in `src/discovery/nostr/runtime.rs` and `src/discovery/nostr/stun.rs` (collapsed `if let Some` chain; two redundant `as i32` casts). These were always present but masked when the feature gate was off; they surface now that the code is unconditionally compiled. - `cargo fmt` settled two minor formatting drift sites in `src/bin/fips-gateway.rs` and `src/config/mod.rs`. Tests: 1083 passed, 0 failed, 4 ignored. clippy clean. fmt clean. |
||
|
|
37c2973e2f |
Test infrastructure overhaul: gateway robustness + full CI coverage
Single combined commit covering five interlocking pieces of test and CI work that landed during the v0.3.0-prep cycle. ## fips-gateway robustness - src/bin/fips-gateway.rs DNS upstream probe converted from a 3-second hard-fail to a bounded retry loop (5 attempts × 1s timeout, 1s sleep between attempts; ~10s worst case). Covers the cold-boot race where the daemon's TUN is up but the DNS responder at [::1]:5354 is still binding. Each failed attempt logs at INFO. In production the binary's retry is the live recovery mechanism; with retry it recovers silently instead of relying on Restart=on-failure (~5s blip + spurious ERROR per cycle). - packaging/debian/fips-gateway.service `ExecStartPre` now waits up to 30 seconds for the daemon's `fips0` TUN to appear before exec'ing the gateway binary. Eliminates the cold-boot race where the gateway exits with `fips0 interface not found` and recovers via `Restart=on-failure`, producing a 5-second blip and a spurious error log per restart cycle. - testing/docker/entrypoint.sh gateway-mode waits up to 30s for the daemon's DNS responder to bind [::1]:5354 (probes once per second with `dig @::1 -p 5354 ... test.fips`) before exec'ing fips-gateway. Belt-and-suspenders with the binary's own retry: in CI we want deterministic startup ordering. On timeout, fall through so the binary's probe reports the definitive error. ## Test infrastructure DNS bind migration to ::1 After session 359's daemon DNS-bind default flipped from `127.0.0.1` to `::1` (the production fix for ISSUE-2026-0002), the static-test infrastructure was carrying a stale workaround that overrode the default back to IPv4 loopback. The fips-gateway integration test exposed the divergence: the gateway probes its DNS upstream at `[::1]:5354` (production default) while the daemon was binding `127.0.0.1:5354` from the template override — IPv6-explicit sockets do not accept v4-mapped traffic, so the upstream probe exhausted retries and the gateway exited. - Drop the explicit `bind_addr: "127.0.0.1"` line from every test config that emits it: testing/static/configs/node.template.yaml, testing/chaos/configs/node.template.yaml, the sidecar heredoc in testing/docker/entrypoint.sh, testing/acl-allowlist/generate-configs.sh (six per-node blocks), testing/nat/scripts/generate-configs.sh, and the four tor templates under testing/tor/. Daemon picks up its production `::1` default. - Flip the dnsmasq forwarder for `.fips` in testing/docker/Dockerfile from `127.0.0.1#5354` to `::1#5354` so dnsmasq on the shared test image continues to reach the daemon. Template and Dockerfile must move together since most static suites resolve `<npub>.fips` via the test-image dnsmasq. ## rekey-accept-off integration variant + UDP unit test - New `rekey-accept-off` topology and docker-compose profile under testing/static/. 2-node variant where node-b runs with `udp.accept_connections: false`. Pins the regression class that ISSUE-2026-0004 fixed (cross-connection winner's rekey msg1 was being filtered by the accept_connections gate, breaking rekey). - testing/static/scripts/rekey-test.sh accepts REKEY_TOPOLOGY and REKEY_ACCEPT_OFF_NODES env vars; its inject-config subcommand applies the per-node `udp.accept_connections: false` edit, and the test asserts no sustained "Dual rekey initiation" log lines. - New UDP variant of `should_admit_msg1` admit-rekey unit test in src/node/tests/handshake.rs. ## ci-local.sh full integration coverage - New runner functions and dispatcher entries for `acl-allowlist`, `nat-cone` / `nat-symmetric` / `nat-lan`, `rekey-accept-off`, `dns-resolver`, `deb-install`. Each integrates with the existing summary tracking via `record`. - New `--with-tor` flag (off by default) gates `tor-socks5-outbound` and `tor-directory-mode` runners. Tor stays opt-in because both harnesses depend on the live Tor network and would introduce a flake source unrelated to the FIPS code. - New suite arrays (`ACL_SUITES`, `NAT_SUITES`, `DNS_RESOLVER_SUITES`, `DEB_INSTALL_SUITES`, `TOR_SUITES`) drive both the default sweep and `--list` output. - `run_suite` extended to accept the new suite names for `--only` invocations. ## GitHub CI matrix expansions - `gateway` matrix entry runs testing/static/scripts/gateway-test.sh against the existing docker-compose `gateway` profile. - `rekey-accept-off` matrix entry exercises the new topology with REKEY_ACCEPT_OFF_NODES=b. - `deb-install` matrix (debian12 + ubuntu24 + ubuntu26) runs testing/deb-install/test.sh with privileged systemd containers. ~5-7 min cold cache, ~2 min warm per distro. Self-contained: builds its own .deb in a Debian 12 cargo-deb builder image; does not depend on the build job's pre-built artifact. - `dns-resolver` matrix entry runs the full 13-scenario harness (per-distro systemd resolver-backend tests + real-fips end-to-end scenarios) in a single job. Pins the production DNS bind path that ISSUE-2026-0002 lived in. ~7-12 min warm, ~12-15 min cold. Verified locally: full `bash testing/ci-local.sh` sweep passes, including 5/5 deb-install distros and all 13 dns-resolver scenarios. Tor-inclusive sweep (`--with-tor`) verified in a follow-up run. |
||
|
|
674c7fe1ff |
UDP transport: outbound_only mode, accept_connections, loopback validation
Three related UDP transport changes that together close a real gap in the v0.2.x "this transport accepts inbound" assumption: - outbound_only (default false). When true, the transport binds a kernel-assigned ephemeral port (0.0.0.0:0) regardless of the configured bind_addr, refuses inbound handshakes (Transport trait's accept_connections() returns false), and is never advertised on Nostr regardless of advertise_on_nostr. Lets a node participate in the mesh as a pure client — initiate outbound links without exposing an inbound listener on a known port. Also closes the "loopback bind as outbound-only workaround" trap: a UDP socket bound to 127.0.0.1 pins 127.0.0.1 as the source IP on outbound packets, and Linux refuses to deliver such packets out an external interface — the daemon happily reports "transport started" while no flow ever reaches an external peer. - accept_connections (default true). Mirrors the existing Ethernet/BLE knob. Lets operators run UDP in a "client" posture (initiate outbound, refuse inbound msg1 from new addresses) without switching transport. The Node-level handshake gate already carves out msg1 from peers established on the transport so rekey works on existing sessions. - Startup validation: reject `transports.udp[*].bind_addr` set to a loopback address (127.x.x.x, ::1, localhost) when at least one peer has a non-loopback UDP address. Replaces the silent "peer link won't establish" failure mode with a clear error pointing at the bind misconfiguration. outbound_only is exempt (it overrides bind_addr to 0.0.0.0:0). The is_punch_packet-based filter from the previous commit, the Node-level admission gate landed earlier on master, and these new config fields together cover the three distinct ways the v0.2.x "this transport accepts inbound" assumption could break. Tests: validation truth table (loopback+external rejected, loopback+loopback ok, outbound_only exempt), is_loopback_addr_str helper, accept_connections wiring (default, explicit-false, outbound_only-forces-false), end-to-end ephemeral-bind in the runtime. 1082 tests pass with --features nostr-discovery. |
||
|
|
23c6609a6e |
Ship fips0 nftables security baseline (Linux)
Add a default-deny nftables ruleset for the fips0 mesh interface as a packaged operator asset, with a companion fips-firewall.service oneshot unit for systemd hosts. Both are shipped disabled — the baseline is an operator conffile and the unit is intentionally not enabled in postinst. Activation is an explicit one-liner: sudo systemctl enable --now fips-firewall.service This is deliberate: silently mutating host firewall state on package install is hostile across the axes that matter (collisions with existing operator nftables / Docker / OPNsense rulesets, surprise behaviour for hosts that already filter elsewhere, conversion of an explicit security decision into an invisible one). The opt-in posture preserves operator agency. The baseline closes a real default-exposure gap: any service on a mesh host bound to a wildcard address (0.0.0.0 or [::]) is reachable from every authenticated peer in the mesh by default. Identity on the mesh is the peer's npub but identity is not authorization, and the mesh is closer to a shared LAN than to the public internet. With this filter loaded, the surface is closed unless a drop-in opens it explicitly. Baseline shape: - Early-return for non-fips0 traffic (every other firewall left undisturbed) - conntrack established/related accept (replies to outbound flows) - ICMPv6 echo-request accept (ping6 reachability) - include "/etc/fips/fips.d/*.nft" — operator-supplied allowances - counter drop default The accompanying docs/fips-security.md lays out the threat model (npub-authenticated mesh is closer to a shared LAN than to the public internet — identity is not authorization), the activation workflow, drop-in extension recipes (allow inbound SSH from a specific peer fd97:.../128, allow HTTP from one /64, etc), drop visibility / debugging via the journal log rule and the drop counter, coexistence with the runtime-managed `inet fips_gateway` table, what the baseline does NOT cover (outbound, application auth, mesh handshake ACL = PR #50 / IDEA-0047 territory), and future cross-OS work (macOS PF baseline, OpenWrt fw4, gateway abstraction). Packaging: - packaging/common/fips.nft → /etc/fips/fips.nft (conffile) - packaging/debian/fips-firewall.service → /lib/systemd/system/ - docs/fips-security.md → /usr/share/doc/fips/ - postinst creates /etc/fips/fips.d/ (mode 0755) on configure - prerm stops/disables fips-firewall.service on remove/purge OpenWrt fw4 path and macOS PF baseline are deferred — separate asymmetries, separate work. |
||
|
|
3092c95d54 |
Filter stray punch probes on adopted UDP transports
When a UDP hole-punch succeeds in only one direction and the local side adopts the punched socket, the remote end keeps retrying its own punch attempt for several seconds. Those retries arrive on the adopted socket and were forwarded to the FMP rx handler, which parsed the first byte (0x4E from PUNCH_MAGIC's "NPTC" big-endian encoding) as FMP protocol version 4 and emitted "Unknown FMP version, dropping" once per probe. The probe stream contaminated post-adoption handshake logs and added timing pressure during the handshake window. Add a transport-level filter in udp_receive_loop that silently drops any datagram whose first 4 bytes match PUNCH_MAGIC or PUNCH_ACK_MAGIC. Filter applies to all UDP transports, not just adopted ones — the magic values cannot collide with valid FMP frames (FMP version 4 is not assigned, and the protocol's versioning is wire-format breaking), so universal filtering is safe and removes any "is this an adopted socket" branching. Move PUNCH_MAGIC / PUNCH_ACK_MAGIC and the new is_punch_packet() helper from the `nostr-discovery`-gated submodule up to crate::discovery (unconditionally compiled) so the UDP transport can import them without requiring the feature. The nostr-discovery types module re-exports the constants so the existing traversal-side imports keep working unchanged. Test: pushes a probe + ack + real frame through the receive loop and asserts only the real frame is delivered to packet_tx. |
||
|
|
c8502cdb97 |
Auto-derive per-commit Debian Version for dev builds
Inject git date + short SHA into the Debian Version field when Cargo.toml's crate version ends in -dev, so apt-based upgrade detection works without operator workarounds. Form: <base>~dev+git<YYYYMMDD>.<sha>[.dirty]-1 e.g. 0.3.0~dev+git20260429.6def31b-1. Each commit produces a uniquely-comparable Version, so `apt install ./*.deb` and `ansible.builtin.apt: deb:` stop silently no-op'ing when one dev .deb is installed on top of another. The ~dev marker sorts pre-tagged-release so 0.3.0 supersedes any prior dev .deb. Tagged builds (Cargo.toml without -dev) keep the clean <version>-1 form. --version override still wins. Note: legacy 0.3.0-dev-1 dev installs sort ABOVE the new form; hosts upgrading from a legacy install will need `dpkg -i` once on the next dev .deb to bypass apt's downgrade refusal. |
||
|
|
6def31bcf6 |
Admit rekey msg1 from established peers regardless of accept_connections
The accept_connections gate at the top of handle_msg1 was applied unconditionally, so rekey msg1 from a peer with whom an established link already existed was dropped on the same path as fresh handshakes from strangers. Combined with the dual-init tie-breaker, this deadlocked at ~25 minutes when both sides' rekey timers fired near-simultaneously: the smaller-NodeAddr side wins as initiator and expects the larger side to consume its rekey msg1, but if the larger side has accept_connections=false the gate dropped it. Both sides retried at 1 Hz indefinitely; the affected peer fell out of MMP-active rotation. Extract the gate decision into Node::should_admit_msg1, which admits unconditionally when addr_to_link already has an entry for the (transport_id, remote_addr) pair (rekey/restart on an established session) and otherwise consults the transport's accept_connections(). Fresh msg1 from strangers is still rejected before any Noise crypto. Three unit tests pin the truth table: no transport (admit), accept_off no-link (reject, behavior unchanged), accept_off with-link (admit, the carve-out). The fix generalizes for free to BLE, which has the same Node-level gate. TCP and Tor were never subject to this deadlock because their accept condition is runtime state (bind_addr.is_some() / onion_address.is_some()), not a config flag. |
||
|
|
bf77ececad |
Fix DNS responder silent-drop on systemd-resolved deployments
The previous default configured systemd-resolved with `resolvectl dns fips0 [<fips0_addr>]:5354`, intended to bypass an Ubuntu 22 systemd 249 interface-scoping bug. That target collides with the daemon's mesh-interface filter on Linux: when an IPv6 packet's destination belongs to a non-loopback interface, the kernel attributes the packet to that interface in IPV6_PKTINFO (ipi6_ifindex == fips0) even though loopback delivery is used (tcpdump shows lo). The mesh-interface filter sees arrival_ifindex == mesh_ifindex and silently drops every query at trace level — invisible to operators at the default debug level. Net effect on stock deployments: every .fips query on systemd-resolved hosts was silently dropped. Daemon side ----------- - Default `dns.bind_addr` changes from "::" to "::1" (IPv6 loopback only). The mesh-interface filter is then defanged on the default path because loopback isn't reachable from mesh peers. The filter remains in place defensively for operators who explicitly bind "::" to expose a mesh-reachable responder. fips-dns-setup backend unification ---------------------------------- - New `try_global_drop_in` backend writes /etc/systemd/resolved.conf.d/fips.conf with DNS=[::1]:5354 and Domains=~fips. Inserted ahead of `try_resolvectl` in the dispatch chain. The standard loopback path has no interface scoping, so ipi6_ifindex reports lo and the filter passes. - All other backends now target [::1]:5354 to match the daemon's default IPv6-loopback bind: - try_dns_delegate writes DNS=[::1]:5354 - try_dnsmasq writes server=/fips/::1#5354 - try_nm_dnsmasq writes server=/fips/::1#5354 - Fixed dns-delegate file path: was /etc/systemd/dns-delegate/, must be /etc/systemd/dns-delegate.d/ (with .d suffix). systemd-resolved silently ignored the previous path. - fips-dns-teardown handles the new global-drop-in backend in cleanup. - The legacy resolvectl per-link backend stays as a fallback, documented to require careful daemon bind_addr coordination. fips-gateway upstream pairing ----------------------------- - gateway.dns.upstream default changes from 127.0.0.1:5354 to [::1]:5354 to match the daemon's default bind. Linux IPv6 sockets bound to explicit ::1 do not accept v4-mapped traffic, so the old default would have caused the gateway's startup DNS reachability probe to time out and systemd to restart-loop the service. - Operators who set a non-default daemon `dns.bind_addr` must also set `gateway.dns.upstream` to match — documented inline. Documentation ------------- - packaging/common/fips.yaml and packaging/openwrt-ipk fips.yaml examples updated; rationale for the bind_addr choice and the daemon/gateway pairing recorded inline. Test coverage ------------- - testing/dns-resolver/test.sh: real-fipsd end-to-end scenario added. Builds fipsd in a Debian 12 builder image (cached), runs the daemon with a real TUN in a privileged container, configures DNS via the setup script, and asserts `dig @127.0.0.53 AAAA <npub>.fips` returns AAAA. Refactored as a parameterized helper running across Debian 12/13 and Ubuntu 22/24/26 (5 e2e scenarios). Backend-aware assertions: on systemd >= 258 the expected backend is dns-delegate; on older systemd it's global-drop-in. Strict content checks fail CI on any [::1]:5354 drift. fips-gateway also exercised in the debian12 scenario to lock the gateway-upstream pairing. Renamed all "fipsd" references to "fips" (project convention). - testing/deb-install/ (new harness): builds the actual .deb via cargo-deb in a Debian 12 builder image (cached), installs via apt across each target distro, verifies maintainer scripts, conffile placement, binary placement, and end-to-end .fips resolution after start. Also exercises fips-gateway against the installed daemon to verify the gateway/daemon default pairing on a real .deb path. - This is the test layer that was missing — the previous harness only verified config files were written, never that queries reached the daemon. Verified: dns-resolver 78/78 assertions, deb-install 55/55 assertions across all 5 distros (debian:12, debian:trixie, ubuntu:22.04, ubuntu:24.04, ubuntu:26.04). |
||
|
|
1e3b2c319e |
testing: rekey settle past drain window + stale tooling references
- testing/static/scripts/rekey-test.sh: bump REKEY_SETTLE from 5s to 12s so post-rekey ping_all samples are taken after the 10s old- session drain window has closed (DRAIN_WINDOW_SECS in handlers/rekey.rs). The 5s sample window straddled the drain, occasionally catching old-session decrypts mid-cutover and producing spurious ping failures. - testing/lib/log_analysis.py: discovery-succeeded match string is "proof verified, route cached" (the wording in handlers/discovery.rs); the regex looked for the older "caching route" wording, so the chaos analysis summary always reported `Succeeded: 0` regardless of how many successes the raw node logs actually showed. - testing/scripts/build.sh: drop the second cargo invocation that passed `--features gateway --bin fips-gateway`. PR #79 removed the `gateway` cargo feature in favor of platform cfg gates, so this invocation now fails with `the package 'fips' does not contain this feature: gateway`. The first cargo build already produces fips-gateway as a workspace binary on every supported target. |
||
|
|
f16b837a12 |
Tune overly aggressive discovery rate limiting
The default 30s post-failure backoff (300s cap, doubling per consecutive failure) was set to bound traffic from chatty apps looking up unreachable targets, but in practice it dominates cold-start mesh convergence: a single timed-out lookup during initial bloom-filter propagation suppresses any retry for 30s, and the existing reset triggers (parent change, new peer, first RTT, reconnection) don't fire on a stable post-handshake topology. The suppression window winds up dictating the protocol's effective time-to-converge instead of bounding repeat traffic. Replaces the single-lookup-with-internal-retry model (`timeout_secs`/`retry_interval_secs`/`max_attempts`) with a per-attempt timeout sequence in `node.discovery.attempt_timeouts_secs`, defaulting to `[1, 2, 4, 8]`. Each attempt sends a fresh LookupRequest with a new random request_id so successive attempts can take different forwarding paths as the bloom and tree state evolve. The destination is declared unreachable only after the sequence is exhausted (15s total at the default). Disables post-failure suppression by default (`backoff_base_secs`/ `backoff_max_secs` now `0`/`0`). The `DiscoveryBackoff` machinery stays in tree (inert at zero base/cap); operators with chatty apps generating repeat lookups against unreachable destinations can opt back in. `PendingLookup` field shape unchanged so the control-socket `show_routing` JSON (`pending_lookups[].attempt`/`initiated_ms`/ `last_sent_ms`) keeps the same schema for fipstop and external consumers; `last_sent_ms` now means "current-attempt start" under the new state machine. |
||
|
|
cbc78091ab |
Rationalize cargo feature and platform-gate surface (#79)
Drop the `tui`, `ble`, and `gateway` cargo features and replace them with platform cfg gates. Plain `cargo build` now produces every subsystem appropriate for the target platform with no feature flags required. Motivation: - `default = ["tui", "ble"]` broke `cargo build` on macOS and Windows because `ble` pulled in `bluer` (BlueZ, Linux-only). Every non-Linux packager needed `--no-default-features`. - The feature flags on `ble` and `gateway` were redundant with their platform-gated deps (`bluer`, `rustables`). The parallel gating was inconsistent and error-prone. - `tui` feature protected against a ratatui binary-size concern that no longer applies in 2026. Cargo.toml: - Remove `tui`, `ble`, `gateway` features; `default = []`. - Promote `ratatui` to a non-optional top-level dependency. - Move `rustables` from top-level optional into the Linux target block, non-optional. - Split `bluer` into its own target block with `cfg(all(target_os = "linux", not(target_env = "musl")))` — BlueZ isn't available on musl router targets and `libdbus-sys` doesn't cross-compile to musl without pkg-config sysroot setup. - Drop `required-features` from the `fipstop` and `fips-gateway` `[[bin]]` entries. build.rs: - Emit a `bluer_available` custom cfg when `target_os == "linux"` and `target_env != "musl"`, for use in place of the verbose full predicate in source cfg gates. Source: - Replace every `#[cfg(feature = "gateway")]` with `#[cfg(target_os = "linux")]`. Gateway code works on both glibc and musl Linux (rustables is fine on musl). - Replace every `#[cfg(feature = "ble")]` with `#[cfg(bluer_available)]`. BLE-specific code (BluerIo module, bluer type conversions, BLE transport instance creation, resolve_ble_addr) is excluded on musl and non-Linux. Generic `BleAddr`, `BleIo` trait, `MockBleIo`, and `BleTransport<I>` still compile on all targets. - `src/bin/fips-gateway.rs`: always compiled, but `main()` is gated to Linux. Non-Linux stub exits 1 with a diagnostic. Existing non-Linux packaging scripts don't ship it, so the stub binary sits unused. Packaging and CI: - Drop `--features` and `--no-default-features` flags from every packaging script and workflow. Defaults now match each platform's capabilities. - AUR `fips-git` automatically aligns with stable `PKGBUILD` (both build with defaults). Verified: `cargo build --release` with no flags produces all four binaries on glibc Linux; all unit and integration tests pass across Linux/macOS/Windows/OpenWrt (musl) in CI. |
||
|
|
be0708ac9b |
Bring acl test module into the test tree
src/node/tests/acl.rs was added by PR #50 but never declared in src/node/tests/mod.rs, so none of its 4 unit tests ran. The tests themselves still compile and pass against current master code — the fix is a one-line mod declaration. Test count goes from 1031 to 1035. No code under test changes. This only adds previously-dormant coverage of the ACL enforcement call sites (outbound connect, inbound msg1, outbound msg2). |
||
|
|
ad5ad53848 |
Merge branch 'maint'
# Conflicts: # CHANGELOG.md |
||
|
|
ed312ac6f2 |
Fix DNS resolution on Ubuntu 22 with systemd-resolved (#77)
On Ubuntu 22 (systemd 249), systemd-resolved applies interface-scoped routing to per-link DNS servers. Configuring `resolvectl dns fips0 127.0.0.1:5354` caused resolved to attempt reaching 127.0.0.1 through fips0 (a TUN with only fd00::/8 routes), silently failing. The DNS responder never received queries. Newer systemd versions (250+) have explicit handling for loopback servers on non-loopback interfaces. Changes: - DNS responder default bind_addr changed from "127.0.0.1" to "::" so it listens on all interfaces, including fips0. Bind logic in lifecycle.rs now parses bind_addr as IpAddr and constructs a SocketAddr, handling IPv6 literal formatting. Factored into Node::bind_dns_socket with explicit IPV6_V6ONLY=0 via socket2, so IPv4 clients on 127.0.0.1:5354 still reach the responder regardless of the kernel's net.ipv6.bindv6only sysctl. - fips-dns-setup resolvectl backend now waits for fips0 to have a global IPv6 address, then configures resolved with [<fips0-addr>]:5354. That address is locally delivered by the kernel regardless of which interface resolved tries to route through. The dnsmasq and NetworkManager backends still use 127.0.0.1 (they don't have the interface-scoping issue). - Dropped hardcoded `bind_addr: "127.0.0.1"` from the packaged fips.yaml (Debian + OpenWrt). The shipped config was overriding the new default. - DNS queries are only accepted from the localhost. Verified end-to-end in a privileged Ubuntu 22.04 systemd container: dig @127.0.0.53 AAAA <npub>.fips resolves cleanly through systemd-resolved. The dns-delegate backend (systemd 258+) still uses 127.0.0.1; it has not been verified whether that backend has the same routing issue. |
||
|
|
de8db82614 |
Make tree ancestry acceptance test deterministic
The test used Identity::generate() for the signing node while pinning the fixed root to node_addr byte[0]=0x01. About 2/256 random identities have byte[0] <= 0x01, which made the generated node_addr the path minimum and triggered AncestryRootNotMinimum. Regenerate the identity until its node_addr is numerically larger than the fixed parent and root, so the test matches the preconditions it asserts. |
||
|
|
03f6db58e8 | Merge branch 'maint' | ||
|
|
db4b32110c |
Validate bloom filter fill ratio on FilterAnnounce ingress
A malformed FilterAnnounce whose fill ratio produces an implausibly high false-positive rate is mostly useless for routing and, once merged into our outgoing filter via bitwise OR, propagates the saturated state to tree peers one hop per announce tick. A saturated filter also made estimated_count() return f64::INFINITY, which compute_mesh_size summed into its cached estimate. handle_filter_announce now rejects inbound FilterAnnounce whose derived FPR exceeds `node.bloom.max_inbound_fpr` (new config field, default 0.05 ≈ fill 0.549 at k=5). Rejection is silent on the wire, logs at WARN, and increments a new `bloom.fill_exceeded` counter. The peer's prior stored filter and filter_sequence are left unchanged so a single rejected announce does not wipe the peer's existing contribution to aggregation. After a successful outgoing FilterAnnounce send, a rate-limited WARN fires if our own filter's FPR exceeds the same cap, surfacing aggregation drift. Limited to once per 60 seconds via a new Node.last_self_warn field. BloomFilter::estimated_count() now takes max_fpr and returns Option<f64>. Returns None for saturated filters (regardless of cap) or when the filter's FPR exceeds max_fpr. Callers updated: debug logs render None as "—", the Debug impl uses f64::INFINITY as "no cap" and prints "saturated" instead of inf, control-socket JSON emits null, and compute_mesh_size propagates None into the already- Option<u64> estimated_mesh_size field. |