Commit Graph
311 Commits
Author SHA1 Message Date
Johnathan Corgan 996a591001 Add clippy to rust-toolchain.toml components
The new GitHub Actions Clippy job introduced in a41f80a failed on the
first push because dtolnay/rust-toolchain@stable installed the stable
channel with components: clippy, but the repo's rust-toolchain.toml
overrides to channel "1.94.1" with components ["rustfmt"] only. cargo
clippy then re-resolves the toolchain to 1.94.1 and bombs because the
clippy component isn't installed for that specific version.

Fix: add clippy to the toolchain override's components list. This
makes the rust-toolchain.toml the single source of truth for which
components every cargo invocation needs, and the GitHub Actions
toolchain-installation step picks them up automatically.

Local clippy continues to pass with this change; verified with
cargo clippy --all-targets --all-features -- -D warnings.
2026-05-02 02:44:47 +00:00
Johnathan Corgan da5d23ccb7 Document nostr-nat ephemeral UDP transport MTU choice
Adopted ephemeral UDP transports created by adopt_established_traversal()
default to UdpConfig::default() (MTU=1280, IPv6 minimum) when the
bootstrap runtime hands a socket without an explicit transport_config
override. This is by design: NAT-traversal middlebox MTU is unpredictable
and the IPv6 minimum is the only value guaranteed by spec to survive
arbitrary paths.

Add an explanatory comment at the call site so future readers find the
rationale without spelunking through ISSUE-2026-0013, and so any future
change to the inheritance behavior is a deliberate decision rather than
an accidental refactor.

No behavior change.
2026-05-02 01:39:52 +00:00
Johnathan Corgan 8448e38510 Make Node::transport_mtu() deterministic across restarts (TCP black hole fix)
Default-config TCP flows between fips peers were stalling completely
(cwnd-pinned, 0 bps for 10s+) on a non-trivial fraction of restarts.
Reproducible with iperf3 between any two peers.

Root cause: `Node::transport_mtu()` iterated `self.transports.values()`
(HashMap with default RandomState hasher) and returned `handle.mtu()`
of the first one whose `is_operational()` returned true. Two stacked
sources of non-determinism stacked on each other: HashMap iteration
order is randomized per-process via RandomState, and async transport
`.start()` completion order races each daemon restart.

The returned value drives the TCP MSS clamp ceiling computed once at
TUN init (src/upper/tun.rs:501-524) and stored as immutable max_mss
in the reader/writer thread state. When the picker landed on a
transport with MTU > 1357 (any non-UDP-1280 in the standard fleet
defaults), `max_mss > 1220` (kernel's natural fips0-MTU-derived MSS),
the daemon's clamp was silently a no-op, and the kernel emitted
1220-byte segments. Those wrap into 1280-byte IPv6 → 1357-byte fips
datagrams that exceed UDP-1280 transports at any forwarding hop,
causing silent drops with no PTB feedback to the kernel TCP stack.

Fix: return min across operational transports instead of first-iterated.
With UDP-1280 in the configured set (the common case),
`transport_mtu = 1280`, `max_mss = 1143 < 1220`, daemon's clamp
engages, MSS=1143 reaches the wire, packets fit, throughput recovers.

Empirical green light from a single-UDP-config end-to-end test:
iperf3-without-`-M` recovered to ~21 Mbps with no operator-side nft
TCPMSS rules.

Adds three unit tests:
- transport_mtu_returns_min_across_operational: pin selection to
  smallest MTU when multiple operational transports differ.
- transport_mtu_fallback_when_no_operational_transports: 1280 fallback.
- transport_mtu_min_with_single_operational: trivial single-transport
  case.

The `effective_ipv6_mtu` field reported by `fipsctl show status` was
also racy (consequence of the same bug); fixed by this change as a
side effect.
2026-05-02 01:27:15 +00:00
Johnathan Corgan 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.
2026-05-02 01:26:54 +00:00
Johnathan Corgan 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.
2026-05-01 18:09:50 +00:00
Johnathan Corgan 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).
2026-05-01 16:54:10 +00:00
Johnathan Corgan 96c6b7dea8 Admit rekey msg1 from established peers when addr forms differ
Companion to the ethernet `accept_connections: false` rekey-deadlock
fix from earlier this release: the same dual-init failure mode shows
up over UDP when peers register by hostname, and the existing
addr_to_link-only carve-out in `should_admit_msg1` doesn't cover it.

The carve-out's first predicate keys `addr_to_link` by the literal
`TransportAddr` that `initiate_connection` inserted, which is the
hostname-form when a peer config carries a hostname (e.g.,
`core-vm.tail65015.ts.net:2121`). Inbound packets always arrive with
numeric source addrs because `udp_receive_loop` builds the
`TransportAddr` from the `SocketAddr` the kernel reports via
`recvfrom`. `TransportAddr` equality is byte-exact, so the two forms
don't match and the lookup misses. With `udp.accept_connections:
false` (or `udp.outbound_only: true`, which forces it false) the
gate then rejects the rekey msg1 from an established peer. The
dual-init tie-breaker stalls because the loser side never produces
msg2; both sides retry indefinitely and the winner side keeps
logging "Dual rekey initiation: we win, dropping their msg1" at 1Hz.

The earlier ethernet fix didn't generalize to this variant because
ethernet TransportAddrs are always numeric MAC bytes — both the
config-time form and the inbound-arrival form match identically.

Add a second predicate to `should_admit_msg1`: an active peer's
`current_addr()` matching `(transport_id, remote_addr)`.
`current_addr` is updated and refreshed from inbound encrypted-frame
source addrs (`handlers/encrypted.rs`), which are always numeric
`SocketAddr`-form, so this catches the established peer regardless
of how its `addr_to_link` key was originally inserted. The fast
`addr_to_link` check stays first; the iteration over peers is
bounded by peer count and only runs when the first predicate misses.

Regression coverage in this commit:

- Unit test `test_should_admit_msg1_admits_rekey_when_addr_form_differs`
  in `src/node/tests/handshake.rs`. Constructs the failing scenario
  in-process: `addr_to_link` populated with hostname-form key, peer's
  `current_addr` at the resolved numeric form, query with numeric form.
  Without the new predicate this fails immediately.

- New integration topology `rekey-outbound-only` plus matching
  docker-compose profile. Same 5-node mesh shape as `rekey-accept-off`
  but `inject-config` sets `udp.outbound_only: true` on node-b and
  rewrites node-b's peer-c address from the numeric docker IP to the
  docker hostname (`node-c:2121`), reproducing the production
  hostname-vs-numeric mismatch. The test asserts no sustained
  "Dual rekey initiation: we win" log lines on any node (>10 = bug)
  and the existing rekey health checks catch the connectivity loss
  the loop produces.

- `testing/ci-local.sh` and `.github/workflows/ci.yml` extended to
  run the new variant in the local sweep and the GitHub CI integration
  matrix alongside `rekey` and `rekey-accept-off`.

Verified locally: full `bash testing/ci-local.sh` sweep passes 29/29
suites (23m 12s) with the new variant green; 1084 unit tests pass.
2026-04-30 13:12:25 +00:00
Johnathan Corgan 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.
2026-04-30 10:24:32 +00:00
Johnathan Corgan 37c2973e2f Test infrastructure overhaul: gateway robustness + full CI coverage
Single combined commit covering five interlocking pieces of test and
CI work that landed during the v0.3.0-prep cycle.

## fips-gateway robustness

- src/bin/fips-gateway.rs DNS upstream probe converted from a 3-second
  hard-fail to a bounded retry loop (5 attempts × 1s timeout, 1s sleep
  between attempts; ~10s worst case). Covers the cold-boot race where
  the daemon's TUN is up but the DNS responder at [::1]:5354 is still
  binding. Each failed attempt logs at INFO. In production the binary's
  retry is the live recovery mechanism; with retry it recovers silently
  instead of relying on Restart=on-failure (~5s blip + spurious ERROR
  per cycle).
- packaging/debian/fips-gateway.service `ExecStartPre` now waits up to
  30 seconds for the daemon's `fips0` TUN to appear before exec'ing
  the gateway binary. Eliminates the cold-boot race where the gateway
  exits with `fips0 interface not found` and recovers via
  `Restart=on-failure`, producing a 5-second blip and a spurious error
  log per restart cycle.
- testing/docker/entrypoint.sh gateway-mode waits up to 30s for the
  daemon's DNS responder to bind [::1]:5354 (probes once per second
  with `dig @::1 -p 5354 ... test.fips`) before exec'ing fips-gateway.
  Belt-and-suspenders with the binary's own retry: in CI we want
  deterministic startup ordering. On timeout, fall through so the
  binary's probe reports the definitive error.

## Test infrastructure DNS bind migration to ::1

After session 359's daemon DNS-bind default flipped from `127.0.0.1`
to `::1` (the production fix for ISSUE-2026-0002), the static-test
infrastructure was carrying a stale workaround that overrode the
default back to IPv4 loopback. The fips-gateway integration test
exposed the divergence: the gateway probes its DNS upstream at
`[::1]:5354` (production default) while the daemon was binding
`127.0.0.1:5354` from the template override — IPv6-explicit sockets
do not accept v4-mapped traffic, so the upstream probe exhausted
retries and the gateway exited.

- Drop the explicit `bind_addr: "127.0.0.1"` line from every test
  config that emits it: testing/static/configs/node.template.yaml,
  testing/chaos/configs/node.template.yaml, the sidecar heredoc in
  testing/docker/entrypoint.sh, testing/acl-allowlist/generate-configs.sh
  (six per-node blocks), testing/nat/scripts/generate-configs.sh, and
  the four tor templates under testing/tor/. Daemon picks up its
  production `::1` default.
- Flip the dnsmasq forwarder for `.fips` in testing/docker/Dockerfile
  from `127.0.0.1#5354` to `::1#5354` so dnsmasq on the shared test
  image continues to reach the daemon. Template and Dockerfile must
  move together since most static suites resolve `<npub>.fips` via
  the test-image dnsmasq.

## rekey-accept-off integration variant + UDP unit test

- New `rekey-accept-off` topology and docker-compose profile under
  testing/static/. 2-node variant where node-b runs with
  `udp.accept_connections: false`. Pins the regression class that
  ISSUE-2026-0004 fixed (cross-connection winner's rekey msg1 was
  being filtered by the accept_connections gate, breaking rekey).
- testing/static/scripts/rekey-test.sh accepts REKEY_TOPOLOGY and
  REKEY_ACCEPT_OFF_NODES env vars; its inject-config subcommand
  applies the per-node `udp.accept_connections: false` edit, and
  the test asserts no sustained "Dual rekey initiation" log lines.
- New UDP variant of `should_admit_msg1` admit-rekey unit test in
  src/node/tests/handshake.rs.

## ci-local.sh full integration coverage

- New runner functions and dispatcher entries for `acl-allowlist`,
  `nat-cone` / `nat-symmetric` / `nat-lan`, `rekey-accept-off`,
  `dns-resolver`, `deb-install`. Each integrates with the existing
  summary tracking via `record`.
- New `--with-tor` flag (off by default) gates `tor-socks5-outbound`
  and `tor-directory-mode` runners. Tor stays opt-in because both
  harnesses depend on the live Tor network and would introduce a
  flake source unrelated to the FIPS code.
- New suite arrays (`ACL_SUITES`, `NAT_SUITES`, `DNS_RESOLVER_SUITES`,
  `DEB_INSTALL_SUITES`, `TOR_SUITES`) drive both the default sweep
  and `--list` output.
- `run_suite` extended to accept the new suite names for `--only`
  invocations.

## GitHub CI matrix expansions

- `gateway` matrix entry runs testing/static/scripts/gateway-test.sh
  against the existing docker-compose `gateway` profile.
- `rekey-accept-off` matrix entry exercises the new topology with
  REKEY_ACCEPT_OFF_NODES=b.
- `deb-install` matrix (debian12 + ubuntu24 + ubuntu26) runs
  testing/deb-install/test.sh with privileged systemd containers.
  ~5-7 min cold cache, ~2 min warm per distro. Self-contained: builds
  its own .deb in a Debian 12 cargo-deb builder image; does not
  depend on the build job's pre-built artifact.
- `dns-resolver` matrix entry runs the full 13-scenario harness
  (per-distro systemd resolver-backend tests + real-fips end-to-end
  scenarios) in a single job. Pins the production DNS bind path that
  ISSUE-2026-0002 lived in. ~7-12 min warm, ~12-15 min cold.

Verified locally: full `bash testing/ci-local.sh` sweep passes,
including 5/5 deb-install distros and all 13 dns-resolver scenarios.
Tor-inclusive sweep (`--with-tor`) verified in a follow-up run.
2026-04-30 10:24:32 +00:00
Johnathan Corgan 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.
2026-04-30 03:11:43 +00:00
Johnathan Corgan 23c6609a6e Ship fips0 nftables security baseline (Linux)
Add a default-deny nftables ruleset for the fips0 mesh interface as
a packaged operator asset, with a companion fips-firewall.service
oneshot unit for systemd hosts. Both are shipped disabled — the
baseline is an operator conffile and the unit is intentionally not
enabled in postinst. Activation is an explicit one-liner:

  sudo systemctl enable --now fips-firewall.service

This is deliberate: silently mutating host firewall state on package
install is hostile across the axes that matter (collisions with
existing operator nftables / Docker / OPNsense rulesets, surprise
behaviour for hosts that already filter elsewhere, conversion of an
explicit security decision into an invisible one). The opt-in
posture preserves operator agency.

The baseline closes a real default-exposure gap: any service on a
mesh host bound to a wildcard address (0.0.0.0 or [::]) is
reachable from every authenticated peer in the mesh by default.
Identity on the mesh is the peer's npub but identity is not
authorization, and the mesh is closer to a shared LAN than to the
public internet. With this filter loaded, the surface is closed
unless a drop-in opens it explicitly.

Baseline shape:
- Early-return for non-fips0 traffic (every other firewall left
  undisturbed)
- conntrack established/related accept (replies to outbound flows)
- ICMPv6 echo-request accept (ping6 reachability)
- include "/etc/fips/fips.d/*.nft" — operator-supplied allowances
- counter drop default

The accompanying docs/fips-security.md lays out the threat model
(npub-authenticated mesh is closer to a shared LAN than to the
public internet — identity is not authorization), the activation
workflow, drop-in extension recipes (allow inbound SSH from a
specific peer fd97:.../128, allow HTTP from one /64, etc), drop
visibility / debugging via the journal log rule and the drop
counter, coexistence with the runtime-managed `inet fips_gateway`
table, what the baseline does NOT cover (outbound, application
auth, mesh handshake ACL = PR #50 / IDEA-0047 territory), and
future cross-OS work (macOS PF baseline, OpenWrt fw4, gateway
abstraction).

Packaging:
- packaging/common/fips.nft       → /etc/fips/fips.nft (conffile)
- packaging/debian/fips-firewall.service → /lib/systemd/system/
- docs/fips-security.md           → /usr/share/doc/fips/
- postinst creates /etc/fips/fips.d/ (mode 0755) on configure
- prerm stops/disables fips-firewall.service on remove/purge

OpenWrt fw4 path and macOS PF baseline are deferred — separate
asymmetries, separate work.
2026-04-30 03:10:56 +00:00
Johnathan Corgan 3092c95d54 Filter stray punch probes on adopted UDP transports
When a UDP hole-punch succeeds in only one direction and the local
side adopts the punched socket, the remote end keeps retrying its
own punch attempt for several seconds. Those retries arrive on the
adopted socket and were forwarded to the FMP rx handler, which
parsed the first byte (0x4E from PUNCH_MAGIC's "NPTC" big-endian
encoding) as FMP protocol version 4 and emitted "Unknown FMP
version, dropping" once per probe. The probe stream contaminated
post-adoption handshake logs and added timing pressure during the
handshake window.

Add a transport-level filter in udp_receive_loop that silently
drops any datagram whose first 4 bytes match PUNCH_MAGIC or
PUNCH_ACK_MAGIC. Filter applies to all UDP transports, not just
adopted ones — the magic values cannot collide with valid FMP
frames (FMP version 4 is not assigned, and the protocol's
versioning is wire-format breaking), so universal filtering is
safe and removes any "is this an adopted socket" branching.

Move PUNCH_MAGIC / PUNCH_ACK_MAGIC and the new is_punch_packet()
helper from the `nostr-discovery`-gated submodule up to
crate::discovery (unconditionally compiled) so the UDP transport
can import them without requiring the feature. The
nostr-discovery types module re-exports the constants so the
existing traversal-side imports keep working unchanged.

Test: pushes a probe + ack + real frame through the receive loop
and asserts only the real frame is delivered to packet_tx.
2026-04-30 03:10:56 +00:00
Johnathan Corgan c8502cdb97 Auto-derive per-commit Debian Version for dev builds
Inject git date + short SHA into the Debian Version field when
Cargo.toml's crate version ends in -dev, so apt-based upgrade
detection works without operator workarounds. Form:
  <base>~dev+git<YYYYMMDD>.<sha>[.dirty]-1
e.g. 0.3.0~dev+git20260429.6def31b-1.

Each commit produces a uniquely-comparable Version, so
`apt install ./*.deb` and `ansible.builtin.apt: deb:` stop
silently no-op'ing when one dev .deb is installed on top of
another. The ~dev marker sorts pre-tagged-release so 0.3.0
supersedes any prior dev .deb. Tagged builds (Cargo.toml without
-dev) keep the clean <version>-1 form. --version override still
wins.

Note: legacy 0.3.0-dev-1 dev installs sort ABOVE the new form;
hosts upgrading from a legacy install will need `dpkg -i` once
on the next dev .deb to bypass apt's downgrade refusal.
2026-04-30 03:10:56 +00:00
Johnathan Corgan 6def31bcf6 Admit rekey msg1 from established peers regardless of accept_connections
The accept_connections gate at the top of handle_msg1 was applied
unconditionally, so rekey msg1 from a peer with whom an established
link already existed was dropped on the same path as fresh handshakes
from strangers. Combined with the dual-init tie-breaker, this
deadlocked at ~25 minutes when both sides' rekey timers fired
near-simultaneously: the smaller-NodeAddr side wins as initiator and
expects the larger side to consume its rekey msg1, but if the larger
side has accept_connections=false the gate dropped it. Both sides
retried at 1 Hz indefinitely; the affected peer fell out of MMP-active
rotation.

Extract the gate decision into Node::should_admit_msg1, which admits
unconditionally when addr_to_link already has an entry for the
(transport_id, remote_addr) pair (rekey/restart on an established
session) and otherwise consults the transport's accept_connections().
Fresh msg1 from strangers is still rejected before any Noise crypto.

Three unit tests pin the truth table: no transport (admit), accept_off
no-link (reject, behavior unchanged), accept_off with-link (admit, the
carve-out).

The fix generalizes for free to BLE, which has the same Node-level gate.
TCP and Tor were never subject to this deadlock because their accept
condition is runtime state (bind_addr.is_some() / onion_address.is_some()),
not a config flag.
2026-04-29 19:24:40 +00:00
Johnathan Corgan 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).
2026-04-29 12:50:11 +00:00
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>
2026-04-27 08:15:58 -07:00
Johnathan Corgan 1e3b2c319e testing: rekey settle past drain window + stale tooling references
- testing/static/scripts/rekey-test.sh: bump REKEY_SETTLE from 5s to
  12s so post-rekey ping_all samples are taken after the 10s old-
  session drain window has closed (DRAIN_WINDOW_SECS in
  handlers/rekey.rs). The 5s sample window straddled the drain,
  occasionally catching old-session decrypts mid-cutover and producing
  spurious ping failures.

- testing/lib/log_analysis.py: discovery-succeeded match string is
  "proof verified, route cached" (the wording in handlers/discovery.rs);
  the regex looked for the older "caching route" wording, so the chaos
  analysis summary always reported `Succeeded: 0` regardless of how
  many successes the raw node logs actually showed.

- testing/scripts/build.sh: drop the second cargo invocation that
  passed `--features gateway --bin fips-gateway`. PR #79 removed the
  `gateway` cargo feature in favor of platform cfg gates, so this
  invocation now fails with `the package 'fips' does not contain this
  feature: gateway`. The first cargo build already produces
  fips-gateway as a workspace binary on every supported target.
2026-04-26 19:14:32 +00:00
Johnathan Corgan 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.
2026-04-26 19:12:19 +00:00
Johnathan CorganandGitHub 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.
2026-04-24 13:42:30 -07:00
Johnathan Corgan be0708ac9b Bring acl test module into the test tree
src/node/tests/acl.rs was added by PR #50 but never declared in
src/node/tests/mod.rs, so none of its 4 unit tests ran. The tests
themselves still compile and pass against current master code —
the fix is a one-line mod declaration. Test count goes from 1031
to 1035.

No code under test changes. This only adds previously-dormant
coverage of the ACL enforcement call sites (outbound connect,
inbound msg1, outbound msg2).
2026-04-22 03:41:12 +00:00
Johnathan Corgan ad5ad53848 Merge branch 'maint'
# Conflicts:
#	CHANGELOG.md
2026-04-22 01:21:51 +00:00
Johnathan CorganandGitHub ed312ac6f2 Fix DNS resolution on Ubuntu 22 with systemd-resolved (#77)
On Ubuntu 22 (systemd 249), systemd-resolved applies interface-scoped
routing to per-link DNS servers. Configuring `resolvectl dns fips0
127.0.0.1:5354` caused resolved to attempt reaching 127.0.0.1 through
fips0 (a TUN with only fd00::/8 routes), silently failing. The DNS
responder never received queries. Newer systemd versions (250+) have
explicit handling for loopback servers on non-loopback interfaces.

Changes:

- DNS responder default bind_addr changed from "127.0.0.1" to "::"
  so it listens on all interfaces, including fips0. Bind logic in
  lifecycle.rs now parses bind_addr as IpAddr and constructs a
  SocketAddr, handling IPv6 literal formatting. Factored into
  Node::bind_dns_socket with explicit IPV6_V6ONLY=0 via socket2, so
  IPv4 clients on 127.0.0.1:5354 still reach the responder
  regardless of the kernel's net.ipv6.bindv6only sysctl.

- fips-dns-setup resolvectl backend now waits for fips0 to have a
  global IPv6 address, then configures resolved with
  [<fips0-addr>]:5354. That address is locally delivered by the
  kernel regardless of which interface resolved tries to route
  through. The dnsmasq and NetworkManager backends still use
  127.0.0.1 (they don't have the interface-scoping issue).

- Dropped hardcoded `bind_addr: "127.0.0.1"` from the packaged
  fips.yaml (Debian + OpenWrt). The shipped config was overriding
  the new default.

- DNS queries are only accepted from the localhost.

Verified end-to-end in a privileged Ubuntu 22.04 systemd container:
dig @127.0.0.53 AAAA <npub>.fips resolves cleanly through
systemd-resolved.

The dns-delegate backend (systemd 258+) still uses 127.0.0.1; it
has not been verified whether that backend has the same routing
issue.
2026-04-21 18:17:56 -07:00
Johnathan Corgan de8db82614 Make tree ancestry acceptance test deterministic
The test used Identity::generate() for the signing node while pinning
the fixed root to node_addr byte[0]=0x01. About 2/256 random identities
have byte[0] <= 0x01, which made the generated node_addr the path
minimum and triggered AncestryRootNotMinimum. Regenerate the identity
until its node_addr is numerically larger than the fixed parent and
root, so the test matches the preconditions it asserts.
2026-04-22 01:17:04 +00:00
Johnathan Corgan 03f6db58e8 Merge branch 'maint' 2026-04-21 19:33:19 +00:00
Johnathan Corgan 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.
2026-04-21 19:28:23 +00:00
AndrewHGitHubandrewheadricke <andrewheadricke>
c83e14ac97 Switch std::atomic to portable_atomic for mips support (#62)
Co-authored-by: andrewheadricke <andrewheadricke>
2026-04-21 08:06:39 -07:00
Alex XieandJohnathan Corgan 745b523ac6 Add peer ACL enforcement with reloadable allow/deny files (#50)
Implement TCP Wrappers-style peer access control using
/etc/fips/peers.allow and /etc/fips/peers.deny files. Evaluation
order: allow overrides deny, default permit when no files exist.

Three enforcement points: outbound connect (before dialing), inbound
handshake (msg1 receipt, after restart/rekey classification), and
outbound handshake completion (msg2, before peer promotion). Files
support npub, hex pubkey, host alias, and ALL wildcard entries with
automatic mtime-based reload.

Adds fipsctl acl show query, 954-line acl module with unit tests,
and a 6-node Docker integration harness (testing/acl-allowlist/)
exercising insider, outsider, and allowed-remote scenarios. CI
matrix entry included.

Closes #50

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-04-16 05:13:07 +00:00
Johnathan Corgan 5cdcff7386 Merge branch 'maint' into master
# Conflicts:
#	CHANGELOG.md
2026-04-15 17:08:50 +00:00
Sats And SportsandJohnathan Corgan b36966be3a Tighten TreeAnnounce validation to match spanning tree specification
Adds TreeAnnounce::validate_semantics() called from handle_tree_announce
before any tree-state mutation. Enforces that the ancestry accompanying
a parent declaration conforms to the spanning tree rules:

- first ancestry entry matches the signed sender
- is_root declarations carry a single-entry ancestry
- non-root declarations include the signed parent as the second entry
- the advertised root is the minimum node_addr in the ancestry

Non-conforming announcements are rejected with a warn log and no state
change. Adds unit tests for each rejected shape plus an integration
test covering the full receive path in a two-node tree.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-04-15 16:55:48 +00:00
Alex XieandGitHub 83b20b3078 gateway: clarify macOS sidecar naming and startup (#67)
Align the macOS example with the repo's existing fips-gateway binary name and override the test image entrypoint so first-run identity generation succeeds.
2026-04-15 07:01:45 +01:00
SatsAndSportsandGitHub 5087ef9a95 Fix testing docker build by staging fips-gateway (#69)
The unified test image always expects fips-gateway in testing/docker, but testing/scripts/build.sh only copied fips, fipsctl, and fipstop.
Build and stage fips-gateway explicitly so local Docker test builds match CI.
2026-04-15 06:49:09 +01:00
Johnathan Corgan 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).
2026-04-15 05:04:37 +00:00
Origami74 c009eb7514 Fix AUR publish workflow bash --command error
Bump KSXGitHub/github-actions-deploy-aur from v4.1.1 to v4.1.2,
which fixes the runuser invocation that caused "bash: --command:
invalid option" during SSH initialization.
2026-04-14 15:25:11 +01:00
ArjenandGitHub 7780dffa93 Add workflow_dispatch trigger to AUR publish workflow (#65) 2026-04-14 15:04:49 +01:00
7b3c2daa12 Arch packaging fix (aur): fix namcap issues in PKGBUILD (#63)
* packaging(aur): fix namcap issues in PKGBUILD

Changes made to fix namcap issues:

1. Added `dbus` to dependencies - the fips binary links against
   libdbus-1.so.3, so dbus must be listed as a runtime dependency.

2. Disabled split debug package - added `!debug` to options to prevent
   creating a broken -debug package. The debug package was generating
   symlinks with incorrect paths, causing namcap errors.

3. Made config file world-readable - changed fips.yaml permissions from
   0600 to 0644.

Remaining namcap warnings (can be ignored):
- "Unused shared library '/usr/lib64/ld-linux-x86-64.so.2'" - False
  positive. The dynamic linker is required for the binary to run.
- "Dependency libgcc detected and implicitly satisfied" / "gcc-libs
  may not be needed" - These are contradictory. The binaries do link
  to libgcc_s.so.1, and gcc-libs provides it. Keeping an explicit
  dependency is correct.

* Updated based on comments

---------

Co-authored-by: redshift <213178690+1ftredsh@users.noreply.github.com>
2026-04-14 12:39:20 +01:00
Johnathan CorganandGitHub 5abae0859e Add historical node and per-peer statistics with btop-style graphs (#64)
In-memory time-series history on the daemon: fast ring (1s × 3600)
plus slow ring (1m × 1440) per metric, covering node-level gauges
(mesh size, tree depth, peer count, active sessions), counters
(parent switches, aggregate bytes/packets in/out), loss rate, and
seven per-peer metrics keyed by NodeAddr (srtt_ms, loss_rate,
bytes_in/out, packets_in/out, ecn_ce). The slow ring is produced by
downsampling the fast ring on minute boundaries with Last / Sum /
Mean aggregation chosen per metric type.

Missing data is first-class. New peers back-fill NaN so every ring
shares a time axis with the node rings; peers absent from a tick
sample NaN (keeps alignment, shows as a visible gap); counter metrics
emit NaN on decrease (new link_stats baseline after reconnect) so
deltas aren't polluted. Peers are evicted 24h after last contact.
Downsampling is NaN-aware: mean skips NaN, all-NaN slow windows stay
NaN. Each history window always returns its full span at the chosen
density (1m / 10m / 1h / 24h), front-padded with NaN when the ring
hasn't yet accumulated enough samples, so switching between windows
feels like zooming in or out rather than clipping to whatever has
arrived. NaN serializes to JSON null via a custom serializer.

Control socket queries:
- show_stats_list enumerates registered metrics plus scope field and
  peer_retention_seconds.
- show_stats_history returns one metric's series for a given window
  and granularity; accepts optional peer (npub) for per-peer metrics.
- show_stats_all_history returns every metric in a single round trip;
  accepts optional peer to fetch all seven per-peer metrics.
- show_stats_peers enumerates tracked peers with lifecycle metadata.
- show_stats_history_all_peers returns one metric across all peers
  for grid rendering.
- show_status carries short sparkline windows for the dashboard so
  the client can render without extra fetches.

fipsctl gains `stats list`, `stats peers`, and `stats history
<metric>` with `--peer` (hostname or npub) and `--plot` for a Unicode
block sparkline. Plot header reports sample count, granularity,
window, and gap count; NaN renders as a blank cell.

fipstop dashboard grows inline sparklines (peer count, mesh size,
aggregate bytes in/out). A new Graphs tab stacks every metric as an
independent mini plot with its own autoscaled range; each plot uses
btop's braille 2×4 filled-area algorithm (25-entry lookup table
packing two samples per character, per-row gradient coloring for the
characteristic btop vertical-band look, rounded borders with embedded
titles). Three modes are cycled with `m`: Node (node-level stack),
MetricByPeer (small-multiples grid, 1 / 2 / 3 columns by terminal
width), PeerByMetric (existing stack scoped to one peer). `n` / `N`
cycles the mode-specific selector (metric or peer), a selector row
shows the current choice, and Graphs-tab refreshes re-fetch
show_stats_peers so selectors track peer churn. Up / Down scrolls
the stack, Left / Right cycles the window, `g` jumps to the tab.

Implements IDEA-0084 (TASK-2026-0062).
2026-04-14 10:24:16 +01:00
Tim O'SheaandJohnathan Corgan 2d342a4e47 Add diagnostic queries for security validation and mesh debugging
Extend the fipsctl control query interface with visibility into internal
state critical for protocol security auditing, mesh troubleshooting, and
operational monitoring.

New command:

  fipsctl show identity-cache

    Lists every node identity cached by the daemon (learned from DNS
    resolution, peer handshakes, sessions, and static config).  Shows
    npub, IPv6 address, display name, and LRU age alongside the
    configured cache capacity.

Extended queries:

  show peers — Noise session counters (send_counter, highest received
    counter) for rekey urgency assessment.  Per-peer replay suppression
    and consecutive decrypt failure counts for active attack detection.
    Session index visibility for hijack analysis.  Rekey lifecycle
    state (in_progress, draining, K-bit epoch).

  show sessions — Handshake resend count during establishment for
    connectivity debugging.  Rekey and session health fields
    (session_start, K-bit, coords warmup, drain state) when
    established.

  show cache — Individual coordinate cache entries with tree
    coordinates, depth, path MTU, and age.  Enables route-level
    debugging by showing exactly which destinations have cached
    routes and via what tree path.  Renames the top-level count
    field from "entries" to "count" for clarity.

  show routing — Pending discovery lookups expanded from count to
    per-target detail (attempt number, age, last sent).  Pending
    TUN packet queue depth for backpressure visibility.  Connection
    retry state per peer (retry count, next attempt, auto-reconnect
    flag).

Updates fipstop to match the revised show_cache and show_routing
response schemas.  Updates README monitoring section with the complete
fipsctl command list.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-04-13 17:34:01 +00:00
Johnathan Corgan 5029b40d49 Update README for current platform, transport, and example state
- Correct the transport matrix: UDP, TCP, and Tor work on Windows
  (previously shown as unsupported). Add an OpenWrt column with BLE
  disabled due to missing libdbus on the target.
- Mention the `.fips` DNS resolver and outbound LAN gateway in the
  Features list, and add the gateway bullet under What works today.
- Reframe the Linux DNS resolver setup: the `.deb` package now
  auto-configures the available backend; the manual resolvectl
  snippet is shown for tarball and manual installs.
- Expand the Examples section to list all three example deployments
  (Nostr relay sidecar, K8s sidecar, macOS WireGuard sidecar) rather
  than only the macOS one.
- Refresh Project Structure to include the `fips-gateway` binary,
  the full packaging list (macOS .pkg, Windows ZIP, OpenWrt ipk, AUR
  in addition to Debian and systemd tarball), and the examples
  directory.
- Mention macOS `.pkg` and Windows ZIP/service packaging in the
  packaging line of What works today.
2026-04-13 06:33:01 +00:00
Johnathan Corgan 6698c4d669 Expand CHANGELOG Unreleased for master-only changes
Adds entries for master-only work since 0.2.0 that wasn't captured
yet: Windows and macOS platform support, the outbound LAN gateway
and its packaging, the macOS WireGuard sidecar example, multi-backend
.fips DNS configuration, the node.log_level config, the Nostr UDP
hole punch protocol proposal doc, the MMP report interval retune,
the info-to-debug log demotion, the rekey msg1 gate fix, and the
fipstop ratatui try_init change.

Fixed entries only cover bugs present in 0.2.0. Fixes against
master-only code (BLE reliability work, new sidecar port mapping)
are rolled into their respective Added entries rather than listed
as Fixed, per Keep a Changelog conventions.
2026-04-13 06:20:45 +00:00
Johnathan Corgan 2a943e6695 Merge branch 'maint'
# Conflicts:
#	src/bin/fipsctl.rs
2026-04-13 06:05:36 +00:00
Johnathan Corgan 7e002a3883 Update CHANGELOG for pending 0.2.1 bug fixes
Captures three fixes landed on maint since 0.2.0 that were not yet
recorded: the bloom-filter greedy-tree fallback fix, auto-connect
reconnect on graceful disconnect (#60), and fipsctl mesh-address
rejection (#61).
2026-04-13 05:49:37 +00:00
Johnathan Corgan d16acf8cea Reject FIPS mesh addresses in fipsctl connect
When a user passes an fd00::/8 address as the endpoint for a udp,
tcp, or ethernet transport, the CLI previously echoed success while
the daemon silently failed the bind with EAFNOSUPPORT. Mesh ULAs are
destinations inside the mesh, not reachable transport endpoints.

fipsctl now validates the address up front and prints a clear error
with examples, exiting 1 before the control socket call. Other
transports (tor) are not inspected since they legitimately accept
non-IP endpoints.

Covered by inline tests for bare/bracketed/with-port ULA syntaxes,
non-ULA IPv6, IPv4, hostnames, and the transport filter.

Fixes #61.
2026-04-13 05:44:23 +00:00
Johnathan Corgan 42834b8008 Fix auto-connect reconnect on graceful peer disconnect
handle_disconnect() called remove_active_peer without scheduling a
reconnect, orphaning auto-connect peers on a clean upstream shutdown.
Mirror the pattern from the other three peer-removal paths (link-dead,
decrypt failure, peer restart) which all schedule reconnect after
removal.

Adds test_disconnect_schedules_reconnect regression test that verifies
handle_disconnect populates retry_pending for an auto-connect peer.
Visibility of handle_disconnect bumped to pub(in crate::node) for
direct unit-test access.

Fixes #60.
2026-04-13 05:34:51 +00:00
Johnathan Corgan 5208d3222a Merge branch 'maint' 2026-04-12 08:39:10 +00:00
Johnathan Corgan 0b81f15369 Add rustfmt to pinned toolchain components
CI format check was failing because the pinned toolchain
rust-toolchain.toml overrides the `dtolnay/rust-toolchain@stable +
rustfmt` action installation — rustup installs rustfmt onto the
stable channel, but when cargo fmt runs inside the repo, rustup
honors the 1.94.1 pin and does an on-demand install that pulls only
rustc/cargo/rust-std.

Declaring components in rust-toolchain.toml ensures the on-demand
install of the pinned toolchain includes rustfmt.
2026-04-12 08:33:38 +00:00
Johnathan CorganandGitHub fe205e74de Multi-backend DNS configuration for .fips domain (#58)
Replace the resolvectl-only fips-dns.service with a detection script
that configures whichever DNS resolver is available:

1. systemd dns-delegate (systemd >= 258, declarative drop-in)
2. systemd-resolved via resolvectl (most systemd distros)
3. dnsmasq (standalone)
4. NetworkManager with dnsmasq plugin
5. Warning with manual instructions if none found

Service reloads are non-fatal — config is written and the backend
is recorded even if the reload fails, preventing state file cleanup
issues under set -e.

Teardown reads the recorded backend from /run/fips/dns-backend and
reverses the configuration, or cleans up all possible backends if
the state file is missing.

Includes a Docker-based test harness (testing/dns-resolver/test.sh)
covering all five backends across Debian 12, Debian 13, Fedora,
and bare systems.

Fixes #52.
2026-04-11 18:45:17 +01:00
OceanSlimandGitHub 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.
2026-04-11 18:31:48 +01:00
Johnathan Corgan 7494ed058d Merge branch 'maint' 2026-04-10 16:43:49 +00:00
Johnathan Corgan 19cb776216 Bump pinned toolchain to 1.94.1
The rustfmt component is no longer available for 1.94.0 on GitHub
Actions runners, causing the format check CI job to fail.
2026-04-10 16:43:45 +00:00
Johnathan Corgan 68dafbc72a Add testing/.gitignore for staged binaries and generated artifacts 2026-04-10 11:16:35 +00:00