mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-12 09:33:23 +00:00
fa736b19a8c5b1517ff04dbcf3c8ec5be8862060
79
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d0dcb40958 |
FreeBSD support: daemon, TUN datapath, .fips DNS, and native pkg packaging
Adds FreeBSD as a supported platform. The daemon, fipsctl, TUN datapath and DNS integration build and run there, with a native pkg and an rc.d service. The one piece of genuinely new datapath logic is the TUN framing. FreeBSD's tun rejects every non-IPv4 packet with EAFNOSUPPORT unless TUNSIFHEAD is set, so nothing IPv6 can be sent at all; with it set, every frame carries a 4-byte network-order address-family prefix the way macOS utun does. The ioctl is issued at device creation and the prefix is stripped on read, which gives callers the same raw-IP contract as Linux and macOS. A frame carrying only the header reads as zero bytes and the reader loops treat it as nothing to do. The address family is now taken from libc rather than hardcoded, because AF_INET6 is 30 on Darwin and 28 on FreeBSD. The reader shutdown path, the writer's address-family header and the supervisor's shutdown pipe were all macOS-only and are now shared with FreeBSD, since neither platform wakes a blocked read when the interface goes down. Linux continues to rely on interface deletion. mdns-sd moves from 0.19 to 0.20 for socket-pktinfo 0.4.1, the first release that builds on FreeBSD, which uses IP_RECVDSTADDR and IP_RECVIF instead of Linux-style IP_PKTINFO. This is the only change here that affects every platform rather than just the new one. The config, ACL, hosts and keygen path constants now treat FreeBSD the same as macOS, since both install under /usr/local/etc/fips. Those constants arrived separately on maint and are merged here rather than duplicated: the predicates widen to cover FreeBSD, the platform-gated tests widen with them, and keygen keeps reading the shared SYSTEM_CONFIG_DIR constant rather than reintroducing a literal. Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com> |
||
|
|
feb8a91fdd | Merge branch 'maint' | ||
|
|
cdb03c76b0 |
Follow the macOS install layout for config, ACL, and identity paths
The macOS packaging installs config under /usr/local/etc/fips, wired through the launchd plist and build-pkg.sh, but the default-path constants and the config search path were hardcoded to /etc/fips for all Unix. On macOS the daemon and fipsctl therefore looked in a directory that does not exist: the ACL and host-map loaders hit their NotFound no-op arm and returned empty state, so a populated peers.deny reported effective_mode "default_open" with enforcement inactive, and host-file aliases went unloaded, with no error. The peers.allow, peers.deny and hosts defaults now follow the platform's packaging, and fipsctl keygen writes its identity there too. The config search path keeps probing /etc/fips first and adds /usr/local/etc/fips after it, so an existing install keeps working across the upgrade and the packaged file still wins over a stale leftover. Both the macOS search-path entry and the keygen output directory read one SYSTEM_CONFIG_DIR constant, so they cannot drift apart. At startup the daemon warns once about hosts, peers.allow or peers.deny stranded at the old location; the config file is deliberately excluded, since both directories stay on the search path and a config left behind is still read. The control-socket snapshot tests repoint the ACL reloader at non-existent paths under the temp dir, so the snapshot no longer reflects whatever ACL files happen to exist on the machine running the tests. Platform-gated unit tests pin both layouts, so a future refactor cannot silently drift either one. Linux and Windows behavior is unchanged. Adding a second system config directory moves the directory the daemon derives the identity key path from, since that comes from whichever config file loaded last. A host carrying fips.yaml at both locations would have resolved fips.key to the new directory, found none, and under persistent generated a fresh identity, silently changing its npub, routing address and mesh IPv6 with no migration path. The daemon now adopts a key stranded at the legacy path and warns to move it rather than generating one. The fallback is confined to keys resolved from the system config directory, so a run using ./fips.yaml or a user config is never redirected to a system key. Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com> |
||
|
|
472665ba2f |
Merge branch 'maint'
The established-link msg1 metering is re-authored against this line's handshake handler rather than taken from the merge. The two versions of src/node/handlers/handshake.rs differ by roughly 1437 lines from the earlier decomposition, so the conflict hunks spanned whole divergent bodies, including this line's restructured dual-initiation handling, and resolving them hunk by hunk would have spliced the two structures together. The file was taken from this line and the change re-applied by hand; the resulting diff against the pre-merge tip is the metering change and nothing else. The rate limiter itself needed no adaptation: src/node/rate_limit.rs was byte-identical on both lines, so the guard, the second bucket and the derivation merged in unchanged. Eighteen explicit pending-slot releases in handle_msg1 are replaced by a single guard held for the function's scope. Every one of the eighteen either preceded a return or ended a match arm, and the success path released at the end of the function, so the guard is behaviour-equivalent. One ordering difference: the slot is now released at function exit rather than immediately before each arm's reject-stat bump. Nothing outside the handler reads the pending count between those two points, so it is unobservable in tree, but it is an ordering change rather than a pure refactor. Four tests are ported from the other line, one with its wire-module import path adjusted for this line's layout. Known coverage gap: no test distinguishes the guard being held from the guard being released at acquire time. Rebinding `_slot` to a bare `_` leaves the whole suite green and draws no clippy warning, so the invariant rests on the binding name. Noted at the site. |
||
|
|
f5be8ec07d |
Meter established-link msg1 separately from stranger admission
The msg1 rate limiter ran before the established-peer carve-out, so a rekey or restart msg1 arriving on an existing link was refused on exactly the same terms as a stranger's first packet and the carve-out below it never applied to the traffic it was written for. On a node with many peers this refuses a large share of ordinary maintenance traffic: a field node at roughly 245 peers refused 8753 msg1 in 25 minutes, and 159 of the 201 distinct sources were peers it already held sessions with. Classify the source before metering it, and give established-link msg1 its own token bucket instead of a bypass. A bypass was rejected deliberately: the limiter is global precisely because UDP sources are spoofable, and an established-peer exemption is by construction keyed on source address, so metering the exempted class keeps that property where bypassing discards it. The bucket is derived from settings the operator already sets, burst from max_peers and rate from max_peers, the rekey interval and the resend budget, so raising the peer limit sizes it automatically rather than leaving a constant nobody revisits. Both parameters can be set explicitly; an explicit zero burst or non-positive rate is rejected at config validation, because it would refuse every rekey msg1 from an established peer rather than disabling the limit. Split out the established-link test as its own predicate so the rate-limit classifier and the accept_connections gate cannot drift, and convert the limiter's pending slot to a guard released on drop. The slot was previously acquired in one place and released explicitly at eighteen exit paths; once some paths stop acquiring one, any path that still released one would have freed a slot belonging to a different in-flight handshake, lifting effective concurrency above the configured maximum with no counter moving and no log firing. The "Msg1 rate limited" line now reports which limb refused, the pending count or the token bucket, which it did not distinguish before. Classification costs an O(peers) scan on every inbound msg1 including refused ones, where the previous order refused at O(1). The scan is only needed because addr_to_link is keyed on the unresolved dial address; correcting that keying reduces this to a single O(1) lookup. Recorded at the predicate. |
||
|
|
66d5be2bfb |
Merge branch 'maint' into master
Both maint changes carry over unchanged. The hop-limit helpers follow the module rename from src/protocol/link.rs to src/proto/link.rs, applied by rename detection and verified at the renamed path rather than assumed; the old path is not recreated. The rekey config validation and its jitter-constant import apply as written, the node module exposing the constant on this branch as well. The only conflict was the unreleased changelog, where master's own Changed entries and the incoming hop-limit entry were both correct for their branch. Resolved as the union, with the incoming entry appended to master's Changed list. |
||
|
|
0cb5574077 |
Reject rekey settings that fire the trigger immediately and forever
Config validation said nothing about the rekey block, so two settings that disable rekey in appearance and hammer it in practice were accepted silently. after_messages of zero makes the message-count arm true on every poll, since the trigger tests the counter against it with a greater-or-equal. It reads like a way to switch the arm off and does the opposite. after_secs at or below the per-session jitter is the same trap on the timer arm. Each session offsets the interval by a random value drawn from plus or minus the jitter bound, so a smaller interval saturates to zero on a negative draw and rekeys on sight, for roughly half of sessions and not the other half. The rule is expressed against the jitter constant rather than its current value, so it tracks if the bound ever moves. Both are checked whether or not rekey is enabled, so turning it on later cannot surface a configuration error at a surprising moment. Neither has an upper bound: a very large value is the established way to disable one arm of the trigger and stays legal. |
||
|
|
f624013b83 |
feat(openwrt): open !FIPS access SSID layer (#126)
Access layer for phones and laptops to reach FIPS routers on OpenWrt, stacked on the 802.11s mesh backhaul from #123. Squashed from five commits by Arjen (Origami74); their original messages follow. * feat(openwrt): open !FIPS access SSID — fips-ap-setup helper, default transport binding, how-to Client access layer for phones and laptops: every FIPS router broadcasts the same open SSID ('!FIPS' — the leading '!' sorts it to the top of alphabetically ordered network pickers), forming one standard ESS. Clients save it once and roam between all FIPS routers natively, with FIPS's Noise IK handshake as the only security layer: - fips-ap-setup: opt-in UCI helper that creates the 'fips-ap0' open AP (encryption none — security type must be uniform across routers or clients treat the ESS as different saved networks), an isolated network with a static ULA /64, RA-only odhcpd addressing (stateless SLAAC, no DHCP — the minimum that satisfies Android's provisioning check; no internet by design, so phones keep cellular as default route), and a locked-down fips_ap firewall zone (no path to br-lan or the WAN; only ICMPv6, mDNS, and the FIPS transports reachable). 'remove' subcommand undoes it. Radio setup stays opt-in; a package must not commandeer radios on install. - fips.yaml: ship 'ap0'/'ap1' Ethernet-transport entries commented out (matching the 802.11s mesh backhaul) so a stock install that never creates fips-ap* logs no per-boot "interface missing" bind warning; fips-ap-setup uncomments the matching block when it creates the interface and re-comments it on remove. - Regression test: extend shipped_openwrt_config_parses to assert the ap0/ap1 entries ship commented out and still parse once uncommented, alongside mesh0/mesh1. - Packaging: install the helper in ipk/apk/buildroot (three synced copies), extend CI structural checks and shellcheck targets. - docs/how-to/set-up-open-access-ssid.md: full guide, including the one-time 'no internet, stay connected' acceptance (stored per SSID, covers every FIPS router) and the security-type-uniformity constraint. * docs(openwrt): correct open-SSID/mesh security framing — peering is open by design The fips-ap-setup/fips-mesh-setup comments and both how-tos claimed a stranger "cannot pass the FIPS handshake" / "their frames die at the handshake" / the handshake surface "drops them". That is wrong: FIPS peer admission is open. An inbound handshake from any net-new identity is promoted (node::handlers::handshake::promote_connection), gated only by the daemon's max-peers cap — there is no allowlist, no PSK, and the AuthChallenge path is not wired to admission. The Noise IK handshake provides authentication (no impersonation of another identity, no MITM), not authorization. Restate the model accurately in all five places: a stranger on the open SSID (or the open mesh) can associate AND form a FIPS peer link — that is the point of open access. Containment is the isolated fips_ap zone (no path to br-lan or the WAN) plus the max-peers cap, not the handshake. Clarify that AP client isolation is an L2 control only: a peered stranger is an overlay peer like any other, so the FIPS overlay, not L2, is the trust boundary between clients. * feat(openwrt): serve DHCPv4 on the access SSID from a fixed roamable subnet RA-only addressing satisfied Android's provisioning check but left anything expecting IPv4 with a self-assigned address and a "no IP" complaint. dnsmasq now leases out of 10.21.<N>.0/24 (N = radio index; prefix echoes FIPS port 2121), deliberately identical on every router: a roaming client keeps its lease across the ESS, and dnsmasq's authoritative mode — the OpenWrt default, pinned by the helper — ACKs the renew a foreign router never issued. Lease collisions across routers surface as a NAK on renew and the client re-DHCPs. The dhcp section's 'dhcpv4 server' is read by both dnsmasq (default images) and odhcpd (only with maindhcp), so either arrangement serves. A DHCPv4/udp-67 accept rule joins the fips_ap zone; DHCPv6 stays off, the ULA RA stays as-is, and nothing depends on an upstream. The zone remains isolated — no forwardings, no internet. * feat(openwrt): enable mDNS rendezvous from fips-ap-setup Phone FIPS apps cannot open raw-Ethernet sockets, so DNS-SD is how they find the router's daemon — but node.rendezvous.lan defaults to off. Ship the lan block commented in fips.yaml (consistent with the apN transport entries) and have fips-ap-setup uncomment it when creating the access SSID. The awk match is scoped to node.rendezvous because transports.ethernet carries a 'lan' entry at the same indent. The switch is daemon-wide, so 'remove' deliberately leaves it on rather than guess whether other transports rely on it. * fix(openwrt): bind UDP dual-stack [::]:2121 so access-SSID clients reach it The shipped router config bound the UDP transport "0.0.0.0:2121" (IPv4 wildcard) while the mDNS LAN advert announces every interface address, including the router's IPv6 link-local — which phones on the !FIPS access SSID rightly prefer (their cellular default route swallows v4, and fd00::/8 is captured by the Myco mesh TUN). Result: the client's Noise msg1 arrives on an unbound v6 port and is silently lost; the handshake resends and times out. Symptom chain (observed on-device): mDNS resolve OK, platform push OK, "Sent Noise handshake message 1" to [fe80::…%N]:2121, four resends, no reply, 30 s stale-timeout. OpenWrt is Linux (bindv6only=0), so "[::]" accepts IPv4 via v4-mapped addresses too — nothing is lost. packaging/common is deliberately left on "0.0.0.0" for now: Windows defaults IPV6_V6ONLY=1, where "[::]" would drop v4 instead. |
||
|
|
a47ddbd5a5 |
Merge maint into master after the v0.4.1 release
Carries the v0.4.1 content up the one-way branch flow. The release itself belongs to maint, so the parts of this merge that identify a version are resolved in master's favor and the parts that describe the project's state are taken from maint. Kept master's: Cargo.toml and Cargo.lock at 0.5.0-dev, the README status badge, and the "FIPS is at v0.5.0-dev on the master branch" line. A patch release consumes no minor version and master's development line is unaffected by it. Took from maint: the bloom antipoison FPR default change and the duplicate-definition fix it rests on, the two docs that describe them, the new v0.4.1 release notes, the correction to the v0.4.0 release date, and the [0.4.1] changelog section, which slots below master's own [Unreleased] and above [0.4.0]. The root RELEASE-NOTES.md mirror moves to v0.4.1 because it tracks the latest shipped release, which also clears the stale provisional date it had been carrying. One line needed splitting rather than choosing: the README said "v0.4.0 has shipped" inside the paragraph that identifies master as 0.5.0-dev. The version identity stays master's and the shipped-release pointer moves to v0.4.1. Quartet green on the result: 1645 tests passed, clippy clean with -D warnings. |
||
|
|
bda327b5f5 |
Raise the bloom antipoison FPR cap default to 0.20
The inbound FilterAnnounce cap at 0.10 rejects aggregates that are legitimately near their operating ceiling, before the network reaches the fixed-filter capacity limit. Raise the default to 0.20, which corresponds to fill 0.7248 at k=5, about 2,114 entries on the 1 KB filter (Swamidass-Baldi). Also remove the duplicate default definitions in BloomConfig. Each default was written twice, once in impl Default and once in the serde default function, with nothing enforcing that they agree, so a config file that omits the key took a different path from one that sets it. impl Default now delegates to the serde default functions, leaving a single source of truth. |
||
|
|
054d17aac5 |
feat(openwrt): 802.11s open-mesh backhaul support
Router-to-router radio backhaul over an open 802.11s mesh interface, with FIPS providing all encryption (Noise IK), authentication, and routing on top of bare L2 neighbor links. The mesh runs OPEN with mesh_fwding 0 — SAE would duplicate the Noise layer and force ath10k raw mode, and FIPS is the routing layer — so the Noise handshake is the real auth/encryption boundary and FIPS's spanning tree does the routing. fips-mesh-setup: an opt-in UCI helper that creates a per-radio mesh-point interface (radio0 -> fips-mesh0, radio1 -> fips-mesh1; trailing-digit derivation with a free-index fallback and a collision guard). Radio setup stays opt-in — a package must not commandeer radios on install. 'remove' takes an optional radio and otherwise removes all instances. Dual-band routers get one instance per radio; FIPS treats the two backhaul paths as failover, not multipath: it keeps one active link per peer (cross-connection resolution picks a single winner), and the second band stands by, re-establishing the peer after keepalive timeout — traffic never uses both bands at once. fips.yaml ships the mesh0/mesh1 Ethernet-transport entries commented out, so a stock install that never creates fips-mesh* logs no per-boot "interface missing" bind warning. fips-mesh-setup uncomments the matching meshN block when it creates the interface and re-comments it on remove, so the flash-and-drop-in flow needs no manual config edit. The file is rewritten 0600-first (it may hold an inline nsec) via an atomic replace. Two field-found silent non-peering causes are surfaced by the helper and the guide: - Same channel: mesh points only peer on a shared channel, and 'auto' lets each radio pick its own. The helper prints the radio's band/channel and warns loudly on 'auto' with the exact uci command to pin one; the how-to gains an ordered no-peers triage (channel mismatch, on-air scan check, DFS CAC wait, regdomain). - STA channel capture: a client (sta) interface drags the whole radio to its upstream AP's channel, so a mesh pinned elsewhere never joins and does not recover until the STA disconnects. The helper warns when the target radio carries a STA; the guide documents the incompatibility of a roaming uplink with a fixed-channel mesh on the same radio. Both the create and remove paths run 'wifi reload', which briefly drops every client AP on all radios; the how-to sets that expectation. Regression test: the shipped OpenWrt fips.yaml must parse via the real Config deserializer in both states — as shipped (mesh inactive) and after the uncomment the helper performs. Packaging: the helper is installed across the ipk/apk/buildroot paths (three synced copies), with the CI structural checks and shellcheck targets extended to cover it. Full guide in docs/how-to/set-up-80211s-mesh-backhaul.md. |
||
|
|
d6ca632251 |
node: add bounded graceful-shutdown drain phase
Add an operator-visible Draining phase on daemon shutdown. On the shutdown signal the node broadcasts Disconnect to all peers, then keeps serving for a bounded window - up to node.drain_timeout_secs (default 2s), exiting early once all peers are gone - before tearing down. This lets in-flight traffic settle and peers observe the disconnect before the transports close, rather than the previous immediate teardown. The lifecycle FSM gains a Draining state plus Drain/DrainDeadlineElapsed events; the run loop observes the shutdown signal and transitions to draining in place - one continuous loop, so the channel receivers are never destructively cancelled. The published NodeState gains a Draining variant, visible via control queries during the window. The immediate stop() path used by tests and non-daemon callers is unchanged: it still tears down immediately with no drain wait. The reconciler-gate actions the drain emits are no-ops until the peering reconciler lands and consumes them. |
||
|
|
cbc089b820 |
transport/ethernet: rename config discovery flag to listen
Rename the Ethernet per-interface config flag from discovery to listen, so the receive/transmit toggle pair reads as the symmetric announce (transmit) / listen (receive) neighbor-beacon vocabulary. The old discovery: key is still accepted via a serde alias, so deployed configs load unchanged; to_yaml re-emits it under the canonical listen: name. Marked deprecated for removal at the v2 cutover. Updates the config field + accessor, the transport listen_enabled local, the one struct-literal test consumer, the chaos sim config generator, packaged fips.yaml examples, and the classified operator-facing docs (ethernet neighbor-beacon subsystem prose; the generic Transport discovery capability prose is left unchanged). Adds a compat parse test asserting the legacy alias, the new key, and that deny_unknown_fields still rejects unknown keys. Behavior-neutral. |
||
|
|
3f80530cc5 |
Move nostr peer rendezvous and mDNS into dedicated module homes
Relocate the overlay peer-rendezvous subsystem out of the overloaded src/discovery/ tree into two focused, independent homes: src/nostr/ (relay-mediated overlay endpoint advertise/resolve/auto-mesh plus NAT traversal) and src/mdns/ (link-local DNS-SD rendezvous). The two subsystems are independent, so they get separate homes rather than sharing one. Drop the ambiguous "Discovery" stem from their identifiers in favor of "Rendezvous": NostrDiscovery -> NostrRendezvous, LanDiscovery -> LanRendezvous, and the matching config, policy, field, and method names. The former src/discovery.rs handoff types (EstablishedTraversal, BootstrapHandoffResult, the punch-packet helpers) fold into src/nostr/handoff and stay reachable via the crate-root re-exports. Pure relocation and rename: no logic, wire-format, config-key, metric, or tracing-target changes. The operator-facing node.rendezvous.nostr.* and node.rendezvous.lan.* config keys and the fips-overlay-v1 advert namespace are byte-identical. cargo fmt/build/clippy clean; lib test suite 1547 passing (baseline unchanged). |
||
|
|
3b401a0cbd |
Disambiguate the mesh-lookup "discovery" name across the source tree
The identifier "discovery" named three unrelated subsystems; the FMP overlay coordinate-lookup subsystem is now consistently "lookup". This finishes the concept-#1 rename across the shell, config, and metric layers left after the earlier proto-layer rename: - Handler module node::handlers::discovery -> node::handlers::lookup, and reset_discovery_backoff -> reset_lookup_backoff. - The Node lookup-engine field Node.discovery -> Node.lookup, renamed by resolved binding so the metrics().discovery and node.discovery config paths are left untouched. - The lookup metric types DiscoveryMetrics -> LookupMetrics, DiscoveryStatsSnapshot -> LookupStatsSnapshot, and Metrics.discovery -> Metrics.lookup. Two surfaces cross a stability boundary and ship behind a compatibility window, both marked in-code for removal at the v2 cutover: - The control-socket metric family is dual-emitted under both "discovery" (deprecated alias) and "lookup" so existing dashboards keep working. - The node.discovery.* config table is split into node.lookup.* (mesh lookup scalars) and node.rendezvous.* (nostr/lan peer rendezvous). NodeConfig does not deny unknown fields, so a naive rename would make a deployed node.discovery: block deserialize into nothing and silently revert every setting to default. A deprecated all-Option DiscoveryConfigCompat field captures a legacy block and a new post-parse Config::normalize_deprecated_keys pass folds it into the new tables with a one-time deprecation warning. Flip the packaged fips.yaml templates to the new keys, add legacy/new/ scalar compat parse tests, and record the split and deprecations in the CHANGELOG. Behavior-neutral; fmt/clippy clean, lib suite green. |
||
|
|
309a91d293 |
proto/mmp: split state into per-role modules and dissolve the vestigial mmp shell
Reorganize the MMP subsystem, behavior unchanged throughout: - Restore the pre-migration role split: move SenderState into sender.rs, ReceiverState (with its GapTracker helper) into receiver.rs, MmpMetrics and RrLog into metrics.rs, and PathMtuState into path_mtu.rs, leaving the Mmp aggregate plus the peer/session state in state.rs (1339 to 196 lines). Home the module constants in a new limits.rs and re-export them at the same paths. Pure code-motion with module rewiring; visibility unchanged. - Dissolve the 97-line src/mmp shell, which held only MmpConfig and the monotonic mono_ms() clock: move MmpConfig into config/node.rs (re-exported as crate::config::MmpConfig), move mono_ms() into a new top-level src/time.rs shell time seam, repoint all callers, and delete src/mmp/. Also correct stale src/mmp/ doc-comment path labels to proto/mmp/ where they name the protocol primitives. |
||
|
|
50a595a0ed | proto/mmp: sans-IO metrics-reporting state machine | ||
|
|
4e43cb81e9 |
Add Nym mixnet transport and single-container demo
Add an outbound-only Nym mixnet transport that tunnels FMP peer links through a local nym-socks5-client SOCKS5 proxy into the Nym mixnet. It structurally mirrors the Tor SOCKS5 transport (connection pool, connect-on-send background promotion, FMP-v0 framing reused from TCP) with the onion, inbound-listener, and control-port machinery removed. Wires the transport through the full TransportHandle dispatch, NymConfig (standard transport-instance pattern), and node instantiation, and surfaces its counters in fipstop. Includes a mock SOCKS5 harness and unit coverage for the address-parsing paths. Also adds an isolated single-container example (examples/sidecar-nostr-mixnet-relay/) demonstrating FIPS peering across the mixnet end to end. No new crate dependencies: tokio_socks, socket2, and futures are already pulled in by the Tor transport. |
||
|
|
e09d9f8412 |
Merge branch 'maint'
# Conflicts: # CHANGELOG.md |
||
|
|
f3eb5bf4c2 |
bloom: raise max_inbound_fpr antipoison cap from 0.05 to 0.10
The inbound FilterAnnounce FPR cap rejects filters whose false-positive rate (fill^k) exceeds the configured maximum. On the fixed 1 KB / k=5 filter, 0.05 corresponds to fill 0.549 (~1,300 reachable entries), and the busiest nodes' aggregates were beginning to hit that ceiling as the mesh grew. Raise the default to 0.10 (fill 0.631, ~1,630 entries) to restore headroom toward the fixed-filter capacity limit. A saturated or poisoned filter is ~100% FPR and remains rejected, so the antipoison gate is not materially weakened. Updates the config default, the config-reference and bloom-filter design docs, and the changelog. |
||
|
|
7d7b551ca1 |
discovery: add opt-in mDNS LAN discovery
Add scoped mDNS / DNS-SD discovery for peers on the same local link, giving sub-second pairing without a relay or NAT-traversal roundtrip. A node advertises its npub, protocol version, and an optional network scope over link-local multicast, and browses for matching adverts to initiate Noise handshakes against same-LAN peers. LAN discovery is disabled by default; operators enable it with node.discovery.lan.enabled: true. Default-off avoids reintroducing a per-LAN identity broadcast on nodes that have deliberately disabled other discovery channels, and avoids any multicast surprise on upgrade. The startup advertised-port picker now excludes bootstrap transports and selects a non-bootstrap operational UDP transport with a stable lowest-id selector, so the advertised port is deterministic across restarts rather than dependent on HashMap iteration order. This matches the per-dial transport selection used for discovered peers. Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com> |
||
|
|
da0d9d39a0 |
node: refresh active peer paths without dropping links
Add Node::update_peers for runtime peer-list refresh. It re-derives the active peer connections from a new peer configuration, adding newly configured peers and removing those no longer present, while keeping links to peers that remain in the set rather than tearing every connection down. The call returns an UpdatePeersOutcome summarizing the added, removed, and retained peers. PeerAddress gains a seen_at_ms recency field (with_seen_at_ms). Active path selection now sorts address candidates by recency so the most recently observed address wins when concurrent path probes race. complete_rekey_msg2 now returns the remote peer's startup epoch alongside the new Noise session, letting the rekey path detect a peer restart and clear stale session state. A stale FSP session is cleared when a peer restart is detected during FMP rekey or cross-connection promotion, so the session-layer map no longer lingers out of sync with the freshly promoted peer. Per-tick work budgets bound the connection churn in a single node tick (MAX_DISCOVERY_CONNECTS_PER_TICK, MAX_RETRY_CONNECTIONS_PER_TICK, MAX_PARALLEL_PATH_CANDIDATES_PER_PEER); work beyond a tick's budget is deferred to the next tick rather than discarded. Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com> |
||
|
|
733ee512d3 |
chore: replace real IPs in docs and configs with placeholders
Operator-facing IPs in user-visible configs/docs (examples, tutorials, packaging, sidecar templates) are now the resolvable hostnames of the public test fleet (test-us01.fips.network, etc.) so they keep working without baking specific addresses into examples. Doc-comment and test fixtures in src/config/transport.rs use RFC 5737 TEST-NET-2 (198.51.100.1) so they cannot accidentally point at a real host. Also resyncs the openwrt-ipk fips.yaml with the common reference (merge from master) and applies the same DNS-name swap there. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
34e00b9f6e |
Add Nostr-mediated overlay discovery and UDP NAT traversal (#53)
Optional peer discovery and NAT hole-punching path gated behind a new
`nostr-discovery` cargo feature. Nodes publish signed overlay endpoint
adverts to public Nostr relays, consume peer adverts to populate
fallback dial addresses, and use STUN-assisted UDP hole punching with
NIP-59 gift-wrap offer/answer signaling to establish direct UDP paths
between NATed peers. Once a punched socket is up, it is handed into
the existing FIPS UDP transport and the standard Noise/FMP session
stack takes over unchanged.
The cargo feature is in the default feature set
(`default = ["nostr-discovery"]`) so stock builds include it; a
build that explicitly disables default features (or selects a
feature set without `nostr-discovery`) does not link the nostr /
nostr-sdk crates and does not emit a no-op poll in the tick loop.
Runtime behavior is independently gated by
`node.discovery.nostr.enabled`, which defaults to false; if the
config enables Nostr on a non-feature build, startup logs a
warning and continues without it.
== Cargo feature and dependencies
- New cargo feature `nostr-discovery = ["dep:nostr", "dep:nostr-sdk"]`.
Not in the default feature set.
- New optional Linux-only dependencies: `nostr 0.44` (features: std,
nip59) and `nostr-sdk 0.44`. Gift-wrap unwrap is hand-rolled in
`src/discovery/nostr/signal.rs` rather than relying on the SDK's
rumor-author check, which FIPS sidesteps by trusting `seal.pubkey`
exclusively.
== Wire format
Overlay advert event: `kind 37195`, parameterized replaceable
(NIP-01 application-defined replaceable range 30000-39999), with
`d = "fips-overlay-v1"`. The digits visually spell FIPS (7=F, 1=I,
9=P, 5=S); a relay survey confirmed the kind is unused.
Advert content carries the version tag, endpoint list
(`udp|tcp|tor` + addr), optional signal-relay and stun-server
metadata, and `issuedAt` / `expiresAt` timestamps. Endpoint
`addr: "nat"` is the sentinel that triggers traversal on the peer
side. NIP-40 `expiration` tag bounds staleness on permanent
shutdown. Lifecycle relies on parameterized-replaceable
supersession; the daemon does not emit NIP-09 kind-5 deletes —
strict relays (Damus, Primal) race delete-against-replace and can
silently drop the replacement.
Gift-wrapped signal event: `kind 21059`. Punch packets carry magic
values `PUNCH_MAGIC` / `PUNCH_ACK_MAGIC`, a sequence number, and a
16-byte session hash.
== Discovery surface
- `src/discovery.rs` (always compiled)
- `EstablishedTraversal`: bound UDP socket + selected remote +
peer npub + optional transport name/config tuning overrides.
- `BootstrapHandoffResult`: returned on successful handoff —
allocated transport id, local/remote addrs, peer NodeAddr,
session id.
- `src/discovery/nostr/` (`#![cfg(feature = "nostr-discovery")]`)
- `types.rs`: wire and control types described above. `ADVERT_KIND`
constant. `BootstrapError` enumerates failure modes (disabled,
missing advert, missing NAT endpoint, no usable relays, invalid
advert, invalid npub, signal timeout, punch timeout, replay,
STUN failure, protocol, nostr, io, serde, event-parse).
- `runtime.rs`: `NostrDiscovery` coordinator. Owns the shared
nostr-sdk `Client`, subscribes to advert + signal event kinds,
maintains a bounded advert cache and a bounded seen-sessions
replay set, drains `BootstrapEvent::{Established, Failed}` for
the node to consume, exposes `update_local_advert`,
`request_connect`, `advert_endpoints_for_peer`,
`cached_open_discovery_candidates`, and `shutdown`.
- `signal.rs`: NIP-59 gift-wrap encode/decode. Outbound wraps are
built against per-attempt ephemeral keys; inbound events are
unwrapped against the node identity.
- `stun.rs`: RFC 5389/8489 Binding Request client with
XOR-MAPPED-ADDRESS parsing for both IPv4 and IPv6; used only to
observe the initiator's own reflexive address against its
locally configured STUN list (peer-advertised STUN is
informational, never an egress target).
- `traversal.rs`: per-attempt candidate-pair punch planner.
Allocates a fresh `0.0.0.0:0` UDP socket per attempt, enumerates
LAN-private and ULA interface addresses alongside the STUN
reflexive address, schedules probe/ack exchanges at the
configured interval for the configured duration, and picks the
first candidate pair that authenticates end-to-end.
Strategy ordering is Reflexive↔Reflexive first, then LAN, then
Mixed. The STUN-observed pair is the only candidate that's reliable
across arbitrary network topologies; trying it first prevents the
planner from latching onto a misleading host-candidate path before
the reflexive path gets a chance. There is no catch-all
Local↔Local strategy: a previous design that paired every local
host candidate from one side with every local host candidate from
the other could declare success on a one-way reachable asymmetric
L3 path (corporate VPN, Tailscale subnet route, overlapping private
address space), only for the FMP handshake to stall because the
return path didn't match. The legitimate `Lan` strategy still pairs
candidates that share a subnet.
== Configuration surface
`node.discovery.nostr.*` (`NostrDiscoveryConfig`), all `serde(default)`
with `deny_unknown_fields`:
- `enabled` (default false), `advertise` (default true)
- `advert_relays`, `dm_relays`, `stun_servers`: defaults are
`wss://relay.damus.io`, `wss://nos.lol`, `wss://offchain.pub`
for both relay lists, and Google / Cloudflare / Twilio for STUN.
Operators are expected to override for production. Other
verified-working public relays for reference:
`nostr.bitcoiner.social`, `nostr-pub.wellorder.net`,
`nostr.oxtr.dev`, `nostr.mom`.
- `app` (default `"fips-overlay-v1"`), `signal_ttl_secs` (120)
- `policy`: `NostrDiscoveryPolicy::{Disabled, ConfiguredOnly (default),
Open}` — controls whether advert-derived endpoints are consumed
only for peers carrying `via_nostr = true`, or also for
non-configured peers within a budget cap.
- `share_local_candidates` (default false) — when false, the offer's
`local_addresses` list is empty and peers see only the reflexive
address. Enable per-node only for genuinely same-LAN deployments;
off-by-default eliminates the misleading-path failure mode for
the common case where peers are not on the same broadcast domain.
- `open_discovery_max_pending` (64) — caps queued open-discovery
retries; bounded by available outbound slots.
- `max_concurrent_incoming_offers` (16) — semaphore against offer
spam; excess offers are debug-logged and dropped.
- `advert_cache_max_entries` (2048) and `seen_sessions_max_entries`
(2048) — bound memory under ambient relay volume; overflow
evictions are debug-logged.
- `attempt_timeout_secs` (10), `replay_window_secs` (300)
- `punch_start_delay_ms` (2000), `punch_interval_ms` (200),
`punch_duration_ms` (10000)
- `advert_ttl_secs` (3600), `advert_refresh_secs` (1800)
Per-peer and per-transport flags:
- `PeerConfig.via_nostr: bool` — when true (and Nostr is enabled),
advert-derived addresses are appended as fallback dial candidates
after static addresses for that peer.
- `PeerConfig.addresses` is now `serde(default)` and may be empty
when `via_nostr: true`; validation requires at least one of the
two to be present per peer, and the error message names the
peer's npub.
- `UdpConfig.advertise_on_nostr: Option<bool>` and
`UdpConfig.public: Option<bool>` — UDP transports can be
advertised either as direct `host:port` (public = true) or as the
`addr: "nat"` sentinel that triggers rendezvous on the peer side.
- `TcpConfig.advertise_on_nostr` and `TorConfig.advertise_on_nostr`
— TCP and Tor onion endpoints can be advertised as directly
reachable.
- A reserved peer address `transport: udp, addr: "nat"` parses without
special-casing in YAML and routes through the bootstrap runtime.
Cross-field validation (`Config::validate`, called from `Node::new`
and `Node::with_identity`):
- Any transport with `advertise_on_nostr = true` requires
`node.discovery.nostr.enabled = true`.
- Any peer with `via_nostr = true` requires
`node.discovery.nostr.enabled = true`.
- A non-public UDP advert (`advertise_on_nostr = true`,
`public = false` — i.e. `udp:nat`) additionally requires at least
one `dm_relay` and at least one `stun_server`.
Surfaced as `ConfigError::Validation`.
== Node integration
`src/node/lifecycle.rs` is the main integration point.
- At node start (after transports are up, before TUN), if Nostr is
enabled and the feature is compiled in, `NostrDiscovery::start` is
invoked, the initial local overlay advert is built from the live
transport set and published, and the runtime handle is stored.
- The rx tick loop calls `poll_nostr_discovery` (feature-gated both
at method definition and call site), which refreshes the local
advert, drains bootstrap events, adopts established traversals,
schedules retries for failed traversals, and — under `policy:
open` — enqueues outbound retries for non-configured peers
visible in the advert cache, bounded by
`open_discovery_max_pending` and the remaining outbound slots.
- Outbound peer dialing is refactored to `try_peer_addresses`, which
first exhausts the static address list in priority order and only
then appends advert-derived fallback addresses; both lists run
through the same `attempt_peer_address_list` code path. The
`udp:nat` sentinel address triggers `NostrDiscovery::request_connect`
for the peer instead of a direct dial and returns `Ok(())`.
- `build_overlay_advert` walks operational transports, consults
per-instance `UdpConfig` / `TcpConfig` / `TorConfig` (matching by
optional transport instance name), and emits an `OverlayAdvert`
including `signalRelays` and `stunServers` when any UDP endpoint
is advertised as NAT.
- `adopt_established_traversal` is the bootstrap handoff API:
allocates a new `TransportId`, constructs a `UdpTransport` with
the user-supplied (or default) `UdpConfig`, calls the new
`adopt_socket_async` to reuse the punched socket verbatim,
registers the transport in the normal transport map, records it
in `bootstrap_transports`, and calls `initiate_connection` so the
normal handshake path runs. On failure, the transport is stopped
and removed cleanly and the set membership is rolled back.
- On clean shutdown, `NostrDiscovery::shutdown` is awaited so
background tasks stop before transports are torn down. (The
advert is not explicitly retracted; NIP-40 expiration plus the
next refresh from any live publisher supersedes it.)
New `Node` fields:
- `nostr_discovery: Option<Arc<NostrDiscovery>>` (feature-gated).
- `bootstrap_transports: HashSet<TransportId>` — per-peer UDP
transports adopted from NAT traversal, cleaned up via
`cleanup_bootstrap_transport_if_unused` whenever the link,
connection, peer, or pending-connect referencing them is removed.
Retry and error surface:
- `RetryState.expires_at_ms: Option<u64>` — optional absolute expiry
for a retry entry. `pump_retries` drops expired entries with an
info log. Used for open-discovery retries, which expire at two
times the advert TTL.
- New `NodeError::BootstrapHandoff(String)` returned from
`adopt_established_traversal` when the underlying transport
adoption fails or local address discovery fails.
- New `ConfigError::Validation(String)`.
- A small refactor extracts `Node::now_ms()` and reuses it across
lifecycle, rx-loop tick, and timeout bookkeeping.
== UDP transport
`src/transport/udp/`:
- `UdpRawSocket::adopt(std::net::UdpSocket, recv_buf, send_buf)`:
adopts an externally bound socket, makes it non-blocking, applies
the configured buffer sizes (warning if the kernel clamps), and
reports the resulting local address. Preserves the NAT mapping —
no rebind.
- `UdpTransport::adopt_socket_async(std::net::UdpSocket)`: the
`start_async` analogue for an already-bound socket, wiring the
async socket and recv task exactly as the fresh-bind path would.
- `Drop` impl for `UdpTransport`: if a transport is dropped while
still holding a recv task or socket (for example on error
teardown), aborts the task, clears the socket, and emits a debug
log so the cleanup is visible in tracing rather than silent.
== Logging and observability
Default `EnvFilter` demotes third-party relay-pool DEBUG output to
TRACE-only: `nostr_relay_pool`, `nostr_sdk`, and `nostr` are pinned
at INFO when our level is anything below TRACE, and at TRACE when
our level is TRACE — so the raw frames are still reachable when
explicitly asked for. RUST_LOG continues to override completely.
Concise one-line DEBUG events are emitted at the meaningful points
in the discovery / hole-punch sequence:
- `advert: published` (event id, relay count, endpoints, ttl)
- `advert: peer cached` (notify-loop ingress for non-self)
- `advert: resolved` (cache hit / relay fetch outcome)
- `traversal: initiator starting`
- `traversal: initiator STUN observed` (reflexive, local count)
- `traversal: offer sent` (session id, relay count, event id)
- `traversal: answer received` (accepted, reflexive, local)
- `traversal: initiator punch succeeded` (remote addr)
- `traversal: offer received` (responder side)
- `traversal: responder STUN observed`
- `traversal: answer sent`
- `traversal: responder punch succeeded`
Npubs are shortened to `npub1<4>..<4>` and event/session ids to
their first 8 hex characters.
Other operator-facing logs:
- `UdpTransport` adoption and drop paths log at info / debug.
- `adopt_established_traversal` logs at debug on entry and info on
successful return, tagged with peer npub, session id, transport
id, and both socket endpoints, so the bootstrap handoff is
traceable end-to-end alongside the `UdpTransport::drop` log.
- `cleanup_bootstrap_transport_if_unused` logs at debug when the
reference-count check drops an adopted transport.
- `connect_peer` tags its entry `debug!` with `peer_npub` so
downstream STUN, punch, and handshake logs for the same peer
correlate for operators.
- Advert-cache and seen-sessions overflow evictions log at debug so
mis-sized caps are visible under ambient relay volume.
- Gift-wrap unwrap failures on `SIGNAL_KIND` events log at trace
(hot path: fires for every unrelated signal event on the same
relay).
- Traversal-offer handler failures log at debug. Expected conditions
such as punch timeout on symmetric NAT are covered there; real
problems are reported upstream via `BootstrapEvent::Failed`.
- Inbound-offer rate-limit messages name the governing config field
(`max_concurrent_incoming_offers`) and state that the offer was
rate-limited rather than failing.
== Tests
- 18 new unit tests in `src/discovery/nostr/tests.rs` covering advert
encoding, signal envelope round-trip, STUN parsing, punch-packet
codec, and replay-window enforcement. Run under the
`nostr-discovery` feature.
- Config-validation tests in `src/config/mod.rs` covering the three
cross-field invariants and YAML parsing of the full
`node.discovery.nostr` block plus `peers[].via_nostr`, empty
`addresses` with `via_nostr: true`, and a `udp: nat` address.
- `src/node/tests/bootstrap.rs` integration tests that drive a
synthetic traversal (bound UDP socket pair + synthetic peer
identity) through `adopt_established_traversal` and assert the
Noise handshake completes over the adopted socket.
- Punch-planner tests assert reflexive-before-LAN ordering and that
same-LAN scenarios still include the LAN target in the plan.
- `testing/nat/` Docker NAT lab harness:
- Local `strfry` relay, local STUN responder, and one or two
router containers performing `iptables` NAT.
- Node LAN interfaces are provisioned with explicit `veth` pairs
injected into the node and router namespaces so every packet
traverses the router namespace (plain Docker bridges are not
used for the LAN).
- `cone` scenario: both peers behind full-cone-emulation NAT
(SNAT with source-port preservation, inbound DNAT back to the
single LAN host regardless of remote source); asserts UDP
traversal succeeds and link remote addresses are on the router
WAN subnet.
- `symmetric` scenario: `MASQUERADE --random-fully`; asserts UDP
traversal fails and TCP fallback converges over router-
published WAN addresses.
- `lan` scenario: both peers share a LAN subnet; asserts LAN
addresses are preferred over reflexive ones.
- Cleanup tears down all profile-gated services
(`--profile cone --profile symmetric --profile lan`) so no
orphan containers survive a run.
- `testing/scripts/build.sh` builds the Docker test image with
`--features "tui nostr-discovery"` by default so NAT-harness
binaries include bootstrap support.
== CI
- Linux release build and nextest unit-test job both use
`--features "gateway nostr-discovery"` so the feature-gated code
and its unit tests compile and run in CI.
- Three new integration matrix entries (`nat-cone`, `nat-symmetric`,
`nat-lan`) invoke `testing/nat/scripts/nat-test.sh`, collect
`docker compose logs` on failure, and always stop containers.
== Packaging and operations
- `packaging/common/fips.yaml` ships a fully commented
`node.discovery.nostr.*` block, plus documented
`advertise_on_nostr` / `public` examples under the UDP transport,
an `advertise_on_nostr` example under TCP, and a `via_nostr: true`
example under the static peer section with both a direct
`host:port` UDP address and a `udp: nat` fallback.
- `.github/workflows/package-openwrt.yml`: NIP-94 release event
publishes target the new default relay set.
== Documentation
- `README.md`: overlay discovery + NAT traversal moved from
"Near-term priorities" into "What works today".
- `docs/design/fips-intro.md`: rewrites the paragraphs that
previously described Nostr discovery and NAT traversal as future
work; describes the shipped mechanism and the feature gate.
- `docs/design/fips-transport-layer.md`: drops the "(future
direction)" qualifier from the Nostr Relay Discovery section,
expands with the `udp:nat` advertisement and bootstrap handoff
description, and updates the Current State callout.
- `docs/design/fips-mesh-layer.md`: notes that mid-session NAT
rebinding (roaming) and initial NAT traversal (Nostr path) are
distinct mechanisms.
- `docs/design/fips-configuration.md`: documents the full
`node.discovery.nostr.*` surface, including the three resource
caps and `share_local_candidates`.
- `docs/design/fips-nostr-discovery.md`: design and configuration
reference for the shipped mechanism, including the empty-
`addresses`-with-`via_nostr` shorthand.
- `docs/proposals/nostr-udp-hole-punch-protocol.md`: adds an
Implemented status callout, clarifies that the punch socket is
per-peer and per-attempt rather than shared with the application
listener, aligns field names with the shipped JSON
(`sessionId`, `issuedAt` / `expiresAt`, `reflexiveAddress`,
`localAddresses`, `stunServer`), sets the `d`-tag to
`fips-overlay-v1`, names the kind as 37195, and notes that
advertised STUN entries are informational.
- `docs/proposals/README.md`: adds a Status column and marks the
hole-punching proposal Implemented.
- `CHANGELOG.md`: Unreleased > Added entry covering the discovery
path, STUN/punch path, configuration surface, and Docker NAT lab.
Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
|
||
|
|
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. |
||
|
|
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. |
||
|
|
213c0e87c3 |
Implement inbound mesh port forwarding
Mesh peers can now reach a configured host:port on the gateway's LAN
via static port-forward rules on fips-gateway. Mirror of the outbound
LAN gateway (IDEA-0079 / TASK-2026-0056).
Config: new gateway.port_forwards list of { listen_port, proto,
target } entries. Targets are SocketAddrV6 — IPv4 is rejected at
parse time. Validation rejects zero listen ports and duplicate
(listen_port, proto) pairs.
NAT: NatManager gains a port_forwards field and set_port_forwards()
setter, rebuilt in the same atomic rustables batch as the address
mappings. Each forward emits a prerouting DNAT rule keyed on
(iifname fips0, nfproto ipv6, l4proto, tcp/udp dport) that rewrites
destination address and port via Nat::with_ip_register +
with_port_register. When any forwards are configured, a single
LAN-side masquerade is installed on (iifname fips0, oifname
lan_interface, nfproto ipv6) so the LAN host sees the gateway as
source and replies flow back through conntrack. This rule is
distinct from the existing oifname fips0 masquerade that serves the
outbound pool.
Binary: fips-gateway validates port_forwards at startup and calls
set_port_forwards after NatManager construction; startup failure
cleans up the nftables table before exiting.
Test: extend testing/static/scripts/gateway-test.sh with Phase 7
that runs a marker HTTP server on the LAN-side client (fd02::20:8080)
and, from the mesh peer, curls the gateway's fips0 address on port
18080 to exercise the full DNAT + LAN masquerade path. The
LAN-side HTTP server is started with 'docker exec -d' plus a
bind-ready poll on ss; 'docker exec bash -c "cmd &"' does not
keep the child alive past the exec session even with nohup.
Test-infra: ci-local.sh now builds/clippies/tests with
--features gateway, matching GitHub CI. Without this the release
fips-gateway binary silently stays stale across runs, since
cargo build --release alone does not compile the gateway bin.
Verified locally: cargo test --features gateway --lib (991 pass),
clippy + fmt clean, full testing/ci-local.sh green (21/21 suites
in 8m36s, including the new gateway Phase 7).
|
||
|
|
774e33fd27 |
Add Windows platform support (#45)
Gate platform-specific code behind cfg attributes and add full Windows
support: TUN device via wintun, TCP control socket on localhost:21210,
Windows Service lifecycle (--install-service/--uninstall-service/--service),
CI build and test matrix, and packaging with ZIP builder and PowerShell
service management scripts.
Key changes:
- Cargo.toml: move tun/libc/rtnetlink behind cfg(unix); add wintun and
windows-service dependencies for Windows
- upper/tun.rs: wintun-based TUN implementation with netsh configuration
for IPv6 address, MTU, and fd00::/8 routing
- control/mod.rs: split into unix_impl/windows_impl; Windows uses TCP on
localhost:21210 with shared connection handler
- bin/fips.rs: refactor main() into run_daemon() accepting a shutdown
signal; add Windows Service support via windows-service crate
- transport/udp/socket.rs: platform-gated modules; Windows uses
tokio::net::UdpSocket (kernel drop count unavailable, returns 0)
- transport/ethernet: gate to cfg(unix); add Windows stub types
- config: platform-conditional default paths (socket, hosts) for Windows
- CI: add windows-latest to build matrix and test-windows job with
cargo-nextest
- packaging/windows: build-zip.ps1, install-service.ps1,
uninstall-service.ps1, and package-windows.yml workflow
- README/docs: Windows build instructions, service management, and
control socket platform differences
Linux and macOS behavior is unchanged.
|
||
|
|
e9da598f8a | Apply rustfmt to master-only code | ||
|
|
6196307f0e |
Merge branch 'maint'
# Conflicts: # src/bin/fips.rs # src/bin/fipstop/app.rs # src/config/mod.rs # src/config/node.rs # src/config/transport.rs # src/mmp/receiver.rs # src/mmp/sender.rs # src/node/handlers/handshake.rs # src/node/handlers/rekey.rs # src/node/lifecycle.rs # src/node/mod.rs # src/transport/ethernet/socket.rs # src/transport/mod.rs # src/upper/tun.rs |
||
|
|
13c0b70dc3 |
Add rustfmt formatting policy and reformat codebase
Add rustfmt.toml with stable defaults and apply cargo fmt to all source files. This establishes a consistent formatting baseline for CI enforcement. |
||
|
|
e693f4fb7e |
Add macOS support, fix bloom filter routing and MMP intervals
macOS platform: - Platform-native TUN interface management with shutdown pipe - Raw Ethernet transport with macOS socket backend (socket_macos.rs) - EthernetTransport and TransportHandle::Ethernet ungated from Linux-only - macOS .pkg packaging (build-pkg.sh, launchd plist, uninstall script) - CI: macOS build and unit test jobs; x86_64 cross-compiled from macos-latest via rustup target add x86_64-apple-darwin Gateway feature flag: - New opt-in `gateway` Cargo feature activates optional `rustables` dep - `pub mod gateway` and `Config.gateway` gated behind the feature so macOS builds never pull in Linux-only nftables bindings - `fips-gateway` bin has `required-features = ["gateway"]` - All Linux/OpenWrt/AUR packaging passes `--features gateway` CI / packaging: - package-linux, package-macos, package-openwrt now trigger on push to master/maint/next and on pull requests; release uploads remain tag-gated - Bloom filter routing fix: fall through to tree routing when no candidate is strictly closer - MMP intervals: raise MIN to 1s / MAX to 5s with 5-sample cold-start phase |
||
|
|
60e5fefb1f |
Implement outbound LAN gateway
Add fips-gateway binary: a separate daemon that allows unmodified LAN hosts to reach FIPS mesh destinations via DNS-allocated virtual IPs and kernel nftables NAT. Gateway DNS resolver: forwarding proxy on [::]:53 that intercepts .fips queries, forwards to daemon resolver (localhost:5354), allocates virtual IPs from pool, returns AAAA records. Always sends AAAA upstream regardless of client query type, returns proper NODATA for non-AAAA. Virtual IP pool: fd01::/112 pool with state machine lifecycle (Allocated → Active → Draining → Free), TTL-based reclamation, conntrack integration for session tracking. NAT manager: nftables DNAT/SNAT rules via rustables netlink API, per-mapping rule lifecycle, fips0 masquerade for LAN client source address rewriting. Network setup: local pool route, proxy NDP for virtual IPs on LAN interface, IPv6 forwarding validation. Control socket at /run/fips/gateway.sock with show_gateway and show_mappings queries. fipstop Gateway tab with pool summary gauge and mappings table. Gateway config section in fips.yaml with pool CIDR, LAN interface, DNS upstream, TTL, and grace period settings. Design doc at docs/design/fips-gateway.md. Integration test (testing/static/scripts/gateway-test.sh): three containers verifying DNS resolution, end-to-end HTTP, NAT state, TTL expiration, SERVFAIL fallback, and clean shutdown. |
||
|
|
cb6f263a1d |
Add node.log_level config, remove RUST_LOG from service files
Add log_level field to NodeConfig (case-insensitive, default: info). The daemon now loads config before initializing tracing so the configured level takes effect. RUST_LOG env var still overrides if set. Remove hardcoded Environment=RUST_LOG=info from systemd units and OpenWrt procd init script — these prevented config-driven log levels from working. |