52 Commits

Author SHA1 Message Date
Johnathan Corgan
d72e619c51 Release v0.2.1
Bump Cargo.toml version 0.2.1-dev -> 0.2.1 and resync Cargo.lock,
move the CHANGELOG [Unreleased] block under [0.2.1] - 2026-05-11,
update the README status badge and prose to v0.2.1, and add the
release notes at docs/releases/release-notes-v0.2.1.md with a
mirrored copy at the repo root as RELEASE-NOTES.md.
2026-05-11 14:34:05 +00: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
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
Sats And Sports
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
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
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 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
68bdcb2c75 Add cargo fmt --check to CI and local CI 2026-04-10 08:45:18 +00:00
Johnathan Corgan
48b1617497 Add .git-blame-ignore-revs for bulk reformat commit 2026-04-10 08:27:23 +00:00
Johnathan Corgan
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.
2026-04-10 08:27:07 +00:00
Johnathan Corgan
a859da7748 Fix bloom filter routing blocking greedy tree fallback
When bloom filter candidates existed but none were strictly closer to
the destination in tree-coordinate distance, find_next_hop returned
None without trying greedy tree routing. This caused NoRoute failures
in topologies where the tree parent was closer but not a bloom
candidate. Fall through to tree routing when no bloom candidate makes
progress.
2026-04-05 12:23:24 +00:00
Johnathan Corgan
15628e5b41 Improve ping test convergence by checking all nodes
Wait for all nodes to reach their expected peer counts instead of
only checking a single node. This prevents false failures on slower
CI runners where remote nodes (especially node E in mesh/chain
topologies) take longer to establish all links.
2026-04-01 11:54:58 +00:00
Johnathan Corgan
f6f2bea792 Update changelog with backported fixes and packaging workflows 2026-03-31 19:52:56 +00:00
sandwich
0ff9139b64 ci: add AUR publish workflow for tagged releases
- Publish fips PKGBUILD to AUR on stable v* tag push
- Skip pre-release tags (containing '-')
- Uses KSXGitHub/github-actions-deploy-aur, continue-on-error
- Requires AUR_SSH_PRIVATE_KEY and AUR_EMAIL secrets
2026-03-31 19:52:25 +00:00
jo
7d33f1f2c9 ci: add Linux packaging workflow and target-aware build scripts
- Add package-linux.yml: builds tarball and .deb for x86_64 and aarch64
  on v* tag push, uploads artifacts to GitHub release with checksums
- Make build-tarball.sh target-aware: --target, --version, --arch, --no-build
- Make build-deb.sh target-aware: --target, --version, --no-build
- Configurable strip binary via STRIP env var
2026-03-31 19:52:25 +00:00
Johnathan Corgan
97fc29eb82 Add ip6 routing policy rule to protect fd00::/8 from interception
Tailscale (and potentially other routing software) installs a default
IPv6 route in an auxiliary routing table with a policy rule that runs
before the main table. This silently diverts fd00::/8 FIPS traffic
away from the fips0 TUN device.

Add an ip6 rule (priority 5265) during TUN setup that directs fd00::/8
to the main routing table, ensuring the fips0 route is always used.
2026-03-31 19:39:27 +00:00
Origami74
8c4455cc1c Fix OpenWrt ipk build: exclude BLE feature that requires D-Bus
The ble feature (bluer crate) pulls in libdbus-sys which cannot
cross-compile with cargo-zigbuild. Disable default features and
explicitly enable only tui for the OpenWrt package build.
2026-03-31 19:39:27 +00:00
Johnathan Corgan
7f33e5f867 Fix lookup-request.svg: remove stale visited bloom filter
The visited_bits field (hash_cnt + 256 bytes) was removed from the
LookupRequest wire format in the discovery-rework (bloom-guided tree
routing replaced the visited filter). Update the diagram to match the
current 46 + 16n byte format.
2026-03-25 03:07:46 +00:00
Johnathan Corgan
bce5619b74 Fix control socket path detection for non-group users (#30)
Check /run/fips/ directory existence instead of the socket file inside
it. Users not in the fips group can stat the directory but not traverse
it, so the socket file check silently returned false and fell back to
$XDG_RUNTIME_DIR with a misleading "No such file" error.
2026-03-23 13:49:52 +00:00
Johnathan Corgan
9757877c0a Update README and changelog for v0.2.0 release 2026-03-23 03:21:44 +00:00
Johnathan Corgan
94884876b8 Bump version to 0.2.1-dev 2026-03-23 03:21:32 +00:00
Johnathan Corgan
a16370e78d Update changelog, version, and design docs for v0.2.0
Bump version to 0.2.0 and finalize changelog with discovery rework,
Tor transport, connect/disconnect commands, reproducible builds, and
12 bug fixes.

Update design documentation for discovery protocol rework:
- fips-wire-formats.md: remove visited bloom filter from LookupRequest,
  update size calculations
- fips-mesh-operation.md: replace flooding description with bloom-guided
  tree routing, add retry/backoff/rate-limiting subsections
- fips-configuration.md: add 5 new discovery config parameters, update
  control socket description for connect/disconnect commands
2026-03-22 20:26:09 +00:00
Johnathan Corgan
537eaf0db7 Fix tarball reproducibility: default SOURCE_DATE_EPOCH and sort entries
build-tarball.sh only applied --mtime when SOURCE_DATE_EPOCH was
explicitly set, and did not sort tar entries. This produced different
tarballs across builds due to varying timestamps and filesystem
ordering. Default SOURCE_DATE_EPOCH to the git commit timestamp and
add --sort=name for deterministic output.
2026-03-22 20:26:00 +00:00
Johnathan Corgan
6aff490251 Improve discovery protocol: bloom-guided tree routing with fallback
Replace discovery flooding with bloom-filter-guided tree routing:
lookups sent only to tree peers (parent + children) whose bloom
filter contains the target. If no tree peer matches, fall back to
non-tree peers with bloom matches before dropping the request. This
produces single-path forwarding through the spanning tree (90%
traffic reduction) while recovering from dead ends caused by stale
bloom filters, tree restructuring, or transit node failures.

Remove visited bloom filter from LookupRequest wire format (-257
bytes per request). Tree routing is inherently loop-free; request_id
dedup handles edge cases during tree restructuring. Add response-
forwarded flag to prevent response routing loops from convergent
request paths.

Add originator-side exponential backoff (30s base, 300s cap) after
lookup timeouts and bloom misses. Backoff resets on topology changes
(parent switch, new peer, first RTT, reconnection).

Add transit-side per-target rate limiting (2s minimum interval) for
forwarded lookups as defense-in-depth.

Add discovery retry within the timeout window (default: send at T=0,
retry at T=5s, fail at T=10s) to compensate for single-path fragility.
Lookups with zero eligible tree peers fail immediately.

Improve discovery logging: promote key events to info (initiation,
success, timeout with failure count). Add debug logging for dedup,
pending packet retry, backoff suppression, forward rate limiting,
and backoff reset.

New config: discovery.backoff_base_secs, backoff_max_secs,
forward_min_interval_secs, retry_interval_secs, max_attempts.
New stats: req_backoff_suppressed, req_forward_rate_limited,
req_bloom_miss, req_no_tree_peer, req_fallback_forwarded.
Removed: req_already_visited, visited bloom filter.
2026-03-22 20:02:00 +00:00
Origami74
7a643a9ac3 Add Nostr release publishing to OpenWrt package workflow
Add versioning job with release channel detection, Blossom upload,
NIP-94 event publishing, and relay verification. Replace setup-zig
action with direct download for act runner compatibility. Pin
cargo-zigbuild and zig versions for reproducible builds.
2026-03-21 06:56:46 -07:00
Origami74
c164de8808 Add reproducible build infrastructure
Pin Rust toolchain to 1.94.0 via rust-toolchain.toml for deterministic
compiler output across environments. Set SOURCE_DATE_EPOCH from git
commit timestamp in CI workflows and packaging scripts to normalize
embedded timestamps.

Make packaging archives reproducible: pass --mtime=@$SOURCE_DATE_EPOCH
to tar in .deb, .ipk, and systemd tarball builds. Normalize ownership
to root:root in systemd tarballs. Pin cargo-zigbuild to 0.19.8 for
cross-compilation stability.

Add SHA-256 hash output to CI build and OpenWrt packaging workflows
for binary verification.
2026-03-20 20:36:02 -07:00
Johnathan Corgan
fed6cc6987 Fix chaos sim DNS pipeline, ephemeral npub tracking, and analysis
Three infrastructure bugs fixed:

- DNS resolution never populated the identity cache because Docker
  overwrites /etc/resolv.conf at container start. Fix: bind-mount
  resolv.conf in the chaos compose template, matching the static and
  sidecar test configs.

- Traffic generator used config-time npubs for iperf3 targets, but
  ephemeral nodes generate fresh keypairs at startup. Fix: share the
  peer churn manager's runtime npub cache with the traffic manager.

- Log analysis had no discovery counters and used wrong patterns.
  Fix: add discovery section with correct log message matching.

Also adds timestamped output directories (YYYYMMDD-HHMMSS-scenario),
coord_ttl_secs override in maelstrom.yaml, FIPS_SIM_OUTPUT env var
support, and maelstrom-sparse scenario for sparse topology testing.
2026-03-20 16:14:10 +00:00
Johnathan Corgan
5053cf673d Add connect/disconnect control commands and maelstrom chaos scenario
Add runtime peer management to the FIPS daemon via control socket
commands, and a new chaos simulation scenario that exercises dynamic
topology mutation with ephemeral node identities.

Daemon (connect/disconnect commands):
- Extend control socket Request with optional params field
- Add commands.rs module for mutating command dispatch, separate from
  read-only queries
- Add api_connect() on Node: builds ephemeral PeerConfig (no auto-
  reconnect), pre-seeds identity cache, reuses initiate_peer_connection
- Add api_disconnect() on Node: calls remove_active_peer(), clears
  retry_pending to suppress reconnection
- Route non-show_* commands to async command dispatch in rx_loop

fipsctl CLI:
- Add Connect and Disconnect subcommands accepting npub or hostname
- Resolve hostnames from /etc/fips/hosts before sending to daemon
- Refactor socket I/O into reusable send_request helper

Chaos simulator (maelstrom scenario):
- Add PeerChurnManager: periodically disconnects a random active link
  and connects a random unconnected node pair via control socket
- Add send_command() to control.py using base64-encoded JSON payloads
  to avoid shell quoting issues in docker exec
- Add PeerChurnConfig to scenario with interval and ephemeral_fraction
- Ephemeral identity support: nodes configured without nsec generate
  fresh keypairs on restart; simulator queries show_status for new
  npub and updates its cache via on_node_restart callback
- Add maelstrom.yaml: all chaos dimensions (netem, link flaps, node
  churn, peer topology churn, traffic) with 50% ephemeral identity
2026-03-20 05:44:31 +00:00
Johnathan Corgan
8d51dbd268 Move K8s sidecar from testing/ to examples/
The K8s sidecar is a standalone example, not a test harness — it has
no CI integration unlike testing/sidecar/. Move it to examples/
alongside sidecar-nostr-relay/ where it belongs. Update internal path
references in Dockerfile, build.sh, and README.
2026-03-19 19:36:39 +00:00
Johnathan Corgan
9e63b42bd9 Consolidate Docker test harness infrastructure
Replace 4 near-identical per-harness Docker setups with unified shared
infrastructure. Net result: -463 lines across 55 files, faster CI
(8.5 min vs ~13.5 min), 14 scenarios (down from 21).

Unified Docker image (testing/docker/):
- Single Dockerfile (trixie-slim) with FIPS_TEST_MODE env var for
  mode dispatch: default, chaos, sidecar, tor-socks5, tor-directory
- Single entrypoint.sh with conditional logic per mode
- Replaces 5 Dockerfiles, 3 entrypoints, 4 resolv.conf copies

Shared build and libraries (testing/scripts/, testing/lib/):
- testing/scripts/build.sh: single build script with macOS zigbuild
  support, replaces 4 per-harness copies
- testing/lib/derive_keys.py: shared key derivation module, replaces
  3 copies of derive-keys.py
- testing/lib/log_analysis.py: shared log analysis extracted from
  chaos sim/logs.py, with CLI interface and rekey cutover tracking
- testing/lib/wait-converge.sh: shared convergence wait helpers
  (wait_for_links, wait_for_peers) using fipsctl JSON polling

Scenario consolidation:
- Remove 7 redundant scenarios: tcp-chain (subsumed by tcp-mesh),
  tcp-only (subsumed by tcp-mesh), chaos-10 (replaced by
  churn-mixed --nodes 10), churn-10/churn-20/churn-20-mixed
  (subsumed by parameterized churn-mixed), cost-mixed-7node
  (overlaps mixed-technology)
- Add churn-mixed scenario with --nodes flag for scale testing
- Reduce idle scenario durations: smoke-10 60s→30s,
  ethernet-only 90s→30s, cost-avoidance 120s→45s,
  depth-vs-cost 120s→45s, bottleneck-parent 120s→60s,
  mixed-technology 180s→90s

CI updates:
- ci-local.sh: unified image build, structured chaos suite entries
  with per-scenario flags, --skip-build for sidecar
- ci.yml: shared binary install + image build step, updated scenario
  matrix, chaos_flags support for parameterized scenarios
- Add tcp-mesh and congestion-stress to CI matrix
- Static ping test uses active peer convergence detection instead
  of hardcoded 5s sleep

Chaos infrastructure improvements (from discovery-rework branch):
- Pre-built Docker image instead of per-service build at scale
- --nodes flag in chaos.sh for runtime topology size override
2026-03-19 18:08:36 +00:00
Origami74
c8b7459fbc Add sidecar-nostr-relay example and fix openwrt CI path
Add a complete example running a strfry Nostr relay exclusively over
the FIPS mesh using the sidecar pattern. Includes Docker Compose
stack, network isolation via iptables, .fips DNS resolution, nak CLI,
build script with cross-compilation support, and documentation.

Also fix the package-openwrt.yml workflow path to match the
openwrt → openwrt-ipk directory rename.
2026-03-19 16:23:13 +00:00
Kieran
8c349f524e Add Kubernetes sidecar deployment
Adds a complete Kubernetes sidecar setup under testing/k8s/ that runs
FIPS as a sidecar container, injecting the mesh TUN interface (fips0)
into any co-located app container via shared pod network namespace.

- Multi-stage Dockerfile builds fips and fipsctl from source (debian
  trixie / rust slim-trixie), producing a minimal runtime image with
  iproute2, iptables, dnsmasq, and the two binaries
- entrypoint.sh generates fips.yaml from environment variables, rewrites
  /etc/resolv.conf to route .fips DNS through dnsmasq, applies optional
  iptables isolation, clamps TCP MSS, then starts dnsmasq and execs the
  daemon
- pod.yaml annotated example Pod manifest with Secret, sysctl tuning,
  readiness/liveness probes, and resource limits
- scripts/build.sh convenience wrapper for docker build
- testing/k8s/.dockerignore keeps the build context small
- README.md usage guide covering quick start, isolation modes, env vars,
  multi-peer JSON config, troubleshooting
2026-03-18 15:26:43 +00:00
Johnathan Corgan
0e098cadad Add top-level packaging Makefile and README 2026-03-17 14:57:01 +00:00
Johnathan Corgan
ebabb6e93c Update changelog for post-v0.1.0 fixes and remove no-op clone 2026-03-17 14:45:29 +00:00
Kieran
a5130b357e Fix ICMPv6 Packet Too Big source address for PMTUD
The PTB source was set to the local FIPS address, which Linux ignores
(loopback PTB security check). Change the source to the original
packet's destination (the remote peer) so the kernel sees it as coming
from a remote router and honors the PMTU update.

Add test coverage for the PTB source address invariant in both the
icmp unit tests and the node integration tests.
2026-03-17 02:14:54 +00:00
Johnathan Corgan
6e03adde72 Replace > 0 counter guards with has_prev_rr flag for delta computation
The loss and goodput delta guards used prev_rr_highest_counter > 0 and
prev_rr_cum_bytes > 0, which conflates "never received a report" with
"received a report where the value was 0." Since Noise transport
counters start at 0, this is semantically wrong. Use a dedicated
has_prev_rr boolean flag instead. Fixes GitHub issue #14 (Bug B).
2026-03-16 17:48:55 +00:00
Johnathan Corgan
1020f90828 Fix reverse delivery ratio using per-interval deltas instead of lifetime cumulative
The reverse delivery ratio was computed as cumulative_packets_recv /
highest_counter (lifetime values), while the forward ratio correctly
used per-interval deltas. This made ETX increasingly unresponsive over
session lifetime — early loss haunted the ratio forever, late loss
barely moved it.

Replace set_delivery_ratio_reverse() with update_reverse_delivery()
that tracks previous snapshot state and computes deltas, matching the
forward ratio's approach. Fixes GitHub issue #14 (Bug A).
2026-03-16 17:30:28 +00:00
Johnathan Corgan
0f24333c0d Fix post-rekey jitter spikes from drain-window frames
After rekey cutover, frames encrypted with the old session can still
arrive during the 10s drain window. These carry large sender timestamps
from the old session, producing enormous transit deltas that spike the
EWMA jitter estimator (observed 2,000-7,000ms spikes on UDP links).

Add a time-based grace period (DRAIN_WINDOW_SECS + 5s = 15s) after
rekey that suppresses jitter updates until drain-window frames have
flushed. Baselines still update every frame so calculation resumes
cleanly after grace expires.

Fixes #10
2026-03-16 12:41:41 +00:00
Johnathan Corgan
ef73e54316 Update changelog with post-v0.1.0 bug fixes and additions 2026-03-16 02:39:01 +00:00
Johnathan Corgan
8697897047 Update changelog with post-v0.1.0 bug fixes and additions 2026-03-16 02:33:14 +00:00
Johnathan Corgan
9e11a8e7ba Return NOERROR for A queries on valid .fips names instead of NXDOMAIN
The DNS responder returned NXDOMAIN for non-AAAA queries (e.g. A record)
on valid .fips names. This caused resolvers like nslookup and tinyproxy
to give up without attempting AAAA, since NXDOMAIN means "name does not
exist." Return NOERROR with an empty answer section instead, which tells
the client the name exists but has no records of that type.

Fixes jmcorgan/fips#9.
2026-03-16 02:28:31 +00:00
Kieran
e8ef15acb7 Fix stale session cleanup and identity cache pre-seeding
Cherry-picked from v0l PR #6 (issue #5):

1. remove_active_peer() now removes the end-to-end session from
   self.sessions when evicting a peer. The stale Established entry
   caused initiate_session() to silently return Ok(()) via the
   is_established() guard, preventing session re-establishment after
   link-layer reconnection.

2. initiate_peer_connections() pre-seeds the identity cache from
   configured peer npubs at startup, so TUN packets can be dispatched
   immediately without waiting for handshake completion.

3. schedule_reconnect() preserves accumulated backoff when a retry
   entry already exists, preventing exponential backoff reset on
   repeated link-dead cycles.

Includes regression tests for all three fixes.
2026-03-16 02:06:58 +00:00
Johnathan Corgan
5f7fe989f3 Fix rekey dual-initiation, parent selection, peer reconnect, and control socket
Rekey dual-initiation race (FMP + FSP):
When both sides' rekey timers fire simultaneously on high-latency links
(Tor ~700ms RTT), both msg1s cross in flight before dampening can
suppress the second initiation. Each node acts as both initiator and
responder, with set_pending_session() from the responder path destroying
the initiator's in-progress state. Each side ends up with a
pending_new_session from a different Noise handshake, causing AEAD
verification failure after cutover and link death. Fix: deterministic
tie-breaker in both FMP msg1 handler and FSP SessionSetup handler
(smaller NodeAddr wins as initiator). Also guard against pending session
overwrite from retransmitted msg1s.

Parent selection SRTT gate:
Peers without MMP RTT data are excluded from parent candidacy via
has_srtt() filter, preventing freshly reconnected peers from appearing
artificially attractive. First-RTT triggers immediate parent re-eval.
The gate in evaluate_parent() skips unmeasured candidates when any peer
has cost data, but falls back to default cost 1.0 during cold start
when no peer has MMP data yet.

Auto-reconnect on all peer removal paths:
The excessive-decryption-failure and peer-restart epoch-mismatch removal
paths were missing schedule_reconnect() calls, causing auto-connect peers
to be permanently abandoned after those events.

Control socket accessibility:
Socket and parent directory chowned to root:fips with mode 0770/0750 so
group members can use fipsctl/fipstop without root.

Log level changes:
Default RUST_LOG changed to debug in systemd service files. "Unknown
session index" log reduced from debug to trace.
2026-03-16 00:51:55 +00:00
Johnathan Corgan
324535e76d Make auto-connect peers retry indefinitely on initial connection failure
Previously, static peers configured with AutoConnect gave up after 6
attempts (1 initial + 5 retries). If the remote peer was offline at
startup, the node permanently abandoned the connection. The reconnect
path (after MMP link-dead) already retried indefinitely but only
activated after a peer had been previously connected.

Remove the reconnect parameter from schedule_retry() and always set
reconnect=true when creating retry entries, since only auto-connect
peers reach this code path. The 300s backoff cap prevents resource waste.
The max_retries=0 config still works as an explicit kill switch.
2026-03-15 18:43:11 +00:00
Johnathan Corgan
959134657f Fix FSP rekey cutover race and MMP metric discontinuity
The FSP XK rekey handshake has a race condition where the initiator
can cut over (K-bit flip) and send data encrypted with the new session
before msg3 reaches the responder. The responder has no pending session
yet, so K-bit detection fails and packets are dropped.

Defer FSP initiator cutover by 2 seconds after handshake completion
(FSP_CUTOVER_DELAY_MS) to give msg3 time to traverse the mesh. FMP
(IK, 2 messages) is unaffected since the responder completes during
msg1 processing.

Also fix MMP metric corruption after rekey cutover: the new session
starts with counter 0 but MMP state carries highest_counter and
GapTracker.expected_next from the old session, producing false
reorder counts, jitter spikes, and invalid OWD trends. Add
reset_for_rekey() methods that clear counter-dependent state while
preserving RTT estimates.

Additional fixes:
- Remove stale peers_by_index entry on abandon_rekey error path
- Replace redundant peers_by_index inserts with debug assertions
  verifying the pre-registration invariant
- Tighten rekey integration test to zero tolerance (was 4 failures)
2026-03-15 17:55:35 +00:00
Johnathan Corgan
6c90cf6c02 Implement Tor transport with operator visibility
Add TorTransport in src/transport/tor/ supporting three operating modes:

Outbound (socks5 mode):
- Non-blocking SOCKS5 connect via tokio-socks with per-destination
  circuit isolation (IsolateSOCKSAuth)
- TorAddr enum for .onion and clearnet address types
- Connection pool with per-connection receive tasks, reuses TCP
  stream FMP framing
- connect_async()/connection_state_sync()/promote_connection() follow
  the same non-blocking polling pattern as TCP transport

Inbound (directory mode — recommended for production):
- Tor manages the onion service via HiddenServiceDir in torrc
- FIPS reads .onion address from hostname file at startup
- No control port needed — enables Tor Sandbox 1 (seccomp-bpf)
- Accept loop mirrors TCP pattern with DirectoryServiceConfig

Monitoring (control_port mode and optional in directory mode):
- Async control port client supporting TCP and Unix socket connections
  via Box<dyn AsyncRead/Write> trait objects
- AUTHENTICATE with cookie or password auth
- 8 GETINFO queries: bootstrap, circuits, traffic, liveness, version,
  dormant state, SOCKS listeners
- Background monitoring task polls every 10s, caches TorMonitoringInfo
  in Arc<RwLock> for synchronous query access
- Bootstrap milestone logging (25/50/75/100%), stall warning (>60s),
  network liveness transitions, dormant mode entry
- Directory mode optionally connects to control port when control_addr
  is configured (non-fatal on failure)

Operator visibility:
- show_transports query exposes tor_mode, onion_address, tor_monitoring
  (bootstrap, circuit_established, traffic, liveness, version, dormant)
- fipstop transport detail view: Tor mode, onion address, SOCKS5/control
  errors, connection stats, Tor daemon status section
- fipstop table view: tor(mode) label with truncated onion address hint

Security hardening:
- Per-destination circuit isolation via IsolateSOCKSAuth
- Unix socket default for control port (/run/tor/control)
- Reference torrc with HiddenServiceDir, VanguardsLiteEnabled,
  ConnectionPadding, DoS protections (PoW + intro rate limiting)

Config:
- TorConfig with socks5, control_port, and directory modes
- DirectoryServiceConfig: hostname_file, bind_addr
- control_addr, control_auth, cookie_path, connect_timeout,
  max_inbound_connections

Testing:
- 69 unit + integration tests with mock SOCKS5 and control servers
- Docker tests: socks5-outbound (clearnet via Tor) and directory-mode
  (HiddenServiceDir onion service)

Documentation:
- Transport layer design doc: Tor architecture, directory mode
- Configuration doc: Tor config tables and examples
2026-03-15 16:19:54 +00:00
Johnathan Corgan
c0f30d8fe8 Add DNS hostname support in peer addresses for UDP and TCP transports
Add resolve_socket_addr() with IP fast path and tokio::net::lookup_host()
fallback for DNS hostnames. Peer addresses can now use hostnames like
"peer1.example.com:2121" alongside IP addresses.

UDP transport adds a per-transport DNS cache (60s TTL) to avoid
per-packet resolution. TCP resolves at connect time (one-shot).

Update design docs, config examples, and changelog to reflect hostname
support in transport addressing.
2026-03-15 00:49:54 +00:00
Johnathan Corgan
d873d0e00e Update design docs for non-blocking transport connect and cleanup stale references
- Rewrite TCP "Connect-on-Send" section to document non-blocking connect
  model (ConnectingPool, PendingConnect, poll_pending_connects)
- Add connect() and connection_state() to Transport trait surface
- Expand Connection Lifecycle section with ConnectionState enum
- Remove phantom TCP socks5_proxy field (removed from code, superseded
  by TorConfig)
- Fix "future Tor transport" references (stream reader already shared)
- Replace misleading "tor:" named TCP instance example
- Update fips-intro.md implementation status (TCP and Ethernet are
  implemented, not "under active design")
2026-03-13 16:37:00 +00:00
Origami74
35ff4a5d0a Add logos and update readme with banner 2026-03-13 12:17:50 +00:00
Johnathan Corgan
1bfb58845a Implement non-blocking transport connect for connection-oriented transports
TCP (and future Tor) transports previously established connections
synchronously inside send(), blocking the node's RX event loop during
TCP handshake. This is particularly problematic for Tor where SOCKS5
circuit establishment can take 30-120 seconds.

Add a non-blocking connect path:
- ConnectionState enum in transport layer (None/Connecting/Connected/Failed)
- connect_async() on TcpTransport spawns background TCP connect task
- connection_state_sync() polls task completion, promotes to pool
- TransportHandle gains connect() and connection_state() dispatch methods
- Node tracks PendingConnect entries for connection-oriented transports
- initiate_connection() defers handshake for connection-oriented transports
- start_handshake() extracted as separate method for deferred invocation
- poll_pending_connects() in tick handler polls and completes handshakes
- Failed connects trigger retry via schedule_retry()

Connectionless transports (UDP, Ethernet) are unchanged — connect()
is a no-op and connection_state() always returns Connected.

The existing connect-on-send path in send_async() is preserved as
fallback for reconnection after connection drops.

811 tests pass (6 new), clippy clean.
2026-03-13 03:21:21 +00:00
232 changed files with 15728 additions and 3541 deletions

2
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,2 @@
# rustfmt bulk reformat (maint)
13c0b70dc3111cf94fef217b0f8b5fdbe469d3eb

36
.github/workflows/aur-publish.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: AUR Publish
on:
push:
tags:
- 'v*'
jobs:
aur-publish-fips:
name: Publish fips to AUR
runs-on: ubuntu-latest
continue-on-error: true
if: "!contains(github.ref_name, '-')"
steps:
- uses: actions/checkout@v4
- name: Update pkgver in PKGBUILD
run: |
VERSION="${GITHUB_REF_NAME#v}"
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/aur/PKGBUILD
- name: Publish to AUR
uses: KSXGitHub/github-actions-deploy-aur@v4.1.1
with:
pkgname: fips
pkgbuild: packaging/aur/PKGBUILD
updpkgsums: true
assets: |
packaging/aur/fips.sysusers
packaging/aur/fips.tmpfiles
packaging/aur/fips.install
commit_username: ${{ github.repository_owner }}
commit_email: ${{ secrets.AUR_EMAIL }}
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: "Update to ${{ github.ref_name }}"

View File

@@ -18,6 +18,7 @@ permissions:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
SOURCE_DATE_EPOCH: 0 # overridden per-step after checkout
# ─────────────────────────────────────────────────────────────────────────────
# Job 1 Build matrix
@@ -26,6 +27,16 @@ env:
# separate ci-compat.yml workflow so their failures don't mark this run red.
# ─────────────────────────────────────────────────────────────────────────────
jobs:
fmt:
name: Format check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --check
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.os }}
@@ -40,6 +51,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -57,6 +71,9 @@ jobs:
- name: Build
run: cargo build --release
- name: SHA-256 hashes
run: sha256sum target/release/fips target/release/fipsctl target/release/fipstop
# Upload the Linux binary so integration jobs can use it without rebuilding
- name: Upload Linux binary
if: matrix.os == 'ubuntu-latest'
@@ -82,6 +99,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -125,8 +145,8 @@ jobs:
# Runs only when both build and test succeed. Each topology / scenario is a
# separate matrix entry so they run in parallel.
#
# Static topologies → build Docker images, start containers, ping-test
# Chaos scenarios → build sim image, run stochastic simulation
# All harnesses share a single Docker image (fips-test:latest) built once
# in the setup step from testing/docker/.
# ─────────────────────────────────────────────────────────────────────────────
integration:
name: Integration (${{ matrix.suite }})
@@ -153,24 +173,25 @@ jobs:
- suite: chaos-smoke-10
type: chaos
scenario: smoke-10
- suite: chaos-10
- suite: churn-mixed-10
type: chaos
scenario: chaos-10
scenario: churn-mixed
chaos_flags: "--nodes 10 --duration 120"
- suite: ethernet-mesh
type: chaos
scenario: ethernet-mesh
- suite: ethernet-only
type: chaos
scenario: ethernet-only
- suite: tcp-mesh
type: chaos
scenario: tcp-mesh
- suite: bottleneck-parent
type: chaos
scenario: bottleneck-parent
- suite: cost-avoidance
type: chaos
scenario: cost-avoidance
- suite: cost-mixed-7node
type: chaos
scenario: cost-mixed-7node
- suite: cost-reeval
type: chaos
scenario: cost-reeval
@@ -183,6 +204,9 @@ jobs:
- suite: mixed-technology
type: chaos
scenario: mixed-technology
- suite: congestion-stress
type: chaos
scenario: congestion-stress
# ── Sidecar deployment ──────────────────────────────────────────
- suite: sidecar
type: sidecar
@@ -197,24 +221,22 @@ jobs:
name: fips-linux
path: _bin
# ── Static topology ────────────────────────────────────────────────────
- name: Install binary (static)
if: matrix.type == 'static'
# Install binaries to unified docker context and build shared image
- name: Install binaries and build Docker image
run: |
chmod +x _bin/fips _bin/fipsctl
cp _bin/fips testing/static/fips
cp _bin/fipsctl testing/static/fipsctl
[ -f _bin/fipstop ] && chmod +x _bin/fipstop || true
cp _bin/fips testing/docker/fips
cp _bin/fipsctl testing/docker/fipsctl
[ -f _bin/fipstop ] && cp _bin/fipstop testing/docker/fipstop || true
docker build -t fips-test:latest testing/docker
docker build -t fips-test-app:latest -f testing/docker/Dockerfile.app testing/docker
# ── Static topology ────────────────────────────────────────────────────
- name: Generate configs (static)
if: matrix.type == 'static'
run: bash testing/static/scripts/generate-configs.sh ${{ matrix.topology }}
- name: Build Docker images (static)
if: matrix.type == 'static'
run: |
docker compose -f testing/static/docker-compose.yml \
--profile ${{ matrix.topology }} build
- name: Start containers (static)
if: matrix.type == 'static'
run: |
@@ -238,25 +260,12 @@ jobs:
--profile ${{ matrix.topology }} down --volumes --remove-orphans
# ── Rekey integration test ──────────────────────────────────────────────
- name: Install binary (rekey)
if: matrix.type == 'rekey'
run: |
chmod +x _bin/fips _bin/fipsctl
cp _bin/fips testing/static/fips
cp _bin/fipsctl testing/static/fipsctl
- name: Generate and inject configs (rekey)
if: matrix.type == 'rekey'
run: |
bash testing/static/scripts/generate-configs.sh rekey
bash testing/static/scripts/rekey-test.sh inject-config
- name: Build Docker images (rekey)
if: matrix.type == 'rekey'
run: |
docker compose -f testing/static/docker-compose.yml \
--profile rekey build
- name: Start containers (rekey)
if: matrix.type == 'rekey'
run: |
@@ -284,20 +293,9 @@ jobs:
if: matrix.type == 'chaos'
run: pip3 install --quiet pyyaml jinja2
- name: Install binary (chaos)
if: matrix.type == 'chaos'
run: |
chmod +x _bin/fips _bin/fipsctl
cp _bin/fips testing/chaos/fips
cp _bin/fipsctl testing/chaos/fipsctl
- name: Build chaos Docker image
if: matrix.type == 'chaos'
run: docker build -t fips-chaos:latest testing/chaos
- name: Run chaos scenario
if: matrix.type == 'chaos'
run: bash testing/chaos/scripts/chaos.sh ${{ matrix.scenario }}
run: bash testing/chaos/scripts/chaos.sh ${{ matrix.scenario }} ${{ matrix.chaos_flags }}
- name: Upload sim results on failure (chaos)
if: matrix.type == 'chaos' && failure()
@@ -308,17 +306,9 @@ jobs:
retention-days: 7
# ── Sidecar deployment ──────────────────────────────────────────────
- name: Install binary (sidecar)
if: matrix.type == 'sidecar'
run: |
chmod +x _bin/fips _bin/fipsctl _bin/fipstop
cp _bin/fips testing/sidecar/fips
cp _bin/fipsctl testing/sidecar/fipsctl
cp _bin/fipstop testing/sidecar/fipstop
- name: Run sidecar integration test
if: matrix.type == 'sidecar'
run: bash testing/sidecar/scripts/test-sidecar.sh
run: bash testing/sidecar/scripts/test-sidecar.sh --skip-build
- name: Collect logs on failure (sidecar)
if: matrix.type == 'sidecar' && failure()
@@ -327,4 +317,4 @@ jobs:
echo "--- sidecar-${node} logs ---"
docker logs "sidecar-${node}-fips-1" 2>&1 || true
echo ""
done
done

196
.github/workflows/package-linux.yml vendored Normal file
View File

@@ -0,0 +1,196 @@
name: Linux Package
on:
push:
tags:
- "v*"
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
determine-versioning:
runs-on: ubuntu-latest
outputs:
linux_package_version: ${{ steps.linux_version.outputs.linux_package_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Derive Linux package version
id: linux_version
shell: bash
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
BASE_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
BRANCH=$(echo "$GITHUB_REF_NAME" | sed 's|[^A-Za-z0-9]|.|g; s/\.\.+/./g; s/^\.//; s/\.$//')
HEIGHT=$(git rev-list --count HEAD)
HASH=$(git rev-parse --short HEAD)
if [[ -z "$BRANCH" ]]; then
BRANCH="ref"
fi
VERSION="${BASE_VERSION}+${BRANCH}.${HEIGHT}.${HASH}"
fi
echo "linux_package_version=${VERSION}" >> "$GITHUB_OUTPUT"
build:
name: Build Linux artifacts (${{ matrix.artifact_arch }})
runs-on: ${{ matrix.os }}
needs: determine-versioning
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
artifact_arch: x86_64
deb_arch: amd64
- os: ubuntu-24.04-arm
artifact_arch: aarch64
deb_arch: arm64
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libdbus-1-dev llvm
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry + build
if: ${{ env.ACT != 'true' }}
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-
- name: Install cargo-deb
run: cargo install cargo-deb --version 3.6.3 --locked
- name: Build release binaries
run: cargo build --release
- name: Build systemd tarball
env:
STRIP: llvm-strip
run: |
packaging/systemd/build-tarball.sh \
--version "${{ needs.determine-versioning.outputs.linux_package_version }}" \
--arch "${{ matrix.artifact_arch }}" \
--no-build
- name: Build Debian package
run: |
packaging/debian/build-deb.sh \
--version "${{ needs.determine-versioning.outputs.linux_package_version }}" \
--no-build
- name: Resolve Linux asset paths
id: linux-assets
shell: bash
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
TARBALL="deploy/fips-${{ needs.determine-versioning.outputs.linux_package_version }}-linux-${{ matrix.artifact_arch }}.tar.gz"
if [[ ! -f "$TARBALL" ]]; then
echo "Missing tarball: $TARBALL" >&2
exit 1
fi
DEB_FILE=$(find deploy -maxdepth 1 -type f -name "fips_*_${{ matrix.deb_arch }}.deb" | sort | head -n 1)
if [[ -z "$DEB_FILE" ]]; then
echo "Missing Debian package for ${{ matrix.deb_arch }}" >&2
exit 1
fi
echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
echo "deb=$DEB_FILE" >> "$GITHUB_OUTPUT"
- name: SHA-256 hashes
run: |
echo "==> Linux release assets:"
sha256sum \
"${{ steps.linux-assets.outputs.tarball }}" \
"${{ steps.linux-assets.outputs.deb }}"
- name: Upload artifact (GitHub only)
if: ${{ env.ACT != 'true' }}
uses: actions/upload-artifact@v4
with:
name: fips_${{ needs.determine-versioning.outputs.linux_package_version }}_${{ matrix.artifact_arch }}_linux
path: |
${{ steps.linux-assets.outputs.tarball }}
${{ steps.linux-assets.outputs.deb }}
retention-days: 30
- name: Build Summary
run: |
echo "Build Summary for linux/${{ matrix.artifact_arch }}:"
echo " Tarball: ${{ steps.linux-assets.outputs.tarball }}"
echo " Debian: ${{ steps.linux-assets.outputs.deb }}"
release:
name: Publish Linux assets to GitHub Release
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Generate Linux release checksums
run: |
cd dist
find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.tar.gz' \) -printf '%P\n' \
| LC_ALL=C sort \
| xargs sha256sum \
> checksums-linux.txt
- name: Wait for tag release
env:
GH_TOKEN: ${{ github.token }}
run: |
for attempt in $(seq 1 20); do
if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
exit 0
fi
echo "Release ${GITHUB_REF_NAME} not available yet; waiting..."
sleep 15
done
echo "Timed out waiting for release ${GITHUB_REF_NAME}" >&2
exit 1
- name: Upload Linux assets
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" \
dist/*.deb \
dist/*.tar.gz \
dist/checksums-linux.txt \
--clobber \
--repo "${GITHUB_REPOSITORY}"

View File

@@ -1,5 +1,4 @@
name: OpenWrt Package
on:
push:
workflow_dispatch:
@@ -11,11 +10,57 @@ on:
env:
CARGO_TERM_COLOR: always
PACKAGE_NAME: "fips"
jobs:
determine-versioning:
runs-on: ubuntu-latest
outputs:
package_version: ${{ steps.version.outputs.package_version }}
release_channel: ${{ steps.channel.outputs.release_channel }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Derive package version
id: version
shell: bash
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
echo "package_version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
else
BRANCH=$(echo "$GITHUB_REF_NAME" | sed 's|/|-|g')
HEIGHT=$(git rev-list --count HEAD)
HASH=$(git rev-parse --short HEAD)
echo "package_version=${BRANCH}.${HEIGHT}.${HASH}" >> "$GITHUB_OUTPUT"
fi
- name: Determine release channel
id: channel
shell: bash
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
if [[ $TAG_NAME =~ ^v[0-9]+\.[0-9]+\.[0-9]+-alpha ]]; then
echo "release_channel=alpha" >> "$GITHUB_OUTPUT"
elif [[ $TAG_NAME =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta ]]; then
echo "release_channel=beta" >> "$GITHUB_OUTPUT"
elif [[ $TAG_NAME =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "release_channel=stable" >> "$GITHUB_OUTPUT"
else
echo "release_channel=dev" >> "$GITHUB_OUTPUT"
fi
else
echo "release_channel=dev" >> "$GITHUB_OUTPUT"
fi
build:
name: Build .ipk (${{ matrix.openwrt_arch }})
runs-on: ubuntu-latest
needs: determine-versioning
strategy:
fail-fast: false
@@ -46,19 +91,13 @@ jobs:
with:
fetch-depth: 0
- name: Derive package version
id: version
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Initialize
run: |
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
BRANCH=$(echo "$GITHUB_REF_NAME" | sed 's|/|-|g')
HEIGHT=$(git rev-list --count HEAD)
HASH=$(git rev-parse --short HEAD)
VERSION="${BRANCH}.${HEIGHT}.${HASH}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "filename=fips_${VERSION}_${{ matrix.openwrt_arch }}.ipk" >> "$GITHUB_OUTPUT"
PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.ipk
echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV
- name: Install Rust toolchain (stable)
if: matrix.rust_channel == 'stable'
@@ -73,6 +112,7 @@ jobs:
components: rust-src
- name: Cache Cargo registry + build
if: ${{ env.ACT != 'true' }}
uses: actions/cache@v4
with:
path: |
@@ -84,29 +124,200 @@ jobs:
openwrt-${{ matrix.rust_target }}-
- name: Install cargo-zigbuild
run: cargo install cargo-zigbuild --locked
run: cargo install cargo-zigbuild --version 0.19.8 --locked
- name: Install zig (required by cargo-zigbuild)
uses: goto-bus-stop/setup-zig@v2
run: |
ZIG_VERSION="0.13.0"
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) ZIG_ARCH="x86_64" ;;
aarch64|arm64) ZIG_ARCH="aarch64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" | sudo tar xJ -C /opt
sudo ln -sf /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
zig version
- name: Install llvm-strip
run: sudo apt-get install -y --no-install-recommends llvm
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends llvm
- name: Install nak
shell: bash
run: |
NAK_VERSION="0.16.2"
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) NAK_ARCH="amd64" ;;
aarch64|arm64) NAK_ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
curl -fsSL "https://github.com/fiatjaf/nak/releases/download/v${NAK_VERSION}/nak-v${NAK_VERSION}-linux-${NAK_ARCH}" \
-o /usr/local/bin/nak
chmod +x /usr/local/bin/nak
nak --version
- name: Install jq
run: |
if ! command -v jq &>/dev/null; then
sudo apt-get update && sudo apt-get install -y jq
fi
# Priority: HIVE_CI_NSEC from env (loom job) > repo secret > generate ephemeral
- name: Resolve signing key
id: keys
shell: bash
env:
SECRET_NSEC: ${{ secrets.HIVE_CI_NSEC }}
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
if [ -n "${HIVE_CI_NSEC:-}" ]; then
echo "Using HIVE_CI_NSEC from loom job environment"
NSEC="$HIVE_CI_NSEC"
elif [ -n "$SECRET_NSEC" ]; then
echo "Using HIVE_CI_NSEC from repository secrets"
NSEC="$SECRET_NSEC"
else
echo "No nsec provided -- generating ephemeral keypair"
NSEC=$(nak key generate)
fi
PUBKEY=$(echo "$NSEC" | nak key public)
echo "::add-mask::$NSEC"
echo "nsec=$NSEC" >> "$GITHUB_OUTPUT"
echo "pubkey=$PUBKEY" >> "$GITHUB_OUTPUT"
echo "Publisher pubkey (hex): $PUBKEY"
- name: Build .ipk
env:
PKG_VERSION: ${{ steps.version.outputs.version }}
PKG_VERSION: ${{ needs.determine-versioning.outputs.package_version }}
LLVM_STRIP: llvm-strip
run: ./packaging/openwrt/build-ipk.sh --arch ${{ matrix.build_arch }}
run: ./packaging/openwrt-ipk/build-ipk.sh --arch ${{ matrix.build_arch }}
- name: Upload artifact
- name: SHA-256 hashes
run: |
echo "==> Binaries:"
sha256sum target/${{ matrix.rust_target }}/release/fips target/${{ matrix.rust_target }}/release/fipsctl target/${{ matrix.rust_target }}/release/fipstop
echo "==> Package:"
sha256sum dist/${{ env.PACKAGE_FILENAME }}
- name: Upload artifact (GitHub only)
if: ${{ env.ACT != 'true' }}
uses: actions/upload-artifact@v4
with:
name: ${{ steps.version.outputs.filename }}
path: dist/${{ steps.version.outputs.filename }}
name: ${{ env.PACKAGE_FILENAME }}
path: dist/${{ env.PACKAGE_FILENAME }}
retention-days: 30
- name: Upload to Blossom
id: blossom_upload
shell: bash
env:
BLOSSOM_SERVER: "https://blossom.primal.net"
NSEC: ${{ steps.keys.outputs.nsec }}
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
UPLOAD_RESPONSE=$(nak blossom upload \
--server "$BLOSSOM_SERVER" \
--sec "$NSEC" \
"dist/${{ env.PACKAGE_FILENAME }}" < /dev/null)
echo "Upload response:"
echo "$UPLOAD_RESPONSE"
FILE_HASH=$(echo "$UPLOAD_RESPONSE" | jq -r '.sha256')
if [ -z "$FILE_HASH" ] || [ "$FILE_HASH" = "null" ]; then
echo "Failed to extract hash from upload response"
exit 1
fi
BLOSSOM_URL="${BLOSSOM_SERVER}/${FILE_HASH}"
echo "url=$BLOSSOM_URL" >> "$GITHUB_OUTPUT"
echo "hash=$FILE_HASH" >> "$GITHUB_OUTPUT"
echo "Uploaded to Blossom: $BLOSSOM_URL"
- name: Publish NIP-94 release event
id: publish
shell: bash
env:
RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay.primal.net"
NSEC: ${{ steps.keys.outputs.nsec }}
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
set -e
VERSION="${{ needs.determine-versioning.outputs.package_version }}"
CHANNEL="${{ needs.determine-versioning.outputs.release_channel }}"
nak event --sec "$NSEC" -k 1063 \
-c "FIPS Package: ${{ env.PACKAGE_NAME }} for ${{ matrix.openwrt_arch }}" \
--tag url="${{ steps.blossom_upload.outputs.url }}" \
--tag m="application/octet-stream" \
--tag x="${{ steps.blossom_upload.outputs.hash }}" \
--tag ox="${{ steps.blossom_upload.outputs.hash }}" \
--tag filename="${{ env.PACKAGE_FILENAME }}" \
--tag A="${{ matrix.openwrt_arch }}" \
--tag v="$VERSION" \
--tag n="${{ env.PACKAGE_NAME }}" \
--tag compression="none" \
> event.json 2> event.err
if [ ! -s event.json ]; then
echo "Failed to create event"
cat event.err 2>/dev/null || true
exit 1
fi
echo "=== Event JSON ==="
cat event.json
echo "=================="
EVENT_ID=$(jq -r '.id' event.json)
if [ -z "$EVENT_ID" ] || [ "$EVENT_ID" = "null" ]; then
echo "Failed to extract event ID"
exit 1
fi
# Publish to relays
cat event.json | nak event $RELAYS 2>&1
echo "eventId=$EVENT_ID" >> "$GITHUB_OUTPUT"
echo "Published NIP-94 event: $EVENT_ID"
- name: Verify NIP-94 event on relays
if: ${{ steps.publish.outputs.eventId != '' }}
env:
EVENT_ID: ${{ steps.publish.outputs.eventId }}
RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay.primal.net"
run: |
echo "Verifying event $EVENT_ID on relays..."
FOUND=0
for relay in $RELAYS; do
echo "Checking $relay..."
RESULT=$(nak req -i "$EVENT_ID" "$relay" 2>/dev/null || echo "")
if [ -n "$RESULT" ]; then
echo "Found on $relay"
FOUND=1
else
echo "Not found on $relay"
fi
done
if [ $FOUND -eq 0 ]; then
echo "Warning: Event not found on any relay yet (may still be propagating)"
else
echo "Event verified on at least one relay"
fi
- name: Build Summary
run: |
echo "Build Summary for ${{ matrix.openwrt_arch }}:"
echo " Package: ${{ env.PACKAGE_FILENAME }}"
echo " Release EventId: ${{ steps.publish.outputs.eventId }}"
echo " Blossom URL: ${{ steps.blossom_upload.outputs.url }}"
release:
name: Publish GitHub Release
name: Publish GitHub Release (github only)
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/')

10
.gitignore vendored
View File

@@ -20,4 +20,12 @@ vps.env
reference/
dist/
*.ipk
*.ipk
sim-results/
# Python
__pycache__/
*.py[cod]
*.egg-info/
*.egg

View File

@@ -5,7 +5,153 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.1] - 2026-05-11
### Added
- Linux release artifact workflow: builds x86_64 and aarch64 tarballs
and `.deb` packages on `v*` tag push, with SHA-256 checksums
- AUR publish workflow for tagged stable releases
### Changed
- Validate bloom filter fill ratio on FilterAnnounce ingress.
Inbound FilterAnnounce messages whose derived false-positive
rate exceeds `node.bloom.max_inbound_fpr` (new config field,
default 0.05) are rejected silently on the wire, logged at WARN,
and counted in a new `bloom.fill_exceeded` counter. A
rate-limited WARN also fires if our own outgoing filter's FPR
exceeds the cap. `BloomFilter::estimated_count` now takes
`max_fpr` and returns `Option<f64>`, returning `None` for
saturated filters; this propagates through `compute_mesh_size`
into `estimated_mesh_size` (already `Option<u64>`)
### Fixed
- Control socket path detection in fipsctl and fipstop now checks for
the `/run/fips/` directory instead of the socket file inside it, so
users not yet in the `fips` group get a clear "Permission denied"
error instead of a misleading "No such file" fallback to
`$XDG_RUNTIME_DIR` ([#30](https://github.com/jmcorgan/fips/issues/30),
reported by [@Sebastix](https://github.com/Sebastix))
- OpenWrt ipk build excluded BLE feature that requires D-Bus, which is
unavailable on OpenWrt targets
- IPv6 routing policy rule added at TUN setup to protect `fd00::/8`
from interception by Tailscale's table 52 default route
- Bloom filter routing no longer swallows traffic when no bloom
candidate is strictly closer than the current node. `find_next_hop`
now falls through to greedy tree routing in that case instead of
returning `NoRoute`, which previously caused dropped packets in
topologies where the tree parent was closer but not a bloom
candidate
- Auto-connect peers now reconnect after a graceful `Disconnect`
notification from the remote side. `handle_disconnect` previously
removed the peer without scheduling a reconnect, orphaning the
entry on a clean upstream shutdown; the other removal paths
(link-dead, decrypt failure, peer restart) already scheduled
reconnect ([#60](https://github.com/jmcorgan/fips/issues/60),
reported by [@SwapMarket](https://github.com/SwapMarket))
- `fipsctl connect` now rejects FIPS mesh (`fd00::/8`) addresses for
`udp`, `tcp`, and `ethernet` transports with a clear error message
instead of echoing success while the daemon silently failed the
bind with `EAFNOSUPPORT`
([#61](https://github.com/jmcorgan/fips/issues/61),
reported by [@SwapMarket](https://github.com/SwapMarket))
- Tighten TreeAnnounce ancestry validation to match the spanning
tree specification. The receive path now verifies that the
ancestry is structurally consistent with the signed parent
declaration before mutating tree state.
- Make the tree ancestry acceptance unit test deterministic.
`test_tree_announce_validate_semantics_accepts_valid_non_root`
generated a random signing identity while pinning the fixed root
to `node_addr[0] = 0x01`; about 2 in 256 random identities were
numerically smaller than the claimed root, triggering
`AncestryRootNotMinimum`. The test now regenerates the identity
until its `node_addr` is strictly larger than both the fixed
parent and root.
## [0.2.0] - 2026-03-22
### Added
#### Operator Tooling
- `fipsctl connect` and `disconnect` commands for runtime peer
management via control socket, with hostname resolution from
`/etc/fips/hosts`
#### IPv6 Adapter
- Pre-seed identity cache from configured peer npubs at startup, so TUN packets can be dispatched immediately without waiting for handshake completion ([@v0l](https://github.com/v0l))
#### Mesh Peer Transports
- New Tor transport with SOCKS5 and directory-mode onion service for anonymous inbound and outbound peering
- DNS hostname support in peer addresses for UDP and TCP transports
- Non-blocking transport connect for connection-oriented transports (TCP, Tor)
#### Packaging and Deployment
- Reproducible build infrastructure: Rust toolchain pinning via
`rust-toolchain.toml`, `SOURCE_DATE_EPOCH` in CI and packaging
scripts, deterministic archive timestamps
- Top-level packaging Makefile for unified build across formats
- Kubernetes sidecar deployment example with Nostr relay demo
- Nostr release publishing in OpenWrt package workflow
- SHA-256 hash output in CI build and OpenWrt workflows
#### Testing and CI
- Maelstrom chaos scenario with dynamic topology mutation and
ephemeral node identities via connect/disconnect commands
- Consolidated Docker test harness infrastructure
### Changed
- Discovery protocol: replace flooding with bloom-filter-guided tree
routing. Includes originator retry (T=0/T=5s/T=10s), exponential
backoff after timeouts and bloom misses, and transit-side per-target
rate limiting. Removed 257-byte visited bloom filter from LookupRequest wire format. *This is a breaking change; nodes running versions prior to this release will not be compatible.*
### Fixed
- DNS responder returned NXDOMAIN for A queries on valid `.fips` names,
causing resolvers to give up without trying AAAA. Now returns NOERROR
with empty answers for non-AAAA queries on resolvable names.
(#9, reported by [@alopatindev](https://github.com/alopatindev))
- Stale end-to-end session left in session table after peer removal blocked session re-establishment on reconnect — `remove_active_peer` now cleans up `self.sessions` and `self.pending_tun_packets`. (#5, [@v0l](https://github.com/v0l))
- `schedule_reconnect` reset exponential backoff to zero on each link-dead
cycle instead of preserving accumulated retry count.
(#5, [@v0l](https://github.com/v0l))
- FMP/FSP rekey dual-initiation race on high-latency links (Tor): both
sides' timers fired simultaneously, both msg1s crossed in flight, each
side's responder path destroyed the initiator state. Fixed with
deterministic tie-breaker (smaller NodeAddr wins as initiator).
- Parent selection SRTT gate bypass: `evaluate_parent` used default cost
1.0 for peers filtered out by `has_srtt()`, defeating the MMP eligibility
gate. Now skips unmeasured candidates when any peer has cost data.
- FSP rekey cutover race: initiator cut over before responder received msg3,
causing AEAD failures. Fixed by deferring initiator cutover by 2 seconds.
- MMP metric discontinuity after rekey: receiver state carried stale
counters across rekey, inflating reorder counts and jitter. Fixed via
`reset_for_rekey()`.
- Auto-connect peers exhausted `max_retries` on initial connection failures
and were permanently abandoned. Now retry indefinitely with exponential
backoff capped at 300 seconds.
- Control socket permissions: non-root users couldn't connect. Daemon now
chowns socket and directory to `root:fips` group at bind time.
- Post-rekey jitter spikes: old-session frames arriving via the drain window
produced 2,0007,000ms jitter spikes that corrupted the EWMA estimator.
Added a 15-second grace period after rekey cutover that suppresses jitter
updates until drain-window frames have flushed. (#10)
- ICMPv6 Packet Too Big source was set to the local FIPS address, which
Linux ignores (loopback PTB check). Now uses the original packet's
destination so the kernel honors the PMTU update.
(#16, [@v0l](https://github.com/v0l))
- Reverse delivery ratio used lifetime cumulative counters instead of
per-interval deltas, making ETX unresponsive to recent loss. (#14)
- MMP delta guards used `prev_rr > 0` to detect first report, conflating
it with a legitimate zero counter. Replaced with `has_prev_rr`. (#14)
## [0.1.0] - 2026-03-12

15
Cargo.lock generated
View File

@@ -783,7 +783,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
[[package]]
name = "fips"
version = "0.1.0"
version = "0.2.1"
dependencies = [
"bech32",
"chacha20poly1305",
@@ -807,6 +807,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-socks",
"tracing",
"tracing-subscriber",
"tun",
@@ -2422,6 +2423,18 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "tokio-socks"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
dependencies = [
"either",
"futures-util",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"

View File

@@ -1,6 +1,6 @@
[package]
name = "fips"
version = "0.1.0"
version = "0.2.1"
edition = "2024"
description = "A distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports"
license = "MIT"
@@ -36,6 +36,7 @@ tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "t
futures = "0.3"
simple-dns = "0.11.2"
socket2 = { version = "0.6.2", features = ["all"] }
tokio-socks = "0.5"
[package.metadata.deb]
maintainer = "Johnathan Corgan <jcorgan@corganlabs.com>"

View File

@@ -1,16 +1,15 @@
# FIPS: Free Internetworking Peering System
![banner](docs/logos/fips_banner.png)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org/)
[![Status](https://img.shields.io/badge/status-alpha%20(0.1.0)-yellow.svg)](#status--roadmap)
[![Status](https://img.shields.io/badge/status-v0.2.1-green.svg)](#status--roadmap)
A distributed, decentralized network routing protocol for mesh nodes
connecting over arbitrary transports.
> **Status: Alpha (0.1.0)**
>
> FIPS is under active development. The protocol and APIs are not stable.
> Expect breaking changes. See [Status & Roadmap](#status--roadmap) below.
> FIPS is under active development. The protocol and APIs are not yet stable.
> See [Status & Roadmap](#status--roadmap) below.
## Overview
@@ -37,22 +36,22 @@ endpoints.
## Features
- **Self-organizing mesh routing** — spanning tree coordinates and bloom
filter candidate selection, no global routing tables
- **Multi-transport** — UDP, TCP, and Ethernet today; designed for
Bluetooth, serial, radio, and Tor
- **Noise encryption** — hop-by-hop link encryption plus independent
end-to-end session encryption, with periodic rekey for forward secrecy
- **Self-organizing mesh routing** — spanning tree coordinates with bloom
filter guided discovery, no global routing tables
- **Multi-transport** — UDP, TCP, Ethernet, and Tor today; designed for
Bluetooth, serial, and radio
- **Noise encryption** — hop-by-hop link encryption (IK) plus independent
end-to-end session encryption (XK), with periodic rekey for forward secrecy
- **Nostr-native identity** — secp256k1 keypairs as node addresses, no
registration or central authority
- **IPv6 adaptation** — TUN interface maps npubs to fd00::/8 addresses for
unmodified IP applications; static hostname mapping (`/etc/fips/hosts`)
- **Metrics Measurement Protocol** — per-link RTT, loss, jitter, and goodput
measurement
measurement with mesh size estimation
- **ECN congestion signaling** — hop-by-hop CE flag relay with RFC 3168 IPv6
marking, transport kernel drop detection
- **Operator visibility** — `fipsctl` CLI and `fipstop` TUI dashboard for
runtime inspection of peers, links, sessions, tree state, and metrics
runtime inspection and runtime peer management
- **Zero configuration** — sensible defaults; a node can start with no config
file, though peer addresses are needed to join a network
@@ -231,7 +230,7 @@ a layered protocol specification. Start with
## Project Structure
```
```text
src/ Rust source (library + fips/fipsctl/fipstop binaries)
packaging/ Debian, systemd tarball, and shared packaging files
docs/design/ Protocol design specifications
@@ -240,29 +239,31 @@ testing/ Docker-based integration test harnesses
## Status & Roadmap
FIPS is at **v0.1.0 (alpha)**. The core protocol works end-to-end over
UDP, TCP, and Ethernet but has not been tested beyond small meshes.
FIPS is at **v0.2.1**. The core protocol works end-to-end over UDP, TCP,
Ethernet, and Tor with a small live mesh of deployed nodes.
### What works today
- Spanning tree construction with greedy coordinate routing
- Bloom filter discovery for finding nodes without global state
- Bloom filter guided discovery (no flooding, single-path with retry)
- Noise IK (link layer) and Noise XK (session layer) encryption
- Periodic Noise rekey with forward secrecy (FMP + FSP)
- Periodic Noise rekey with hitless cutover for forward secrecy (FMP + FSP)
- Persistent node identity with key file management
- IPv6 TUN adapter with DNS resolution of `.fips` names
- Static hostname mapping (`/etc/fips/hosts`) with auto-reload
- Per-link metrics (RTT, loss, jitter, goodput) and mesh size estimation
- ECN congestion signaling (hop-by-hop CE relay, IPv6 CE marking, kernel drop detection)
- UDP, TCP, and Ethernet transports
- Runtime inspection via `fipsctl` and `fipstop`
- UDP, TCP, Ethernet, and Tor transports (SOCKS5 outbound + directory-mode onion service inbound)
- Runtime inspection and peer management via `fipsctl` and `fipstop`
- Reproducible builds with toolchain pinning and SOURCE_DATE_EPOCH
- Debian and systemd tarball packaging
- Docker-based integration and chaos testing
### Near-term priorities
- Peer discovery via Nostr relays (bootstrap without static peer lists)
- Additional transports (Bluetooth, Tor)
- Improved routing resilience under churn
- Native API for FIPS-aware applications (npub:port addressing)
- Additional transports (Bluetooth)
- Security audit of cryptographic protocols
### Longer-term

141
RELEASE-NOTES.md Normal file
View File

@@ -0,0 +1,141 @@
# FIPS v0.2.1
**Released**: 2026-05-11
v0.2.1 is a maintenance release on the v0.2.x line. No new features
and no wire-format changes; operators running v0.2.0 can upgrade in
place. The release rolls up bug fixes and operational hardening for
issues surfaced in v0.2.0 deployments, plus a bloom-filter fill-ratio
validation that protects mesh-size estimates from saturated-filter
inputs.
## At a glance
- 22 commits since v0.2.0, 5 committers plus 2 issue reporters.
- All changes are backwards-compatible with v0.2.0 on the wire.
- Bloom filter fill-ratio validation hardens the FilterAnnounce
ingress path.
- TreeAnnounce ancestry validation tightened to match the
spanning-tree specification.
- Signed-tarball + `.deb` artifact workflow added for tagged
releases; AUR auto-publish on stable tags.
## Behavior changes worth flagging
- **Bloom filter fill-ratio validation** runs on every inbound
`FilterAnnounce`. Filters whose derived false-positive rate exceeds
`node.bloom.max_inbound_fpr` (new config field, default `0.05`) are
rejected silently on the wire, logged at WARN, and counted in a
new `bloom.fill_exceeded` counter. A rate-limited WARN also fires
when the local outgoing filter exceeds the cap.
`BloomFilter::estimated_count` now takes `max_fpr` and returns
`Option<f64>`, returning `None` for saturated filters; this
propagates through `compute_mesh_size` into `estimated_mesh_size`.
- **TreeAnnounce ancestry validation** is now run before tree-state
mutation, enforcing ancestry-self-match, root-single-entry,
parent-second-entry, and root-is-minimum-NodeAddr. Non-conforming
announces are rejected with a WARN. Mixed v0.2.0 / v0.2.1 meshes
may produce WARN log lines on the v0.2.1 side until all peers
upgrade; behavior is correct, log noise only.
## Notable bug fixes
- **Control socket path detection** in `fipsctl` and `fipstop` now
checks for the `/run/fips/` directory instead of the socket file
inside it. Users not yet in the `fips` group get a clear
"Permission denied" error instead of a misleading "No such file"
fallback to `$XDG_RUNTIME_DIR`
([#30](https://github.com/jmcorgan/fips/issues/30), reported by
[@Sebastix](https://github.com/Sebastix)).
- **`fd00::/8` routing protected from Tailscale interception.** The
daemon installs an IPv6 routing-policy rule
(`ip -6 rule to fd00::/8 lookup main priority 5265`) at TUN setup,
so Tailscale's table 52 default route can no longer divert mesh
traffic.
- **Bloom filter routing greedy-tree fallback.** `find_next_hop` no
longer returns `NoRoute` when the bloom candidate set is non-empty
but no candidate is strictly closer than the current node; it
falls through to greedy tree routing instead. Previously, this
caused dropped packets in topologies where the tree parent was
closer but not a bloom candidate.
- **Auto-connect peers reconnect after a graceful Disconnect.**
Previously, a clean upstream shutdown left the auto-connect peer
orphaned; only the link-dead, decrypt-fail, and peer-restart paths
scheduled a reconnect
([#60](https://github.com/jmcorgan/fips/issues/60), reported by
[@SwapMarket](https://github.com/SwapMarket)).
- **`fipsctl connect` rejects FIPS mesh addresses** (`fd00::/8`) for
`udp`, `tcp`, and `ethernet` transports with a clear error message
instead of echoing success while the daemon silently failed the
bind with `EAFNOSUPPORT`
([#61](https://github.com/jmcorgan/fips/issues/61), reported by
[@SwapMarket](https://github.com/SwapMarket)).
- **OpenWrt ipk** cross-compiles cleanly again after excluding the
BLE feature that requires D-Bus, which is unavailable on OpenWrt
targets.
## Packaging
- **Linux release artifact workflow** builds x86_64 and aarch64
tarballs and `.deb` packages on `v*` tag push, with SHA-256
checksums, and publishes them to the GitHub release page.
- **AUR publish workflow** auto-publishes the `fips` PKGBUILD on
stable `v*` tags.
## Upgrade notes
Operator-actionable items when moving from v0.2.0 to v0.2.1:
- **Bloom filter fill-ratio cap (default 0.05).** Inbound
`FilterAnnounce` messages whose derived FPR exceeds the cap are
rejected silently on the wire. Operators with unusually saturated
filters in the field may want to confirm that the default applies
cleanly to their deployment; check the new `bloom.fill_exceeded`
counter if mesh-size estimates drift after upgrade.
- **TreeAnnounce ancestry tightening.** Mixed v0.2.0 / v0.2.1 meshes
may produce WARN log lines on the v0.2.1 side until all peers
upgrade. Behavior is correct, log noise only.
## Getting v0.2.1
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
[v0.2.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.2.1).
- **Arch Linux**: `fips` from the AUR.
- **OpenWrt**: `.ipk` at the v0.2.1 release page.
- **From source**: `cargo build --release` from a checkout of the
v0.2.1 tag.
The full per-commit changelog lives in
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
## Contributors
Thanks to everyone who contributed code or bug reports to this
release.
**Code and packaging**:
- [@jcorgan](https://github.com/jmcorgan): release shepherd, bloom
fill-ratio validation, auto-connect reconnect fix, `fipsctl`
mesh-address rejection, control-socket path detection,
Tailscale-vs-`fd00::/8` routing policy, bloom routing greedy
fallback, rustfmt baseline.
- [@Origami74](https://github.com/Origami74): OpenWrt ipk
BLE-feature build fix.
- [@jodobear](https://github.com/jodobear): Linux release-artifact
workflow and target-aware build scripts.
- [@dskvr](https://github.com/dskvr): AUR publish workflow.
- [@SatsAndSports](https://github.com/SatsAndSports): TreeAnnounce
semantic validation.
**Issue reports that drove fixes in this release**:
- [@Sebastix](https://github.com/Sebastix): `fipsctl` / `fipstop`
control-socket path detection
([#30](https://github.com/jmcorgan/fips/issues/30)).
- [@SwapMarket](https://github.com/SwapMarket): auto-connect
reconnect after graceful disconnect
([#60](https://github.com/jmcorgan/fips/issues/60)) and
`fipsctl` mesh-address rejection
([#61](https://github.com/jmcorgan/fips/issues/61)).

View File

@@ -1,9 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 478" font-family="monospace" font-size="13">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 430" font-family="monospace" font-size="13">
<!-- Background -->
<rect width="580" height="478" fill="#1a1a2e" rx="4"/>
<rect width="580" height="430" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">LookupRequest (0x30) — 303 + 16n bytes</text>
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">LookupRequest (0x30) — 46 + 16n bytes</text>
<!-- Row 0 (03): msg_type + request_id starts -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">03</text>
@@ -58,15 +58,6 @@
<rect x="55" y="348" width="520" height="40" fill="#1f1f3a" stroke="#4ad9d9" stroke-width="1" stroke-dasharray="4,3" rx="3"/>
<text x="315" y="372" fill="#8af8f8" text-anchor="middle" font-size="11">origin_coords × n (16 bytes each — NodeAddr only)</text>
<!-- Row 7: visited bloom -->
<text x="50" y="414" fill="#666" font-size="10" text-anchor="end">+1+256</text>
<rect x="55" y="394" width="130" height="40" fill="#3d3d2d" stroke="#a0a04a" stroke-width="1.5" rx="3"/>
<text x="120" y="418" fill="#d8d88a" text-anchor="middle" font-size="10">hash_cnt</text>
<rect x="185" y="394" width="390" height="40" fill="#1f1f3a" stroke="#a0a04a" stroke-width="1" stroke-dasharray="4,3" rx="3"/>
<text x="380" y="418" fill="#d8d88a" text-anchor="middle" font-size="11">visited_bits (256 bytes)</text>
<!-- Total -->
<text x="310" y="462" fill="#777" font-size="10" text-anchor="middle">total: 303 + (n × 16) bytes, where n = origin depth + 1</text>
<text x="310" y="414" fill="#777" font-size="10" text-anchor="middle">total: 46 + (n × 16) bytes, where n = origin depth + 1</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -56,9 +56,11 @@ peers: # Static peer list
| `node.control.enabled` | bool | `true` | Enable the Unix domain control socket |
| `node.control.socket_path` | string | *(auto)* | Socket file path. Default: `$XDG_RUNTIME_DIR/fips/control.sock`, then `/run/fips/control.sock` (if root), then `/tmp/fips-control.sock` |
The control socket provides read-only access to node state via the
`fipsctl` command-line tool. See the project
[README](../../README.md#inspect) for the command list.
The control socket provides access to node state and runtime management
via the `fipsctl` command-line tool. In addition to read-only status
queries, `fipsctl connect` and `fipsctl disconnect` enable runtime peer
management. See the project [README](../../README.md#inspect) for the
command list.
All tunable protocol parameters live under `node.*`, organized as sysctl-style
dotted paths. The top-level sections (`tun`, `dns`, `transports`, `peers`)
@@ -144,13 +146,18 @@ Controls caching of tree coordinates and identity mappings.
### Discovery Protocol (`node.discovery.*`)
Controls flood-based node discovery (LookupRequest/LookupResponse).
Controls bloom-guided node discovery (LookupRequest/LookupResponse).
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `node.discovery.ttl` | u8 | `64` | Hop limit for LookupRequest flood |
| `node.discovery.ttl` | u8 | `64` | Hop limit for LookupRequest forwarding |
| `node.discovery.timeout_secs` | u64 | `10` | Lookup completion timeout |
| `node.discovery.recent_expiry_secs` | u64 | `10` | Dedup cache expiry for recent request IDs |
| `node.discovery.retry_interval_secs` | u64 | `5` | Retry interval within the timeout window; after this interval without a response, resend the lookup |
| `node.discovery.max_attempts` | u8 | `2` | Max attempts per lookup (1 = no retry, 2 = one retry) |
| `node.discovery.backoff_base_secs` | u64 | `30` | Base for exponential backoff after lookup failure; doubles per consecutive failure |
| `node.discovery.backoff_max_secs` | u64 | `300` | Cap on exponential backoff (5 minutes) |
| `node.discovery.forward_min_interval_secs` | u64 | `2` | Transit-side rate limiting: minimum interval between forwarded lookups for the same target |
### Spanning Tree (`node.tree.*`)
@@ -359,7 +366,6 @@ overhead.
| `transports.tcp.recv_buf_size` | usize | `2097152` | Socket receive buffer size in bytes (2 MB) |
| `transports.tcp.send_buf_size` | usize | `2097152` | Socket send buffer size in bytes (2 MB) |
| `transports.tcp.max_inbound_connections` | usize | `256` | Maximum simultaneous inbound connections |
| `transports.tcp.socks5_proxy` | string | *(none)* | SOCKS5 proxy for outbound connections (implementation deferred) |
**Named instances.** Like other transports, multiple TCP instances can
be configured with named sub-keys:
@@ -369,9 +375,114 @@ transports:
tcp:
public:
bind_addr: "0.0.0.0:443"
tor:
socks5_proxy: "127.0.0.1:9050"
connect_timeout_ms: 30000
internal:
bind_addr: "10.0.0.1:8443"
max_inbound_connections: 64
```
### Tor (`transports.tor.*`)
Tor transport routes FIPS traffic through the Tor network for anonymity.
Requires an external Tor daemon providing a SOCKS5 proxy. Three modes:
`socks5` for outbound-only, `control_port` for outbound + monitoring,
`directory` for outbound + inbound via Tor-managed onion service.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `transports.tor.mode` | string | `"socks5"` | Tor access mode: `socks5` (outbound only), `control_port` (outbound + monitoring), or `directory` (outbound + inbound onion service) |
| `transports.tor.socks5_addr` | string | `"127.0.0.1:9050"` | SOCKS5 proxy address (host:port) |
| `transports.tor.connect_timeout_ms` | u64 | `120000` | Connect timeout in milliseconds. Tor circuits take 1060s. |
| `transports.tor.mtu` | u16 | `1400` | Default MTU |
| `transports.tor.control_addr` | string | `"/run/tor/control"` | Tor control port address: Unix socket path or host:port. Used in `control_port` mode; optional in `directory` mode for monitoring. |
| `transports.tor.control_auth` | string | `"cookie"` | Control port authentication: `"cookie"`, `"cookie:/path/to/cookie"`, or `"password:<secret>"`. |
| `transports.tor.cookie_path` | string | `"/var/run/tor/control.authcookie"` | Path to Tor control cookie file. Used when `control_auth` is `"cookie"`. |
| `transports.tor.max_inbound_connections` | usize | `64` | Maximum inbound connections via onion service. |
| `transports.tor.directory_service.hostname_file` | string | `"/var/lib/tor/fips_onion_service/hostname"` | Path to Tor-managed hostname file containing the `.onion` address. |
| `transports.tor.directory_service.bind_addr` | string | `"127.0.0.1:8443"` | Local bind address for the listener that Tor forwards inbound connections to. Must match `HiddenServicePort` target in `torrc`. |
**Named instances.** Like other transports, multiple Tor instances can
be configured with named sub-keys for different SOCKS5 proxy endpoints.
**Directory mode** (recommended for production). Tor manages the onion
service via `HiddenServiceDir` in `torrc`. FIPS reads the `.onion`
address from the hostname file and binds a local TCP listener. This
enables Tor's `Sandbox 1` (seccomp-bpf). If `control_addr` is also
set, the transport connects to the control port for daemon monitoring
(non-fatal on failure).
**Control port mode.** Connects to the Tor daemon's control port for
monitoring only (bootstrap status, circuit health, traffic stats).
No inbound connections. Both `control_addr` and `control_auth` are
required.
### UDP + Tor Bridge Example
A node bridging clearnet (UDP) and anonymous (Tor) portions of the mesh:
```yaml
node:
identity:
persistent: true
tun:
enabled: true
transports:
udp:
bind_addr: "0.0.0.0:2121"
mtu: 1472
tor:
socks5_addr: "127.0.0.1:9050"
peers:
- npub: "npub1abc..."
alias: "clearnet-peer"
addresses:
- transport: udp
addr: "203.0.113.5:2121"
- npub: "npub1def..."
alias: "anonymous-peer"
addresses:
- transport: tor
addr: "abc123...xyz.onion:2121"
```
### Tor Directory Mode Example
A node accepting inbound connections via Tor-managed onion service
(recommended for production — enables Sandbox 1):
```yaml
node:
identity:
persistent: true
tun:
enabled: true
transports:
tor:
mode: "directory"
socks5_addr: "127.0.0.1:9050"
control_addr: "/run/tor/control" # optional, for monitoring
control_auth: "cookie"
directory_service:
hostname_file: "/var/lib/tor/fips/hostname"
bind_addr: "127.0.0.1:8444"
peers:
- npub: "npub1abc..."
alias: "tor-peer"
addresses:
- transport: tor
addr: "abcdef...xyz.onion:8443"
```
Requires a corresponding `torrc`:
```text
HiddenServiceDir /var/lib/tor/fips
HiddenServicePort 8443 127.0.0.1:8444
```
## Peers (`peers[]`)
@@ -382,8 +493,8 @@ Static peer list. Each entry defines a peer to connect to.
|-----------|------|---------|-------------|
| `peers[].npub` | string | *(required)* | Peer's Nostr public key (npub-encoded) |
| `peers[].alias` | string | *(none)* | Human-readable name for logging |
| `peers[].addresses[].transport` | string | *(required)* | Transport type: `udp`, `tcp`, or `ethernet` |
| `peers[].addresses[].addr` | string | *(required)* | Transport address. UDP/TCP: `"ip:port"`. Ethernet: `"interface/mac"` (e.g., `"eth0/aa:bb:cc:dd:ee:ff"`) |
| `peers[].addresses[].transport` | string | *(required)* | Transport type: `udp`, `tcp`, `ethernet`, or `tor` |
| `peers[].addresses[].addr` | string | *(required)* | Transport address. UDP/TCP: `"host:port"` (IP or DNS hostname). Ethernet: `"interface/mac"` (e.g., `"eth0/aa:bb:cc:dd:ee:ff"`). Tor: `".onion:port"` or `"host:port"` |
| `peers[].addresses[].priority` | u8 | `100` | Address priority (lower = preferred) |
| `peers[].connect_policy` | string | `"auto_connect"` | Connection policy: `auto_connect`, `on_demand`, or `manual` |
| `peers[].auto_reconnect` | bool | `true` | Automatically reconnect after MMP link-dead removal (exponential backoff, unlimited retries) |
@@ -512,6 +623,11 @@ node:
ttl: 64
timeout_secs: 10
recent_expiry_secs: 10
retry_interval_secs: 5
max_attempts: 2
backoff_base_secs: 30
backoff_max_secs: 300
forward_min_interval_secs: 2
tree:
announce_min_interval_ms: 500
parent_hysteresis: 0.2 # cost improvement fraction for parent switch
@@ -590,7 +706,20 @@ transports:
# recv_buf_size: 2097152 # 2 MB
# send_buf_size: 2097152 # 2 MB
# max_inbound_connections: 256 # resource protection limit
# socks5_proxy: null # SOCKS5 for outbound (deferred)
# tor: # uncomment to enable Tor transport
# mode: "socks5" # "socks5", "control_port", or "directory"
# socks5_addr: "127.0.0.1:9050" # SOCKS5 proxy address
# connect_timeout_ms: 120000 # connect timeout (120s for Tor circuits)
# mtu: 1400 # default MTU
# # monitoring (control_port mode, or optional in directory mode):
# # control_addr: "/run/tor/control" # Unix socket or host:port
# # control_auth: "cookie" # "cookie" or "password:<secret>"
# # cookie_path: "/var/run/tor/control.authcookie"
# # directory mode (inbound via Tor-managed onion service):
# # directory_service:
# # hostname_file: "/var/lib/tor/fips/hostname"
# # bind_addr: "127.0.0.1:8444"
# # max_inbound_connections: 64
peers: # static peer list
# - npub: "npub1..."

View File

@@ -216,7 +216,7 @@ address prepends `fd` to the first 15 bytes of the node_addr, providing a
ULA overlay address for unmodified IP applications via the TUN interface.
Below the FIPS identity layer, each transport uses its own native addressing
— IP:port tuples, MAC addresses, .onion identifiers. These **link
— IP:port or hostname:port addresses, MAC addresses, .onion identifiers. These **link
addresses** are opaque to everything above FMP and discarded once link
authentication completes.
@@ -524,9 +524,9 @@ forwarding, a publicly addressed peer, or relay through other mesh nodes.
UDP hole punching and relay-assisted NAT traversal are potential future
mechanisms but are not part of the current design.
> **Implementation status**: UDP/IP is implemented. Ethernet and Bluetooth
> transports are under active design and development. All others are future
> directions.
> **Implementation status**: UDP/IP, TCP/IP, Ethernet, and Tor
> (SOCKS5 outbound + directory-mode inbound via onion service)
> transports are implemented. All others are future directions.
See [fips-transport-layer.md](fips-transport-layer.md) for the full transport
layer specification.

View File

@@ -15,7 +15,7 @@ updates, coordinate discovery, and forwarded session datagrams — all encrypted
per-hop.
FMP is the boundary between opaque transport addresses and identified peers.
Below FMP, everything is transport-specific addresses (IP:port, MAC, .onion).
Below FMP, everything is transport-specific addresses (host:port, MAC, .onion).
Above FMP, everything is peers identified by public keys and routable by
node_addr. The transport layer never sees FIPS-level structure; FSP never sees
transport addresses or routing details.
@@ -280,7 +280,7 @@ authenticated peer regardless of what transport address the packet arrived
from. FMP updates the peer's current address to the packet's source address,
and subsequent outbound packets use the updated address.
This allows peers to change transport addresses (e.g., IP:port for UDP)
This allows peers to change transport addresses (e.g., host:port for UDP)
without session interruption. The mechanism is:
1. Packet arrives from a different address than expected

View File

@@ -330,7 +330,9 @@ fresh SessionSetup re-warms transit caches (still within their 300s TTL).
## Discovery Protocol
Discovery resolves a destination's tree coordinates so that multi-hop routing
can proceed.
can proceed. Requests are forwarded using **bloom-guided tree routing**
only to tree peers (parent + children) whose bloom filter contains the
target — producing single-path forwarding through the spanning tree.
### When Discovery Is Needed
@@ -347,17 +349,70 @@ The source creates a LookupRequest containing:
- **origin**: The requester's node_addr
- **origin_coords**: The requester's current tree coordinates (so the
response can route back)
- **TTL**: Bounds the flood radius
- **TTL**: Bounds the forwarding radius
The request floods through the mesh: each node decrements TTL, adds itself
to a visited filter (preventing loops on a single path), and forwards to all
peers not in the visited filter. Bloom filters may help direct the flood
toward likely candidates.
### Bloom-Guided Tree Routing
**Deduplication**: Nodes maintain a short-lived request_id dedup cache
(default 10s window) to drop convergent duplicates (the same request
arriving via different paths). This is a protocol requirement, not an
optimization.
Rather than flooding to all peers, the request is forwarded only to **tree
peers** (parent + children) whose bloom filter contains the target. Because
bloom filters propagate along tree edges with split-horizon exclusion,
typically only one tree peer matches — producing a single directed path
through the spanning tree toward the target's subtree. This reduces
discovery traffic by roughly 90% compared to flooding.
If no tree peer's bloom filter matches the target, the request falls back
to **non-tree peers** whose bloom filter contains the target. This recovers
from dead ends caused by stale bloom filters, tree restructuring, or transit
node failures. If no peer at all has a bloom match, the request is dropped
at that node.
**Loop prevention**: The spanning tree is inherently loop-free, so tree-only
forwarding cannot loop. The `request_id` dedup cache (default 10s window)
provides defense-in-depth, catching edge cases during tree restructuring
where a request might arrive via both tree and fallback paths.
### Retry Logic
Single-path forwarding is more fragile than flooding — if any transit node
on the path has a stale bloom filter or loses a link, the request fails.
To compensate, the originator retries:
- **T=0**: Initial lookup sent
- **T=5s**: Retry if no response (configurable via `retry_interval_secs`)
- **T=10s**: Timeout, fail (configurable via `timeout_secs`)
The default `max_attempts` is 2 (initial + one retry). Each retry generates
a fresh `request_id` and re-evaluates bloom filter matches, so it can take
a different path if the tree has restructured.
### Originator Backoff
After a lookup times out or no peer's bloom filter contains the target, the
originator enters **exponential backoff** before re-attempting discovery for
the same target:
- **Base delay**: 30s (configurable via `backoff_base_secs`)
- **Multiplier**: 2x per consecutive failure
- **Cap**: 300s (configurable via `backoff_max_secs`)
Backoff is **reset on topology changes** that might make previously
unreachable targets reachable: parent switch, new peer connection, first
RTT measurement from MMP, or peer reconnection.
### Bloom Filter Pre-Check
Before initiating a lookup, the originator checks whether *any* peer's
bloom filter contains the target. If no peer advertises reachability, the
lookup is skipped entirely and recorded as a failure for backoff purposes.
This avoids wasting network resources when the target is not in the mesh.
### Transit-Side Rate Limiting
Transit nodes enforce a per-target minimum interval (default 2s, configurable
via `forward_min_interval_secs`) for forwarded lookups. This is
defense-in-depth against misbehaving nodes that generate fresh `request_id`s
at high rate to bypass dedup. The rate limiter collapses rapid-fire lookups
for the same target regardless of `request_id`.
### LookupResponse
@@ -373,14 +428,21 @@ direct peer), a LookupResponse is created containing:
authenticates that the response is genuine and the target holds the
claimed tree position
The response routes back to the requester using reverse-path routing as the
primary mechanism: each transit node looks up the request_id in its
The response routes back to the requester using **reverse-path routing** as
the primary mechanism: each transit node looks up the `request_id` in its
`recent_requests` table to find the peer that forwarded the original request,
and sends the response back through that peer. This ensures the response
follows the same path as the request. Greedy tree routing toward the
origin_coords is used only as a fallback if the reverse-path entry has
`origin_coords` is used only as a fallback if the reverse-path entry has
expired.
**Response-forwarded flag**: Each `recent_requests` entry tracks whether a
response has already been forwarded for that `request_id`. If a second
response arrives (e.g., from convergent request paths that reached the
target via different routes), the transit node drops it. This prevents
response routing loops where multiple responses for the same request
circulate through the network.
**Proof verification**: The source verifies the Schnorr proof upon receipt,
confirming that the target actually signed the response. The proof covers
`(request_id || target || target_coords)` — coordinates are included because
@@ -390,12 +452,14 @@ annotation modified at each hop.
### Discovery Outcome
On receiving a LookupResponse, the source caches the target's coordinates.
Subsequent routing to that destination can proceed via the normal
`find_next_hop()` priority chain.
On receiving a verified LookupResponse, the source caches the target's
coordinates and clears any backoff state for that target. Subsequent routing
to that destination can proceed via the normal `find_next_hop()` priority
chain.
If discovery times out (no response), queued packets receive ICMPv6
Destination Unreachable.
If discovery times out (no response after all retry attempts), queued
packets receive ICMPv6 Destination Unreachable and the target enters
backoff.
## SessionSetup Self-Bootstrapping
@@ -467,7 +531,7 @@ coordinates for the destination. It cannot make a forwarding decision.
2. Reset CP warmup counter — subsequent data packets piggyback coordinates
when possible, or trigger additional CoordsWarmup messages when
piggybacking would exceed the transport MTU
3. Initiate discovery (LookupRequest flood) for the destination
3. Initiate discovery (bloom-guided LookupRequest) for the destination
4. When discovery completes, warmup counter resets again (covers timing gap)
The crypto session remains active throughout — only routing state is
@@ -553,8 +617,9 @@ sequence:
identity cache with NodeAddr + PublicKey
2. **Session initiation attempt**: Fails because no coordinates are cached
for the destination
3. **Discovery**: LookupRequest floods through the mesh; LookupResponse
returns the destination's coordinates
3. **Discovery**: LookupRequest routes through the spanning tree via
bloom-guided forwarding; LookupResponse returns the destination's
coordinates
4. **Session establishment**: SessionSetup carries coordinates, warming
transit caches along the path
5. **Warmup**: First N data packets include CP flag, reinforcing transit
@@ -643,7 +708,7 @@ routing decisions but retains its own end-to-end encryption and identity.
| ------- | ------------ | ---- | ---------- |
| TreeAnnounce | Variable (depth-dependent) | Topology changes | No (peer-to-peer) |
| FilterAnnounce | ~1 KB | Topology changes | No (peer-to-peer) |
| LookupRequest | ~300 bytes | First contact, recovery | Yes (flood) |
| LookupRequest | ~300 bytes | First contact, recovery | Yes (bloom-guided tree) |
| LookupResponse | ~400 bytes | Response to discovery | Yes (greedy routed) |
| SessionDatagram + SessionSetup | ~232402 bytes | Session establishment | Yes (routed) |
| SessionDatagram + SessionAck | ~170 bytes | Session confirmation | Yes (routed) |
@@ -696,10 +761,14 @@ recovery).
| Flap dampening (hysteresis + hold-down) | **Implemented** |
| Link liveness (dead timeout) | **Implemented** |
| Discovery request deduplication | **Implemented** |
| Discovery bloom-guided tree routing | **Implemented** |
| Discovery retry logic | **Implemented** |
| Discovery originator backoff | **Implemented** |
| Discovery transit-side rate limiting | **Implemented** |
| Discovery response-forwarded dedup | **Implemented** |
| Leaf-only operation | Under development |
| Link cost in parent selection (ETX) | **Implemented** |
| Link cost in candidate ranking | **Implemented** |
| Discovery path accumulation | Future direction |
## References

View File

@@ -14,7 +14,7 @@ address, deliver the datagram to that address, and push inbound datagrams up
to the FIPS Mesh Protocol (FMP) above.
The transport layer deals exclusively in **transport addresses** — IP:port
tuples, MAC addresses, .onion identifiers, radio device addresses. These are
or hostname:port addresses, MAC addresses, .onion identifiers, radio device addresses. These are
opaque to every layer above FMP. The mapping from transport address to FIPS
identity happens at the link layer after the Noise IK link handshake completes.
The word "peer" belongs to the link layer and above; the transport layer
@@ -69,10 +69,30 @@ forwarding and LookupResponse transit annotation.
For connection-oriented transports, manage the underlying connection: TCP
handshake, Tor circuit establishment, Bluetooth pairing. FMP cannot begin
the Noise IK link handshake until the transport-layer connection is established.
the Noise IK link handshake until the transport-layer connection is
established.
Connectionless transports (UDP, raw Ethernet) skip this — datagrams can flow
immediately to any reachable address.
Connection-oriented transports expose a non-blocking connect interface.
`connect(addr)` initiates the connection in a background task and returns
immediately. `connection_state(addr)` reports the current status:
```text
ConnectionState {
None No connection attempt in progress
Connecting Background task running
Connected Ready for send()
Failed(msg) Error message from failed attempt
}
```
Connectionless transports (UDP, raw Ethernet) return `Connected`
immediately — no async work needed.
At the node level, `PendingConnect` entries track links waiting for
transport connection. `poll_pending_connects()` runs each tick, checks
`connection_state()`, and calls `start_handshake()` on success or
`schedule_retry()` on failure. This decouples transport-layer connection
(which may take seconds for Tor circuits) from the FMP event loop.
### Discovery (Optional)
@@ -95,8 +115,8 @@ for internet connectivity:
| Transport | Addressing | MTU | Reliability | Notes |
| --------- | ---------- | --- | ----------- | ----- |
| UDP/IP | IP:port | 12801472 | Unreliable | Primary internet transport |
| TCP/IP | IP:port | Stream | Reliable | Requires length-prefix framing |
| UDP/IP | host:port | 12801472 | Unreliable | Primary internet transport |
| TCP/IP | host:port | Stream | Reliable | Requires length-prefix framing |
| WebSocket | URL | Stream | Reliable | Browser-compatible |
| Tor | .onion | Stream | Reliable | High latency, strong anonymity |
@@ -326,8 +346,7 @@ Ethernet transport started name=eth0 interface=eth0 mac=aa:bb:cc:dd:ee:ff mtu=14
## TCP/IP: Firewall Traversal Transport
For networks where UDP is blocked but TCP port 443 is open, the TCP
transport provides an alternative path. It also serves as the foundation
for the future Tor transport.
transport provides an alternative path.
FIPS protocols (FMP, FSP, MMP) are all unreliable datagrams. Running them
over TCP introduces head-of-line blocking, which adds latency jitter. MMP
@@ -338,16 +357,18 @@ penalizes TCP links (higher SRTT leads to higher link cost). ETX will be
### Architecture
Unlike UDP (one socket serves all peers), TCP requires one `TcpStream` per
peer. The transport maintains a connection pool (`HashMap<TransportAddr,
TcpConnection>`) plus an optional `TcpListener` for inbound connections.
peer. The transport maintains two pools: a `ConnectingPool` for background
connection attempts in progress, and an established connection pool
(`HashMap<TransportAddr, TcpConnection>`) for active connections, plus an
optional `TcpListener` for inbound connections.
| Property | Value |
| -------- | ----- |
| Addressing | IP:port (same as UDP) |
| Addressing | host:port — IP address or DNS hostname |
| Default MTU | 1400 bytes |
| Per-link MTU | Derived from `TCP_MAXSEG` socket option |
| Framing | FMP header-based (zero overhead) |
| Connection model | Connect-on-send, optional listener |
| Connection model | Non-blocking connect, connect-on-send fallback, optional listener |
| Platform | Cross-platform (no `#[cfg]` gates) |
### FMP Header-Based Framing
@@ -364,29 +385,36 @@ packet boundaries:
This provides zero framing overhead and built-in phase validation. The
stream reader is implemented in a separate module (`stream.rs`) for reuse
by the future Tor transport.
by the Tor transport.
### Connect-on-Send
### Connection Establishment
When `send(addr, data)` is called with no existing connection:
TCP connections use a non-blocking connect model. When FMP needs to reach
a configured peer address, the node calls `connect(addr)` on the transport,
which spawns a background tokio task to perform the TCP handshake and socket
configuration (TCP_NODELAY, keepalive, buffer sizes, TCP_MAXSEG query). The
call returns immediately without blocking the event loop.
1. Connect with configurable timeout (default 5s)
2. Configure socket: `TCP_NODELAY`, keepalive, buffer sizes
3. Read `TCP_MAXSEG` for per-connection MTU
4. Split stream into read/write halves
5. Spawn per-connection receive task
6. Store connection in pool
7. Write packet directly to stream
The node tracks each pending connection in a `PendingConnect` entry. On
every tick, `poll_pending_connects()` calls `connection_state(addr)` to
check progress. When the transport reports `Connected`, the completed
connection is promoted to the established pool (stream split into
read/write halves, per-connection receive task spawned), and the node
initiates the Noise IK link handshake. If the transport reports `Failed`,
the node schedules a retry with exponential backoff.
If connect fails, return error. The node's handshake retry mechanism
handles re-attempts.
As a fallback, `send(addr, data)` still performs synchronous
connect-on-send if no connection exists — this handles the case where a
send arrives before the node-level connect path runs. The non-blocking
path is the primary mechanism for configured peers.
### Session Independence
TCP connection loss does **not** tear down the FIPS peer. Noise keys, MMP
state, and FSP sessions are bound to the peer's npub, not the TCP
connection. The transport reconnects transparently on the next send via
connect-on-send. MMP liveness timeout is the sole authority for peer death.
connection. The transport reconnects transparently via the non-blocking
connect path or connect-on-send fallback. MMP liveness timeout is the sole
authority for peer death.
### Connection Deduplication
@@ -408,13 +436,231 @@ transports:
recv_buf_size: 2097152 # SO_RCVBUF (2 MB)
send_buf_size: 2097152 # SO_SNDBUF (2 MB)
max_inbound_connections: 256 # Resource protection limit
socks5_proxy: "127.0.0.1:9050" # SOCKS5 for outbound (deferred)
```
If `bind_addr` is configured, the transport accepts inbound connections.
Without it, the transport operates in outbound-only mode (no listener
socket is created).
## Tor: The Anonymity Transport
The Tor transport routes FIPS traffic through the Tor network, hiding
a node's IP address from its peers. A node behind Tor connects outbound
through a local Tor SOCKS5 proxy; the remote peer sees the Tor exit
node's IP, not the initiator's. After the Noise IK handshake, the remote
peer knows the initiator's FIPS identity (npub) but not its network
location.
Like TCP, Tor is connection-oriented and reliable. The same TCP-over-TCP
considerations apply — MMP correctly measures the elevated latency and
cost-based parent selection naturally deprioritizes Tor links.
### Architecture
The Tor transport is a separate `TorTransport` implementation, not a TCP
variant, because it manages SOCKS5 proxy negotiation, has different
address semantics (.onion vs IP:port), and has significantly different
latency characteristics. It reuses the FMP header-based stream reader
(`tcp/stream.rs`) for packet framing on the underlying TCP connection.
The transport maintains two pools (same pattern as TCP): a
`ConnectingPool` for background SOCKS5 connection attempts, and an
established pool of `TorConnection` entries. Each `TorConnection` holds
a write half, a per-connection receive task, the negotiated MTU, and
a connection timestamp.
| Property | Value |
| -------- | ----- |
| Addressing | .onion:port or IP:port |
| Default MTU | 1400 bytes |
| Framing | FMP header-based (shared with TCP) |
| Connection model | Non-blocking connect, outbound SOCKS5 + inbound via onion service |
| Platform | Cross-platform (requires external Tor daemon) |
### Address Types
The Tor transport accepts three address formats, parsed into a `TorAddr`
enum:
- **Onion**: `.onion:port` — connects to a Tor hidden service. Both
sides anonymous. (e.g., `abcdef...xyz.onion:8443`)
- **Clearnet IP**: `IP:port` — connects through a Tor exit node to a
remote TCP listener. Hides the initiator's IP; the remote peer sees
the exit node's IP.
- **Clearnet Hostname**: `hostname:port` — hostname is passed through
SOCKS5 for Tor-side DNS resolution, avoiding local DNS leaks. Compatible
with SafeSocks 1. (e.g., `fips.example.com:8443`)
All address types are routed through the same SOCKS5 proxy.
### Connection Establishment
Connection setup follows the same non-blocking pattern as TCP. When FMP
needs to reach a peer, the node calls `connect(addr)` on the transport.
The transport spawns a background tokio task that:
1. Opens a SOCKS5 connection through the local Tor proxy
2. Configures the socket: `TCP_NODELAY`, keepalive (30s)
3. Returns the connected stream
The call returns immediately. `connection_state(addr)` reports progress.
Tor circuit establishment typically takes 1060 seconds (vs milliseconds
for TCP), making non-blocking connect essential — a blocking connect
would stall the entire FMP event loop.
The connect timeout defaults to 120 seconds (vs 5 seconds for TCP),
accounting for Tor circuit setup time. As a fallback, `send(addr, data)`
performs synchronous connect-on-send if no connection exists.
### Inbound via Onion Service (Directory Mode)
In `directory` mode (recommended for production), Tor manages the onion
service via `HiddenServiceDir` in `torrc`. FIPS reads the `.onion` address
from the hostname file at startup and binds a local TCP listener that the
Tor daemon forwards inbound connections to.
This mode enables Tor's `Sandbox 1` (seccomp-bpf) — the strongest single
hardening option — because no control port interaction is required for
onion service management. Tor handles key generation and persistence
directly through the `HiddenServiceDir`.
The inbound accept loop mirrors the TCP transport's pattern: accept
connection, configure socket (TCP_NODELAY, keepalive), spawn a
per-connection receive loop using the shared FMP stream reader. Inbound
connections arrive from `127.0.0.1` (Tor daemon's local forwarding); peer
identity is resolved during the Noise IK handshake, not from the transport
address.
Configuration requires coordinating `torrc` and `fips.yaml`:
```text
# torrc
HiddenServiceDir /var/lib/tor/fips
HiddenServicePort 8443 127.0.0.1:8444
# fips.yaml tor section
mode: "directory"
directory_service:
hostname_file: "/var/lib/tor/fips/hostname"
bind_addr: "127.0.0.1:8444"
```
The `HiddenServicePort` external port (8443) is what peers connect to.
The bind_addr must match the `HiddenServicePort` target address.
### Session Independence
Same as TCP: Tor connection loss does **not** tear down the FIPS peer.
Noise keys, MMP state, and FSP sessions survive reconnection.
### Bridge Node Pattern
A node running both Tor and UDP transports acts as a bridge between
anonymous and clearnet portions of the mesh:
```text
[Anonymous node] --tor--> [Bridge node] --udp--> [Clearnet node]
```
No special code is needed — FIPS multi-transport routing handles it.
Anonymous nodes connect to the bridge via Tor; the bridge forwards
traffic to clearnet peers over UDP. Clearnet peers never see the
anonymous node's IP.
### Latency Characteristics
Tor adds 200ms2s RTT per circuit. First-packet latency after connection
is higher (~2.8s) due to circuit warm-up. MMP measures this elevated
latency, and cost-based parent selection penalizes Tor links (high SRTT
→ high link cost). ETX is 1.0 since TCP handles retransmission.
Tor throughput is typically 15 Mbps — adequate for control plane and
moderate data transfer, not for bulk transfer.
### Monitoring
In `control_port` mode and optionally in `directory` mode (when
`control_addr` is configured), the transport spawns a background
monitoring task that polls the Tor daemon every 10 seconds via the
control port. The cached monitoring data is exposed through the
`show_transports` control socket query and displayed in fipstop.
Monitoring data includes:
- **Bootstrap progress** (0100%) with INFO logging at milestones
(25/50/75/100%) and WARN if stalled >60s
- **Circuit status** (whether Tor has a working circuit)
- **Network liveness** (up/down) with WARN on transitions
- **Dormant mode** detection with WARN on entry
- **Tor daemon version** and **traffic counters** (bytes read/written)
The control port connection uses cookie authentication by default
(reading from `/var/run/tor/control.authcookie`). Unix socket
connections (`/run/tor/control`) are preferred over TCP for security.
### Configuration
```yaml
transports:
tor:
mode: "socks5" # "socks5", "control_port", or "directory"
socks5_addr: "127.0.0.1:9050" # SOCKS5 proxy address
connect_timeout_ms: 120000 # Connect timeout (120s for Tor circuits)
mtu: 1400 # Default MTU
# control_port mode: monitoring via Tor control port (no inbound)
# control_addr: "/run/tor/control" # Unix socket (preferred) or host:port
# control_auth: "cookie" # "cookie" or "password:<secret>"
# cookie_path: "/var/run/tor/control.authcookie"
# directory mode: inbound via Tor-managed HiddenServiceDir
# directory_service:
# hostname_file: "/var/lib/tor/fips/hostname"
# bind_addr: "127.0.0.1:8444"
# max_inbound_connections: 64
```
Three modes are available:
- **`socks5`** (default): Outbound-only through a SOCKS5 proxy. No
control port, no inbound connections.
- **`control_port`**: Outbound via SOCKS5 plus control port connection
for Tor daemon monitoring. No inbound connections.
- **`directory`** (recommended for inbound): Outbound via SOCKS5 plus
inbound via Tor-managed `HiddenServiceDir` onion service. Optionally
connects to the control port for monitoring when `control_addr` is set.
Enables Tor's `Sandbox 1` for maximum security.
The Tor transport requires an external Tor daemon. Named instances are
supported for multiple proxy endpoints.
### Implementation Roadmap
- Outbound SOCKS5 connections to .onion, clearnet IP, and clearnet
hostname addresses *(implemented)*
- Inbound connections via Tor onion service using `HiddenServiceDir`
directory mode *(implemented)*
- Operator visibility: cached monitoring snapshot, control socket
exposure, fipstop display, bootstrap/liveness logging *(implemented)*
- Embedded `arti` (Rust Tor implementation) for self-contained operation
without an external Tor daemon *(future)*
### Statistics
The transport tracks per-instance statistics:
| Counter | Description |
| ------- | ----------- |
| `packets_sent` / `bytes_sent` | Successful sends |
| `packets_recv` / `bytes_recv` | Successful receives |
| `send_errors` / `recv_errors` | Send/receive failures |
| `connections_established` | Successful SOCKS5 connections |
| `connect_timeouts` | Connection timeout count |
| `connect_refused` | Connection refused count |
| `socks5_errors` | SOCKS5 protocol errors |
| `mtu_exceeded` | Packets rejected for MTU violation |
| `connections_accepted` | Accepted inbound connections via onion service |
| `connections_rejected` | Rejected inbound connections (limit exceeded) |
| `control_errors` | Tor control port errors |
## Discovery
Discovery determines that a FIPS-capable endpoint is reachable at a given
@@ -423,7 +669,7 @@ detection — a new TCP connection or UDP packet from an unknown source is not
discovery; a FIPS-specific announcement or response is.
Discovery is an optional transport capability. Transports that don't support
it (configured UDP endpoints, TCP) simply don't provide discovery events.
it (configured UDP endpoints, TCP, Tor) simply don't provide discovery events.
FMP handles both cases uniformly: with discovery, it waits for events then
initiates link setup; without discovery, it initiates link setup directly to
configured addresses.
@@ -453,7 +699,7 @@ X." FMP does not need to distinguish beacons from query responses.
For internet-reachable transports, a node publishes a signed Nostr event
containing its FIPS discovery information — public key and reachable
transport endpoints (UDP IP:port, TCP IP:port, .onion address). Other FIPS
transport endpoints (UDP host:port, TCP host:port, .onion address). Other FIPS
nodes subscribing on the same relays learn about available peers.
Nostr relay discovery is not a transport — it is a discovery service that
@@ -472,13 +718,13 @@ Key properties:
### Current State
> **Implemented**: UDP and TCP peers are configured via YAML. Ethernet
> peers are discovered via beacon broadcast — the `discover()` trait
> method returns newly seen endpoints, and per-transport `auto_connect()`
> / `accept_connections()` policies control whether discovered peers are
> connected automatically or require explicit configuration. TCP has no
> discovery mechanism (peers are configured). Nostr relay discovery is
> not yet implemented.
> **Implemented**: UDP, TCP, Tor, and Ethernet peers can be configured
> statically via YAML. Ethernet peers can also be discovered via beacon
> broadcast — the `discover()` trait method returns newly seen endpoints,
> and per-transport `auto_connect()` / `accept_connections()` policies
> control whether discovered peers are connected automatically or require
> explicit configuration. TCP and Tor have no discovery mechanism.
> Nostr relay discovery is not yet implemented.
## Transport Interface
@@ -496,6 +742,8 @@ link_mtu(addr) → u16 Per-link MTU (defaults to mtu())
start() → lifecycle Bring transport up (bind socket, open device)
stop() → lifecycle Bring transport down
send(addr, data) → delivery Send datagram to transport address
connect(addr) → () Initiate non-blocking connection (connection-oriented only)
connection_state(addr)→ ConnectionState Poll connection status (None/Connecting/Connected/Failed)
close_connection(addr)→ () Close a specific connection (no-op for connectionless)
congestion() → TransportCongestion Local congestion indicators (optional)
discover() → Vec<DiscoveredPeer> Report discovered FIPS endpoints (optional)
@@ -553,13 +801,14 @@ on all forwarded datagrams.
| Transport | Congestion Source | Mechanism |
| --------- | ----------------- | --------- |
| UDP | `SO_RXQ_OVFL` kernel drop counter | `recvmsg()` ancillary data on every packet |
| TCP | Not yet implemented | Returns `None` (TCP handles congestion internally) |
| Ethernet | Not yet implemented | Returns `None` |
| TCP | Not implemented | Returns `None` (TCP handles congestion internally) |
| Tor | Not implemented | Returns `None` (TCP handles congestion internally) |
| Ethernet | Not implemented | Returns `None` |
### Transport Addresses
Transport addresses (`TransportAddr`) are opaque byte vectors. The transport
layer interprets them (e.g., UDP parses "ip:port" strings); all layers above
layer interprets them (e.g., UDP/TCP resolve "host:port" strings (IP fast path, DNS fallback with 60s cache for UDP)); all layers above
treat them as opaque handles passed back to the transport for sending.
### Transport State Machine
@@ -579,10 +828,10 @@ transitions through `Starting` to `Up` (operational). `stop()` moves to
| Transport | Status | Notes |
| --------- | ------ | ----- |
| UDP/IP | **Implemented** | Primary transport, AsyncFd/recvmsg, SO_RXQ_OVFL kernel drop detection |
| TCP/IP | **Implemented** | FMP header-based framing, connect-on-send, per-connection MSS MTU |
| TCP/IP | **Implemented** | FMP header-based framing, non-blocking connect, per-connection MSS MTU |
| Ethernet | **Implemented** | AF_PACKET SOCK_DGRAM, EtherType 0x2121, beacon discovery, Linux only |
| WiFi | Future direction | Infrastructure mode = Ethernet driver |
| Tor | Future direction | High latency, .onion addressing |
| Tor | **Implemented** | Outbound SOCKS5, inbound via onion service, .onion and clearnet addressing |
| BLE | Future direction | ATT_MTU negotiation, per-link MTU |
| Radio | Future direction | Constrained MTU (51222 bytes) |
| Serial | Future direction | SLIP/COBS framing, point-to-point |

View File

@@ -21,7 +21,8 @@ Datagram-oriented transports (UDP, raw Ethernet, radio) preserve natural
packet boundaries and require no additional framing. Stream-oriented
transports (TCP, WebSocket, Tor) must delineate FIPS packets within the
byte stream; the common prefix `payload_len` field provides this
framing directly.
framing directly. TCP and Tor share a common stream reader
(`tcp/stream.rs`) that implements this framing.
**Ethernet data frame header.** The Ethernet transport prepends a 3-byte
header before the FMP payload on data frames: a 1-byte frame type
@@ -254,7 +255,11 @@ With link overhead: 1,072 bytes.
### LookupRequest (0x30)
Coordinate discovery request, flooded through the mesh.
Coordinate discovery request, routed through the spanning tree via
bloom-filter-guided forwarding. Each transit node forwards only to tree
peers (parent + children) whose bloom filter contains the target.
Request_id dedup in recent_requests handles edge cases from tree
restructuring.
![LookupRequest](diagrams/lookup-request.svg)
@@ -268,20 +273,19 @@ Coordinate discovery request, flooded through the mesh.
| 42 | min_mtu | 2 bytes LE | Minimum transport MTU the origin requires (0 = no requirement) |
| 44 | origin_coords_cnt | 2 bytes LE | Number of coordinate entries |
| 46 | origin_coords | 16 x n bytes | Requester's ancestry (NodeAddr only) |
| 46 + 16n | visited_hash_cnt | 1 byte | Hash count for visited filter |
| 47 + 16n | visited_bits | 256 bytes | Compact bloom of visited nodes |
**Size**: `303 + (n x 16)` bytes, where n = origin depth + 1
**Size**: `46 + (n x 16)` bytes, where n = origin depth + 1
| Origin Depth | Payload |
| ------------ | ------- |
| 3 | 351 bytes |
| 5 | 383 bytes |
| 10 | 463 bytes |
| 3 | 110 bytes |
| 5 | 142 bytes |
| 10 | 222 bytes |
### LookupResponse (0x31)
Coordinate discovery response, greedy-routed back to requester.
Coordinate discovery response, reverse-path routed back to the
requester via the transit nodes that forwarded the request.
![LookupResponse](diagrams/lookup-response.svg)

BIN
docs/logos/fips_banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
docs/logos/fips_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -0,0 +1,141 @@
# FIPS v0.2.1
**Released**: 2026-05-11
v0.2.1 is a maintenance release on the v0.2.x line. No new features
and no wire-format changes; operators running v0.2.0 can upgrade in
place. The release rolls up bug fixes and operational hardening for
issues surfaced in v0.2.0 deployments, plus a bloom-filter fill-ratio
validation that protects mesh-size estimates from saturated-filter
inputs.
## At a glance
- 22 commits since v0.2.0, 5 committers plus 2 issue reporters.
- All changes are backwards-compatible with v0.2.0 on the wire.
- Bloom filter fill-ratio validation hardens the FilterAnnounce
ingress path.
- TreeAnnounce ancestry validation tightened to match the
spanning-tree specification.
- Signed-tarball + `.deb` artifact workflow added for tagged
releases; AUR auto-publish on stable tags.
## Behavior changes worth flagging
- **Bloom filter fill-ratio validation** runs on every inbound
`FilterAnnounce`. Filters whose derived false-positive rate exceeds
`node.bloom.max_inbound_fpr` (new config field, default `0.05`) are
rejected silently on the wire, logged at WARN, and counted in a
new `bloom.fill_exceeded` counter. A rate-limited WARN also fires
when the local outgoing filter exceeds the cap.
`BloomFilter::estimated_count` now takes `max_fpr` and returns
`Option<f64>`, returning `None` for saturated filters; this
propagates through `compute_mesh_size` into `estimated_mesh_size`.
- **TreeAnnounce ancestry validation** is now run before tree-state
mutation, enforcing ancestry-self-match, root-single-entry,
parent-second-entry, and root-is-minimum-NodeAddr. Non-conforming
announces are rejected with a WARN. Mixed v0.2.0 / v0.2.1 meshes
may produce WARN log lines on the v0.2.1 side until all peers
upgrade; behavior is correct, log noise only.
## Notable bug fixes
- **Control socket path detection** in `fipsctl` and `fipstop` now
checks for the `/run/fips/` directory instead of the socket file
inside it. Users not yet in the `fips` group get a clear
"Permission denied" error instead of a misleading "No such file"
fallback to `$XDG_RUNTIME_DIR`
([#30](https://github.com/jmcorgan/fips/issues/30), reported by
[@Sebastix](https://github.com/Sebastix)).
- **`fd00::/8` routing protected from Tailscale interception.** The
daemon installs an IPv6 routing-policy rule
(`ip -6 rule to fd00::/8 lookup main priority 5265`) at TUN setup,
so Tailscale's table 52 default route can no longer divert mesh
traffic.
- **Bloom filter routing greedy-tree fallback.** `find_next_hop` no
longer returns `NoRoute` when the bloom candidate set is non-empty
but no candidate is strictly closer than the current node; it
falls through to greedy tree routing instead. Previously, this
caused dropped packets in topologies where the tree parent was
closer but not a bloom candidate.
- **Auto-connect peers reconnect after a graceful Disconnect.**
Previously, a clean upstream shutdown left the auto-connect peer
orphaned; only the link-dead, decrypt-fail, and peer-restart paths
scheduled a reconnect
([#60](https://github.com/jmcorgan/fips/issues/60), reported by
[@SwapMarket](https://github.com/SwapMarket)).
- **`fipsctl connect` rejects FIPS mesh addresses** (`fd00::/8`) for
`udp`, `tcp`, and `ethernet` transports with a clear error message
instead of echoing success while the daemon silently failed the
bind with `EAFNOSUPPORT`
([#61](https://github.com/jmcorgan/fips/issues/61), reported by
[@SwapMarket](https://github.com/SwapMarket)).
- **OpenWrt ipk** cross-compiles cleanly again after excluding the
BLE feature that requires D-Bus, which is unavailable on OpenWrt
targets.
## Packaging
- **Linux release artifact workflow** builds x86_64 and aarch64
tarballs and `.deb` packages on `v*` tag push, with SHA-256
checksums, and publishes them to the GitHub release page.
- **AUR publish workflow** auto-publishes the `fips` PKGBUILD on
stable `v*` tags.
## Upgrade notes
Operator-actionable items when moving from v0.2.0 to v0.2.1:
- **Bloom filter fill-ratio cap (default 0.05).** Inbound
`FilterAnnounce` messages whose derived FPR exceeds the cap are
rejected silently on the wire. Operators with unusually saturated
filters in the field may want to confirm that the default applies
cleanly to their deployment; check the new `bloom.fill_exceeded`
counter if mesh-size estimates drift after upgrade.
- **TreeAnnounce ancestry tightening.** Mixed v0.2.0 / v0.2.1 meshes
may produce WARN log lines on the v0.2.1 side until all peers
upgrade. Behavior is correct, log noise only.
## Getting v0.2.1
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
[v0.2.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.2.1).
- **Arch Linux**: `fips` from the AUR.
- **OpenWrt**: `.ipk` at the v0.2.1 release page.
- **From source**: `cargo build --release` from a checkout of the
v0.2.1 tag.
The full per-commit changelog lives in
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
## Contributors
Thanks to everyone who contributed code or bug reports to this
release.
**Code and packaging**:
- [@jcorgan](https://github.com/jmcorgan): release shepherd, bloom
fill-ratio validation, auto-connect reconnect fix, `fipsctl`
mesh-address rejection, control-socket path detection,
Tailscale-vs-`fd00::/8` routing policy, bloom routing greedy
fallback, rustfmt baseline.
- [@Origami74](https://github.com/Origami74): OpenWrt ipk
BLE-feature build fix.
- [@jodobear](https://github.com/jodobear): Linux release-artifact
workflow and target-aware build scripts.
- [@dskvr](https://github.com/dskvr): AUR publish workflow.
- [@SatsAndSports](https://github.com/SatsAndSports): TreeAnnounce
semantic validation.
**Issue reports that drove fixes in this release**:
- [@Sebastix](https://github.com/Sebastix): `fipsctl` / `fipstop`
control-socket path detection
([#30](https://github.com/jmcorgan/fips/issues/30)).
- [@SwapMarket](https://github.com/SwapMarket): auto-connect
reconnect after graceful disconnect
([#60](https://github.com/jmcorgan/fips/issues/60)) and
`fipsctl` mesh-address rejection
([#61](https://github.com/jmcorgan/fips/issues/61)).

View File

@@ -0,0 +1,11 @@
target/
.git/
.github/
testing/chaos/
testing/static/
testing/sidecar/
testing/tor/
fips
fipsctl
../sidecar/fips
../sidecar/fipsctl

View File

@@ -0,0 +1,63 @@
# -----------------------------------------------------------------------------
# Stage 1 — build
# Compile fips and fipsctl inside a Rust toolchain image so no local Rust
# install is needed and the image is fully reproducible.
# -----------------------------------------------------------------------------
FROM rust:slim-trixie AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy the full workspace source. .dockerignore filters target/ and other
# large paths so the context stays small.
COPY . .
# Build only the two binaries needed at runtime; skip the TUI (fipstop)
# to avoid pulling in ratatui and crossterm. The daemon does not require
# any default features to run.
RUN cargo build --release --no-default-features \
--bin fips \
--bin fipsctl
# -----------------------------------------------------------------------------
# Stage 2 — runtime
# Minimal Debian image with only the packages required at runtime.
# -----------------------------------------------------------------------------
FROM debian:trixie-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
iproute2 \
iptables \
dnsmasq \
procps \
curl && \
rm -rf /var/lib/apt/lists/*
# dnsmasq: forward .fips queries to the FIPS daemon DNS resolver;
# forward everything else to the upstream resolver passed in at runtime.
# The upstream placeholder is replaced by entrypoint.sh with the pod's
# original nameserver(s) from /etc/resolv.conf before dnsmasq starts.
RUN printf '%s\n' \
'port=53' \
'listen-address=127.0.0.1' \
'bind-interfaces' \
'server=/fips/127.0.0.1#5354' \
'no-resolv' \
> /etc/dnsmasq.d/fips.conf
COPY --from=builder /build/target/release/fips /usr/local/bin/fips
COPY --from=builder /build/target/release/fipsctl /usr/local/bin/fipsctl
COPY examples/k8s-sidecar/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD fipsctl show status
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,176 @@
# FIPS Kubernetes Sidecar
Run FIPS as a sidecar container in a Kubernetes Pod, injecting the FIPS mesh
network interface into every other container in the Pod. All containers in
the Pod share the same network namespace, so once the sidecar creates `fips0`
it is immediately visible to the app container(s) without any further
configuration.
## Quick Start
```bash
# 1. Build the sidecar image
cd examples/k8s-sidecar
./scripts/build.sh --tag fips-k8s-sidecar:latest
# 2. Push to your registry (replace with your own)
docker tag fips-k8s-sidecar:latest registry.example.com/fips-k8s-sidecar:latest
docker push registry.example.com/fips-k8s-sidecar:latest
# 3. Create the identity secret
kubectl create secret generic fips-identity \
--from-literal=nsec=$(openssl rand -hex 32)
# 4. Deploy
kubectl apply -f pod.yaml
```
## How It Works
In Kubernetes, all containers in a Pod share the same network namespace. The
fips sidecar:
1. Generates `/etc/fips/fips.yaml` from environment variables.
2. Rewrites `/etc/resolv.conf` to route `.fips` DNS through dnsmasq (which
forwards `.fips` names to the FIPS daemon's built-in DNS resolver and
everything else to the original cluster DNS).
3. Applies `iptables` isolation rules so that the pod's physical interface
(`eth0`) only carries FIPS transport traffic.
4. Starts dnsmasq and then `exec`s the FIPS daemon.
The app container starts concurrently and immediately sees `lo`, `eth0`, and
`fips0`. DNS for `<npub>.fips` names resolves to `fd::/8` addresses via the
dnsmasq → FIPS daemon pipeline.
```text
┌────────────────────────────────────────────────────┐
│ Kubernetes Pod (shared network namespace) │
│ │
│ ┌─────────────────┐ ┌───────────────────────────┐│
│ │ fips (sidecar) │ │ app container(s) ││
│ │ │ │ ││
│ │ fips daemon │ │ your workload ││
│ │ fipsctl │ │ sees: lo, eth0, fips0 ││
│ │ dnsmasq │ │ ││
│ └─────────────────┘ └───────────────────────────┘│
│ │
│ Interfaces: │
│ lo — loopback (unrestricted) │
│ eth0 — pod CNI interface (iptables: FIPS only) │
│ fips0 — FIPS TUN (fd00::/8, unrestricted) │
└────────────────────────────────────────────────────┘
```
## Network Isolation
`FIPS_ISOLATE` controls whether the sidecar locks down `eth0`:
| Value | Behaviour |
|---|---|
| `false` **(default)** | `fips0` is added alongside normal cluster networking. `eth0` continues to work — services, DNS, other pods, and the internet are all reachable as normal. Application traffic to FIPS mesh peers uses `fips0`. |
| `true` | All traffic on `eth0` is dropped except FIPS transport (UDP 2121). The app container can **only** communicate via `fips0`. Use this for deployments where the workload must never bypass the mesh. |
> **Common gotcha**: if you deploy the sidecar and find that services or
> other pods are suddenly unreachable, check that `FIPS_ISOLATE` is not
> set to `true`. The mesh-only mode is intentionally strict and will break
> normal cluster connectivity.
## Requirements
| Requirement | Notes |
|---|---|
| `NET_ADMIN` capability | Required for TUN creation and iptables |
| `/dev/net/tun` | HostPath volume mount (see `pod.yaml`) |
| Linux kernel ≥ 4.9 | Standard on all current distributions |
| No gVisor / kata-containers | Requires real kernel TUN support |
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `FIPS_NSEC` | *(required)* | Node secret key (hex or nsec1 bech32) |
| `FIPS_PEER_NPUB` | *(empty)* | Single peer npub |
| `FIPS_PEER_ADDR` | *(empty)* | Single peer transport address (`host:port`) |
| `FIPS_PEER_ALIAS` | `peer` | Single peer alias |
| `FIPS_PEER_TRANSPORT` | `udp` | Single peer transport type |
| `FIPS_PEERS_JSON` | *(empty)* | JSON array of peer objects (overrides single-peer vars). **Note**: the included parser handles simple host:port addresses only; IPv6 addresses with brackets may not parse correctly. |
| `FIPS_UDP_BIND` | `0.0.0.0:2121` | UDP transport bind address |
| `FIPS_UDP_PORT` | *(from `FIPS_UDP_BIND`)* | UDP port for iptables rules |
| `FIPS_TCP_BIND` | *(disabled)* | TCP transport bind address |
| `FIPS_TUN_NAME` | `fips0` | TUN interface name |
| `FIPS_TUN_MTU` | `1280` | TUN MTU |
| `FIPS_ISOLATE` | `false` | Apply iptables isolation (blocks all eth0 traffic except FIPS transport) |
| `FIPS_POD_IFACE` | `eth0` | Pod physical interface name |
| `FIPS_REWRITE_DNS` | `true` | Rewrite `/etc/resolv.conf` |
| `RUST_LOG` | `info` | FIPS log level |
### Multiple Peers
Use `FIPS_PEERS_JSON` to configure more than one peer:
```yaml
- name: FIPS_PEERS_JSON
value: |
[
{"npub":"npub1abc...","alias":"gw1","addr":"203.0.113.10:2121","transport":"udp"},
{"npub":"npub1def...","alias":"gw2","addr":"198.51.100.5:2121","transport":"udp"}
]
```
Each object supports the keys: `npub` (required), `addr` (required),
`alias`, `transport`, `priority`.
Alternatively, mount a hand-crafted `fips.yaml` and set `FIPS_NSEC` to
anything (the mount takes precedence because the entrypoint writes to
`/etc/fips/fips.yaml` which is then overridden by the volume):
```yaml
volumes:
- name: fips-config
configMap:
name: my-fips-config
containers:
- name: fips
volumeMounts:
- name: fips-config
mountPath: /etc/fips/fips.yaml
subPath: fips.yaml
```
### JSON Parsing Limitations
`FIPS_PEERS_JSON` supports parsing with a simple awk-based parser. It handles common `host:port` addresses but does not support IPv6 addresses with brackets (e.g., `[::1]:2121`) or values containing embedded commas/colons. For complex configs, install `jq` in the container and modify the entrypoint, or use a mounted `fips.yaml`.
## Files
| File | Purpose |
|---|---|
| `Dockerfile` | Builds the sidecar image |
| `entrypoint.sh` | Generates config, rewrites DNS, applies iptables, starts fips |
| `pod.yaml` | Example Pod manifest (single app container + fips sidecar) |
| `scripts/build.sh` | Compiles FIPS and builds the Docker image |
## Troubleshooting
**`FIPS_NSEC is required`** — The secret was not injected. Verify the
`secretKeyRef` name and key match the secret you created with `kubectl
create secret`.
**`fips0` not visible in app container** — Check that the fips sidecar
started successfully: `kubectl logs <pod> -c fips`. The sidecar must start
and run the daemon before `fips0` appears. Add a startup probe or
`postStart` lifecycle hook to your app container if you need to wait.
**iptables errors** — The `NET_ADMIN` capability and `/dev/net/tun` volume
mount are both required. Verify they are present in the Pod spec.
**`.fips` DNS not resolving** — Check that `FIPS_REWRITE_DNS=true` and that
dnsmasq started: `kubectl exec <pod> -c fips -- pgrep dnsmasq`. Verify the
FIPS DNS listener: `kubectl exec <pod> -c fips -- fipsctl show status`.
**CNI uses a different interface name** — Set `FIPS_POD_IFACE` to match your
CNI's interface name (e.g. `ens3`, `net1` for Multus secondary interfaces).
**gVisor / kata-containers** — These sandboxed runtimes intercept syscalls
and do not support `AF_PACKET` or `/dev/net/tun` in the same way as a
standard kernel. Use a standard RuntimeClass for FIPS sidecar pods.

View File

@@ -0,0 +1,309 @@
#!/bin/bash
# FIPS Kubernetes sidecar entrypoint.
#
# Generates /etc/fips/fips.yaml from environment variables, rewrites the
# pod's resolv.conf to route .fips DNS through dnsmasq, applies iptables
# isolation rules so that the shared pod network namespace can only reach
# the outside world via the FIPS mesh, then launches the FIPS daemon.
#
# Required environment variables:
# FIPS_NSEC Node secret key (hex or nsec1 bech32)
#
# Optional environment variables (single peer shorthand):
# FIPS_PEER_NPUB Peer's npub
# FIPS_PEER_ADDR Peer's transport address (e.g. 203.0.113.10:2121)
# FIPS_PEER_ALIAS Human-readable peer name (default: peer)
# FIPS_PEER_TRANSPORT Transport type (default: udp)
#
# Multiple peers via JSON (overrides single-peer vars when set):
# FIPS_PEERS_JSON JSON array of peer objects, e.g.:
# '[{"npub":"npub1...","alias":"gw","addr":"1.2.3.4:2121","transport":"udp"}]'
#
# Transport / TUN tuning:
# FIPS_UDP_BIND UDP bind address (default: 0.0.0.0:2121)
# FIPS_UDP_PORT UDP transport port (derived from FIPS_UDP_BIND when unset)
# FIPS_TCP_BIND TCP bind address (default: disabled)
# FIPS_TUN_NAME TUN interface name (default: fips0)
# FIPS_TUN_MTU TUN interface MTU (default: 1280, IPv6 minimum)
#
# Network isolation:
# FIPS_ISOLATE Apply iptables isolation (default: false)
# Set to "true" for mesh-only deployments where the app
# must not communicate outside the FIPS mesh.
# Set to "false" to add fips0 alongside normal cluster
# networking without restricting eth0.
# FIPS_POD_IFACE Pod network interface (default: eth0)
# Adjust if your CNI uses a different name (e.g. ens3).
#
# DNS:
# FIPS_REWRITE_DNS Rewrite /etc/resolv.conf (default: true)
# Set to "false" if you manage DNS via another mechanism.
#
# Logging:
# RUST_LOG FIPS log level (default: info)
set -euo pipefail
# ---------------------------------------------------------------------------
# IPv6 kernel configuration
#
# net.ipv6.conf.all.disable_ipv6, net.ipv6.conf.default.disable_ipv6, and
# net.ipv6.bindv6only are set via the pod securityContext.sysctls field in
# pod.yaml so that kubelet applies them before any container starts.
# Nothing to do here.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required — provide your node secret key}"
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
FIPS_TCP_BIND="${FIPS_TCP_BIND:-}"
FIPS_TUN_NAME="${FIPS_TUN_NAME:-fips0}"
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
FIPS_ISOLATE="${FIPS_ISOLATE:-false}"
FIPS_POD_IFACE="${FIPS_POD_IFACE:-eth0}"
FIPS_REWRITE_DNS="${FIPS_REWRITE_DNS:-true}"
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-udp}"
FIPS_PEER_ALIAS="${FIPS_PEER_ALIAS:-peer}"
# Derive the UDP port for iptables rules from FIPS_UDP_BIND (last colon-field).
FIPS_UDP_PORT="${FIPS_UDP_PORT:-${FIPS_UDP_BIND##*:}}"
mkdir -p /etc/fips
# ---------------------------------------------------------------------------
# Build peers YAML section
# ---------------------------------------------------------------------------
build_peers_yaml() {
# FIPS_PEERS_JSON wins when set (supports multiple peers).
if [ -n "${FIPS_PEERS_JSON:-}" ]; then
# Parse the JSON array with awk — no jq required.
# Expected element keys: npub, alias, addr, transport, priority
#
# Note: This parser handles simple host:port addresses only.
# IPv6 addresses with brackets ([::1]:2121) or values containing
# commas/colons may not parse correctly. Use single-peer env vars
# for complex configs or install jq and replace this block.
echo "$FIPS_PEERS_JSON" | awk '
BEGIN { RS="}"; FS="," }
{
npub=""; alias="peer"; addr=""; transport="udp"; priority=100
for (i=1; i<=NF; i++) {
gsub(/[{}\[\] \t\n\r]/, "", $i)
if ($i ~ /"npub"/) { split($i,a,":"); npub=a[2]; gsub(/"/,"",npub) }
if ($i ~ /"alias"/) { split($i,a,":"); alias=a[2]; gsub(/"/,"",alias) }
if ($i ~ /"addr"/) { n=split($i,a,":"); addr=a[2]":"a[3]; gsub(/"/,"",addr) }
if ($i ~ /"transport"/) { split($i,a,":"); transport=a[2]; gsub(/"/,"",transport) }
if ($i ~ /"priority"/) { split($i,a,":"); priority=a[2]; gsub(/"/,"",priority) }
}
if (npub != "" && addr != "") {
printf " - npub: \"%s\"\n alias: \"%s\"\n addresses:\n - transport: %s\n addr: \"%s\"\n priority: %s\n connect_policy: auto_connect\n auto_reconnect: true\n", npub, alias, transport, addr, priority
}
}'
return
fi
# Single-peer shorthand via individual env vars.
if [ -n "${FIPS_PEER_NPUB:-}" ] && [ -n "${FIPS_PEER_ADDR:-}" ]; then
cat <<EOF
- npub: "${FIPS_PEER_NPUB}"
alias: "${FIPS_PEER_ALIAS}"
addresses:
- transport: ${FIPS_PEER_TRANSPORT}
addr: "${FIPS_PEER_ADDR}"
connect_policy: auto_connect
auto_reconnect: true
EOF
return
fi
# No peers configured — start standalone (useful for a new node joining
# via discovery or when peers are added later via fipsctl).
echo " []"
}
# ---------------------------------------------------------------------------
# Build optional TCP transport stanza
# ---------------------------------------------------------------------------
build_tcp_yaml() {
if [ -n "$FIPS_TCP_BIND" ]; then
cat <<EOF
tcp:
bind_addr: "${FIPS_TCP_BIND}"
EOF
fi
}
# ---------------------------------------------------------------------------
# Generate /etc/fips/fips.yaml
# ---------------------------------------------------------------------------
PEERS_YAML="$(build_peers_yaml)"
TCP_YAML="$(build_tcp_yaml)"
cat > /etc/fips/fips.yaml <<EOF
node:
identity:
nsec: "${FIPS_NSEC}"
control:
enabled: true
socket_path: "/run/fips/control.sock"
tun:
enabled: true
name: ${FIPS_TUN_NAME}
mtu: ${FIPS_TUN_MTU}
dns:
enabled: true
bind_addr: "127.0.0.1"
port: 5354
transports:
udp:
bind_addr: "${FIPS_UDP_BIND}"
${TCP_YAML}
peers:
${PEERS_YAML}
EOF
echo "[fips-sidecar] Generated /etc/fips/fips.yaml"
# ---------------------------------------------------------------------------
# Rewrite /etc/resolv.conf to route .fips DNS through dnsmasq
#
# Strategy:
# 1. Harvest the nameservers the kubelet injected.
# 2. Write dnsmasq upstream rules for each harvested nameserver.
# 3. Point /etc/resolv.conf at 127.0.0.1 (dnsmasq).
# 4. dnsmasq forwards .fips → FIPS daemon (127.0.0.1:5354),
# everything else → original cluster DNS (typically 169.254.20.10 or
# the kube-dns ClusterIP injected by kubelet).
# ---------------------------------------------------------------------------
if [ "$FIPS_REWRITE_DNS" = "true" ]; then
# Collect existing upstream nameservers before we overwrite resolv.conf.
UPSTREAM_NS=()
while IFS= read -r line; do
if [[ "$line" =~ ^nameserver[[:space:]]+(.+)$ ]]; then
ns="${BASH_REMATCH[1]}"
# Skip loopback — we're about to replace it.
if [[ "$ns" != "127."* ]] && [[ "$ns" != "::1" ]]; then
UPSTREAM_NS+=("$ns")
fi
fi
done < /etc/resolv.conf
# Append upstream server lines to dnsmasq fips config.
# (The placeholder in the image has no upstream server= lines yet.)
for ns in "${UPSTREAM_NS[@]}"; do
echo "server=${ns}" >> /etc/dnsmasq.d/fips.conf
done
# If no upstream was found fall back to Google (better than no resolution).
if [ "${#UPSTREAM_NS[@]}" -eq 0 ]; then
echo "[fips-sidecar] WARNING: no upstream nameserver found in /etc/resolv.conf; falling back to 8.8.8.8"
echo "server=8.8.8.8" >> /etc/dnsmasq.d/fips.conf
fi
# Preserve the search/domain lines so pod DNS short-names still work.
SEARCH_LINE=""
while IFS= read -r line; do
if [[ "$line" =~ ^(search|domain)[[:space:]] ]]; then
SEARCH_LINE="$line"
break
fi
done < /etc/resolv.conf
{
echo "nameserver 127.0.0.1"
[ -n "$SEARCH_LINE" ] && echo "$SEARCH_LINE"
} > /etc/resolv.conf
echo "[fips-sidecar] DNS rewritten: upstream ${UPSTREAM_NS[*]:-8.8.8.8} → dnsmasq → fips/cluster"
fi
# ---------------------------------------------------------------------------
# iptables isolation
#
# Goal: only FIPS transport traffic may leave/enter the pod via the physical
# interface. All other pod-to-external traffic is dropped. Traffic on the
# fips0 TUN and loopback is unrestricted. The app container sharing this
# network namespace therefore can only communicate over the FIPS mesh.
#
# Skip when FIPS_ISOLATE=false (e.g. already handled by a NetworkPolicy,
# or when the operator wants unrestricted egress alongside mesh traffic).
# ---------------------------------------------------------------------------
if [ "$FIPS_ISOLATE" = "true" ]; then
IFACE="$FIPS_POD_IFACE"
UDP_PORT="$FIPS_UDP_PORT"
# IPv4: allow loopback + FIPS UDP transport; drop everything else on
# the pod's physical interface.
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o "$IFACE" -p udp --dport "$UDP_PORT" -j ACCEPT
iptables -A OUTPUT -o "$IFACE" -p udp --sport "$UDP_PORT" -j ACCEPT
iptables -A INPUT -i "$IFACE" -p udp --dport "$UDP_PORT" -j ACCEPT
iptables -A INPUT -i "$IFACE" -p udp --sport "$UDP_PORT" -j ACCEPT
# Allow TCP transport port when configured.
if [ -n "$FIPS_TCP_BIND" ]; then
TCP_PORT="${FIPS_TCP_BIND##*:}"
iptables -A OUTPUT -o "$IFACE" -p tcp --dport "$TCP_PORT" -j ACCEPT
iptables -A INPUT -i "$IFACE" -p tcp --sport "$TCP_PORT" -j ACCEPT
iptables -A OUTPUT -o "$IFACE" -p tcp --sport "$TCP_PORT" -j ACCEPT
iptables -A INPUT -i "$IFACE" -p tcp --dport "$TCP_PORT" -j ACCEPT
fi
# Allow established/related so existing connections aren't torn down mid-flight.
iptables -A INPUT -i "$IFACE" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -o "$IFACE" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -o "$IFACE" -j DROP
iptables -A INPUT -i "$IFACE" -j DROP
# IPv6: allow loopback and fips TUN; block physical interface entirely.
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A OUTPUT -o "$FIPS_TUN_NAME" -j ACCEPT
ip6tables -A INPUT -i "$FIPS_TUN_NAME" -j ACCEPT
ip6tables -A OUTPUT -o "$IFACE" -j DROP
ip6tables -A INPUT -i "$IFACE" -j DROP
echo "[fips-sidecar] iptables isolation applied on ${IFACE} (UDP ${UDP_PORT})"
fi
# ---------------------------------------------------------------------------
# TCP MSS clamping on fips0
#
# The kernel derives MSS from the TUN MTU: 1280 - 60 = 1220. But FIPS adds
# FIPS_IPV6_OVERHEAD (77) bytes of encapsulation that the kernel doesn't know
# Clamp the MSS in TCP SYN packets traversing fips0 to the path MTU so that
# segments fit within the effective MTU after FIPS encapsulation.
# This is applied regardless of FIPS_ISOLATE mode since it's needed for
# correct TCP negotiation even when normal network access is allowed.
# ---------------------------------------------------------------------------
ip6tables -t mangle -A FORWARD -o "$FIPS_TUN_NAME" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
ip6tables -t mangle -A FORWARD -i "$FIPS_TUN_NAME" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
ip6tables -t mangle -A OUTPUT -o "$FIPS_TUN_NAME" -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
echo "[fips-sidecar] TCP MSS clamped to PMTU on ${FIPS_TUN_NAME}"
# ---------------------------------------------------------------------------
# Start dnsmasq and launch FIPS daemon
# ---------------------------------------------------------------------------
dnsmasq --conf-dir=/etc/dnsmasq.d
echo "[fips-sidecar] dnsmasq started"
exec fips --config /etc/fips/fips.yaml

View File

@@ -0,0 +1,207 @@
# FIPS Kubernetes sidecar — example Pod manifest
#
# WARNING: The Secret below contains a placeholder nsec. Replace it with your
# real key before applying. Do NOT commit real keys to version control.
# Generate a fresh key with: openssl rand -hex 32
#
# Apply:
# kubectl apply -f pod.yaml
#
# Requirements on the node:
# - /dev/net/tun must exist (standard on all modern Linux kernels)
# - The container runtime must honour NET_ADMIN (true for containerd/CRI-O
# with default settings; may require a RuntimeClass tweak for gVisor)
#
# Kubernetes version: 1.28+
# -----------------------------------------------------------------------------
# Secret — node identity
# Replace the nsec value with your real key (hex or nsec1 bech32).
# In production, manage this with a secrets operator (External Secrets,
# Sealed Secrets, Vault Agent, etc.) rather than a plaintext manifest.
# -----------------------------------------------------------------------------
apiVersion: v1
kind: Secret
metadata:
name: fips-identity
type: Opaque
stringData:
nsec: "replace-this-with-your-hex-or-nsec1-secret-key"
---
apiVersion: v1
kind: Pod
metadata:
name: fips-sidecar-example
labels:
app: fips-sidecar-example
spec:
# Containers in the same Pod share the same network namespace automatically.
# The fips sidecar creates fips0; the app container sees it immediately.
#
# Start order: initContainers run first, then all containers start
# concurrently. If your app needs the TUN interface to be ready before it
# starts, use a readiness probe on the sidecar and a dependency in the app
# (or use a startup init container that waits for fips0 to appear).
# net.ipv6.* sysctls are namespaced per-pod in Linux. Declaring them here
# allows the fips sidecar entrypoint to set them at runtime via sysctl(8).
# bindv6only=0 means apps listening on 0.0.0.0 will also receive IPv6
# connections arriving via fips0, without needing to be rewritten.
# These are "unsafe" sysctls and require the kubelet --allowed-unsafe-sysctls
# flag or a corresponding RuntimeClass / PodSecurityPolicy exception.
securityContext:
sysctls:
- name: net.ipv6.conf.all.disable_ipv6
value: "0"
- name: net.ipv6.conf.default.disable_ipv6
value: "0"
- name: net.ipv6.bindv6only
value: "0"
volumes:
- name: tun-device
hostPath:
path: /dev/net/tun
type: CharDevice
# Optional: mount a pre-built fips.yaml instead of generating from env.
# - name: fips-config
# configMap:
# name: fips-config
containers:
# -----------------------------------------------------------------------
# FIPS sidecar — owns the network namespace setup
# -----------------------------------------------------------------------
- name: fips
image: your-registry.example.com/fips-sidecar:latest
imagePullPolicy: Always
securityContext:
capabilities:
add:
- NET_ADMIN # required: TUN creation and iptables
runAsNonRoot: false # fips needs root for iptables/TUN
runAsUser: 0
volumeMounts:
- name: tun-device
mountPath: /dev/net/tun
env:
# --- Identity (required) ---
- name: FIPS_NSEC
valueFrom:
secretKeyRef:
name: fips-identity
key: nsec
# --- Single peer shorthand ---
# Remove these and use FIPS_PEERS_JSON below for multiple peers.
- name: FIPS_PEER_NPUB
value: "" # e.g. npub1abc...
- name: FIPS_PEER_ADDR
value: "" # e.g. 203.0.113.10:2121
- name: FIPS_PEER_ALIAS
value: "gateway"
- name: FIPS_PEER_TRANSPORT
value: "udp"
# --- Multiple peers via JSON (overrides single-peer vars when non-empty) ---
# - name: FIPS_PEERS_JSON
# value: |
# [
# {"npub":"npub1abc...","alias":"gw1","addr":"203.0.113.10:2121","transport":"udp"},
# {"npub":"npub1def...","alias":"gw2","addr":"198.51.100.5:2121","transport":"udp"}
# ]
# --- Transport tuning ---
- name: FIPS_UDP_BIND
value: "0.0.0.0:2121"
# Uncomment to enable TCP transport (useful on networks that block UDP):
# - name: FIPS_TCP_BIND
# value: "0.0.0.0:8443"
# --- TUN interface ---
- name: FIPS_TUN_NAME
value: "fips0"
- name: FIPS_TUN_MTU
value: "1280"
# --- Network isolation ---
# "false" — fips0 is added alongside normal cluster networking. eth0
# continues to work as usual (services, DNS, other pods all reachable).
# "true" — eth0 is locked to FIPS transport only; app can only talk
# via fips0. Use for high-security mesh-only deployments.
- name: FIPS_ISOLATE
value: "false"
# Adjust if your CNI names the pod interface differently (e.g. ens3, net1).
- name: FIPS_POD_IFACE
value: "eth0"
# --- DNS ---
# Set to "false" if you manage pod DNS externally.
- name: FIPS_REWRITE_DNS
value: "true"
# --- Logging ---
- name: RUST_LOG
value: "info"
ports:
- name: fips-udp
containerPort: 2121
protocol: UDP
# - name: fips-tcp
# containerPort: 8443
# protocol: TCP
readinessProbe:
exec:
command: ["fipsctl", "show", "status"]
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
livenessProbe:
exec:
command: ["fipsctl", "show", "status"]
initialDelaySeconds: 15
periodSeconds: 30
failureThreshold: 3
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "500m"
memory: "128Mi"
# -----------------------------------------------------------------------
# App container — shares the network namespace created by the fips sidecar
# -----------------------------------------------------------------------
- name: app
image: your-app:latest
imagePullPolicy: IfNotPresent
# No special capabilities needed — app uses fips0 like a normal interface.
securityContext:
runAsNonRoot: true
# The app sees: lo, eth0 (isolated by iptables), fips0 (mesh).
# All outbound traffic from the app flows through fips0.
# DNS resolution for <npub>.fips names works via dnsmasq on 127.0.0.1.
command: ["sleep", "infinity"] # replace with your actual workload command
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "1000m"
memory: "512Mi"
# Restart policy for the Pod. Use "Always" for long-running workloads.
restartPolicy: Always

View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Build the FIPS Kubernetes sidecar Docker image.
# The build happens entirely inside Docker (multi-arch friendly).
# Usage: ./scripts/build.sh [--tag TAG] [-- <extra docker buildx args>]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DOCKER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PROJECT_ROOT="$(cd "$DOCKER_DIR/../.." && pwd)"
IMAGE_TAG="fips-k8s-sidecar:latest"
while [[ $# -gt 0 ]]; do
case "$1" in
--tag) IMAGE_TAG="$2"; shift 2 ;;
--) shift; break ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [ ! -f "$PROJECT_ROOT/Cargo.toml" ]; then
echo "Error: Cannot find Cargo.toml at $PROJECT_ROOT" >&2
echo "Expected layout: <project-root>/examples/k8s-sidecar/scripts/build.sh" >&2
exit 1
fi
echo "Building Docker image: $IMAGE_TAG"
docker build \
-t "$IMAGE_TAG" \
-f "$DOCKER_DIR/Dockerfile" \
"$@" \
"$PROJECT_ROOT"
echo ""
echo "Done. Image: $IMAGE_TAG"
echo ""
echo "Push to your registry, then apply the example manifest:"
echo " kubectl apply -f examples/k8s-sidecar/pod.yaml"

View File

@@ -0,0 +1,12 @@
# FIPS sidecar default configuration.
# Override these values or create a .env.local file.
# Node identity — generate with: fipsctl keygen
# Must be set before running: export FIPS_NSEC=<your-nsec>
FIPS_NSEC=
# Peer configuration (leave empty for standalone operation)
FIPS_PEER_NPUB=npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98
FIPS_PEER_ADDR=217.77.8.91:2121
FIPS_PEER_ALIAS=vps
FIPS_PEER_TRANSPORT=udp

View File

@@ -0,0 +1,3 @@
fips
fipsctl
fipstop

View File

@@ -2,11 +2,31 @@ FROM debian:trixie-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
iproute2 iputils-ping dnsutils dnsmasq iptables \
openssh-client openssh-server python3 \
tcpdump netcat-openbsd curl iperf3 && \
rm -rf /var/lib/apt/lists/*
# Install nak (Nostr Army Knife) — detect arch and download the correct binary.
# Asset naming: nak-<version>-linux-<arch>
RUN ARCH=$(dpkg --print-architecture) && \
case "$ARCH" in \
amd64) NAK_ARCH="linux-amd64" ;; \
arm64) NAK_ARCH="linux-arm64" ;; \
armhf) NAK_ARCH="linux-arm" ;; \
*) echo "Unsupported arch: $ARCH" && exit 1 ;; \
esac && \
NAK_VERSION=$(curl -sSL --retry 3 \
"https://api.github.com/repos/fiatjaf/nak/releases/latest" \
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') && \
echo "Installing nak ${NAK_VERSION} for ${NAK_ARCH}" && \
curl -sSL --retry 3 \
"https://github.com/fiatjaf/nak/releases/download/${NAK_VERSION}/nak-${NAK_VERSION}-${NAK_ARCH}" \
-o /usr/local/bin/nak && \
chmod +x /usr/local/bin/nak && \
nak --version
# Setup SSH server with no authentication (test only!)
RUN mkdir -p /var/run/sshd && \
ssh-keygen -A && \

View File

@@ -0,0 +1,49 @@
# strfry: official multi-arch image (linux/amd64 + linux/arm64)
# replaces the nostr-rs-relay source build which was amd64-only.
#
# IMPORTANT: use alpine (same musl-based libc as the strfry source image) as
# the final stage. Copying the strfry binary into a glibc-based image such as
# debian:bookworm-slim causes "not found" at exec time because the musl dynamic
# linker (/lib/ld-musl-*.so.1) and its shared libraries are absent there.
FROM ghcr.io/hoytech/strfry:latest AS strfry
FROM alpine:3.18
RUN apk add --no-cache nginx
# nginx: reverse proxy port 80 (IPv4 + IPv6) → strfry 127.0.0.1:7777
RUN printf 'server {\n\
listen 80;\n\
listen [::]:80;\n\
location / {\n\
proxy_pass http://127.0.0.1:7777;\n\
proxy_http_version 1.1;\n\
proxy_read_timeout 1d;\n\
proxy_send_timeout 1d;\n\
proxy_set_header Upgrade $http_upgrade;\n\
proxy_set_header Connection "Upgrade";\n\
proxy_set_header Host $host;\n\
}\n\
}\n' > /etc/nginx/http.d/nostr-relay.conf && \
rm -f /etc/nginx/http.d/default.conf
COPY --from=strfry /app/strfry /usr/local/bin/strfry
# Copy musl shared libraries that strfry depends on from the source image.
COPY --from=strfry \
/usr/lib/liblmdb.so.0 \
/usr/lib/libcrypto.so.50 \
/usr/lib/libssl.so.53 \
/usr/lib/libsecp256k1.so.2 \
/usr/lib/libzstd.so.1 \
/usr/lib/libstdc++.so.6 \
/usr/lib/libgcc_s.so.1 \
/usr/lib/
COPY --from=strfry /lib/libz.so.1 /lib/
WORKDIR /usr/src/app
RUN mkdir -p strfry-db
RUN printf '#!/bin/sh\nnginx\nexec strfry relay\n' > /entrypoint-app.sh && \
chmod +x /entrypoint-app.sh
ENTRYPOINT ["/entrypoint-app.sh"]

View File

@@ -0,0 +1,289 @@
# FIPS Nostr Relay Sidecar
Runs a [strfry](https://github.com/hoytech/strfry) Nostr relay reachable
exclusively over the FIPS mesh. The relay container shares the FIPS
sidecar's network namespace and is isolated from the host network by
iptables — it can only be reached via the node's `.fips` name.
## How to Run
### 1. Build the FIPS binaries
From the repo root, compile FIPS for Linux and copy the binaries into the
Docker build context:
```bash
cd examples/sidecar-nostr-relay
./scripts/build.sh
```
Cross-compilation from macOS is supported via `cargo-zigbuild`.
### 2. Set your node identity
The relay needs a unique FIPS identity. Generate one with:
```bash
fipsctl keygen
```
Then set it in `.env`:
```bash
# .env
FIPS_NSEC=nsec1... # paste your nsec here
```
`FIPS_NSEC` is required — the container will refuse to start without it.
### 3. Start the stack
```bash
docker compose up -d
```
This starts two containers that share a network namespace:
- **fips** — FIPS daemon + dnsmasq. Owns the namespace, creates `fips0`.
- **app** — strfry relay + nginx. Joins the namespace via `network_mode: service:fips`.
### 4. Verify
```bash
# FIPS node is up and has a mesh address:
docker exec sidecar-nostr-relay-fips-1 fipsctl show status
# Relay is listening (should show nginx on :80 and strfry on :7777):
docker exec sidecar-nostr-relay-fips-1 ss -tlnp
# Peer link to the public node is established:
docker exec sidecar-nostr-relay-fips-1 fipsctl show peers
# Or check the logs:
docker compose logs -f
```
### 5. Connect to the relay
Your node's npub (and therefore its `.fips` name) is derived from its keypair:
```bash
docker exec sidecar-nostr-relay-fips-1 fipsctl show status
```
Connect from any FIPS-peered client using the node's npub:
```
ws://npub1xxxx.fips:80
```
## Security Model
The sidecar pattern enforces strict network isolation on the app container:
- **No IPv4 access**: iptables blocks all eth0 traffic except FIPS UDP
transport (port 2121) and TCP transport (port 443). The app container
cannot reach the Docker bridge, the host network, or any IPv4 address.
- **No IPv6 on eth0**: ip6tables blocks all IPv6 traffic on eth0. The app
container cannot use link-local or any Docker-assigned IPv6 addresses.
- **FIPS mesh only**: The only routable network path is through `fips0`
(`fd::/8`). All application traffic traverses the FIPS mesh with
end-to-end encryption.
- **Loopback allowed**: `lo` is unrestricted for inter-process communication
within the shared namespace.
This means the app container treats the FIPS mesh as its sole network. Even
if the application is compromised, it cannot bypass the mesh or communicate
with the transport layer directly.
## Architecture
```text
┌───────────────────────────────────────────────────┐
│ Shared network namespace │
│ │
│ ┌───────────────┐ ┌──────────────────────────┐ │
│ │ fips-sidecar │ │ fips-app │ │
│ │ │ │ │ │
│ │ fips daemon │ │ your workload │ │
│ │ fipsctl │ │ │ │
│ │ dnsmasq │ │ │ │
│ └───────────────┘ └──────────────────────────┘ │
│ │
│ Interfaces: │
│ lo — loopback (unrestricted) │
│ eth0 — Docker bridge (iptables: FIPS only) │
│ fips0 — FIPS TUN (fd::/8, unrestricted) │
└───────────────────────────────────────────────────┘
```
The FIPS sidecar owns the network namespace and creates the `fips0` TUN
interface. The app container joins via `network_mode: service:fips` and
sees the same interfaces. The entrypoint script applies iptables rules
before launching the FIPS daemon:
**IPv4 rules** (iptables):
- ACCEPT on `lo` (both directions)
- ACCEPT UDP sport/dport 2121 on `eth0` (FIPS UDP transport)
- ACCEPT TCP dport 443 / sport 443 on `eth0` (FIPS TCP transport)
- DROP everything else on `eth0`
**IPv6 rules** (ip6tables):
- ACCEPT on `lo` (both directions)
- ACCEPT on `fips0` (both directions)
- DROP everything on `eth0`
### DNS Resolution
DNS inside the container is handled by dnsmasq (127.0.0.1:53):
- `.fips` queries are forwarded to the FIPS daemon's built-in DNS resolver
(127.0.0.1:5354), which resolves npub-based names to `fd::/8` addresses
- All other queries are forwarded to Docker's embedded DNS (127.0.0.11)
The `resolv.conf` mount points the container's resolver at 127.0.0.1,
where dnsmasq handles the routing.
## Build
```bash
cd examples/sidecar-nostr-relay
./scripts/build.sh
```
This compiles FIPS for Linux, copies the binaries into the Docker context,
and builds the sidecar and app images. Cross-compilation from macOS is
supported via `cargo-zigbuild`.
## Run with Peers
To connect the sidecar to an existing mesh, provide the peer's npub and
transport address:
```bash
FIPS_PEER_NPUB=npub1... \
FIPS_PEER_ADDR=203.0.113.10:2121 \
FIPS_PEER_ALIAS=gateway \
docker compose up -d
```
Verify the peer link:
```bash
docker exec sidecar-nostr-relay-fips-1 fipsctl show peers
docker exec sidecar-nostr-relay-fips-1 fipsctl show links
```
## Verify Connectivity and Isolation
From the app container:
```bash
# Ping a mesh node by npub (resolves via .fips DNS):
docker exec sidecar-nostr-relay-app-1 ping6 -c3 npub1xxxx.fips
# Fetch a web page from a mesh node over FIPS:
docker exec sidecar-nostr-relay-app-1 curl -6 "http://npub1xxxx.fips:8000/"
# Docker bridge is blocked — this should fail:
docker exec sidecar-nostr-relay-app-1 ping -c1 -W2 172.20.0.13
# Loopback is allowed:
docker exec sidecar-nostr-relay-app-1 ping -c1 127.0.0.1
```
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `FIPS_NSEC` | *(required)* | Node secret key (hex or nsec1 bech32) |
| `FIPS_PEER_NPUB` | *(empty)* | Peer's npub to connect to |
| `FIPS_PEER_ADDR` | *(empty)* | Peer's transport address (e.g. `203.0.113.10:2121`) |
| `FIPS_PEER_ALIAS` | `peer` | Human-readable peer name |
| `FIPS_UDP_BIND` | `0.0.0.0:2121` | UDP transport bind address |
| `FIPS_TCP_BIND` | `0.0.0.0:8443` | TCP transport bind address |
| `FIPS_PEER_TRANSPORT` | `udp` | Peer transport type (`udp` or `tcp`) |
| `FIPS_TUN_MTU` | `1280` | TUN interface MTU |
| `FIPS_NETWORK` | `fips-sidecar-net` | Docker network name (set to join external network) |
| `FIPS_SUBNET` | `172.20.1.0/24` | Docker network subnet |
| `FIPS_IPV4` | `172.20.1.20` | Sidecar's IPv4 address on the Docker network |
| `RUST_LOG` | `info` | FIPS log level |
## Troubleshooting
**`FIPS_NSEC is required`** — The `FIPS_NSEC` environment variable is not
set. Either add it to `.env` or pass it on the command line. Generate a
random key with: `openssl rand -hex 32`
**`fips0` interface not appearing** — The FIPS daemon needs `/dev/net/tun`
and `NET_ADMIN` capability. Check that the compose file includes both:
```yaml
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
```
**No peer connection established** — Verify the peer address is reachable
from the sidecar container (`docker exec sidecar-nostr-relay-fips-1 ping -c1 <peer-ip>`).
If joining an external Docker network, ensure `FIPS_NETWORK`, `FIPS_SUBNET`,
and `FIPS_IPV4` match the target network. Check logs with
`docker logs sidecar-nostr-relay-fips-1`.
**DNS not resolving `.fips` names** — Verify dnsmasq is running:
`docker exec sidecar-nostr-relay-fips-1 pgrep dnsmasq`. Check that `resolv.conf` is
mounted (should contain `nameserver 127.0.0.1`). Verify the FIPS DNS
resolver is listening: `docker exec sidecar-nostr-relay-fips-1 dig @127.0.0.1 -p 5354 <npub>.fips AAAA`.
**iptables errors in entrypoint** — The sidecar container requires
`NET_ADMIN` capability for iptables. Without it, the isolation rules
cannot be applied and the entrypoint will fail.
## Production Considerations
**Secrets management**: The default `.env` contains a hardcoded nsec for
development. In production, use Docker secrets, a vault, or inject the key
via a secure CI/CD pipeline. Never commit production keys to version control.
**Logging**: Set `RUST_LOG` to control log verbosity (`debug`, `info`,
`warn`, `error`). For production, configure the Docker logging driver with
size limits:
```yaml
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
```
**Resource limits**: Add memory and CPU constraints in the compose file:
```yaml
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"
```
**Multiple peers**: The entrypoint supports a single peer via environment
variables. For multiple peers, mount a custom `fips.yaml` directly:
```yaml
volumes:
- ./my-fips.yaml:/etc/fips/fips.yaml:ro
```
**Health checks**: Add a Docker health check using `fipsctl`:
```yaml
healthcheck:
test: ["CMD", "fipsctl", "show", "status"]
interval: 30s
timeout: 5s
retries: 3
```

View File

@@ -0,0 +1,6 @@
# Override: use a pre-existing external network instead of creating one.
# Used by test-sidecar.sh for multi-instance testing on a shared network.
networks:
fips-net:
external: true
name: ${FIPS_NETWORK:-fips-sidecar-net}

View File

@@ -0,0 +1,54 @@
networks:
fips-net:
name: ${FIPS_NETWORK:-fips-sidecar-net}
driver: bridge
ipam:
config:
- subnet: ${FIPS_SUBNET:-172.20.1.0/24}
services:
fips:
build:
context: .
hostname: fips-sidecar
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
restart: "no"
ports:
- "2121:2121/udp" # FIPS UDP transport
- "8443:8443/tcp" # FIPS TCP transport
- "80:80/tcp" # Nostr relay WebSocket (via nginx)
environment:
- RUST_LOG=${RUST_LOG:-info}
- FIPS_NSEC=${FIPS_NSEC}
- FIPS_PEER_NPUB=${FIPS_PEER_NPUB:-}
- FIPS_PEER_ADDR=${FIPS_PEER_ADDR:-}
- FIPS_PEER_ALIAS=${FIPS_PEER_ALIAS:-peer}
- FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121}
- FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280}
- FIPS_PEER_TRANSPORT=${FIPS_PEER_TRANSPORT:-udp}
volumes:
- ./resolv.conf:/etc/resolv.conf:ro
networks:
fips-net:
ipv4_address: ${FIPS_IPV4:-172.20.1.20}
app:
build:
context: .
dockerfile: Dockerfile.app
network_mode: "service:fips"
restart: unless-stopped
depends_on:
- fips
volumes:
- relay-data:/usr/src/app/strfry-db
- ./relay/strfry.conf:/usr/src/app/strfry.conf:ro
- ./resolv.conf:/etc/resolv.conf:ro
volumes:
relay-data:

View File

@@ -6,6 +6,7 @@ set -e
FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}"
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
FIPS_TCP_BIND="${FIPS_TCP_BIND:-0.0.0.0:8443}"
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-udp}"
@@ -41,7 +42,8 @@ transports:
udp:
bind_addr: "${FIPS_UDP_BIND}"
mtu: 1472
tcp: {}
tcp:
bind_addr: "${FIPS_TCP_BIND}"
peers:
${PEERS_SECTION:- []}

View File

@@ -0,0 +1,49 @@
# nostr-rs-relay configuration for FIPS mesh deployment.
# Full reference: https://github.com/scsibug/nostr-rs-relay
[info]
# Shown in NIP-11 relay information document.
relay_url = "" # Set to ws://[<your-fips-ipv6>]:8080 after first start
name = "FIPS Nostr Relay"
description = "A Nostr relay accessible over the FIPS mesh network."
pubkey = "" # Optional: your npub (hex) as relay operator
contact = "" # Optional: contact address
[database]
# SQLite database path inside the container (mapped to relay-data volume).
data_directory = "/usr/src/app/db"
[network]
# Bind on all interfaces (IPv4 + IPv6) — FIPS delivers traffic via the fips0
# TUN as IPv6 (fd00::/8). Using 0.0.0.0 with port 8080; the relay also needs
# to accept IPv6 connections so we set port separately and rely on the OS
# dual-stack socket (IPV6_V6ONLY=0).
# nostr-rs-relay address field is just the IP, port is separate.
address = "0.0.0.0"
port = 8080
ping_interval_seconds = 300
[limits]
# Adjust to taste. These are conservative defaults for a personal relay.
messages_per_sec = 10
subscriptions_per_min = 20
max_blocking_threads = 4
max_event_bytes = 131072 # 128 KiB per event
max_ws_message_bytes = 131072
max_ws_frame_bytes = 131072
broadcast_buffer = 16384
event_persist_buffer = 4096
[authorization]
# Set to true to require NIP-42 AUTH before accepting events.
# Useful for a private relay — only authenticated users can publish.
nip42_auth = false
nip42_dms = false
[verified_users]
# NIP-05 verification (optional).
mode = "disabled"
[pay_to_relay]
# Lightning payments for relay access (optional).
enabled = false

View File

@@ -0,0 +1,71 @@
##
## strfry configuration for FIPS mesh deployment.
## Full reference: https://github.com/hoytech/strfry
##
db = "/usr/src/app/strfry-db/"
dbParams {
# Maximum size of the database (bytes). 10 GiB is a safe default.
mapsize = 10737418240
}
relay {
# Bind on all interfaces so the FIPS TUN (IPv4 + IPv6) can reach it.
bind = "0.0.0.0"
port = 7777
nofiles = 0
info {
name = "FIPS Nostr Relay"
description = "A Nostr relay accessible over the FIPS mesh network."
pubkey = ""
contact = ""
}
# Maximum size of an inbound WebSocket message (bytes).
maxWebsocketPayloadSize = 131072
# Send a ping every N seconds to keep connections alive.
autoPingSeconds = 55
# Enable per-message compression.
enableTcpNoDelay = false
rejectFutureEventsSeconds = 900
rejectEphemeralEventsOlderThanSeconds = 60
rejectEventsNewerThanSeconds = 900
maxFilterLimit = 500
maxSubsPerConnection = 20
writePolicy {
# Plugin executable for write-policy decisions (leave empty to allow all).
plugin = ""
}
compression {
enabled = true
slidingWindow = true
}
logging {
dumpInAll = false
dumpInEvents = false
dumpInReqs = false
dbScanPerf = false
}
numThreads {
ingester = 3
reqWorker = 3
reqMonitor = 3
negentropy = 2
}
negentropy {
enabled = true
maxSyncEvents = 1000000
}
}

View File

@@ -10,7 +10,7 @@ DOCKER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PROJECT_ROOT="$(cd "$DOCKER_DIR/../.." && pwd)"
if [ ! -f "$PROJECT_ROOT/Cargo.toml" ]; then
echo "Error: Cannot find Cargo.toml at $PROJECT_ROOT" >&2
echo "Expected layout: <project-root>/testing/sidecar/scripts/build.sh" >&2
echo "Expected layout: <project-root>/examples/sidecar-nostr-relay/scripts/build.sh" >&2
exit 1
fi
@@ -54,4 +54,4 @@ echo ""
echo "Building Docker image..."
docker compose -f "$DOCKER_DIR/docker-compose.yml" build
echo ""
echo "Ready: cd testing/sidecar && docker compose up -d"
echo "Ready: cd examples/sidecar-nostr-relay && docker compose up -d"

31
packaging/Makefile Normal file
View File

@@ -0,0 +1,31 @@
# FIPS Packaging Makefile
#
# Builds release packages for supported target platforms.
# All outputs are placed in deploy/ at the project root.
#
# Usage:
# make deb Build a Debian/Ubuntu .deb package
# make tarball Build a systemd install tarball
# make ipk Build an OpenWrt .ipk package
# make all Build deb and tarball (default)
# make clean Remove deploy/ directory
SHELL := /bin/bash
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
.PHONY: all deb tarball ipk clean
all: deb tarball
deb:
@bash $(PACKAGING_DIR)/debian/build-deb.sh
tarball:
@bash $(PACKAGING_DIR)/systemd/build-tarball.sh
ipk:
@bash $(PACKAGING_DIR)/openwrt/build-ipk.sh
clean:
rm -rf $(PROJECT_ROOT)/deploy

86
packaging/README.md Normal file
View File

@@ -0,0 +1,86 @@
# FIPS Packaging
This directory contains packaging for all supported target platforms.
All build outputs go to `deploy/` at the project root.
## Quick Start
```sh
make deb # Debian/Ubuntu .deb
make tarball # systemd install tarball
make ipk # OpenWrt .ipk
make all # deb + tarball (default)
```
## Directory Structure
```text
packaging/
common/ Shared assets (default config, hosts file)
debian/ Debian/Ubuntu .deb packaging via cargo-deb
systemd/ Generic Linux systemd tarball packaging
openwrt/ OpenWrt .ipk packaging via cargo-zigbuild
```
## Formats
### Debian/Ubuntu (`.deb`)
Built with [cargo-deb](https://github.com/kornelski/cargo-deb). Installs
`fips`, `fipsctl`, and `fipstop` to `/usr/bin/`, places config at
`/etc/fips/fips.yaml` (preserved on upgrade), and enables the systemd
service.
```sh
# Build
make deb
# Install
sudo dpkg -i deploy/fips_<version>_<arch>.deb
# Remove (preserves config and keys)
sudo dpkg -r fips
# Purge (removes config and identity keys)
sudo dpkg -P fips
```
### systemd Tarball
A self-contained tarball with binaries and an `install.sh` script for
any systemd-based Linux distribution.
```sh
# Build
make tarball
# Install (on target host)
tar -xzf deploy/fips-<version>-linux-<arch>.tar.gz
sudo ./fips-<version>-linux-<arch>/install.sh
```
See [systemd/README.install.md](systemd/README.install.md) for full
installation and configuration instructions.
### OpenWrt (`.ipk`)
Cross-compiled with cargo-zigbuild and assembled as a standard `.ipk`
archive. Supports aarch64, mipsel, mips, arm, and x86\_64 targets.
```sh
# Build (default: aarch64)
make ipk
# Build for a specific architecture
bash packaging/openwrt/build-ipk.sh --arch mipsel
```
See [openwrt/README.md](openwrt/README.md) for router-specific
installation instructions.
## Shared Assets
`common/` contains assets used across packaging formats:
- `fips.yaml` — default configuration (ephemeral identity, UDP/TCP/TUN/DNS)
- `hosts` — static hostname-to-npub mappings for `.fips` DNS resolution

View File

@@ -43,5 +43,5 @@ peers: []
# alias: "gateway"
# addresses:
# - transport: udp
# addr: "217.77.8.91:2121" # public FIPS testing node
# addr: "217.77.8.91:2121" # IP or hostname (e.g., "peer.example.com:2121")
# connect_policy: auto_connect

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Build a .deb package for FIPS using cargo-deb.
#
# Usage: ./build-deb.sh
# Usage: ./build-deb.sh [--target <triple>] [--version <version>] [--no-build]
#
# Prerequisites: cargo-deb (install with: cargo install cargo-deb)
# Output: deploy/fips_<version>_<arch>.deb
@@ -11,6 +11,48 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="${SCRIPT_DIR}/../.."
usage() {
cat <<'EOF'
Usage: packaging/debian/build-deb.sh [options]
Options:
--target <triple> Rust target triple to build/package
--version <version> Override Debian package version
--no-build Package existing binaries without running cargo build
-h, --help Show this help
EOF
}
TARGET_TRIPLE=""
VERSION_OVERRIDE=""
NO_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
TARGET_TRIPLE="${2:?missing value for --target}"
shift 2
;;
--version)
VERSION_OVERRIDE="${2:?missing value for --version}"
shift 2
;;
--no-build)
NO_BUILD=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
cd "${PROJECT_ROOT}"
# Ensure cargo-deb is available
@@ -19,16 +61,34 @@ if ! command -v cargo-deb &>/dev/null; then
exit 1
fi
# Derive SOURCE_DATE_EPOCH from git if not already set (reproducible builds)
if [ -z "${SOURCE_DATE_EPOCH:-}" ]; then
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
fi
# Build the .deb package
echo "Building .deb package..."
cargo deb
OUTPUT_DIR="$(mktemp -d)"
trap 'rm -rf "${OUTPUT_DIR}"' EXIT
cargo_args=(deb --output "${OUTPUT_DIR}")
if [[ -n "${TARGET_TRIPLE}" ]]; then
cargo_args+=(--target "${TARGET_TRIPLE}")
fi
if [[ -n "${VERSION_OVERRIDE}" ]]; then
cargo_args+=(--deb-version "${VERSION_OVERRIDE}")
fi
if [[ "${NO_BUILD}" -eq 1 ]]; then
cargo_args+=(--no-build)
fi
cargo "${cargo_args[@]}"
# Move output to deploy/
mkdir -p deploy
DEB_FILE=$(find target/debian -name '*.deb' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)
DEB_FILE=$(find "${OUTPUT_DIR}" -maxdepth 1 -name '*.deb' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)
if [ -z "${DEB_FILE}" ]; then
echo "Error: No .deb file found in target/debian/" >&2
echo "Error: No .deb file found in ${OUTPUT_DIR}" >&2
exit 1
fi

View File

@@ -10,11 +10,10 @@ Restart=on-failure
RestartSec=5
# Logging: RUST_LOG controls verbosity.
# Use "info" for production, "debug" for troubleshooting.
Environment=RUST_LOG=info
Environment=RUST_LOG=debug
# Control socket directory (/run/fips/).
# Group-readable so 'fips' group members can use fipsctl/fipstop.
# Group-accessible so 'fips' group members can use fipsctl/fipstop.
RuntimeDirectory=fips
RuntimeDirectoryMode=0750

View File

@@ -109,6 +109,8 @@ cd "$PROJECT_ROOT"
cargo zigbuild \
--release \
--target "$RUST_TARGET" \
--no-default-features \
--features tui \
--bin fips \
--bin fipsctl \
--bin fipstop
@@ -244,7 +246,11 @@ fi
ipk_tar() {
# ipk_tar <output.tar.gz> <source-dir> [paths...]
local out="$1" src="$2"; shift 2
COPYFILE_DISABLE=1 "$TAR_CMD" $TAR_EXTRA_FLAGS -czf "$out" -C "$src" "$@"
local mtime_flags=""
if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then
mtime_flags="--mtime=@$SOURCE_DATE_EPOCH"
fi
COPYFILE_DISABLE=1 "$TAR_CMD" $TAR_EXTRA_FLAGS $mtime_flags -czf "$out" -C "$src" "$@"
}
ipk_tar "$IPK_WORK/control.tar.gz" "$CONTROL_DIR" .

View File

@@ -61,4 +61,4 @@ peers: []
# alias: "gateway"
# addresses:
# - transport: udp
# addr: "1.2.3.4:2121"
# addr: "1.2.3.4:2121" # IP or hostname

View File

@@ -72,7 +72,7 @@ peers:
alias: "gateway"
addresses:
- transport: udp
addr: "217.77.8.91:2121" # public FIPS testing node
addr: "217.77.8.91:2121" # IP or hostname (e.g., "peer.example.com:2121")
connect_policy: auto_connect
```

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Build FIPS release binaries and create an install tarball.
#
# Usage: ./packaging/build-tarball.sh
# Usage: ./packaging/build-tarball.sh [--target <triple>] [--version <version>] [--arch <arch>] [--no-build]
# Output: deploy/fips-<version>-linux-<arch>.tar.gz
set -euo pipefail
@@ -10,29 +10,108 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PACKAGING_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PROJECT_ROOT="$(cd "${PACKAGING_DIR}/.." && pwd)"
# Extract version from Cargo.toml
VERSION=$(grep '^version' "${PROJECT_ROOT}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
ARCH=$(uname -m)
usage() {
cat <<'EOF'
Usage: packaging/systemd/build-tarball.sh [options]
Options:
--target <triple> Rust target triple to build/package
--version <version> Override artifact version
--arch <arch> Override artifact architecture name
--no-build Package existing binaries without running cargo build
-h, --help Show this help
EOF
}
target_to_arch() {
local target="$1"
printf '%s\n' "${target%%-*}"
}
VERSION_OVERRIDE=""
TARGET_TRIPLE=""
ARCH_OVERRIDE=""
NO_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
TARGET_TRIPLE="${2:?missing value for --target}"
shift 2
;;
--version)
VERSION_OVERRIDE="${2:?missing value for --version}"
shift 2
;;
--arch)
ARCH_OVERRIDE="${2:?missing value for --arch}"
shift 2
;;
--no-build)
NO_BUILD=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
VERSION="${VERSION_OVERRIDE:-$(grep '^version' "${PROJECT_ROOT}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')}"
if [[ -n "${ARCH_OVERRIDE}" ]]; then
ARCH="${ARCH_OVERRIDE}"
elif [[ -n "${TARGET_TRIPLE}" ]]; then
ARCH="$(target_to_arch "${TARGET_TRIPLE}")"
else
ARCH="$(uname -m)"
fi
TARBALL_NAME="fips-${VERSION}-linux-${ARCH}"
DEPLOY_DIR="${PROJECT_ROOT}/deploy"
STAGING_DIR="${DEPLOY_DIR}/${TARBALL_NAME}"
STRIP_BIN="${STRIP:-strip}"
if [[ -n "${TARGET_TRIPLE}" ]]; then
BINARY_DIR="${PROJECT_ROOT}/target/${TARGET_TRIPLE}/release"
else
BINARY_DIR="${PROJECT_ROOT}/target/release"
fi
echo "Building FIPS v${VERSION} for ${ARCH}..."
# Build release binaries (tui is a default feature, includes fipstop)
cargo build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml"
if [[ "${NO_BUILD}" -eq 0 ]]; then
cargo_args=(build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml")
if [[ -n "${TARGET_TRIPLE}" ]]; then
cargo_args+=(--target "${TARGET_TRIPLE}")
fi
cargo "${cargo_args[@]}"
fi
# Create staging directory
rm -rf "${STAGING_DIR}"
mkdir -p "${STAGING_DIR}"
# Copy binaries
cp "${PROJECT_ROOT}/target/release/fips" "${STAGING_DIR}/"
cp "${PROJECT_ROOT}/target/release/fipsctl" "${STAGING_DIR}/"
cp "${PROJECT_ROOT}/target/release/fipstop" "${STAGING_DIR}/"
for bin in fips fipsctl fipstop; do
if [[ ! -f "${BINARY_DIR}/${bin}" ]]; then
echo "Missing binary: ${BINARY_DIR}/${bin}" >&2
exit 1
fi
cp "${BINARY_DIR}/${bin}" "${STAGING_DIR}/"
done
# Strip binaries to reduce size
strip "${STAGING_DIR}/fips" "${STAGING_DIR}/fipsctl" "${STAGING_DIR}/fipstop"
if ! command -v "${STRIP_BIN}" &>/dev/null; then
echo "Strip tool not found: ${STRIP_BIN}" >&2
exit 1
fi
"${STRIP_BIN}" "${STAGING_DIR}/fips" "${STAGING_DIR}/fipsctl" "${STAGING_DIR}/fipstop"
# Copy packaging files
cp "${SCRIPT_DIR}/install.sh" "${STAGING_DIR}/"
@@ -45,9 +124,20 @@ cp "${SCRIPT_DIR}/README.install.md" "${STAGING_DIR}/"
chmod +x "${STAGING_DIR}/install.sh" "${STAGING_DIR}/uninstall.sh"
# Create tarball
# Create tarball (reproducible: normalize timestamps, ownership, and sort order)
cd "${DEPLOY_DIR}"
tar czf "${TARBALL_NAME}.tar.gz" "${TARBALL_NAME}/"
# Default SOURCE_DATE_EPOCH to git commit timestamp if not set
if [ -z "${SOURCE_DATE_EPOCH:-}" ]; then
SOURCE_DATE_EPOCH=$(git -C "${PROJECT_ROOT}" log -1 --format=%ct)
export SOURCE_DATE_EPOCH
fi
TAR_REPRO_FLAGS="--mtime=@${SOURCE_DATE_EPOCH} --sort=name"
if tar --version 2>/dev/null | grep -q 'GNU tar'; then
TAR_REPRO_FLAGS="${TAR_REPRO_FLAGS} --numeric-owner --owner=0 --group=0"
fi
COPYFILE_DISABLE=1 tar ${TAR_REPRO_FLAGS} -czf "${TARBALL_NAME}.tar.gz" "${TARBALL_NAME}/"
rm -rf "${STAGING_DIR}"
echo ""

View File

@@ -10,11 +10,10 @@ Restart=on-failure
RestartSec=5
# Logging: RUST_LOG controls verbosity.
# Use "info" for production, "debug" for troubleshooting.
Environment=RUST_LOG=info
Environment=RUST_LOG=debug
# Control socket directory (/run/fips/).
# Group-readable so 'fips' group members can use fipsctl/fipstop.
# Group-accessible so 'fips' group members can use fipsctl/fipstop.
RuntimeDirectory=fips
RuntimeDirectoryMode=0750

83
packaging/torrc.fips Normal file
View File

@@ -0,0 +1,83 @@
### FIPS-specific Tor configuration ###
###
### Reference torrc for running Tor alongside the FIPS daemon.
### Uses HiddenServiceDir (directory mode) for the onion service,
### which enables Sandbox mode — the strongest single hardening option.
###
### Install: cp torrc.fips /etc/tor/torrc
### Verify: tor --verify-config -f /etc/tor/torrc
###
### The corresponding fips.yaml transport section should be:
###
### transports:
### tor:
### mode: "directory"
### directory_service:
### hostname_file: "/var/lib/tor/fips_onion_service/hostname"
### bind_addr: "127.0.0.1:8443"
## Identity
DataDirectory /var/lib/tor
User debian-tor
## SOCKS proxy (for outbound peer connections)
## IsolateSOCKSAuth: username/password fields serve as circuit isolation
## keys — each FIPS peer destination gets its own Tor circuit.
## IsolateDestAddr + IsolateDestPort: additional isolation by destination.
## IsolateSOCKSAuth: required — FIPS uses SOCKS5 username/password fields
## as per-destination circuit isolation keys (not real authentication).
##
## SafeSocks 1 rejects SOCKS5 CONNECT to raw IP addresses. FIPS supports
## DNS hostnames in peer addresses — when clearnet peers use hostnames,
## they are passed through SOCKS5 for Tor-side resolution (no local DNS
## leak). Enable SafeSocks 1 when all clearnet peers use hostnames or
## when running .onion-only. Not enabled by default for compatibility
## with IP-addressed peers.
SocksPort 127.0.0.1:9050 IsolateSOCKSAuth
#TestSocks 1 # Enable during development only
## Control access (Unix socket, not TCP — filesystem permission control)
## Only needed if fips uses control_port mode. In directory mode, the
## control socket is not required but can be useful for monitoring.
ControlSocket /run/tor/control GroupWritable RelaxDirModeCheck
ControlSocketsGroupWritable 1
## Do NOT use ControlPort 9051 — TCP control ports are accessible to any
## local process that authenticates. Use Unix socket only.
## Authentication
CookieAuthentication 1
CookieAuthFileGroupReadable 1
CookieAuthFile /run/tor/control.authcookie
## Onion service (persistent, filesystem-managed by Tor)
## Tor manages the key in HiddenServiceDir. FIPS reads the .onion
## address from the hostname file at startup.
HiddenServiceDir /var/lib/tor/fips_onion_service
HiddenServicePort 8443 127.0.0.1:8443
## Onion service hardening
HiddenServiceMaxStreams 100
HiddenServiceMaxStreamsCloseCircuit 1
## Onion service DoS protection (Tor 0.4.8+)
## Intro point rate limiting — prevents introduction cell floods.
HiddenServiceEnableIntroDoSDefense 1
HiddenServiceEnableIntroDoSRatePerSec 25
HiddenServiceEnableIntroDoSBurstPerSec 200
## Proof-of-work defense — auto-scaling computational puzzle for clients.
## Minimal cost to legitimate peers, expensive for flood attacks.
HiddenServicePoWDefensesEnabled 1
HiddenServicePoWQueueRate 250
HiddenServicePoWQueueBurst 2500
## Security
ExitRelay 0
ExitPolicy reject *:*
Sandbox 1
VanguardsLiteEnabled 1
ConnectionPadding 1
SafeLogging 1
## Logging
Log notice syslog

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "1.94.1"
components = ["rustfmt"]

1
rustfmt.toml Normal file
View File

@@ -0,0 +1 @@
# Use stable defaults

View File

@@ -3,12 +3,12 @@
//! Loads configuration and creates the top-level node instance.
use clap::Parser;
use fips::config::{resolve_identity, IdentitySource};
use fips::config::{IdentitySource, resolve_identity};
use fips::version;
use fips::{Config, Node};
use std::path::PathBuf;
use tracing::{error, info, warn, Level};
use tracing_subscriber::{fmt, EnvFilter};
use tracing::{Level, error, info, warn};
use tracing_subscriber::{EnvFilter, fmt};
/// FIPS mesh network daemon
#[derive(Parser, Debug)]
@@ -31,10 +31,7 @@ async fn main() {
.with_default_directive(Level::INFO.into())
.from_env_lossy();
fmt()
.with_env_filter(filter)
.with_target(true)
.init();
fmt().with_env_filter(filter).with_target(true).init();
let args = Args::parse();
@@ -47,7 +44,11 @@ async fn main() {
match Config::load_file(config_path) {
Ok(config) => (config, vec![config_path.clone()]),
Err(e) => {
error!("Failed to load configuration from {}: {}", config_path.display(), e);
error!(
"Failed to load configuration from {}: {}",
config_path.display(),
e
);
std::process::exit(1);
}
}
@@ -80,8 +81,12 @@ async fn main() {
};
match &resolved.source {
IdentitySource::Config => info!("Using identity from configuration"),
IdentitySource::KeyFile(p) => info!(path = %p.display(), "Loaded persistent identity from key file"),
IdentitySource::Generated(p) => info!(path = %p.display(), "Generated persistent identity, saved to key file"),
IdentitySource::KeyFile(p) => {
info!(path = %p.display(), "Loaded persistent identity from key file")
}
IdentitySource::Generated(p) => {
info!(path = %p.display(), "Generated persistent identity, saved to key file")
}
IdentitySource::Ephemeral => info!("Using ephemeral identity (new keypair each start)"),
}

View File

@@ -1,13 +1,15 @@
//! fipsctl — FIPS control client
//!
//! Connects to the FIPS daemon's Unix domain control socket, sends a
//! query command, and pretty-prints the JSON response.
//! Connects to the FIPS daemon's Unix domain control socket, sends
//! commands, and pretty-prints the JSON response.
use clap::{Parser, Subcommand};
use fips::config::{write_key_file, write_pub_file};
use fips::upper::hosts::HostMap;
use fips::version;
use fips::{encode_nsec, Identity};
use fips::{Identity, encode_nsec};
use std::io::{BufRead, BufReader, Write};
use std::net::{Ipv6Addr, SocketAddrV6};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -18,7 +20,7 @@ use std::time::Duration;
name = "fipsctl",
version = version::short_version(),
long_version = version::long_version(),
about = "Query a running FIPS daemon"
about = "Control a running FIPS daemon"
)]
struct Cli {
/// Control socket path override
@@ -48,6 +50,20 @@ enum Commands {
#[arg(short = 's', long = "stdout")]
stdout: bool,
},
/// Connect to a peer
Connect {
/// Peer identifier: npub (bech32) or hostname from /etc/fips/hosts
peer: String,
/// Transport address (e.g., "192.168.1.1:2121")
address: String,
/// Transport type: udp, tcp, tor, ethernet
transport: String,
},
/// Disconnect a peer
Disconnect {
/// Peer identifier: npub (bech32) or hostname from /etc/fips/hosts
peer: String,
},
}
#[derive(Subcommand, Debug)]
@@ -98,8 +114,10 @@ impl ShowCommands {
///
/// Checks the system-wide path first (used when the daemon runs as a
/// systemd service), then falls back to the user's XDG runtime directory.
/// Uses directory existence rather than socket file existence so the check
/// works even when the user lacks traverse permission on /run/fips/ (0750).
fn default_socket_path() -> PathBuf {
if Path::new("/run/fips/control.sock").exists() {
if Path::new("/run/fips").exists() {
PathBuf::from("/run/fips/control.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
@@ -108,123 +126,58 @@ fn default_socket_path() -> PathBuf {
}
}
fn main() {
let cli = Cli::parse();
// Commands that don't require a running daemon
match &cli.command {
Commands::Keygen {
dir,
force,
stdout,
} => {
let identity = Identity::generate();
let nsec = encode_nsec(&identity.keypair().secret_key());
let npub = identity.npub();
if *stdout {
println!("{}", nsec);
println!("{}", npub);
return;
}
let key_path = dir.join("fips.key");
let pub_path = dir.join("fips.pub");
if key_path.exists() && !force {
eprintln!("error: key file already exists: {}", key_path.display());
eprintln!("Use --force to overwrite.");
std::process::exit(1);
}
if let Err(e) = std::fs::create_dir_all(dir) {
eprintln!("error: cannot create directory {}: {}", dir.display(), e);
std::process::exit(1);
}
if let Err(e) = write_key_file(&key_path, &nsec) {
eprintln!("error: failed to write key file: {}", e);
std::process::exit(1);
}
if let Err(e) = write_pub_file(&pub_path, &npub) {
eprintln!("error: failed to write pub file: {}", e);
std::process::exit(1);
}
eprintln!("{}", npub);
eprintln!("Key files written to: {}/", dir.display());
eprintln!();
eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml");
eprintln!(" or these keys will be overwritten on next daemon start.");
return;
}
Commands::Show { .. } => {}
}
let socket_path = cli.socket.unwrap_or_else(default_socket_path);
let command_name = match &cli.command {
Commands::Show { what } => what.command_name(),
Commands::Keygen { .. } => unreachable!(),
};
// Connect to the control socket
let mut stream = match UnixStream::connect(&socket_path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"error: cannot connect to {}: {}",
/// Send a JSON request to the control socket and return the response.
fn send_request(socket_path: &Path, request_json: &str) -> Result<serde_json::Value, String> {
let mut stream = UnixStream::connect(socket_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
format!(
"cannot connect to {}: {}\n\
Hint: add your user to the 'fips' group: sudo usermod -aG fips $USER\n\
Then log out and back in for the change to take effect.",
socket_path.display(),
e
);
if e.kind() == std::io::ErrorKind::PermissionDenied {
eprintln!(
"Hint: add your user to the 'fips' group: sudo usermod -aG fips $USER"
);
eprintln!("Then log out and back in for the change to take effect.");
} else {
eprintln!("Is the FIPS daemon running?");
}
std::process::exit(1);
)
} else {
format!(
"cannot connect to {}: {}\nIs the FIPS daemon running?",
socket_path.display(),
e
)
}
};
})?;
// Set timeouts
let timeout = Duration::from_secs(2);
let timeout = Duration::from_secs(5);
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
// Send request
let request = format!("{{\"command\":\"{}\"}}\n", command_name);
if let Err(e) = stream.write_all(request.as_bytes()) {
eprintln!("error: failed to send request: {}", e);
std::process::exit(1);
}
// Shutdown write half to signal end of request
stream
.write_all(request_json.as_bytes())
.map_err(|e| format!("failed to send request: {e}"))?;
let _ = stream.shutdown(std::net::Shutdown::Write);
// Read response
let reader = BufReader::new(&stream);
let response_line = match reader.lines().next() {
Some(Ok(line)) => line,
Some(Err(e)) => {
eprintln!("error: failed to read response: {}", e);
std::process::exit(1);
}
None => {
eprintln!("error: no response from daemon");
std::process::exit(1);
}
};
let line = reader
.lines()
.next()
.ok_or("no response from daemon")?
.map_err(|e| format!("failed to read response: {e}"))?;
// Parse and pretty-print
let Ok(value) = serde_json::from_str::<serde_json::Value>(&response_line) else {
// Not JSON, print raw
println!("{}", response_line);
return;
};
serde_json::from_str(&line).map_err(|e| format!("invalid response JSON: {e}"))
}
/// Build a request JSON string for a simple command (no params).
fn build_query(command: &str) -> String {
format!("{{\"command\":\"{command}\"}}\n")
}
/// Build a request JSON string for a command with params.
fn build_command(command: &str, params: serde_json::Value) -> String {
let req = serde_json::json!({"command": command, "params": params});
format!("{}\n", serde_json::to_string(&req).unwrap())
}
/// Print a control socket response, handling error status.
fn print_response(value: &serde_json::Value) {
let status = value
.get("status")
.and_then(|v| v.as_str())
@@ -235,15 +188,223 @@ fn main() {
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
eprintln!("error: {}", msg);
eprintln!("error: {msg}");
std::process::exit(1);
}
// Pretty-print the data field (or whole response if no data field)
let output = if let Some(data) = value.get("data") {
serde_json::to_string_pretty(data).unwrap_or(response_line)
serde_json::to_string_pretty(data)
} else {
serde_json::to_string_pretty(&value).unwrap_or(response_line)
serde_json::to_string_pretty(value)
};
println!("{}", output);
println!("{}", output.unwrap_or_else(|_| format!("{value}")));
}
/// Check if `address` is an IPv6 literal in `fd00::/8` (FIPS mesh ULA range).
///
/// Handles three common syntaxes:
/// - bare IPv6: `fd9d:...`
/// - bracketed + port: `[fd9d:...]:2121`
/// - bare IPv6 + port: `fd9d:...:2121` (ambiguous; accepted if tail is numeric)
fn is_fips_mesh_address(address: &str) -> bool {
let is_ula = |a: &Ipv6Addr| a.octets()[0] == 0xfd;
if let Ok(a) = address.parse::<Ipv6Addr>() {
return is_ula(&a);
}
if let Ok(sa) = address.parse::<SocketAddrV6>() {
return is_ula(sa.ip());
}
if let Some((host, port)) = address.rsplit_once(':')
&& port.chars().all(|c| c.is_ascii_digit())
&& !port.is_empty()
{
let host = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(a) = host.parse::<Ipv6Addr>() {
return is_ula(&a);
}
}
false
}
/// Reject `fd00::/8` addresses for transports that expect a reachable network endpoint.
///
/// FIPS mesh ULAs are derived from npubs and only make sense as destinations
/// inside an already-established mesh — they are not valid udp/tcp/ethernet
/// transport endpoints. Without this check the CLI echoes success while the
/// daemon rejects the bind with EAFNOSUPPORT (issue #61).
fn validate_connect_address(address: &str, transport: &str) -> Result<(), String> {
let checked = matches!(transport, "udp" | "tcp" | "ethernet");
if checked && is_fips_mesh_address(address) {
return Err(format!(
"'{address}' is a FIPS mesh address (fd00::/8), not a reachable {transport} endpoint.\n\
Provide the peer's routable IP/hostname and port (e.g., '192.0.2.1:2121' or 'peer.example.com:2121')."
));
}
Ok(())
}
/// Resolve a peer identifier to an npub.
///
/// If the identifier starts with "npub1", it's returned as-is.
/// Otherwise, it's looked up as a hostname in /etc/fips/hosts.
fn resolve_peer(peer: &str) -> String {
if peer.starts_with("npub1") {
return peer.to_string();
}
let hosts = HostMap::load_hosts_file(Path::new(fips::upper::hosts::DEFAULT_HOSTS_PATH));
match hosts.lookup_npub(peer) {
Some(npub) => npub.to_string(),
None => {
eprintln!("error: unknown host '{peer}'");
eprintln!("Not found in /etc/fips/hosts and not an npub.");
std::process::exit(1);
}
}
}
fn main() {
let cli = Cli::parse();
// Commands that don't require a running daemon
if let Commands::Keygen { dir, force, stdout } = &cli.command {
let identity = Identity::generate();
let nsec = encode_nsec(&identity.keypair().secret_key());
let npub = identity.npub();
if *stdout {
println!("{nsec}");
println!("{npub}");
return;
}
let key_path = dir.join("fips.key");
let pub_path = dir.join("fips.pub");
if key_path.exists() && !force {
eprintln!("error: key file already exists: {}", key_path.display());
eprintln!("Use --force to overwrite.");
std::process::exit(1);
}
if let Err(e) = std::fs::create_dir_all(dir) {
eprintln!("error: cannot create directory {}: {e}", dir.display());
std::process::exit(1);
}
if let Err(e) = write_key_file(&key_path, &nsec) {
eprintln!("error: failed to write key file: {e}");
std::process::exit(1);
}
if let Err(e) = write_pub_file(&pub_path, &npub) {
eprintln!("error: failed to write pub file: {e}");
std::process::exit(1);
}
eprintln!("{npub}");
eprintln!("Key files written to: {}/", dir.display());
eprintln!();
eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml");
eprintln!(" or these keys will be overwritten on next daemon start.");
return;
}
let socket_path = cli.socket.unwrap_or_else(default_socket_path);
let request = match &cli.command {
Commands::Show { what } => build_query(what.command_name()),
Commands::Connect {
peer,
address,
transport,
} => {
if let Err(e) = validate_connect_address(address, transport) {
eprintln!("error: {e}");
std::process::exit(1);
}
let npub = resolve_peer(peer);
build_command(
"connect",
serde_json::json!({
"npub": npub,
"address": address,
"transport": transport,
}),
)
}
Commands::Disconnect { peer } => {
let npub = resolve_peer(peer);
build_command("disconnect", serde_json::json!({"npub": npub}))
}
Commands::Keygen { .. } => unreachable!(),
};
match send_request(&socket_path, &request) {
Ok(value) => print_response(&value),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_bare_ula_literal() {
assert!(is_fips_mesh_address("fd9d:abcd::1"));
assert!(is_fips_mesh_address("fd00::"));
assert!(is_fips_mesh_address(
"fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
));
}
#[test]
fn detects_bracketed_ula_with_port() {
assert!(is_fips_mesh_address("[fd9d:abcd::1]:2121"));
assert!(is_fips_mesh_address("[fd00::1]:8443"));
}
#[test]
fn detects_bare_ula_with_port() {
assert!(is_fips_mesh_address("fd9d:abcd::1:2121"));
}
#[test]
fn rejects_non_ula_ipv6() {
// fc00::/7 other half (fcXX:) is also ULA but not fd00::/8 — we only
// block the fd-prefixed half that FIPS actually uses.
assert!(!is_fips_mesh_address("fc00::1"));
assert!(!is_fips_mesh_address("::1"));
assert!(!is_fips_mesh_address("2001:db8::1"));
assert!(!is_fips_mesh_address("[2001:db8::1]:2121"));
}
#[test]
fn ignores_ipv4_and_hostnames() {
assert!(!is_fips_mesh_address("192.0.2.1:2121"));
assert!(!is_fips_mesh_address("peer.example.com:2121"));
assert!(!is_fips_mesh_address("coinos.pro:2121"));
}
#[test]
fn validates_only_target_transports() {
assert!(validate_connect_address("fd9d::1:2121", "udp").is_err());
assert!(validate_connect_address("fd9d::1:2121", "tcp").is_err());
assert!(validate_connect_address("fd9d::1:2121", "ethernet").is_err());
// Other transports are not inspected — they may legitimately accept
// non-IP endpoints (tor onion, etc.).
assert!(validate_connect_address("fd9d::1:2121", "tor").is_ok());
}
#[test]
fn allows_valid_endpoints() {
assert!(validate_connect_address("192.0.2.1:2121", "udp").is_ok());
assert!(validate_connect_address("peer.example.com:2121", "tcp").is_ok());
assert!(validate_connect_address("[2001:db8::1]:2121", "udp").is_ok());
}
}

View File

@@ -94,10 +94,7 @@ impl Tab {
/// Whether this tab has a table view with row selection.
pub fn has_table(&self) -> bool {
matches!(
self,
Tab::Peers | Tab::Sessions | Tab::Transports
)
matches!(self, Tab::Peers | Tab::Sessions | Tab::Transports)
}
}
@@ -172,7 +169,10 @@ impl App {
return;
}
let state = self.table_states.entry(self.active_tab).or_default();
let i = state.selected().map(|s| (s + 1).min(count - 1)).unwrap_or(0);
let i = state
.selected()
.map(|s| (s + 1).min(count - 1))
.unwrap_or(0);
state.select(Some(i));
}

View File

@@ -17,27 +17,29 @@ impl EventHandler {
pub fn new(tick_rate: Duration) -> Self {
let (tx, rx) = mpsc::channel();
thread::spawn(move || loop {
if event::poll(tick_rate).unwrap_or(false) {
if let Ok(evt) = event::read() {
match evt {
CrosstermEvent::Key(key) => {
if tx.send(Event::Key(key)).is_err() {
return;
thread::spawn(move || {
loop {
if event::poll(tick_rate).unwrap_or(false) {
if let Ok(evt) = event::read() {
match evt {
CrosstermEvent::Key(key) => {
if tx.send(Event::Key(key)).is_err() {
return;
}
}
}
CrosstermEvent::Resize(..) => {
if tx.send(Event::Resize).is_err() {
return;
CrosstermEvent::Resize(..) => {
if tx.send(Event::Resize).is_err() {
return;
}
}
_ => {}
}
_ => {}
}
}
} else {
// Poll timed out — send a tick
if tx.send(Event::Tick).is_err() {
return;
} else {
// Poll timed out — send a tick
if tx.send(Event::Tick).is_err() {
return;
}
}
}
});

View File

@@ -34,8 +34,10 @@ struct Cli {
///
/// Checks the system-wide path first (used when the daemon runs as a
/// systemd service), then falls back to the user's XDG runtime directory.
/// Uses directory existence rather than socket file existence so the check
/// works even when the user lacks traverse permission on /run/fips/ (0750).
fn default_socket_path() -> PathBuf {
if Path::new("/run/fips/control.sock").exists() {
if Path::new("/run/fips").exists() {
PathBuf::from("/run/fips/control.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))

View File

@@ -12,8 +12,8 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Bloom) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
@@ -22,7 +22,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(7), // Bloom Filter State
Constraint::Length(15), // Bloom Announce Stats
Constraint::Min(3), // Peer Filters
Constraint::Min(3), // Peer Filters
])
.split(area);
@@ -64,16 +64,28 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
helpers::section_header("Inbound"),
helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")),
helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")),
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "stats", "decode_error")),
helpers::kv_line(
"Decode Error",
&helpers::nested_u64(data, "stats", "decode_error"),
),
helpers::kv_line("Invalid", &helpers::nested_u64(data, "stats", "invalid")),
helpers::kv_line("Non-V1", &helpers::nested_u64(data, "stats", "non_v1")),
helpers::kv_line("Unknown Peer", &helpers::nested_u64(data, "stats", "unknown_peer")),
helpers::kv_line(
"Unknown Peer",
&helpers::nested_u64(data, "stats", "unknown_peer"),
),
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
Line::from(""),
helpers::section_header("Outbound"),
helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")),
helpers::kv_line("Debounce Suppressed", &helpers::nested_u64(data, "stats", "debounce_suppressed")),
helpers::kv_line("Send Failed", &helpers::nested_u64(data, "stats", "send_failed")),
helpers::kv_line(
"Debounce Suppressed",
&helpers::nested_u64(data, "stats", "debounce_suppressed"),
),
helpers::kv_line(
"Send Failed",
&helpers::nested_u64(data, "stats", "send_failed"),
),
];
let max_lines = inner.height as usize;
@@ -97,8 +109,7 @@ fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
frame.render_widget(block, area);
if filters.is_empty() {
let msg =
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
let msg = Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}

View File

@@ -12,18 +12,18 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&crate::app::Tab::Node) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
let chunks = Layout::vertical([
Constraint::Length(7), // Runtime
Constraint::Length(7), // Identity
Constraint::Length(5), // State
Constraint::Length(9), // Traffic
Constraint::Length(7), // Runtime
Constraint::Length(7), // Identity
Constraint::Length(5), // State
Constraint::Length(9), // Traffic
Constraint::Min(0), // remaining
])
.split(area);
@@ -35,16 +35,17 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
}
fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Runtime ");
let block = Block::default().borders(Borders::ALL).title(" Runtime ");
let inner = block.inner(area);
frame.render_widget(block, area);
let version = helpers::str_field(data, "version");
let pid = helpers::u64_field(data, "pid");
let exe = helpers::str_field(data, "exe_path");
let uptime_secs = data.get("uptime_secs").and_then(|v| v.as_u64()).unwrap_or(0);
let uptime_secs = data
.get("uptime_secs")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let uptime = format_uptime(uptime_secs);
let socket = helpers::str_field(data, "control_socket");
let tun_name = helpers::str_field(data, "tun_name");
@@ -78,9 +79,7 @@ fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
}
fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Identity ");
let block = Block::default().borders(Borders::ALL).title(" Identity ");
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -109,9 +108,7 @@ fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
}
fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" State ");
let block = Block::default().borders(Borders::ALL).title(" State ");
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -167,9 +164,7 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
}
fn draw_node_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Traffic ");
let block = Block::default().borders(Borders::ALL).title(" Traffic ");
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -197,7 +192,10 @@ fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str
.and_then(|f| f.get(byte_key))
.and_then(|v| v.as_u64())
.unwrap_or(0);
helpers::kv_line(label, &format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)))
helpers::kv_line(
label,
&format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)),
)
}
/// Format seconds as human-readable uptime (e.g., "3d 2h 15m 4s").

View File

@@ -119,7 +119,13 @@ pub fn nested_f64(data: &Value, outer: &str, inner: &str, decimals: usize) -> St
}
/// Get a nested f64 field, preferring `preferred` key with fallback to `fallback` key.
pub fn nested_f64_prefer(data: &Value, outer: &str, preferred: &str, fallback: &str, decimals: usize) -> String {
pub fn nested_f64_prefer(
data: &Value,
outer: &str,
preferred: &str,
fallback: &str,
decimals: usize,
) -> String {
data.get(outer)
.and_then(|o| o.get(preferred).or_else(|| o.get(fallback)))
.and_then(|v| v.as_f64())
@@ -161,11 +167,7 @@ pub fn section_header(title: &str) -> Line<'static> {
/// Key-value line for detail views.
pub fn kv_line(key: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {key}: "),
Style::default().fg(Color::DarkGray),
),
Span::styled(format!(" {key}: "), Style::default().fg(Color::DarkGray)),
Span::raw(value.to_string()),
])
}

View File

@@ -12,18 +12,15 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Mmp) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
let chunks = Layout::vertical([
Constraint::Percentage(60),
Constraint::Percentage(40),
])
.split(area);
let chunks =
Layout::vertical([Constraint::Percentage(60), Constraint::Percentage(40)]).split(area);
draw_link_mmp(frame, data, chunks[0]);
draw_session_mmp(frame, data, chunks[1]);
@@ -44,8 +41,7 @@ fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
frame.render_widget(block, area);
if peers.is_empty() {
let msg =
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
let msg = Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
@@ -152,8 +148,7 @@ fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
frame.render_widget(block, area);
if sessions.is_empty() {
let msg = Paragraph::new(" No sessions")
.style(Style::default().fg(Color::DarkGray));
let msg = Paragraph::new(" No sessions").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
@@ -234,4 +229,3 @@ fn trend_color(trend: &str, bad_rising: bool) -> Color {
_ => Color::DarkGray, // "stable"
}
}

View File

@@ -19,7 +19,7 @@ use crate::app::{App, ConnectionState, Tab};
pub fn draw(frame: &mut Frame, app: &mut App) {
let chunks = Layout::vertical([
Constraint::Length(3), // tab bar
Constraint::Min(1), // content
Constraint::Min(1), // content
Constraint::Length(1), // status bar
])
.split(frame.area());
@@ -51,7 +51,11 @@ fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect) {
spans.push(Span::styled(" | ", divider));
}
}
let style = if *tab == app.active_tab { highlight } else { normal };
let style = if *tab == app.active_tab {
highlight
} else {
normal
};
spans.push(Span::styled(tab.label(), style));
}

View File

@@ -2,7 +2,9 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table};
use ratatui::widgets::{
Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
};
use crate::app::{App, Tab};
@@ -14,11 +16,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
if app.detail_view.is_some() {
// Split: left 40% table, right 60% detail
let chunks = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(area);
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
draw_table(frame, app, chunks[0], &peers, row_count);
draw_detail(frame, app, chunks[1], &peers);
@@ -29,7 +28,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
/// Get peers sorted by LQI ascending (best first). Peers without LQI sort last.
fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
let mut peers = app.data
let mut peers = app
.data
.get(&Tab::Peers)
.and_then(|v| v.get("peers"))
.and_then(|v| v.as_array())
@@ -37,8 +37,14 @@ fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
.unwrap_or_default();
peers.sort_by(|a, b| {
let lqi_a = a.get("mmp").and_then(|m| m.get("lqi")).and_then(|v| v.as_f64());
let lqi_b = b.get("mmp").and_then(|m| m.get("lqi")).and_then(|v| v.as_f64());
let lqi_a = a
.get("mmp")
.and_then(|m| m.get("lqi"))
.and_then(|v| v.as_f64());
let lqi_b = b
.get("mmp")
.and_then(|m| m.get("lqi"))
.and_then(|v| v.as_f64());
match (lqi_a, lqi_b) {
(Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Less,
@@ -50,7 +56,13 @@ fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
peers
}
fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json::Value], row_count: usize) {
fn draw_table(
frame: &mut Frame,
app: &mut App,
area: Rect,
peers: &[serde_json::Value],
row_count: usize,
) {
let header = Row::new(vec![
Cell::from("Name"),
Cell::from("Npub"),
@@ -74,13 +86,25 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json:
.map(|peer| {
let name = helpers::str_field(peer, "display_name");
let npub = helpers::str_field(peer, "npub");
let is_parent = peer.get("is_parent").and_then(|v| v.as_bool()).unwrap_or(false);
let is_child = peer.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
let is_parent = peer
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_child = peer
.get("is_child")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Transport: "type addr" (e.g., "udp 1.2.3.4:2121")
let transport = {
let t_type = peer.get("transport_type").and_then(|v| v.as_str()).unwrap_or("");
let t_addr = peer.get("transport_addr").and_then(|v| v.as_str()).unwrap_or("");
let t_type = peer
.get("transport_type")
.and_then(|v| v.as_str())
.unwrap_or("");
let t_addr = peer
.get("transport_addr")
.and_then(|v| v.as_str())
.unwrap_or("");
if t_type.is_empty() && t_addr.is_empty() {
"-".to_string()
} else if t_type.is_empty() {
@@ -92,11 +116,15 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json:
}
};
let dir = peer.get("direction").and_then(|v| v.as_str()).map(|d| match d {
"inbound" => "in",
"outbound" => "out",
other => other,
}).unwrap_or("-");
let dir = peer
.get("direction")
.and_then(|v| v.as_str())
.map(|d| match d {
"inbound" => "in",
"outbound" => "out",
other => other,
})
.unwrap_or("-");
let srtt = helpers::nested_f64(peer, "mmp", "srtt_ms", 1);
let loss = helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 3);
let lqi = helpers::nested_f64(peer, "mmp", "lqi", 2);
@@ -130,16 +158,16 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json:
.collect();
let widths = [
Constraint::Min(12), // Name
Constraint::Length(67), // Npub (full bech32)
Constraint::Min(20), // Transport
Constraint::Length(4), // Dir
Constraint::Length(8), // SRTT
Constraint::Length(7), // Loss
Constraint::Length(6), // LQI
Constraint::Length(10), // Goodput
Constraint::Length(9), // Pkts Tx
Constraint::Length(9), // Pkts Rx
Constraint::Min(12), // Name
Constraint::Length(67), // Npub (full bech32)
Constraint::Min(20), // Transport
Constraint::Length(4), // Dir
Constraint::Length(8), // SRTT
Constraint::Length(7), // Loss
Constraint::Length(6), // LQI
Constraint::Length(10), // Goodput
Constraint::Length(9), // Pkts Tx
Constraint::Length(9), // Pkts Rx
];
let table = Table::new(rows, widths)
@@ -189,10 +217,22 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
return;
};
let has_tree = peer.get("has_tree_position").and_then(|v| v.as_bool()).unwrap_or(false);
let has_bloom = peer.get("has_bloom_filter").and_then(|v| v.as_bool()).unwrap_or(false);
let is_parent = peer.get("is_parent").and_then(|v| v.as_bool()).unwrap_or(false);
let is_child = peer.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
let has_tree = peer
.get("has_tree_position")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let has_bloom = peer
.get("has_bloom_filter")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_parent = peer
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_child = peer
.get("is_child")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let tree_role = if is_parent {
"parent"
@@ -215,7 +255,12 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
helpers::section_header("Connection"),
helpers::kv_line("Connectivity", helpers::str_field(peer, "connectivity")),
helpers::kv_line("Link ID", &helpers::u64_field(peer, "link_id")),
helpers::kv_line("Direction", peer.get("direction").and_then(|v| v.as_str()).unwrap_or("-")),
helpers::kv_line(
"Direction",
peer.get("direction")
.and_then(|v| v.as_str())
.unwrap_or("-"),
),
];
if let Some(addr) = peer.get("transport_addr").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Transport Addr", addr));
@@ -227,30 +272,52 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
let link_id = peer.get("link_id").and_then(|v| v.as_u64());
let link = lookup_link(app, link_id);
if let Some(ref link) = link {
lines.push(helpers::kv_line("Link State", helpers::str_field(link, "state")));
lines.push(helpers::kv_line(
"Link State",
helpers::str_field(link, "state"),
));
}
lines.extend([
helpers::kv_line("Authenticated", &helpers::format_elapsed_ms(
peer.get("authenticated_at_ms").and_then(|v| v.as_u64()).unwrap_or(0),
)),
helpers::kv_line("Last Seen", &helpers::format_elapsed_ms(
peer.get("last_seen_ms").and_then(|v| v.as_u64()).unwrap_or(0),
)),
helpers::kv_line(
"Authenticated",
&helpers::format_elapsed_ms(
peer.get("authenticated_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
helpers::kv_line(
"Last Seen",
&helpers::format_elapsed_ms(
peer.get("last_seen_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
Line::from(""),
]);
// Transport info (cross-referenced from link -> transport)
if let Some(transport) = link.as_ref().and_then(|l| lookup_transport(app, l)) {
lines.push(helpers::section_header("Transport"));
lines.push(helpers::kv_line("Type", helpers::str_field(&transport, "type")));
lines.push(helpers::kv_line(
"Type",
helpers::str_field(&transport, "type"),
));
if let Some(name) = transport.get("name").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Name", name));
}
lines.push(helpers::kv_line("MTU", &helpers::u64_field(&transport, "mtu")));
lines.push(helpers::kv_line(
"MTU",
&helpers::u64_field(&transport, "mtu"),
));
if let Some(addr) = transport.get("local_addr").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Local Addr", addr));
}
lines.push(helpers::kv_line("State", helpers::str_field(&transport, "state")));
lines.push(helpers::kv_line(
"State",
helpers::str_field(&transport, "state"),
));
lines.push(Line::from(""));
}
@@ -268,28 +335,70 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
Line::from(""),
// Stats
helpers::section_header("Link Stats"),
helpers::kv_line("Pkts Sent", &helpers::nested_u64(peer, "stats", "packets_sent")),
helpers::kv_line("Pkts Recv", &helpers::nested_u64(peer, "stats", "packets_recv")),
helpers::kv_line("Bytes Sent", &helpers::format_bytes(
peer.get("stats").and_then(|s| s.get("bytes_sent")).and_then(|v| v.as_u64()).unwrap_or(0),
)),
helpers::kv_line("Bytes Recv", &helpers::format_bytes(
peer.get("stats").and_then(|s| s.get("bytes_recv")).and_then(|v| v.as_u64()).unwrap_or(0),
)),
helpers::kv_line(
"Pkts Sent",
&helpers::nested_u64(peer, "stats", "packets_sent"),
),
helpers::kv_line(
"Pkts Recv",
&helpers::nested_u64(peer, "stats", "packets_recv"),
),
helpers::kv_line(
"Bytes Sent",
&helpers::format_bytes(
peer.get("stats")
.and_then(|s| s.get("bytes_sent"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
helpers::kv_line(
"Bytes Recv",
&helpers::format_bytes(
peer.get("stats")
.and_then(|s| s.get("bytes_recv"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
Line::from(""),
]);
// MMP (if present)
if peer.get("mmp").is_some() {
lines.push(helpers::section_header("MMP Metrics"));
lines.push(helpers::kv_line("Mode", &helpers::nested_str(peer, "mmp", "mode")));
lines.push(helpers::kv_line("SRTT", &format!("{}ms", helpers::nested_f64(peer, "mmp", "srtt_ms", 1))));
lines.push(helpers::kv_line("Loss Rate", &helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 4)));
lines.push(helpers::kv_line("ETX", &helpers::nested_f64_prefer(peer, "mmp", "smoothed_etx", "etx", 2)));
lines.push(helpers::kv_line("LQI", &helpers::nested_f64(peer, "mmp", "lqi", 2)));
lines.push(helpers::kv_line("Goodput", &helpers::nested_throughput(peer, "mmp", "goodput_bps")));
lines.push(helpers::kv_line("Delivery Fwd", &helpers::nested_f64(peer, "mmp", "delivery_ratio_forward", 3)));
lines.push(helpers::kv_line("Delivery Rev", &helpers::nested_f64(peer, "mmp", "delivery_ratio_reverse", 3)));
lines.push(helpers::kv_line(
"Mode",
&helpers::nested_str(peer, "mmp", "mode"),
));
lines.push(helpers::kv_line(
"SRTT",
&format!("{}ms", helpers::nested_f64(peer, "mmp", "srtt_ms", 1)),
));
lines.push(helpers::kv_line(
"Loss Rate",
&helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 4),
));
lines.push(helpers::kv_line(
"ETX",
&helpers::nested_f64_prefer(peer, "mmp", "smoothed_etx", "etx", 2),
));
lines.push(helpers::kv_line(
"LQI",
&helpers::nested_f64(peer, "mmp", "lqi", 2),
));
lines.push(helpers::kv_line(
"Goodput",
&helpers::nested_throughput(peer, "mmp", "goodput_bps"),
));
lines.push(helpers::kv_line(
"Delivery Fwd",
&helpers::nested_f64(peer, "mmp", "delivery_ratio_forward", 3),
));
lines.push(helpers::kv_line(
"Delivery Rev",
&helpers::nested_f64(peer, "mmp", "delivery_ratio_reverse", 3),
));
}
let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0);
@@ -320,4 +429,3 @@ fn lookup_transport(app: &App, link: &serde_json::Value) -> Option<serde_json::V
.find(|t| t.get("transport_id").and_then(|v| v.as_u64()) == Some(transport_id))
.cloned()
}

View File

@@ -12,16 +12,16 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Routing) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
let chunks = Layout::vertical([
Constraint::Length(7), // Routing State
Constraint::Length(8), // Coordinate Cache
Constraint::Length(7), // Routing State
Constraint::Length(8), // Coordinate Cache
Constraint::Min(3), // Routing Statistics
])
.split(area);
@@ -33,7 +33,10 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
fn draw_routing_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let lines = vec![
helpers::kv_line("Coord Cache", &helpers::u64_field(data, "coord_cache_entries")),
helpers::kv_line(
"Coord Cache",
&helpers::u64_field(data, "coord_cache_entries"),
),
helpers::kv_line(
"Identity Cache",
&helpers::u64_field(data, "identity_cache_entries"),
@@ -68,7 +71,10 @@ fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str
.and_then(|f| f.get(byte_key))
.and_then(|v| v.as_u64())
.unwrap_or(0);
helpers::kv_line(label, &format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)))
helpers::kv_line(
label,
&format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)),
)
}
fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
@@ -78,11 +84,8 @@ fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let inner = block.inner(area);
frame.render_widget(block, area);
let cols = Layout::horizontal([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(inner);
let cols =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
// Left column: Forwarding + Discovery
let mut left = vec![
@@ -91,45 +94,147 @@ fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
fwd_line(data, "Delivered", "delivered_packets", "delivered_bytes"),
fwd_line(data, "Forwarded", "forwarded_packets", "forwarded_bytes"),
fwd_line(data, "Originated", "originated_packets", "originated_bytes"),
fwd_line(data, "Decode Error", "decode_error_packets", "decode_error_bytes"),
fwd_line(data, "TTL Exhausted", "ttl_exhausted_packets", "ttl_exhausted_bytes"),
fwd_line(data, "No Route", "drop_no_route_packets", "drop_no_route_bytes"),
fwd_line(data, "MTU Exceeded", "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"),
fwd_line(data, "Send Error", "drop_send_error_packets", "drop_send_error_bytes"),
fwd_line(
data,
"Decode Error",
"decode_error_packets",
"decode_error_bytes",
),
fwd_line(
data,
"TTL Exhausted",
"ttl_exhausted_packets",
"ttl_exhausted_bytes",
),
fwd_line(
data,
"No Route",
"drop_no_route_packets",
"drop_no_route_bytes",
),
fwd_line(
data,
"MTU Exceeded",
"drop_mtu_exceeded_packets",
"drop_mtu_exceeded_bytes",
),
fwd_line(
data,
"Send Error",
"drop_send_error_packets",
"drop_send_error_bytes",
),
Line::from(""),
helpers::section_header("Discovery Requests"),
helpers::kv_line("Received", &helpers::nested_u64(data, "discovery", "req_received")),
helpers::kv_line("Forwarded", &helpers::nested_u64(data, "discovery", "req_forwarded")),
helpers::kv_line("Initiated", &helpers::nested_u64(data, "discovery", "req_initiated")),
helpers::kv_line("Deduplicated", &helpers::nested_u64(data, "discovery", "req_deduplicated")),
helpers::kv_line("Target Is Us", &helpers::nested_u64(data, "discovery", "req_target_is_us")),
helpers::kv_line("Duplicate", &helpers::nested_u64(data, "discovery", "req_duplicate")),
helpers::kv_line("Already Visited", &helpers::nested_u64(data, "discovery", "req_already_visited")),
helpers::kv_line("TTL Exhausted", &helpers::nested_u64(data, "discovery", "req_ttl_exhausted")),
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "discovery", "req_decode_error")),
helpers::kv_line(
"Received",
&helpers::nested_u64(data, "discovery", "req_received"),
),
helpers::kv_line(
"Forwarded",
&helpers::nested_u64(data, "discovery", "req_forwarded"),
),
helpers::kv_line(
"Initiated",
&helpers::nested_u64(data, "discovery", "req_initiated"),
),
helpers::kv_line(
"Deduplicated",
&helpers::nested_u64(data, "discovery", "req_deduplicated"),
),
helpers::kv_line(
"Target Is Us",
&helpers::nested_u64(data, "discovery", "req_target_is_us"),
),
helpers::kv_line(
"Duplicate",
&helpers::nested_u64(data, "discovery", "req_duplicate"),
),
helpers::kv_line(
"Bloom Miss",
&helpers::nested_u64(data, "discovery", "req_bloom_miss"),
),
helpers::kv_line(
"Backoff Suppressed",
&helpers::nested_u64(data, "discovery", "req_backoff_suppressed"),
),
helpers::kv_line(
"Fwd Rate Limited",
&helpers::nested_u64(data, "discovery", "req_forward_rate_limited"),
),
helpers::kv_line(
"TTL Exhausted",
&helpers::nested_u64(data, "discovery", "req_ttl_exhausted"),
),
helpers::kv_line(
"Decode Error",
&helpers::nested_u64(data, "discovery", "req_decode_error"),
),
Line::from(""),
helpers::section_header("Discovery Responses"),
helpers::kv_line("Received", &helpers::nested_u64(data, "discovery", "resp_received")),
helpers::kv_line("Accepted", &helpers::nested_u64(data, "discovery", "resp_accepted")),
helpers::kv_line("Forwarded", &helpers::nested_u64(data, "discovery", "resp_forwarded")),
helpers::kv_line("Timed Out", &helpers::nested_u64(data, "discovery", "resp_timed_out")),
helpers::kv_line("Identity Miss", &helpers::nested_u64(data, "discovery", "resp_identity_miss")),
helpers::kv_line("Proof Failed", &helpers::nested_u64(data, "discovery", "resp_proof_failed")),
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "discovery", "resp_decode_error")),
helpers::kv_line(
"Received",
&helpers::nested_u64(data, "discovery", "resp_received"),
),
helpers::kv_line(
"Accepted",
&helpers::nested_u64(data, "discovery", "resp_accepted"),
),
helpers::kv_line(
"Forwarded",
&helpers::nested_u64(data, "discovery", "resp_forwarded"),
),
helpers::kv_line(
"Timed Out",
&helpers::nested_u64(data, "discovery", "resp_timed_out"),
),
helpers::kv_line(
"Identity Miss",
&helpers::nested_u64(data, "discovery", "resp_identity_miss"),
),
helpers::kv_line(
"Proof Failed",
&helpers::nested_u64(data, "discovery", "resp_proof_failed"),
),
helpers::kv_line(
"Decode Error",
&helpers::nested_u64(data, "discovery", "resp_decode_error"),
),
];
// Right column: Error Signals + Congestion
let mut right = vec![
helpers::section_header("Error Signals"),
helpers::kv_line("Coords Required", &helpers::nested_u64(data, "error_signals", "coords_required")),
helpers::kv_line("Path Broken", &helpers::nested_u64(data, "error_signals", "path_broken")),
helpers::kv_line("MTU Exceeded", &helpers::nested_u64(data, "error_signals", "mtu_exceeded")),
helpers::kv_line(
"Coords Required",
&helpers::nested_u64(data, "error_signals", "coords_required"),
),
helpers::kv_line(
"Path Broken",
&helpers::nested_u64(data, "error_signals", "path_broken"),
),
helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(data, "error_signals", "mtu_exceeded"),
),
Line::from(""),
helpers::section_header("Congestion"),
helpers::kv_line("CE Forwarded", &helpers::nested_u64(data, "congestion", "ce_forwarded")),
helpers::kv_line("CE Received", &helpers::nested_u64(data, "congestion", "ce_received")),
helpers::kv_line("Congestion Detected", &helpers::nested_u64(data, "congestion", "congestion_detected")),
helpers::kv_line("Kernel Drops", &helpers::nested_u64(data, "congestion", "kernel_drop_events")),
helpers::kv_line(
"CE Forwarded",
&helpers::nested_u64(data, "congestion", "ce_forwarded"),
),
helpers::kv_line(
"CE Received",
&helpers::nested_u64(data, "congestion", "ce_received"),
),
helpers::kv_line(
"Congestion Detected",
&helpers::nested_u64(data, "congestion", "congestion_detected"),
),
helpers::kv_line(
"Kernel Drops",
&helpers::nested_u64(data, "congestion", "kernel_drop_events"),
),
];
let max_lines = cols[0].height as usize;

View File

@@ -15,11 +15,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let row_count = sessions.len();
if app.detail_view.is_some() {
let chunks = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(area);
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
draw_table(frame, app, chunks[0], &sessions, row_count);
draw_detail(frame, app, chunks[1], &sessions);
@@ -223,10 +220,7 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, sessions: &[serde_json:
));
lines.push(helpers::kv_line(
"SRTT",
&format!(
"{}ms",
helpers::nested_f64(session, "mmp", "srtt_ms", 1)
),
&format!("{}ms", helpers::nested_f64(session, "mmp", "srtt_ms", 1)),
));
lines.push(helpers::kv_line(
"Loss Rate",
@@ -271,4 +265,3 @@ fn state_styled(state: &str) -> Span<'static> {
};
Span::styled(state.to_string(), Style::default().fg(color))
}

View File

@@ -33,11 +33,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
update_selected_tree_item(app, &tree_rows);
if app.detail_view.is_some() {
let chunks = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(area);
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
draw_table(frame, app, chunks[0], &transports, &links, &tree_rows);
draw_detail(frame, app, chunks[1], &transports, &links, &tree_rows);
@@ -110,9 +107,7 @@ fn update_selected_tree_item(app: &mut App, tree_rows: &[TreeRow]) {
.unwrap_or(0);
app.selected_tree_item = match tree_rows.get(selected) {
Some(TreeRow::Transport { transport_id, .. }) => {
SelectedTreeItem::Transport(*transport_id)
}
Some(TreeRow::Transport { transport_id, .. }) => SelectedTreeItem::Transport(*transport_id),
Some(TreeRow::Link { .. }) => SelectedTreeItem::Link,
None => SelectedTreeItem::None,
};
@@ -160,6 +155,20 @@ fn draw_table(
let addr = t.get("local_addr").and_then(|v| v.as_str()).unwrap_or("");
let label = if !name.is_empty() {
format!("{indicator}{typ} {name}")
} else if typ == "tor" {
let mode = t
.get("tor_mode")
.and_then(|v| v.as_str())
.unwrap_or("socks5");
let onion_hint = t
.get("onion_address")
.and_then(|v| v.as_str())
.map(|a| {
let short = if a.len() > 16 { &a[..16] } else { a };
format!(" {short}..")
})
.unwrap_or_default();
format!("{indicator}tor({mode}){onion_hint}")
} else if !addr.is_empty() {
format!("{indicator}{typ} {addr}")
} else {
@@ -191,7 +200,11 @@ fn draw_table(
}
TreeRow::Link { index, is_last } => {
let link = &links[*index];
let tree_char = if *is_last { "\u{2514}\u{2500}" } else { "\u{251C}\u{2500}" }; // └─ or ├─
let tree_char = if *is_last {
"\u{2514}\u{2500}"
} else {
"\u{251C}\u{2500}"
}; // └─ or ├─
let dir = helpers::str_field(link, "direction");
let dir_short = match dir {
"Outbound" => "Out",
@@ -285,13 +298,10 @@ fn draw_detail(
.unwrap_or(0);
let Some(tree_row) = tree_rows.get(selected) else {
let block = Block::default()
.borders(Borders::ALL)
.title(" Detail ");
let block = Block::default().borders(Borders::ALL).title(" Detail ");
let inner = block.inner(area);
frame.render_widget(block, area);
let msg = Paragraph::new(" No item selected")
.style(Style::default().fg(Color::DarkGray));
let msg = Paragraph::new(" No item selected").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
};
@@ -328,6 +338,14 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso
lines.push(helpers::kv_line("Local Addr", addr));
}
// Tor-specific info
if let Some(mode) = t.get("tor_mode").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Tor Mode", mode));
}
if let Some(onion) = t.get("onion_address").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Onion Address", onion));
}
// Transport stats
if let Some(stats) = t.get("stats") {
let typ = helpers::str_field(t, "type");
@@ -424,6 +442,42 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso
&helpers::nested_u64(t, "stats", "connect_refused"),
));
}
"tor" => {
lines.push(helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(t, "stats", "mtu_exceeded"),
));
lines.push(helpers::kv_line(
"SOCKS5 Errors",
&helpers::nested_u64(t, "stats", "socks5_errors"),
));
lines.push(helpers::kv_line(
"Control Errors",
&helpers::nested_u64(t, "stats", "control_errors"),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Connections"));
lines.push(helpers::kv_line(
"Established",
&helpers::nested_u64(t, "stats", "connections_established"),
));
lines.push(helpers::kv_line(
"Accepted",
&helpers::nested_u64(t, "stats", "connections_accepted"),
));
lines.push(helpers::kv_line(
"Rejected",
&helpers::nested_u64(t, "stats", "connections_rejected"),
));
lines.push(helpers::kv_line(
"Timeouts",
&helpers::nested_u64(t, "stats", "connect_timeouts"),
));
lines.push(helpers::kv_line(
"Refused",
&helpers::nested_u64(t, "stats", "connect_refused"),
));
}
"ethernet" => {
lines.push(Line::from(""));
lines.push(helpers::section_header("Beacons"));
@@ -448,6 +502,57 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso
}
_ => {}
}
// Tor daemon monitoring (when control port data is available)
if let Some(mon) = t.get("tor_monitoring") {
lines.push(Line::from(""));
lines.push(helpers::section_header("Tor Daemon"));
lines.push(helpers::kv_line(
"Bootstrap",
&format!("{}%", helpers::nested_u64(t, "tor_monitoring", "bootstrap")),
));
lines.push(helpers::kv_line(
"Circuit",
if mon
.get("circuit_established")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
"established"
} else {
"none"
},
));
lines.push(helpers::kv_line(
"Version",
&helpers::nested_str(t, "tor_monitoring", "version"),
));
lines.push(helpers::kv_line(
"Network",
&helpers::nested_str(t, "tor_monitoring", "network_liveness"),
));
lines.push(helpers::kv_line(
"Dormant",
helpers::bool_field(mon, "dormant"),
));
let tor_read = mon
.get("traffic_read")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let tor_written = mon
.get("traffic_written")
.and_then(|v| v.as_u64())
.unwrap_or(0);
lines.push(helpers::kv_line(
"Tor Read",
&helpers::format_bytes(tor_read),
));
lines.push(helpers::kv_line(
"Tor Written",
&helpers::format_bytes(tor_written),
));
}
}
let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0);

View File

@@ -12,8 +12,8 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Tree) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
@@ -22,7 +22,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(10), // Tree Position
Constraint::Length(22), // Tree Announce Stats
Constraint::Min(3), // Tree Peers
Constraint::Min(3), // Tree Peers
])
.split(area);
@@ -70,16 +70,12 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
)];
if coords.is_empty() {
path_parts.push(Span::styled(
"[root]",
Style::default().fg(Color::Yellow),
));
path_parts.push(Span::styled("[root]", Style::default().fg(Color::Yellow)));
} else {
// Reverse: root first, self last
for (i, entry) in coords.iter().rev().enumerate() {
if i > 0 {
path_parts
.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
path_parts.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
}
let hex = entry.as_str().unwrap_or("-");
let color = if i == 0 {
@@ -87,8 +83,10 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
} else {
Color::White
};
path_parts
.push(Span::styled(helpers::truncate_hex(hex, 8), Style::default().fg(color)));
path_parts.push(Span::styled(
helpers::truncate_hex(hex, 8),
Style::default().fg(color),
));
}
path_parts.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
path_parts.push(Span::styled(
@@ -121,24 +119,60 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
helpers::section_header("Inbound"),
helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")),
helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")),
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "stats", "decode_error")),
helpers::kv_line("Unknown Peer", &helpers::nested_u64(data, "stats", "unknown_peer")),
helpers::kv_line("Addr Mismatch", &helpers::nested_u64(data, "stats", "addr_mismatch")),
helpers::kv_line("Sig Failed", &helpers::nested_u64(data, "stats", "sig_failed")),
helpers::kv_line(
"Decode Error",
&helpers::nested_u64(data, "stats", "decode_error"),
),
helpers::kv_line(
"Unknown Peer",
&helpers::nested_u64(data, "stats", "unknown_peer"),
),
helpers::kv_line(
"Addr Mismatch",
&helpers::nested_u64(data, "stats", "addr_mismatch"),
),
helpers::kv_line(
"Sig Failed",
&helpers::nested_u64(data, "stats", "sig_failed"),
),
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
helpers::kv_line("Parent Switched", &helpers::nested_u64(data, "stats", "parent_switched")),
helpers::kv_line("Loop Detected", &helpers::nested_u64(data, "stats", "loop_detected")),
helpers::kv_line("Ancestry Changed", &helpers::nested_u64(data, "stats", "ancestry_changed")),
helpers::kv_line(
"Parent Switched",
&helpers::nested_u64(data, "stats", "parent_switched"),
),
helpers::kv_line(
"Loop Detected",
&helpers::nested_u64(data, "stats", "loop_detected"),
),
helpers::kv_line(
"Ancestry Changed",
&helpers::nested_u64(data, "stats", "ancestry_changed"),
),
Line::from(""),
helpers::section_header("Outbound"),
helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")),
helpers::kv_line("Rate Limited", &helpers::nested_u64(data, "stats", "rate_limited")),
helpers::kv_line("Send Failed", &helpers::nested_u64(data, "stats", "send_failed")),
helpers::kv_line(
"Rate Limited",
&helpers::nested_u64(data, "stats", "rate_limited"),
),
helpers::kv_line(
"Send Failed",
&helpers::nested_u64(data, "stats", "send_failed"),
),
Line::from(""),
helpers::section_header("Cumulative"),
helpers::kv_line("Parent Switches", &helpers::nested_u64(data, "stats", "parent_switches")),
helpers::kv_line("Parent Losses", &helpers::nested_u64(data, "stats", "parent_losses")),
helpers::kv_line("Flap Dampened", &helpers::nested_u64(data, "stats", "flap_dampened")),
helpers::kv_line(
"Parent Switches",
&helpers::nested_u64(data, "stats", "parent_switches"),
),
helpers::kv_line(
"Parent Losses",
&helpers::nested_u64(data, "stats", "parent_losses"),
),
helpers::kv_line(
"Flap Dampened",
&helpers::nested_u64(data, "stats", "flap_dampened"),
),
];
// Trim to fit available height
@@ -164,8 +198,7 @@ fn draw_peers(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
frame.render_widget(block, area);
if peers.is_empty() {
let msg =
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
let msg = Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}

View File

@@ -2,6 +2,8 @@
use std::fmt;
use tracing::trace;
use super::{BloomError, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
use crate::NodeAddr;
@@ -143,16 +145,30 @@ impl BloomFilter {
///
/// Uses the formula: n = -(m/k) * ln(1 - X/m)
/// where m = num_bits, k = hash_count, X = count_ones
pub fn estimated_count(&self) -> f64 {
///
/// Returns `None` when the filter's FPR exceeds `max_fpr` (antipoison
/// cap) or the filter is saturated (`count_ones() >= num_bits`). Pass
/// `f64::INFINITY` for `max_fpr` to disable the cap — useful in
/// Debug/log contexts where no policy is in scope. The saturated
/// branch is always honored regardless of `max_fpr`, preventing the
/// `f64::INFINITY` return that the previous signature produced.
pub fn estimated_count(&self, max_fpr: f64) -> Option<f64> {
let m = self.num_bits as f64;
let k = self.hash_count as f64;
let x = self.count_ones() as f64;
if x >= m {
return f64::INFINITY;
return None;
}
-(m / k) * (1.0 - x / m).ln()
let fill = x / m;
let fpr = fill.powi(self.hash_count as i32);
if fpr > max_fpr {
trace!(fill, fpr, max_fpr, "estimated_count: filter above cap");
return None;
}
Some(-(m / k) * (1.0 - fill).ln())
}
/// Check if the filter is empty.
@@ -234,7 +250,13 @@ impl fmt::Debug for BloomFilter {
.field("bits", &self.num_bits)
.field("hash_count", &self.hash_count)
.field("fill_ratio", &format!("{:.2}%", self.fill_ratio() * 100.0))
.field("est_count", &format!("{:.0}", self.estimated_count()))
.field(
"est_count",
&match self.estimated_count(f64::INFINITY) {
Some(n) => format!("{:.0}", n),
None => "saturated".to_string(),
},
)
.finish()
}
}

View File

@@ -149,8 +149,7 @@ fn test_bloom_filter_from_bytes() {
let original = BloomFilter::new();
let bytes = original.as_bytes().to_vec();
let restored =
BloomFilter::from_bytes(bytes, original.hash_count()).unwrap();
let restored = BloomFilter::from_bytes(bytes, original.hash_count()).unwrap();
assert_eq!(original, restored);
}
@@ -160,7 +159,7 @@ fn test_bloom_filter_estimated_count() {
let mut filter = BloomFilter::new();
// Empty filter
assert_eq!(filter.estimated_count(), 0.0);
assert_eq!(filter.estimated_count(f64::INFINITY), Some(0.0));
// Insert some items
for i in 0..50 {
@@ -168,7 +167,7 @@ fn test_bloom_filter_estimated_count() {
}
// Estimate should be reasonably close to 50
let estimate = filter.estimated_count();
let estimate = filter.estimated_count(f64::INFINITY).unwrap();
assert!(
estimate > 30.0 && estimate < 100.0,
"Unexpected estimate: {}",
@@ -235,7 +234,42 @@ fn test_bloom_filter_estimated_count_saturated() {
let bytes = vec![0xFF; 8]; // all bits set
let filter = BloomFilter::from_bytes(bytes, 3).unwrap();
assert!(filter.estimated_count().is_infinite());
// Saturated filter returns None regardless of cap (defense in depth).
// Previously returned f64::INFINITY.
assert_eq!(filter.estimated_count(f64::INFINITY), None);
assert_eq!(filter.estimated_count(0.05), None);
}
#[test]
fn test_bloom_filter_estimated_count_fpr_cap_boundary() {
// Cap boundary: FPR = fill^k = 0.05 at k=5 ⇒ fill ≈ 0.5493
// 1KB filter (8192 bits). 560 bytes of 0xFF = 4480 bits set =
// fill 0.5469, FPR ≈ 0.04877 — just below cap.
// 564 bytes of 0xFF = 4512 bits set = fill 0.5508, FPR ≈ 0.05060 —
// just above cap.
let mut below = vec![0x00u8; 1024];
below[..560].fill(0xFF);
let below_filter = BloomFilter::from_bytes(below, DEFAULT_HASH_COUNT).unwrap();
assert!(
below_filter.estimated_count(0.05).is_some(),
"fill 0.5469 (FPR ≈ 0.049) must be accepted by cap 0.05"
);
let mut above = vec![0x00u8; 1024];
above[..564].fill(0xFF);
let above_filter = BloomFilter::from_bytes(above, DEFAULT_HASH_COUNT).unwrap();
assert_eq!(
above_filter.estimated_count(0.05),
None,
"fill 0.5508 (FPR ≈ 0.051) must be rejected by cap 0.05"
);
// Same above-cap filter with a looser cap is accepted.
assert!(
above_filter.estimated_count(0.10).is_some(),
"fill 0.5508 (FPR ≈ 0.051) must be accepted by cap 0.10"
);
}
#[test]

View File

@@ -6,10 +6,10 @@
use std::collections::HashMap;
use super::entry::CacheEntry;
use super::CacheStats;
use crate::tree::TreeCoordinate;
use super::entry::CacheEntry;
use crate::NodeAddr;
use crate::tree::TreeCoordinate;
/// Default maximum entries in coordinate cache.
pub const DEFAULT_COORD_CACHE_SIZE: usize = 50_000;

View File

@@ -34,7 +34,10 @@ pub use node::{
TreeConfig,
};
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
pub use transport::{EthernetConfig, TcpConfig, TransportInstances, TransportsConfig, UdpConfig};
pub use transport::{
DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
TransportsConfig, UdpConfig,
};
/// Default config filename.
const CONFIG_FILENAME: &str = "fips.yaml";
@@ -539,10 +542,7 @@ node:
override_config.node.identity.nsec = Some("override_nsec".to_string());
base.merge(override_config);
assert_eq!(
base.node.identity.nsec,
Some("override_nsec".to_string())
);
assert_eq!(base.node.identity.nsec, Some("override_nsec".to_string()));
}
#[test]
@@ -559,9 +559,8 @@ node:
#[test]
fn test_create_identity_from_nsec() {
let mut config = Config::new();
config.node.identity.nsec = Some(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
);
config.node.identity.nsec =
Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
let identity = config.create_identity().unwrap();
assert!(!identity.npub().is_empty());
@@ -660,9 +659,11 @@ node:
assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
// Should include /etc/fips
assert!(paths
.iter()
.any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml")));
assert!(
paths
.iter()
.any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))
);
}
#[test]
@@ -747,16 +748,21 @@ node:
#[test]
fn test_key_file_path_derivation() {
let config_path = PathBuf::from("/etc/fips/fips.yaml");
assert_eq!(key_file_path(&config_path), PathBuf::from("/etc/fips/fips.key"));
assert_eq!(pub_file_path(&config_path), PathBuf::from("/etc/fips/fips.pub"));
assert_eq!(
key_file_path(&config_path),
PathBuf::from("/etc/fips/fips.key")
);
assert_eq!(
pub_file_path(&config_path),
PathBuf::from("/etc/fips/fips.pub")
);
}
#[test]
fn test_resolve_identity_from_config() {
let mut config = Config::new();
config.node.identity.nsec = Some(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
);
config.node.identity.nsec =
Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
let resolved = resolve_identity(&config, &[]).unwrap();
assert!(matches!(resolved.source, IdentitySource::Config));
@@ -803,11 +809,7 @@ node:
let config_path = temp_dir.path().join("fips.yaml");
let key_path = temp_dir.path().join("fips.key");
fs::write(
&config_path,
"node:\n identity:\n persistent: true\n",
)
.unwrap();
fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
// Write a key file
let identity = crate::Identity::generate();
@@ -827,11 +829,7 @@ node:
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
fs::write(
&config_path,
"node:\n identity:\n persistent: true\n",
)
.unwrap();
fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
let config = Config::load_file(&config_path).unwrap();
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
@@ -892,8 +890,7 @@ transports:
assert_eq!(config.transports.udp.len(), 2);
let instances: std::collections::HashMap<_, _> =
config.transports.udp.iter().collect();
let instances: std::collections::HashMap<_, _> = config.transports.udp.iter().collect();
// Named instances have Some(name)
assert!(instances.contains_key(&Some("main")));

View File

@@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize};
use super::IdentityConfig;
use crate::mmp::{MmpConfig, MmpMode, DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE};
use crate::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpConfig, MmpMode};
// ============================================================================
// Node Configuration Subsections
@@ -42,10 +42,18 @@ impl Default for LimitsConfig {
}
impl LimitsConfig {
fn default_max_connections() -> usize { 256 }
fn default_max_peers() -> usize { 128 }
fn default_max_links() -> usize { 256 }
fn default_max_pending_inbound() -> usize { 1000 }
fn default_max_connections() -> usize {
256
}
fn default_max_peers() -> usize {
128
}
fn default_max_links() -> usize {
256
}
fn default_max_pending_inbound() -> usize {
1000
}
}
/// Rate limiting (`node.rate_limit.*`).
@@ -86,12 +94,24 @@ impl Default for RateLimitConfig {
}
impl RateLimitConfig {
fn default_handshake_burst() -> u32 { 100 }
fn default_handshake_rate() -> f64 { 10.0 }
fn default_handshake_timeout_secs() -> u64 { 30 }
fn default_handshake_resend_interval_ms() -> u64 { 1000 }
fn default_handshake_resend_backoff() -> f64 { 2.0 }
fn default_handshake_max_resends() -> u32 { 5 }
fn default_handshake_burst() -> u32 {
100
}
fn default_handshake_rate() -> f64 {
10.0
}
fn default_handshake_timeout_secs() -> u64 {
30
}
fn default_handshake_resend_interval_ms() -> u64 {
1000
}
fn default_handshake_resend_backoff() -> f64 {
2.0
}
fn default_handshake_max_resends() -> u32 {
5
}
}
/// Retry/backoff configuration (`node.retry.*`).
@@ -119,9 +139,15 @@ impl Default for RetryConfig {
}
impl RetryConfig {
fn default_max_retries() -> u32 { 5 }
fn default_base_interval_secs() -> u64 { 5 }
fn default_max_backoff_secs() -> u64 { 300 }
fn default_max_retries() -> u32 {
5
}
fn default_base_interval_secs() -> u64 {
5
}
fn default_max_backoff_secs() -> u64 {
300
}
}
/// Cache parameters (`node.cache.*`).
@@ -149,9 +175,15 @@ impl Default for CacheConfig {
}
impl CacheConfig {
fn default_coord_size() -> usize { 50_000 }
fn default_coord_ttl_secs() -> u64 { 300 }
fn default_identity_size() -> usize { 10_000 }
fn default_coord_size() -> usize {
50_000
}
fn default_coord_ttl_secs() -> u64 {
300
}
fn default_identity_size() -> usize {
10_000
}
}
/// Discovery protocol (`node.discovery.*`).
@@ -166,6 +198,27 @@ pub struct DiscoveryConfig {
/// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`).
#[serde(default = "DiscoveryConfig::default_recent_expiry_secs")]
pub recent_expiry_secs: u64,
/// Base backoff after first lookup failure in seconds (`node.discovery.backoff_base_secs`).
/// Doubles per consecutive failure up to `backoff_max_secs`.
#[serde(default = "DiscoveryConfig::default_backoff_base_secs")]
pub backoff_base_secs: u64,
/// Maximum backoff cap in seconds (`node.discovery.backoff_max_secs`).
#[serde(default = "DiscoveryConfig::default_backoff_max_secs")]
pub backoff_max_secs: u64,
/// Minimum interval between forwarded lookups for the same target in seconds
/// (`node.discovery.forward_min_interval_secs`).
/// Defense-in-depth against misbehaving nodes.
#[serde(default = "DiscoveryConfig::default_forward_min_interval_secs")]
pub forward_min_interval_secs: u64,
/// Retry interval within the timeout window in seconds
/// (`node.discovery.retry_interval_secs`).
/// After this interval without a response, resend the lookup.
#[serde(default = "DiscoveryConfig::default_retry_interval_secs")]
pub retry_interval_secs: u64,
/// Maximum attempts per lookup (`node.discovery.max_attempts`).
/// 1 = no retry, 2 = one retry, etc.
#[serde(default = "DiscoveryConfig::default_max_attempts")]
pub max_attempts: u8,
}
impl Default for DiscoveryConfig {
@@ -174,14 +227,40 @@ impl Default for DiscoveryConfig {
ttl: 64,
timeout_secs: 10,
recent_expiry_secs: 10,
backoff_base_secs: 30,
backoff_max_secs: 300,
forward_min_interval_secs: 2,
retry_interval_secs: 5,
max_attempts: 2,
}
}
}
impl DiscoveryConfig {
fn default_ttl() -> u8 { 64 }
fn default_timeout_secs() -> u64 { 10 }
fn default_recent_expiry_secs() -> u64 { 10 }
fn default_ttl() -> u8 {
64
}
fn default_timeout_secs() -> u64 {
10
}
fn default_recent_expiry_secs() -> u64 {
10
}
fn default_backoff_base_secs() -> u64 {
30
}
fn default_backoff_max_secs() -> u64 {
300
}
fn default_forward_min_interval_secs() -> u64 {
2
}
fn default_retry_interval_secs() -> u64 {
5
}
fn default_max_attempts() -> u8 {
2
}
}
/// Spanning tree (`node.tree.*`).
@@ -236,13 +315,27 @@ impl Default for TreeConfig {
}
impl TreeConfig {
fn default_announce_min_interval_ms() -> u64 { 500 }
fn default_parent_hysteresis() -> f64 { 0.2 }
fn default_hold_down_secs() -> u64 { 30 }
fn default_reeval_interval_secs() -> u64 { 60 }
fn default_flap_threshold() -> u32 { 4 }
fn default_flap_window_secs() -> u64 { 60 }
fn default_flap_dampening_secs() -> u64 { 120 }
fn default_announce_min_interval_ms() -> u64 {
500
}
fn default_parent_hysteresis() -> f64 {
0.2
}
fn default_hold_down_secs() -> u64 {
30
}
fn default_reeval_interval_secs() -> u64 {
60
}
fn default_flap_threshold() -> u32 {
4
}
fn default_flap_window_secs() -> u64 {
60
}
fn default_flap_dampening_secs() -> u64 {
120
}
}
/// Bloom filter (`node.bloom.*`).
@@ -251,16 +344,31 @@ pub struct BloomConfig {
/// Debounce interval for filter updates in ms (`node.bloom.update_debounce_ms`).
#[serde(default = "BloomConfig::default_update_debounce_ms")]
pub update_debounce_ms: u64,
/// Antipoison cap: reject inbound FilterAnnounce whose FPR exceeds
/// this value (`node.bloom.max_inbound_fpr`). Valid range `(0.0, 1.0)`.
/// Default `0.05` ≈ fill 0.549 at k=5 ≈ ~3,200 entries on the 1KB
/// filter. Conceptually distinct from future autoscaling hysteresis
/// setpoints — same unit, different knobs.
#[serde(default = "BloomConfig::default_max_inbound_fpr")]
pub max_inbound_fpr: f64,
}
impl Default for BloomConfig {
fn default() -> Self {
Self { update_debounce_ms: 500 }
Self {
update_debounce_ms: 500,
max_inbound_fpr: 0.05,
}
}
}
impl BloomConfig {
fn default_update_debounce_ms() -> u64 { 500 }
fn default_update_debounce_ms() -> u64 {
500
}
fn default_max_inbound_fpr() -> f64 {
0.05
}
}
/// Session/data plane (`node.session.*`).
@@ -306,12 +414,24 @@ impl Default for SessionConfig {
}
impl SessionConfig {
fn default_ttl() -> u8 { 64 }
fn default_pending_packets_per_dest() -> usize { 16 }
fn default_pending_max_destinations() -> usize { 256 }
fn default_idle_timeout_secs() -> u64 { 90 }
fn default_coords_warmup_packets() -> u8 { 5 }
fn default_coords_response_interval_ms() -> u64 { 2000 }
fn default_ttl() -> u8 {
64
}
fn default_pending_packets_per_dest() -> usize {
16
}
fn default_pending_max_destinations() -> usize {
256
}
fn default_idle_timeout_secs() -> u64 {
90
}
fn default_coords_warmup_packets() -> u8 {
5
}
fn default_coords_response_interval_ms() -> u64 {
2000
}
}
/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`).
@@ -346,8 +466,12 @@ impl Default for SessionMmpConfig {
}
impl SessionMmpConfig {
fn default_log_interval_secs() -> u64 { DEFAULT_LOG_INTERVAL_SECS }
fn default_owd_window_size() -> usize { DEFAULT_OWD_WINDOW_SIZE }
fn default_log_interval_secs() -> u64 {
DEFAULT_LOG_INTERVAL_SECS
}
fn default_owd_window_size() -> usize {
DEFAULT_OWD_WINDOW_SIZE
}
}
/// Control socket configuration (`node.control.*`).
@@ -371,7 +495,9 @@ impl Default for ControlConfig {
}
impl ControlConfig {
fn default_enabled() -> bool { true }
fn default_enabled() -> bool {
true
}
fn default_socket_path() -> String {
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
@@ -409,9 +535,15 @@ impl Default for BuffersConfig {
}
impl BuffersConfig {
fn default_packet_channel() -> usize { 1024 }
fn default_tun_channel() -> usize { 1024 }
fn default_dns_channel() -> usize { 64 }
fn default_packet_channel() -> usize {
1024
}
fn default_tun_channel() -> usize {
1024
}
fn default_dns_channel() -> usize {
64
}
}
// ============================================================================
@@ -449,9 +581,15 @@ impl Default for RekeyConfig {
}
impl RekeyConfig {
fn default_enabled() -> bool { true }
fn default_after_secs() -> u64 { 120 }
fn default_after_messages() -> u64 { 1 << 16 }
fn default_enabled() -> bool {
true
}
fn default_after_secs() -> u64 {
120
}
fn default_after_messages() -> u64 {
1 << 16
}
}
/// ECN congestion signaling configuration (`node.ecn.*`).
@@ -489,9 +627,15 @@ impl Default for EcnConfig {
}
impl EcnConfig {
fn default_enabled() -> bool { true }
fn default_loss_threshold() -> f64 { 0.05 }
fn default_etx_threshold() -> f64 { 3.0 }
fn default_enabled() -> bool {
true
}
fn default_loss_threshold() -> f64 {
0.05
}
fn default_etx_threshold() -> f64 {
3.0
}
}
// ============================================================================
@@ -611,10 +755,18 @@ impl Default for NodeConfig {
}
impl NodeConfig {
fn default_tick_interval_secs() -> u64 { 1 }
fn default_base_rtt_ms() -> u64 { 100 }
fn default_heartbeat_interval_secs() -> u64 { 10 }
fn default_link_dead_timeout_secs() -> u64 { 30 }
fn default_tick_interval_secs() -> u64 {
1
}
fn default_base_rtt_ms() -> u64 {
100
}
fn default_heartbeat_interval_secs() -> u64 {
10
}
fn default_link_dead_timeout_secs() -> u64 {
30
}
}
#[cfg(test)]

View File

@@ -35,9 +35,9 @@ pub struct PeerAddress {
/// Transport-specific address string.
///
/// Format depends on transport type:
/// - UDP: "host:port" (e.g., "192.168.1.1:2121")
/// - Tor: "onion_address:port" (e.g., "xyz...abc.onion:2121")
/// - Ethernet: "interface/mac" (future)
/// - UDP/TCP: "host:port" — IP address or DNS hostname
/// (e.g., "192.168.1.1:2121" or "peer1.example.com:2121")
/// - Ethernet: "interface/mac" (e.g., "eth0/aa:bb:cc:dd:ee:ff")
pub addr: String,
/// Priority for address selection (lower = preferred).
@@ -66,7 +66,11 @@ impl PeerAddress {
}
/// Create a new peer address with priority.
pub fn with_priority(transport: impl Into<String>, addr: impl Into<String>, priority: u8) -> Self {
pub fn with_priority(
transport: impl Into<String>,
addr: impl Into<String>,
priority: u8,
) -> Self {
Self {
transport: transport.into(),
addr: addr.into(),
@@ -118,7 +122,11 @@ impl Default for PeerConfig {
impl PeerConfig {
/// Create a new peer config with a single address.
pub fn new(npub: impl Into<String>, transport: impl Into<String>, addr: impl Into<String>) -> Self {
pub fn new(
npub: impl Into<String>,
transport: impl Into<String>,
addr: impl Into<String>,
) -> Self {
Self {
npub: npub.into(),
alias: None,

View File

@@ -112,15 +112,12 @@ impl<T> TransportInstances<T> {
/// Named instances have `Some(name)`.
pub fn iter(&self) -> impl Iterator<Item = (Option<&str>, &T)> {
match self {
TransportInstances::Single(config) => {
vec![(None, config)].into_iter()
}
TransportInstances::Named(map) => {
map.iter()
.map(|(k, v)| (Some(k.as_str()), v))
.collect::<Vec<_>>()
.into_iter()
}
TransportInstances::Single(config) => vec![(None, config)].into_iter(),
TransportInstances::Named(map) => map
.iter()
.map(|(k, v)| (Some(k.as_str()), v))
.collect::<Vec<_>>()
.into_iter(),
}
}
}
@@ -293,10 +290,6 @@ pub struct TcpConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send_buf_size: Option<usize>,
/// SOCKS5 proxy for outbound connections (placeholder; not yet implemented).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub socks5_proxy: Option<String>,
/// Maximum simultaneous inbound connections. Defaults to 256.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_inbound_connections: Option<usize>,
@@ -310,7 +303,8 @@ impl TcpConfig {
/// Get the connect timeout in milliseconds.
pub fn connect_timeout_ms(&self) -> u64 {
self.connect_timeout_ms.unwrap_or(DEFAULT_TCP_CONNECT_TIMEOUT_MS)
self.connect_timeout_ms
.unwrap_or(DEFAULT_TCP_CONNECT_TIMEOUT_MS)
}
/// Whether TCP_NODELAY is enabled. Default: true.
@@ -335,7 +329,183 @@ impl TcpConfig {
/// Get the maximum number of inbound connections. Default: 256.
pub fn max_inbound_connections(&self) -> usize {
self.max_inbound_connections.unwrap_or(DEFAULT_TCP_MAX_INBOUND)
self.max_inbound_connections
.unwrap_or(DEFAULT_TCP_MAX_INBOUND)
}
}
// ============================================================================
// Tor Transport Configuration
// ============================================================================
/// Default Tor SOCKS5 proxy address.
const DEFAULT_TOR_SOCKS5_ADDR: &str = "127.0.0.1:9050";
/// Default Tor control port address.
const DEFAULT_TOR_CONTROL_ADDR: &str = "/run/tor/control";
/// Default Tor control cookie file path (Debian standard location).
const DEFAULT_TOR_COOKIE_PATH: &str = "/var/run/tor/control.authcookie";
/// Default Tor connect timeout in milliseconds (120s — Tor circuit
/// establishment can take 30-60s on first connect, plus SOCKS5 handshake).
const DEFAULT_TOR_CONNECT_TIMEOUT_MS: u64 = 120_000;
/// Default Tor MTU (same as TCP).
const DEFAULT_TOR_MTU: u16 = 1400;
/// Default max inbound connections via onion service.
const DEFAULT_TOR_MAX_INBOUND: usize = 64;
/// Default HiddenServiceDir hostname file path.
const DEFAULT_HOSTNAME_FILE: &str = "/var/lib/tor/fips_onion_service/hostname";
/// Default directory mode bind address.
const DEFAULT_DIRECTORY_BIND_ADDR: &str = "127.0.0.1:8443";
/// Tor transport instance configuration.
///
/// Supports three modes:
/// - `socks5`: Outbound-only connections through a Tor SOCKS5 proxy.
/// - `control_port`: Full bidirectional support — outbound via SOCKS5
/// plus inbound via Tor onion service managed through the control port.
/// - `directory`: Full bidirectional support — outbound via SOCKS5,
/// inbound via a Tor-managed `HiddenServiceDir` onion service. No
/// control port needed. Enables Tor `Sandbox 1` mode.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TorConfig {
/// Tor access mode: "socks5", "control_port", or "directory".
/// Default: "socks5".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
/// SOCKS5 proxy address (host:port). Defaults to "127.0.0.1:9050".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub socks5_addr: Option<String>,
/// Outbound connect timeout in milliseconds. Defaults to 120000 (120s).
/// Tor circuit establishment can take 30-60s, so this must be generous.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_timeout_ms: Option<u64>,
/// Default MTU for Tor connections. Defaults to 1400.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtu: Option<u16>,
/// Control port address: a Unix socket path (`/run/tor/control`) or
/// TCP address (`host:port`). Unix sockets are preferred for security.
/// Defaults to "/run/tor/control".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_addr: Option<String>,
/// Control port authentication method:
/// `"cookie"` (read from default path),
/// `"cookie:/path/to/cookie"` (read from specified path), or
/// `"password:secret"` (password auth). Default: `"cookie"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_auth: Option<String>,
/// Path to the Tor control cookie file. Used when control_auth is "cookie".
/// Defaults to "/var/run/tor/control.authcookie".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cookie_path: Option<String>,
/// Maximum number of inbound connections via onion service. Default: 64.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_inbound_connections: Option<usize>,
/// Directory-mode onion service configuration. Only valid in
/// "directory" mode. Tor manages the onion service via HiddenServiceDir
/// in torrc; fips reads the .onion hostname from a file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory_service: Option<DirectoryServiceConfig>,
}
/// Directory-mode onion service configuration.
///
/// In `directory` mode, Tor manages the onion service via `HiddenServiceDir`
/// in torrc. FIPS reads the `.onion` address from the hostname file and
/// binds a local TCP listener for Tor to forward inbound connections to.
/// This mode requires no control port and enables Tor's `Sandbox 1`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DirectoryServiceConfig {
/// Path to the Tor-managed hostname file containing the .onion address.
/// Defaults to "/var/lib/tor/fips_onion_service/hostname".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname_file: Option<String>,
/// Local bind address for the listener that Tor forwards inbound
/// connections to. Must match the target in torrc's `HiddenServicePort`.
/// Defaults to "127.0.0.1:8443".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bind_addr: Option<String>,
}
impl DirectoryServiceConfig {
/// Path to the hostname file. Default: "/var/lib/tor/fips_onion_service/hostname".
pub fn hostname_file(&self) -> &str {
self.hostname_file
.as_deref()
.unwrap_or(DEFAULT_HOSTNAME_FILE)
}
/// Local bind address for the listener. Default: "127.0.0.1:8443".
pub fn bind_addr(&self) -> &str {
self.bind_addr
.as_deref()
.unwrap_or(DEFAULT_DIRECTORY_BIND_ADDR)
}
}
impl TorConfig {
/// Get the access mode. Default: "socks5".
pub fn mode(&self) -> &str {
self.mode.as_deref().unwrap_or("socks5")
}
/// Get the SOCKS5 proxy address. Default: "127.0.0.1:9050".
pub fn socks5_addr(&self) -> &str {
self.socks5_addr
.as_deref()
.unwrap_or(DEFAULT_TOR_SOCKS5_ADDR)
}
/// Get the control port address. Default: "/run/tor/control".
pub fn control_addr(&self) -> &str {
self.control_addr
.as_deref()
.unwrap_or(DEFAULT_TOR_CONTROL_ADDR)
}
/// Get the control auth string. Default: "cookie".
pub fn control_auth(&self) -> &str {
self.control_auth.as_deref().unwrap_or("cookie")
}
/// Get the cookie file path. Default: "/var/run/tor/control.authcookie".
pub fn cookie_path(&self) -> &str {
self.cookie_path
.as_deref()
.unwrap_or(DEFAULT_TOR_COOKIE_PATH)
}
/// Get the connect timeout in milliseconds. Default: 120000.
pub fn connect_timeout_ms(&self) -> u64 {
self.connect_timeout_ms
.unwrap_or(DEFAULT_TOR_CONNECT_TIMEOUT_MS)
}
/// Get the default MTU. Default: 1400.
pub fn mtu(&self) -> u16 {
self.mtu.unwrap_or(DEFAULT_TOR_MTU)
}
/// Get the max inbound connections. Default: 64.
pub fn max_inbound_connections(&self) -> usize {
self.max_inbound_connections
.unwrap_or(DEFAULT_TOR_MAX_INBOUND)
}
}
@@ -360,6 +530,10 @@ pub struct TransportsConfig {
/// TCP transport instances.
#[serde(default, skip_serializing_if = "is_transport_empty")]
pub tcp: TransportInstances<TcpConfig>,
/// Tor transport instances.
#[serde(default, skip_serializing_if = "is_transport_empty")]
pub tor: TransportInstances<TorConfig>,
}
/// Helper for skip_serializing_if on TransportInstances.
@@ -370,7 +544,10 @@ fn is_transport_empty<T>(instances: &TransportInstances<T>) -> bool {
impl TransportsConfig {
/// Check if any transports are configured.
pub fn is_empty(&self) -> bool {
self.udp.is_empty() && self.ethernet.is_empty() && self.tcp.is_empty()
self.udp.is_empty()
&& self.ethernet.is_empty()
&& self.tcp.is_empty()
&& self.tor.is_empty()
}
/// Merge another TransportsConfig into this one.
@@ -386,5 +563,8 @@ impl TransportsConfig {
if !other.tcp.is_empty() {
self.tcp = other.tcp;
}
if !other.tor.is_empty() {
self.tor = other.tor;
}
}
}

68
src/control/commands.rs Normal file
View File

@@ -0,0 +1,68 @@
//! Mutating control socket commands.
//!
//! Commands that modify node state (connect, disconnect) are handled here,
//! separate from read-only queries in `queries.rs`.
use super::protocol::Response;
use crate::node::Node;
use serde_json::Value;
use tracing::info;
/// Dispatch a mutating command to the appropriate handler.
pub async fn dispatch(node: &mut Node, command: &str, params: Option<&Value>) -> Response {
match command {
"connect" => connect(node, params).await,
"disconnect" => disconnect(node, params),
_ => Response::error(format!("unknown command: {command}")),
}
}
/// Connect to a peer.
///
/// Params: `{"npub": "npub1...", "address": "host:port", "transport": "udp"}`
async fn connect(node: &mut Node, params: Option<&Value>) -> Response {
let Some(params) = params else {
return Response::error("missing params for connect");
};
let npub = match params.get("npub").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'npub' parameter"),
};
let address = match params.get("address").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'address' parameter"),
};
let transport = match params.get("transport").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'transport' parameter"),
};
info!(npub = %npub, address = %address, transport = %transport, "API connect requested");
match node.api_connect(npub, address, transport).await {
Ok(data) => Response::ok(data),
Err(msg) => Response::error(msg),
}
}
/// Disconnect a peer.
///
/// Params: `{"npub": "npub1..."}`
fn disconnect(node: &mut Node, params: Option<&Value>) -> Response {
let Some(params) = params else {
return Response::error("missing params for disconnect");
};
let npub = match params.get("npub").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'npub' parameter"),
};
info!(npub = %npub, "API disconnect requested");
match node.api_disconnect(npub) {
Ok(data) => Response::ok(data),
Err(msg) => Response::error(msg),
}
}

View File

@@ -1,9 +1,10 @@
//! Control socket for runtime observability.
//! Control socket for runtime management and observability.
//!
//! Provides a Unix domain socket that accepts query commands and returns
//! structured JSON data about the node's current state. Read-only queries
//! only — no state mutation through this channel.
//! Provides a Unix domain socket that accepts commands and returns
//! structured JSON responses. Supports both read-only queries (show_*)
//! and mutating commands (connect, disconnect).
pub mod commands;
pub mod protocol;
pub mod queries;
@@ -54,6 +55,16 @@ impl ControlSocket {
}
let listener = UnixListener::bind(&socket_path)?;
// Make the socket and its parent directory group-accessible so
// 'fips' group members can use fipsctl/fipstop without root.
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o770))?;
Self::chown_to_fips_group(&socket_path);
if let Some(parent) = socket_path.parent() {
Self::chown_to_fips_group(parent);
}
info!(path = %socket_path.display(), "Control socket listening");
Ok(Self {
@@ -85,6 +96,37 @@ impl ControlSocket {
}
}
/// Set group ownership of a path to the 'fips' group (best-effort).
fn chown_to_fips_group(path: &Path) {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
// Look up the 'fips' group
let group_name = CString::new("fips").unwrap();
let grp = unsafe { libc::getgrnam(group_name.as_ptr()) };
if grp.is_null() {
debug!(
"'fips' group not found, skipping chown for {}",
path.display()
);
return;
}
let gid = unsafe { (*grp).gr_gid };
let c_path = match CString::new(path.as_os_str().as_bytes()) {
Ok(p) => p,
Err(_) => return,
};
let ret = unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) };
if ret != 0 {
warn!(
path = %path.display(),
error = %std::io::Error::last_os_error(),
"Failed to chown control socket to 'fips' group"
);
}
}
/// Run the accept loop, forwarding requests to the main event loop via mpsc.
///
/// Each accepted connection is handled in a spawned task:
@@ -145,9 +187,7 @@ impl ControlSocket {
.await;
let response = match read_result {
Ok(Ok(())) if line.is_empty() => {
Response::error("empty request")
}
Ok(Ok(())) if line.is_empty() => Response::error("empty request"),
Ok(Ok(())) => {
// Parse the request
match serde_json::from_str::<Request>(line.trim()) {

View File

@@ -8,8 +8,11 @@ use serde::{Deserialize, Serialize};
/// A control request from a client.
#[derive(Debug, Deserialize)]
pub struct Request {
/// The command to execute (e.g., "show_status", "show_peers").
/// The command to execute (e.g., "show_status", "connect").
pub command: String,
/// Optional parameters for mutating commands.
#[serde(default)]
pub params: Option<serde_json::Value>,
}
/// A control response to a client.
@@ -81,6 +84,24 @@ mod tests {
assert_eq!(req.command, "show_peers");
}
#[test]
fn test_deserialize_request_with_params() {
let json = r#"{"command": "connect", "params": {"npub": "npub1abc", "address": "1.2.3.4:2121", "transport": "udp"}}"#;
let req: Request = serde_json::from_str(json).unwrap();
assert_eq!(req.command, "connect");
let params = req.params.unwrap();
assert_eq!(params["npub"], "npub1abc");
assert_eq!(params["transport"], "udp");
}
#[test]
fn test_deserialize_request_without_params() {
let json = r#"{"command": "show_status"}"#;
let req: Request = serde_json::from_str(json).unwrap();
assert_eq!(req.command, "show_status");
assert!(req.params.is_none());
}
#[test]
fn test_deserialize_malformed_request() {
let json = r#"{"not_command": "foo"}"#;

View File

@@ -5,7 +5,7 @@
use crate::identity::encode_npub;
use crate::node::Node;
use serde_json::{json, Value};
use serde_json::{Value, json};
/// Helper: get current Unix time in milliseconds.
fn now_ms() -> u64 {
@@ -70,113 +70,120 @@ pub fn show_peers(node: &Node) -> Value {
let parent_id = *tree.my_declaration().parent_id();
let is_root = tree.is_root();
let peers: Vec<Value> = node.peers().map(|peer| {
let node_addr = *peer.node_addr();
let addr_hex = hex::encode(node_addr.as_bytes());
let peers: Vec<Value> = node
.peers()
.map(|peer| {
let node_addr = *peer.node_addr();
let addr_hex = hex::encode(node_addr.as_bytes());
// Determine tree relationship
let is_parent = !is_root && node_addr == parent_id;
let is_child = tree.peer_declaration(&node_addr)
.is_some_and(|decl| *decl.parent_id() == my_addr);
// Determine tree relationship
let is_parent = !is_root && node_addr == parent_id;
let is_child = tree
.peer_declaration(&node_addr)
.is_some_and(|decl| *decl.parent_id() == my_addr);
let mut peer_json = json!({
"node_addr": addr_hex,
"npub": peer.npub(),
"display_name": node.peer_display_name(&node_addr),
"ipv6_addr": format!("{}", peer.address()),
"connectivity": format!("{}", peer.connectivity()),
"link_id": peer.link_id().as_u64(),
"authenticated_at_ms": peer.authenticated_at(),
"last_seen_ms": peer.last_seen(),
"has_tree_position": peer.has_tree_position(),
"has_bloom_filter": peer.filter_sequence() > 0,
"filter_sequence": peer.filter_sequence(),
"is_parent": is_parent,
"is_child": is_child,
});
// Add transport address if available
if let Some(addr) = peer.current_addr() {
peer_json["transport_addr"] = json!(format!("{}", addr));
}
// Add link info (direction, transport type)
let link_id = peer.link_id();
if let Some(link) = node.get_link(&link_id) {
peer_json["direction"] = json!(format!("{}", link.direction()));
let transport_id = link.transport_id();
if let Some(handle) = node.get_transport(&transport_id) {
peer_json["transport_type"] = json!(handle.transport_type().name);
}
}
// Add tree depth if available
if let Some(coords) = peer.coords() {
peer_json["tree_depth"] = json!(coords.depth());
}
// Add link stats
let stats = peer.link_stats();
peer_json["stats"] = json!({
"packets_sent": stats.packets_sent,
"packets_recv": stats.packets_recv,
"bytes_sent": stats.bytes_sent,
"bytes_recv": stats.bytes_recv,
});
// Add MMP metrics if available
if let Some(mmp) = peer.mmp() {
let mut mmp_json = json!({
"mode": format!("{}", mmp.mode()),
let mut peer_json = json!({
"node_addr": addr_hex,
"npub": peer.npub(),
"display_name": node.peer_display_name(&node_addr),
"ipv6_addr": format!("{}", peer.address()),
"connectivity": format!("{}", peer.connectivity()),
"link_id": peer.link_id().as_u64(),
"authenticated_at_ms": peer.authenticated_at(),
"last_seen_ms": peer.last_seen(),
"has_tree_position": peer.has_tree_position(),
"has_bloom_filter": peer.filter_sequence() > 0,
"filter_sequence": peer.filter_sequence(),
"is_parent": is_parent,
"is_child": is_child,
});
if let Some(srtt) = mmp.metrics.srtt_ms() {
mmp_json["srtt_ms"] = json!(srtt);
}
mmp_json["loss_rate"] = json!(mmp.metrics.loss_rate());
mmp_json["etx"] = json!(mmp.metrics.etx);
mmp_json["goodput_bps"] = json!(mmp.metrics.goodput_bps);
mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward);
mmp_json["delivery_ratio_reverse"] = json!(mmp.metrics.delivery_ratio_reverse);
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
mmp_json["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
mmp_json["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = mmp.metrics.srtt_ms()
&& let Some(setx) = mmp.metrics.smoothed_etx()
{
mmp_json["lqi"] = json!(setx * (1.0 + srtt / 100.0));
}
peer_json["mmp"] = mmp_json;
}
peer_json
}).collect();
// Add transport address if available
if let Some(addr) = peer.current_addr() {
peer_json["transport_addr"] = json!(format!("{}", addr));
}
// Add link info (direction, transport type)
let link_id = peer.link_id();
if let Some(link) = node.get_link(&link_id) {
peer_json["direction"] = json!(format!("{}", link.direction()));
let transport_id = link.transport_id();
if let Some(handle) = node.get_transport(&transport_id) {
peer_json["transport_type"] = json!(handle.transport_type().name);
}
}
// Add tree depth if available
if let Some(coords) = peer.coords() {
peer_json["tree_depth"] = json!(coords.depth());
}
// Add link stats
let stats = peer.link_stats();
peer_json["stats"] = json!({
"packets_sent": stats.packets_sent,
"packets_recv": stats.packets_recv,
"bytes_sent": stats.bytes_sent,
"bytes_recv": stats.bytes_recv,
});
// Add MMP metrics if available
if let Some(mmp) = peer.mmp() {
let mut mmp_json = json!({
"mode": format!("{}", mmp.mode()),
});
if let Some(srtt) = mmp.metrics.srtt_ms() {
mmp_json["srtt_ms"] = json!(srtt);
}
mmp_json["loss_rate"] = json!(mmp.metrics.loss_rate());
mmp_json["etx"] = json!(mmp.metrics.etx);
mmp_json["goodput_bps"] = json!(mmp.metrics.goodput_bps);
mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward);
mmp_json["delivery_ratio_reverse"] = json!(mmp.metrics.delivery_ratio_reverse);
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
mmp_json["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
mmp_json["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = mmp.metrics.srtt_ms()
&& let Some(setx) = mmp.metrics.smoothed_etx()
{
mmp_json["lqi"] = json!(setx * (1.0 + srtt / 100.0));
}
peer_json["mmp"] = mmp_json;
}
peer_json
})
.collect();
json!({ "peers": peers })
}
/// `show_links` — Active links.
pub fn show_links(node: &Node) -> Value {
let links: Vec<Value> = node.links().map(|link| {
let stats = link.stats();
json!({
"link_id": link.link_id().as_u64(),
"transport_id": link.transport_id().as_u32(),
"remote_addr": format!("{}", link.remote_addr()),
"direction": format!("{}", link.direction()),
"state": format!("{}", link.state()),
"created_at_ms": link.created_at(),
"stats": {
"packets_sent": stats.packets_sent,
"packets_recv": stats.packets_recv,
"bytes_sent": stats.bytes_sent,
"bytes_recv": stats.bytes_recv,
"last_recv_ms": stats.last_recv_ms,
},
let links: Vec<Value> = node
.links()
.map(|link| {
let stats = link.stats();
json!({
"link_id": link.link_id().as_u64(),
"transport_id": link.transport_id().as_u32(),
"remote_addr": format!("{}", link.remote_addr()),
"direction": format!("{}", link.direction()),
"state": format!("{}", link.state()),
"created_at_ms": link.created_at(),
"stats": {
"packets_sent": stats.packets_sent,
"packets_recv": stats.packets_recv,
"bytes_sent": stats.bytes_sent,
"bytes_recv": stats.bytes_recv,
"last_recv_ms": stats.last_recv_ms,
},
})
})
}).collect();
.collect();
json!({ "links": links })
}
@@ -188,29 +195,34 @@ pub fn show_tree(node: &Node) -> Value {
let decl = tree.my_declaration();
// Build coords array as hex strings
let coords: Vec<String> = my_coords.entries()
let coords: Vec<String> = my_coords
.entries()
.iter()
.map(|e| hex::encode(e.node_addr.as_bytes()))
.collect();
// Build peer tree data
let peers: Vec<Value> = tree.peer_ids().map(|peer_id| {
let mut peer_json = json!({
"node_addr": hex::encode(peer_id.as_bytes()),
"display_name": node.peer_display_name(peer_id),
});
if let Some(coords) = tree.peer_coords(peer_id) {
let coord_path: Vec<String> = coords.entries()
.iter()
.map(|e| hex::encode(e.node_addr.as_bytes()))
.collect();
peer_json["depth"] = json!(coords.depth());
peer_json["root"] = json!(hex::encode(coords.root_id().as_bytes()));
peer_json["coords"] = json!(coord_path);
peer_json["distance_to_us"] = json!(my_coords.distance_to(coords));
}
peer_json
}).collect();
let peers: Vec<Value> = tree
.peer_ids()
.map(|peer_id| {
let mut peer_json = json!({
"node_addr": hex::encode(peer_id.as_bytes()),
"display_name": node.peer_display_name(peer_id),
});
if let Some(coords) = tree.peer_coords(peer_id) {
let coord_path: Vec<String> = coords
.entries()
.iter()
.map(|e| hex::encode(e.node_addr.as_bytes()))
.collect();
peer_json["depth"] = json!(coords.depth());
peer_json["root"] = json!(hex::encode(coords.root_id().as_bytes()));
peer_json["coords"] = json!(coord_path);
peer_json["distance_to_us"] = json!(my_coords.distance_to(coords));
}
peer_json
})
.collect();
// Determine parent display name
let parent_addr = my_coords.parent_id();
@@ -237,68 +249,71 @@ pub fn show_tree(node: &Node) -> Value {
/// `show_sessions` — End-to-end sessions.
pub fn show_sessions(node: &Node) -> Value {
let sessions: Vec<Value> = node.session_entries().map(|(addr, entry)| {
let state_str = if entry.is_established() {
"established"
} else if entry.is_initiating() {
"initiating"
} else if entry.is_awaiting_msg3() {
"awaiting_msg3"
} else {
"unknown"
};
let sessions: Vec<Value> = node
.session_entries()
.map(|(addr, entry)| {
let state_str = if entry.is_established() {
"established"
} else if entry.is_initiating() {
"initiating"
} else if entry.is_awaiting_msg3() {
"awaiting_msg3"
} else {
"unknown"
};
let mut session_json = json!({
"remote_addr": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"state": state_str,
"is_initiator": entry.is_initiator(),
"last_activity_ms": entry.last_activity(),
});
// Derive npub from session's remote public key
let (xonly, _parity) = entry.remote_pubkey().x_only_public_key();
session_json["npub"] = json!(encode_npub(&xonly));
// Traffic counters
let (pkts_tx, pkts_rx, bytes_tx, bytes_rx) = entry.traffic_counters();
session_json["stats"] = json!({
"packets_sent": pkts_tx,
"packets_recv": pkts_rx,
"bytes_sent": bytes_tx,
"bytes_recv": bytes_rx,
});
// Add session MMP if available
if let Some(mmp) = entry.mmp() {
let mut mmp_json = json!({
"mode": format!("{}", mmp.mode()),
"loss_rate": mmp.metrics.loss_rate(),
"etx": mmp.metrics.etx,
"goodput_bps": mmp.metrics.goodput_bps,
"delivery_ratio_forward": mmp.metrics.delivery_ratio_forward,
"delivery_ratio_reverse": mmp.metrics.delivery_ratio_reverse,
"path_mtu": mmp.path_mtu.current_mtu(),
let mut session_json = json!({
"remote_addr": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"state": state_str,
"is_initiator": entry.is_initiator(),
"last_activity_ms": entry.last_activity(),
});
if let Some(srtt) = mmp.metrics.srtt_ms() {
mmp_json["srtt_ms"] = json!(srtt);
}
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
mmp_json["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
mmp_json["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = mmp.metrics.srtt_ms()
&& let Some(setx) = mmp.metrics.smoothed_etx()
{
mmp_json["sqi"] = json!(setx * (1.0 + srtt / 100.0));
}
session_json["mmp"] = mmp_json;
}
session_json
}).collect();
// Derive npub from session's remote public key
let (xonly, _parity) = entry.remote_pubkey().x_only_public_key();
session_json["npub"] = json!(encode_npub(&xonly));
// Traffic counters
let (pkts_tx, pkts_rx, bytes_tx, bytes_rx) = entry.traffic_counters();
session_json["stats"] = json!({
"packets_sent": pkts_tx,
"packets_recv": pkts_rx,
"bytes_sent": bytes_tx,
"bytes_recv": bytes_rx,
});
// Add session MMP if available
if let Some(mmp) = entry.mmp() {
let mut mmp_json = json!({
"mode": format!("{}", mmp.mode()),
"loss_rate": mmp.metrics.loss_rate(),
"etx": mmp.metrics.etx,
"goodput_bps": mmp.metrics.goodput_bps,
"delivery_ratio_forward": mmp.metrics.delivery_ratio_forward,
"delivery_ratio_reverse": mmp.metrics.delivery_ratio_reverse,
"path_mtu": mmp.path_mtu.current_mtu(),
});
if let Some(srtt) = mmp.metrics.srtt_ms() {
mmp_json["srtt_ms"] = json!(srtt);
}
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
mmp_json["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
mmp_json["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = mmp.metrics.srtt_ms()
&& let Some(setx) = mmp.metrics.smoothed_etx()
{
mmp_json["sqi"] = json!(setx * (1.0 + srtt / 100.0));
}
session_json["mmp"] = mmp_json;
}
session_json
})
.collect();
json!({ "sessions": sessions })
}
@@ -307,27 +322,32 @@ pub fn show_sessions(node: &Node) -> Value {
pub fn show_bloom(node: &Node) -> Value {
let bloom = node.bloom_state();
let leaf_deps: Vec<String> = bloom.leaf_dependents()
let leaf_deps: Vec<String> = bloom
.leaf_dependents()
.iter()
.map(|addr| hex::encode(addr.as_bytes()))
.collect();
// Build per-peer filter info
let peer_filters: Vec<Value> = node.peers().map(|peer| {
let addr = *peer.node_addr();
let mut pf = json!({
"peer": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(&addr),
"has_filter": peer.filter_sequence() > 0,
"filter_sequence": peer.filter_sequence(),
});
if let Some(filter) = peer.inbound_filter() {
pf["estimated_count"] = json!(filter.estimated_count());
pf["set_bits"] = json!(filter.count_ones());
pf["fill_ratio"] = json!(filter.fill_ratio());
}
pf
}).collect();
let peer_filters: Vec<Value> = node
.peers()
.map(|peer| {
let addr = *peer.node_addr();
let mut pf = json!({
"peer": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(&addr),
"has_filter": peer.filter_sequence() > 0,
"filter_sequence": peer.filter_sequence(),
});
if let Some(filter) = peer.inbound_filter() {
let max_fpr = node.config().node.bloom.max_inbound_fpr;
pf["estimated_count"] = json!(filter.estimated_count(max_fpr));
pf["set_bits"] = json!(filter.count_ones());
pf["fill_ratio"] = json!(filter.fill_ratio());
}
pf
})
.collect();
let bloom_stats = node.stats().snapshot().bloom;
@@ -397,36 +417,39 @@ pub fn show_mmp(node: &Node) -> Value {
}).collect();
// Session-layer MMP
let sessions: Vec<Value> = node.session_entries().filter_map(|(addr, entry)| {
let mmp = entry.mmp()?;
let metrics = &mmp.metrics;
let sessions: Vec<Value> = node
.session_entries()
.filter_map(|(addr, entry)| {
let mmp = entry.mmp()?;
let metrics = &mmp.metrics;
let mut session_layer = json!({
"loss_rate": metrics.loss_rate(),
"etx": metrics.etx,
"path_mtu": mmp.path_mtu.current_mtu(),
});
let mut session_layer = json!({
"loss_rate": metrics.loss_rate(),
"etx": metrics.etx,
"path_mtu": mmp.path_mtu.current_mtu(),
});
if let Some(smoothed_loss) = metrics.smoothed_loss() {
session_layer["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = metrics.smoothed_etx() {
session_layer["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = metrics.srtt_ms() {
session_layer["srtt_ms"] = json!(srtt);
if let Some(setx) = metrics.smoothed_etx() {
session_layer["sqi"] = json!(setx * (1.0 + srtt / 100.0));
if let Some(smoothed_loss) = metrics.smoothed_loss() {
session_layer["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = metrics.smoothed_etx() {
session_layer["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = metrics.srtt_ms() {
session_layer["srtt_ms"] = json!(srtt);
if let Some(setx) = metrics.smoothed_etx() {
session_layer["sqi"] = json!(setx * (1.0 + srtt / 100.0));
}
}
}
Some(json!({
"remote": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"mode": format!("{}", mmp.mode()),
"session_layer": session_layer,
}))
}).collect();
Some(json!({
"remote": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"mode": format!("{}", mmp.mode()),
"session_layer": session_layer,
}))
})
.collect();
json!({
"peers": peers,
@@ -452,48 +475,65 @@ pub fn show_cache(node: &Node) -> Value {
/// `show_connections` — Pending handshakes.
pub fn show_connections(node: &Node) -> Value {
let now = now_ms();
let connections: Vec<Value> = node.connections().map(|conn| {
let mut conn_json = json!({
"link_id": conn.link_id().as_u64(),
"direction": format!("{}", conn.direction()),
"handshake_state": format!("{}", conn.handshake_state()),
"started_at_ms": conn.started_at(),
"idle_ms": now.saturating_sub(conn.last_activity()),
"resend_count": conn.resend_count(),
});
let connections: Vec<Value> = node
.connections()
.map(|conn| {
let mut conn_json = json!({
"link_id": conn.link_id().as_u64(),
"direction": format!("{}", conn.direction()),
"handshake_state": format!("{}", conn.handshake_state()),
"started_at_ms": conn.started_at(),
"idle_ms": now.saturating_sub(conn.last_activity()),
"resend_count": conn.resend_count(),
});
if let Some(identity) = conn.expected_identity() {
conn_json["expected_peer"] = json!(identity.npub());
}
if let Some(identity) = conn.expected_identity() {
conn_json["expected_peer"] = json!(identity.npub());
}
conn_json
}).collect();
conn_json
})
.collect();
json!({ "connections": connections })
}
/// `show_transports` — Transport instances.
pub fn show_transports(node: &Node) -> Value {
let transports: Vec<Value> = node.transport_ids().map(|id| {
let handle = node.get_transport(id).unwrap();
let mut t_json = json!({
"transport_id": id.as_u32(),
"type": handle.transport_type().name,
"state": format!("{}", handle.state()),
"mtu": handle.mtu(),
});
let transports: Vec<Value> = node
.transport_ids()
.map(|id| {
let handle = node.get_transport(id).unwrap();
let mut t_json = json!({
"transport_id": id.as_u32(),
"type": handle.transport_type().name,
"state": format!("{}", handle.state()),
"mtu": handle.mtu(),
});
if let Some(name) = handle.name() {
t_json["name"] = json!(name);
}
if let Some(addr) = handle.local_addr() {
t_json["local_addr"] = json!(format!("{}", addr));
}
if let Some(name) = handle.name() {
t_json["name"] = json!(name);
}
if let Some(addr) = handle.local_addr() {
t_json["local_addr"] = json!(format!("{}", addr));
}
t_json["stats"] = handle.transport_stats();
// Tor-specific fields
if let Some(mode) = handle.tor_mode() {
t_json["tor_mode"] = json!(mode);
}
if let Some(onion) = handle.onion_address() {
t_json["onion_address"] = json!(onion);
}
if let Some(monitoring) = handle.tor_monitoring() {
t_json["tor_monitoring"] = serde_json::to_value(&monitoring).unwrap_or_default();
}
t_json
}).collect();
t_json["stats"] = handle.transport_stats();
t_json
})
.collect();
json!({ "transports": transports })
}

View File

@@ -3,7 +3,7 @@
use std::fmt;
use std::net::Ipv6Addr;
use super::{IdentityError, NodeAddr, FIPS_ADDRESS_PREFIX};
use super::{FIPS_ADDRESS_PREFIX, IdentityError, NodeAddr};
/// 128-bit FIPS address with IPv6-compatible format.
///

View File

@@ -3,9 +3,9 @@
use secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey};
use std::fmt;
use super::auth::{auth_challenge_digest, AuthResponse};
use super::auth::{AuthResponse, auth_challenge_digest};
use super::encoding::{decode_secret, encode_npub};
use super::{sha256, FipsAddress, IdentityError, NodeAddr};
use super::{FipsAddress, IdentityError, NodeAddr, sha256};
/// A FIPS node identity consisting of a keypair and derived identifiers.
///
@@ -22,8 +22,8 @@ impl Identity {
pub fn generate() -> Self {
let mut secret_bytes = [0u8; 32];
rand::Rng::fill_bytes(&mut rand::rng(), &mut secret_bytes);
let secret_key = SecretKey::from_slice(&secret_bytes)
.expect("32 random bytes is a valid secret key");
let secret_key =
SecretKey::from_slice(&secret_bytes).expect("32 random bytes is a valid secret key");
Self::from_secret_key(secret_key)
}

View File

@@ -4,7 +4,7 @@ use secp256k1::XOnlyPublicKey;
use sha2::{Digest, Sha256};
use std::fmt;
use super::{hex_encode, IdentityError};
use super::{IdentityError, hex_encode};
/// 16-byte node identifier derived from truncated SHA-256(pubkey).
///

View File

@@ -4,7 +4,7 @@ use secp256k1::{Parity, PublicKey, Secp256k1, XOnlyPublicKey};
use std::fmt;
use super::encoding::{decode_npub, encode_npub};
use super::{sha256, FipsAddress, IdentityError, NodeAddr};
use super::{FipsAddress, IdentityError, NodeAddr, sha256};
/// A known peer's identity (public key only, no signing capability).
///
@@ -99,7 +99,8 @@ impl PeerIdentity {
pub fn verify(&self, data: &[u8], signature: &secp256k1::schnorr::Signature) -> bool {
let secp = Secp256k1::new();
let digest = sha256(data);
secp.verify_schnorr(signature, &digest, &self.pubkey).is_ok()
secp.verify_schnorr(signature, &digest, &self.pubkey)
.is_ok()
}
}

View File

@@ -110,9 +110,9 @@ fn test_node_addr_ordering() {
fn test_identity_from_secret_bytes() {
// A known secret key (32 bytes)
let secret_bytes: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1d, 0x1e, 0x1f, 0x20,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
0x1f, 0x20,
];
let identity1 = Identity::from_secret_bytes(&secret_bytes).unwrap();
@@ -163,9 +163,10 @@ fn test_identity_sign() {
// Verify the signature manually
let secp = secp256k1::Secp256k1::new();
let digest = super::sha256(data);
assert!(secp
.verify_schnorr(&sig, &digest, &identity.pubkey())
.is_ok());
assert!(
secp.verify_schnorr(&sig, &digest, &identity.pubkey())
.is_ok()
);
}
#[test]
@@ -193,9 +194,9 @@ fn test_npub_roundtrip() {
fn test_npub_known_vector() {
// Test against a known npub (from NIP-19 test vectors or generated externally)
let secret_bytes: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1d, 0x1e, 0x1f, 0x20,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
0x1f, 0x20,
];
let identity = Identity::from_secret_bytes(&secret_bytes).unwrap();
@@ -274,9 +275,9 @@ fn test_peer_identity_display() {
#[test]
fn test_nsec_roundtrip() {
let secret_bytes: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1d, 0x1e, 0x1f, 0x20,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
0x1f, 0x20,
];
let secret_key = SecretKey::from_slice(&secret_bytes).unwrap();
@@ -301,9 +302,9 @@ fn test_decode_nsec_invalid_prefix() {
#[test]
fn test_decode_secret_nsec() {
let secret_bytes: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1d, 0x1e, 0x1f, 0x20,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
0x1f, 0x20,
];
let secret_key = SecretKey::from_slice(&secret_bytes).unwrap();
@@ -319,9 +320,9 @@ fn test_decode_secret_hex() {
let decoded = decode_secret(hex_str).unwrap();
let expected: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1d, 0x1e, 0x1f, 0x20,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
0x1f, 0x20,
];
assert_eq!(decoded.secret_bytes(), expected);
}
@@ -329,9 +330,9 @@ fn test_decode_secret_hex() {
#[test]
fn test_identity_from_secret_str_nsec() {
let secret_bytes: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1d, 0x1e, 0x1f, 0x20,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
0x1f, 0x20,
];
let secret_key = SecretKey::from_slice(&secret_bytes).unwrap();
@@ -347,9 +348,9 @@ fn test_identity_from_secret_str_nsec() {
fn test_identity_from_secret_str_hex() {
let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
let secret_bytes: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1d, 0x1e, 0x1f, 0x20,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
0x1f, 0x20,
];
let identity = Identity::from_secret_str(hex_str).unwrap();
@@ -379,11 +380,8 @@ fn test_hex_conversion_case2() {
#[test]
fn test_decode_npub_invalid_length() {
// Encode 16 bytes (too short) as bech32 with npub prefix
let short = bech32::encode::<bech32::Bech32>(
bech32::Hrp::parse_unchecked("npub"),
&[0u8; 16],
)
.unwrap();
let short =
bech32::encode::<bech32::Bech32>(bech32::Hrp::parse_unchecked("npub"), &[0u8; 16]).unwrap();
let result = decode_npub(&short);
assert!(matches!(result, Err(IdentityError::InvalidNpubLength(16))));
}
@@ -391,11 +389,8 @@ fn test_decode_npub_invalid_length() {
#[test]
fn test_decode_nsec_invalid_length() {
// Encode 16 bytes (too short) as bech32 with nsec prefix
let short = bech32::encode::<bech32::Bech32>(
bech32::Hrp::parse_unchecked("nsec"),
&[0u8; 16],
)
.unwrap();
let short =
bech32::encode::<bech32::Bech32>(bech32::Hrp::parse_unchecked("nsec"), &[0u8; 16]).unwrap();
let result = decode_nsec(&short);
assert!(matches!(result, Err(IdentityError::InvalidNsecLength(16))));
}
@@ -418,8 +413,8 @@ fn test_decode_secret_hex_invalid_chars() {
#[test]
fn test_node_addr_debug() {
let bytes = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00,
];
let node_addr = NodeAddr::from_bytes(bytes);
let debug = format!("{:?}", node_addr);
@@ -429,8 +424,8 @@ fn test_node_addr_debug() {
#[test]
fn test_node_addr_display() {
let bytes = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
0x32, 0x10,
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32,
0x10,
];
let node_addr = NodeAddr::from_bytes(bytes);
let display = format!("{}", node_addr);
@@ -587,9 +582,9 @@ fn test_peer_identity_pubkey_full_preserved_parity() {
// Create two identities and find one with odd parity to make this test meaningful
let secp = Secp256k1::new();
let secret_bytes: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1d, 0x1e, 0x1f, 0x20,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
0x1f, 0x20,
];
let keypair = Keypair::from_seckey_slice(&secp, &secret_bytes).unwrap();
let full_pubkey = keypair.public_key();

View File

@@ -3,30 +3,30 @@
//! A distributed, decentralized network routing protocol for mesh nodes
//! connecting over arbitrary transports.
pub mod version;
pub mod bloom;
pub mod cache;
pub mod config;
pub mod control;
pub mod identity;
pub mod mmp;
pub mod noise;
pub mod utils;
pub mod node;
pub mod noise;
pub mod peer;
pub mod protocol;
pub mod transport;
pub mod tree;
pub mod upper;
pub mod utils;
pub mod version;
// Re-export identity types
pub use identity::{
decode_npub, decode_nsec, decode_secret, encode_npub, encode_nsec, AuthChallenge, AuthResponse,
FipsAddress, Identity, IdentityError, NodeAddr, PeerIdentity,
AuthChallenge, AuthResponse, FipsAddress, Identity, IdentityError, NodeAddr, PeerIdentity,
decode_npub, decode_nsec, decode_secret, encode_npub, encode_nsec,
};
// Re-export config types
pub use config::{Config, ConfigError, IdentityConfig, UdpConfig};
pub use config::{Config, ConfigError, IdentityConfig, TorConfig, UdpConfig};
pub use upper::config::{DnsConfig, TunConfig};
// Re-export tree types
@@ -36,18 +36,18 @@ pub use tree::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError, TreeSta
pub use bloom::{BloomError, BloomFilter, BloomState};
// Re-export transport types
pub use transport::{
packet_channel, DiscoveredPeer, Link, LinkDirection, LinkId, LinkState, LinkStats, PacketRx,
PacketTx, ReceivedPacket, Transport, TransportAddr, TransportError, TransportHandle,
TransportId, TransportState, TransportType,
};
pub use transport::udp::UdpTransport;
pub use transport::{
DiscoveredPeer, Link, LinkDirection, LinkId, LinkState, LinkStats, PacketRx, PacketTx,
ReceivedPacket, Transport, TransportAddr, TransportError, TransportHandle, TransportId,
TransportState, TransportType, packet_channel,
};
// Re-export protocol types
pub use protocol::{
CoordsRequired, FilterAnnounce, HandshakeMessageType, LinkMessageType,
LookupRequest, LookupResponse, PathBroken, ProtocolError, SessionAck, SessionDatagram,
SessionFlags, SessionMessageType, SessionSetup, TreeAnnounce,
CoordsRequired, FilterAnnounce, HandshakeMessageType, LinkMessageType, LookupRequest,
LookupResponse, PathBroken, ProtocolError, SessionAck, SessionDatagram, SessionFlags,
SessionMessageType, SessionSetup, TreeAnnounce,
};
// Re-export cache types
@@ -55,10 +55,9 @@ pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache};
// Re-export peer types
pub use peer::{
cross_connection_winner, ActivePeer, ConnectivityState, HandshakeState, PeerConnection,
PeerError, PeerSlot, PromotionResult,
ActivePeer, ConnectivityState, HandshakeState, PeerConnection, PeerError, PeerSlot,
PromotionResult, cross_connection_winner,
};
// Re-export node types
pub use node::{Node, NodeError, NodeState};

View File

@@ -192,6 +192,11 @@ impl OwdTrendDetector {
}
}
/// Clear all samples, keeping the same capacity.
pub fn clear(&mut self) {
self.samples.clear();
}
/// Add an OWD sample.
///
/// `seq` is a monotonic sequence number (e.g., truncated frame counter).
@@ -366,7 +371,10 @@ mod tests {
}
// Should converge near 1000µs
let jitter = j.jitter_us();
assert!(jitter > 900 && jitter < 1100, "jitter={jitter}, expected ~1000");
assert!(
jitter > 900 && jitter < 1100,
"jitter={jitter}, expected ~1000"
);
}
#[test]
@@ -386,10 +394,7 @@ mod tests {
s.update(50_000);
}
let srtt = s.srtt_us();
assert!(
(srtt - 50_000).abs() < 1000,
"srtt={srtt}, expected ~50000"
);
assert!((srtt - 50_000).abs() < 1000, "srtt={srtt}, expected ~50000");
}
#[test]
@@ -412,7 +417,12 @@ mod tests {
e.update(100.0);
}
// Short should be closer to 100 than long
assert!(e.short() > e.long(), "short={} long={}", e.short(), e.long());
assert!(
e.short() > e.long(),
"short={} long={}",
e.short(),
e.long()
);
}
#[test]
@@ -432,7 +442,10 @@ mod tests {
d.push(i, 5000 + (i as i64) * 100); // increasing by 100µs per packet
}
let trend = d.trend_us_per_sec();
assert!(trend > 0, "increasing OWD should have positive trend, got {trend}");
assert!(
trend > 0,
"increasing OWD should have positive trend, got {trend}"
);
}
#[test]

View File

@@ -45,9 +45,39 @@ pub struct MmpMetrics {
prev_rr_reorder: u32,
/// Time of previous ReceiverReport (for goodput rate computation).
prev_rr_time: Option<Instant>,
/// Whether we have a previous ReceiverReport for delta computation.
has_prev_rr: bool,
// --- State for reverse delivery ratio delta computation ---
/// Previous reverse-side cumulative packets received (our receiver state).
prev_reverse_packets: u64,
/// Previous reverse-side highest counter (our receiver state).
prev_reverse_highest: u64,
/// Whether we have a previous reverse-side snapshot for delta computation.
has_prev_reverse: bool,
}
impl MmpMetrics {
/// Reset state derived from ReceiverReport counters for rekey cutover.
///
/// The new session starts with counter 0, so the prev_rr deltas must
/// be reset to avoid computing bogus loss/goodput from the counter
/// discontinuity. RTT (SRTT) is preserved since it remains valid.
pub fn reset_for_rekey(&mut self) {
self.prev_rr_cum_packets = 0;
self.prev_rr_cum_bytes = 0;
self.prev_rr_highest_counter = 0;
self.prev_rr_ecn_ce = 0;
self.prev_rr_reorder = 0;
self.prev_rr_time = None;
self.has_prev_rr = false;
self.delivery_ratio_forward = 1.0;
self.prev_reverse_packets = 0;
self.prev_reverse_highest = 0;
self.has_prev_reverse = false;
// Keep srtt, etx, trends, goodput_bps — they'll refresh from data
}
pub fn new() -> Self {
Self {
srtt: SrttEstimator::new(),
@@ -66,6 +96,10 @@ impl MmpMetrics {
prev_rr_ecn_ce: 0,
prev_rr_reorder: 0,
prev_rr_time: None,
has_prev_rr: false,
prev_reverse_packets: 0,
prev_reverse_highest: 0,
has_prev_reverse: false,
}
}
@@ -73,7 +107,17 @@ impl MmpMetrics {
///
/// `our_timestamp_ms` is the current session-relative time in ms (for RTT).
/// `now` is the current monotonic time (for goodput rate computation).
pub fn process_receiver_report(&mut self, rr: &ReceiverReport, our_timestamp_ms: u32, now: Instant) {
///
/// Returns `true` if this report produced the first SRTT measurement
/// (transition from uninitialized to initialized).
pub fn process_receiver_report(
&mut self,
rr: &ReceiverReport,
our_timestamp_ms: u32,
now: Instant,
) -> bool {
let had_srtt = self.srtt.initialized();
// --- RTT from timestamp echo ---
// RTT = now - echoed_timestamp - dwell_time
if rr.timestamp_echo > 0 {
@@ -98,9 +142,13 @@ impl MmpMetrics {
// --- Loss rate from cumulative counters ---
// Delta: frames the peer should have received vs. actually received
if self.prev_rr_highest_counter > 0 {
let counter_span = rr.highest_counter.saturating_sub(self.prev_rr_highest_counter);
let packets_delta = rr.cumulative_packets_recv.saturating_sub(self.prev_rr_cum_packets);
if self.has_prev_rr {
let counter_span = rr
.highest_counter
.saturating_sub(self.prev_rr_highest_counter);
let packets_delta = rr
.cumulative_packets_recv
.saturating_sub(self.prev_rr_cum_packets);
if counter_span > 0 {
let delivery = (packets_delta as f64) / (counter_span as f64);
@@ -113,8 +161,10 @@ impl MmpMetrics {
}
// --- Goodput from cumulative bytes + time delta ---
if self.prev_rr_cum_bytes > 0 {
let bytes_delta = rr.cumulative_bytes_recv.saturating_sub(self.prev_rr_cum_bytes);
if self.has_prev_rr {
let bytes_delta = rr
.cumulative_bytes_recv
.saturating_sub(self.prev_rr_cum_bytes);
self.goodput_trend.update(bytes_delta as f64);
// Compute bytes/sec if we have a time reference
@@ -143,13 +193,31 @@ impl MmpMetrics {
self.prev_rr_ecn_ce = rr.ecn_ce_count;
self.prev_rr_reorder = rr.cumulative_reorder_count;
self.prev_rr_time = Some(now);
self.has_prev_rr = true;
!had_srtt && self.srtt.initialized()
}
/// Update the reverse delivery ratio (from our own receiver state about the peer's traffic).
pub fn set_delivery_ratio_reverse(&mut self, ratio: f64) {
self.delivery_ratio_reverse = ratio.clamp(0.0, 1.0);
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
self.etx_trend.update(self.etx);
/// Update the reverse delivery ratio from our own receiver state.
///
/// Computes a per-interval delta (same as forward ratio) rather than
/// a lifetime cumulative ratio, so ETX responds to recent conditions.
pub fn update_reverse_delivery(&mut self, our_recv_packets: u64, peer_highest: u64) {
if self.has_prev_reverse {
let counter_span = peer_highest.saturating_sub(self.prev_reverse_highest);
let packets_delta = our_recv_packets.saturating_sub(self.prev_reverse_packets);
if counter_span > 0 {
let delivery = (packets_delta as f64) / (counter_span as f64);
self.delivery_ratio_reverse = delivery.clamp(0.0, 1.0);
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
self.etx_trend.update(self.etx);
}
}
self.prev_reverse_packets = our_recv_packets;
self.prev_reverse_highest = peer_highest;
self.has_prev_reverse = true;
}
/// Current smoothed RTT in milliseconds, or `None` if not yet measured.
@@ -272,9 +340,15 @@ mod tests {
let mut m = MmpMetrics::new();
assert_eq!(m.etx, 1.0); // initial: perfect
// Simulate some loss
// Simulate some loss via forward ratio
m.delivery_ratio_forward = 0.9;
m.set_delivery_ratio_reverse(0.95);
// First call establishes the baseline (no ETX update yet)
m.update_reverse_delivery(100, 100);
assert_eq!(m.etx, 1.0); // still perfect — baseline only
// Second call: 190 of 200 frames received (5% loss)
m.update_reverse_delivery(290, 300);
assert!(m.etx > 1.0);
assert!(m.etx < 2.0);
}
@@ -316,7 +390,64 @@ mod tests {
// Second report 1s later: 150KB total (100KB delta in 1s = 100KB/s)
let rr2 = make_rr(300, 290, 150_000, 0, 0, 0);
m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1));
assert!(m.goodput_bps() > 90_000.0, "goodput={}, expected ~100000", m.goodput_bps());
assert!(m.goodput_bps() < 110_000.0, "goodput={}, expected ~100000", m.goodput_bps());
assert!(
m.goodput_bps() > 90_000.0,
"goodput={}, expected ~100000",
m.goodput_bps()
);
assert!(
m.goodput_bps() < 110_000.0,
"goodput={}, expected ~100000",
m.goodput_bps()
);
}
#[test]
fn test_reverse_delivery_delta() {
let mut m = MmpMetrics::new();
// First call: baseline only, no ratio update
m.update_reverse_delivery(100, 100);
assert_eq!(m.delivery_ratio_reverse, 1.0); // unchanged from default
// Second call: perfect delivery (200 new frames, all received)
m.update_reverse_delivery(300, 300);
assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001);
// Third call: 50% loss (100 frames sent, 50 received)
m.update_reverse_delivery(350, 400);
assert!(
(m.delivery_ratio_reverse - 0.5).abs() < 0.001,
"reverse={}, expected 0.5",
m.delivery_ratio_reverse
);
}
#[test]
fn test_reverse_delivery_rekey_reset() {
let mut m = MmpMetrics::new();
// Establish baseline and one measurement
m.update_reverse_delivery(100, 100);
m.update_reverse_delivery(300, 300);
assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001);
// Rekey resets reverse state
m.reset_for_rekey();
// First call after rekey: baseline only
m.update_reverse_delivery(50, 50);
// delivery_ratio_reverse was reset to 1.0 by reset_for_rekey's
// clearing of delivery_ratio_forward; reverse is not explicitly
// reset — but the delta state is, so next call computes fresh.
assert_eq!(m.delivery_ratio_reverse, 1.0);
// Second call after rekey: 80% delivery
m.update_reverse_delivery(90, 100);
assert!(
(m.delivery_ratio_reverse - 0.8).abs() < 0.001,
"reverse={}, expected 0.8",
m.delivery_ratio_reverse
);
}
}

View File

@@ -197,6 +197,12 @@ impl MmpPeerState {
}
}
/// Reset counter-dependent state for rekey cutover.
pub fn reset_for_rekey(&mut self, now: Instant) {
self.receiver.reset_for_rekey(now);
self.metrics.reset_for_rekey();
}
/// Current operating mode.
pub fn mode(&self) -> MmpMode {
self.mode
@@ -256,6 +262,12 @@ impl MmpSessionState {
}
}
/// Reset counter-dependent state for rekey cutover.
pub fn reset_for_rekey(&mut self, now: Instant) {
self.receiver.reset_for_rekey(now);
self.metrics.reset_for_rekey();
}
/// Current operating mode.
pub fn mode(&self) -> MmpMode {
self.mode

View File

@@ -7,8 +7,18 @@ use std::time::{Duration, Instant};
use crate::mmp::algorithms::{JitterEstimator, OwdTrendDetector};
use crate::mmp::report::ReceiverReport;
use crate::mmp::{DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_OWD_WINDOW_SIZE,
MAX_REPORT_INTERVAL_MS, MIN_REPORT_INTERVAL_MS};
use crate::mmp::{
DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_OWD_WINDOW_SIZE, MAX_REPORT_INTERVAL_MS,
MIN_REPORT_INTERVAL_MS,
};
/// Grace period after rekey before resuming jitter calculation.
///
/// During rekey cutover, frames from the old session may still arrive via the
/// drain window (DRAIN_WINDOW_SECS = 10s). These carry large sender timestamps
/// from the old session, producing enormous transit deltas that spike the EWMA
/// jitter estimator. We suppress jitter updates for drain window + 5s margin.
const REKEY_JITTER_GRACE_SECS: u64 = 15;
// ============================================================================
// Gap Tracker (burst loss detection)
@@ -161,6 +171,11 @@ pub struct ReceiverState {
/// Local time when the most recent frame was received (for dwell computation).
last_recv_time: Option<Instant>,
// --- Rekey grace ---
/// When set, jitter updates are suppressed until this instant passes.
/// Prevents drain-window frames from spiking the jitter estimator.
rekey_jitter_grace_until: Option<Instant>,
// --- Report timing ---
last_report_time: Option<Instant>,
report_interval: Duration,
@@ -192,12 +207,36 @@ impl ReceiverState {
ecn_ce_count: 0,
last_sender_timestamp: 0,
last_recv_time: None,
rekey_jitter_grace_until: None,
last_report_time: None,
report_interval: Duration::from_millis(cold_start_ms),
interval_has_data: false,
}
}
/// Reset counter-dependent state for rekey cutover.
///
/// After cutover, the new session starts with counter 0 and reset
/// timestamps. Without resetting, the old `highest_counter` and
/// `GapTracker.expected_next` cause false reorder/loss detection.
pub fn reset_for_rekey(&mut self, now: Instant) {
self.highest_counter = 0;
self.cumulative_reorder_count = 0;
self.gap_tracker = GapTracker::new();
self.interval_packets_recv = 0;
self.interval_bytes_recv = 0;
self.jitter = JitterEstimator::new();
self.owd_trend.clear();
self.owd_seq = 0;
self.last_sender_timestamp = 0;
self.last_recv_time = None;
self.rekey_jitter_grace_until = Some(now + Duration::from_secs(REKEY_JITTER_GRACE_SECS));
self.ecn_ce_count = 0;
self.interval_has_data = false;
// Keep cumulative_packets_recv, cumulative_bytes_recv (lifetime stats)
// Keep last_report_time, report_interval (report scheduling)
}
/// Record a received frame from this peer.
///
/// Called on the RX path after AEAD decryption, before message dispatch.
@@ -242,11 +281,18 @@ impl ReceiverState {
let sender_us = (sender_timestamp_ms as i64) * 1000;
// We can't get absolute µs from Instant, but we can compute the delta
// between consecutive transits using relative Instant differences.
if let Some(prev_recv) = self.last_recv_time {
let recv_delta_us = now.duration_since(prev_recv).as_micros() as i64;
let send_delta_us = sender_us - (self.last_sender_timestamp as i64 * 1000);
let transit_delta = (recv_delta_us - send_delta_us) as i32;
self.jitter.update(transit_delta);
// Skip during post-rekey grace period to avoid drain-window spikes.
let in_grace = self
.rekey_jitter_grace_until
.is_some_and(|deadline| now < deadline);
if !in_grace {
self.rekey_jitter_grace_until = None; // clear expired grace
if let Some(prev_recv) = self.last_recv_time {
let recv_delta_us = now.duration_since(prev_recv).as_micros() as i64;
let send_delta_us = sender_us - (self.last_sender_timestamp as i64 * 1000);
let transit_delta = (recv_delta_us - send_delta_us) as i32;
self.jitter.update(transit_delta);
}
}
// OWD trend: use sender timestamp as a proxy for send time
@@ -273,7 +319,8 @@ impl ReceiverState {
}
// Dwell time: ms between last frame reception and report generation
let dwell_time = self.last_recv_time
let dwell_time = self
.last_recv_time
.map(|t| now.duration_since(t).as_millis() as u16)
.unwrap_or(0);
@@ -320,7 +367,11 @@ impl ReceiverState {
///
/// Receiver reports at 1× SRTT, clamped to [MIN, MAX].
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
self.update_report_interval_with_bounds(srtt_us, MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
self.update_report_interval_with_bounds(
srtt_us,
MIN_REPORT_INTERVAL_MS,
MAX_REPORT_INTERVAL_MS,
);
}
/// Update the report interval based on SRTT with custom bounds.
@@ -531,4 +582,36 @@ mod tests {
r.update_report_interval_from_srtt(500_000);
assert_eq!(r.report_interval(), Duration::from_millis(500));
}
#[test]
fn test_rekey_jitter_grace_suppresses_spikes() {
let mut r = ReceiverState::new(32);
let t0 = Instant::now();
// Establish baseline with two frames so jitter starts updating
r.record_recv(1, 1000, 100, false, t0);
r.record_recv(2, 2000, 100, false, t0 + Duration::from_secs(1));
assert_eq!(r.jitter_us(), 0); // perfect 1s spacing → 0 jitter
// Simulate rekey: reset, then send a frame with a large old-session
// timestamp followed by a new-session timestamp near zero.
// Without grace, this would produce a huge jitter spike.
r.reset_for_rekey(t0 + Duration::from_secs(2));
// Frame arrives during grace period with old-session timestamp
r.record_recv(0, 120_000, 100, false, t0 + Duration::from_secs(3));
// Next frame with new-session timestamp near zero
r.record_recv(1, 100, 100, false, t0 + Duration::from_secs(4));
// Jitter should still be zero — updates suppressed during grace
assert_eq!(r.jitter_us(), 0);
// After grace expires, jitter updates resume
let after_grace =
t0 + Duration::from_secs(2) + Duration::from_secs(REKEY_JITTER_GRACE_SECS + 1);
r.record_recv(2, 200, 100, false, after_grace);
r.record_recv(3, 300, 100, false, after_grace + Duration::from_millis(100));
// Now jitter should be updating (non-zero or zero depending on timing)
// The key assertion is that it's not a multi-second spike
assert!(r.jitter_us() < 1_000_000); // less than 1 second
}
}

View File

@@ -117,7 +117,9 @@ impl SenderState {
match self.last_report_time {
None => true, // Never sent a report — send immediately
Some(last) => {
let effective = self.report_interval.mul_f64(self.send_failure_backoff_multiplier());
let effective = self
.report_interval
.mul_f64(self.send_failure_backoff_multiplier());
now.duration_since(last) >= effective
}
}
@@ -152,7 +154,11 @@ impl SenderState {
///
/// Sender reports at 2× SRTT clamped to [MIN, MAX].
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
self.update_report_interval_with_bounds(srtt_us, MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
self.update_report_interval_with_bounds(
srtt_us,
MIN_REPORT_INTERVAL_MS,
MAX_REPORT_INTERVAL_MS,
);
}
/// Update the report interval based on SRTT with custom bounds.
@@ -301,7 +307,10 @@ mod tests {
// 2s RTT → 4s, clamped to max 2s
s.update_report_interval_from_srtt(2_000_000);
assert_eq!(s.report_interval(), Duration::from_millis(MAX_REPORT_INTERVAL_MS));
assert_eq!(
s.report_interval(),
Duration::from_millis(MAX_REPORT_INTERVAL_MS)
);
}
#[test]

View File

@@ -3,13 +3,13 @@
//! Handles building, sending, and receiving FilterAnnounce messages,
//! including debounced propagation to peers.
use crate::NodeAddr;
use crate::bloom::BloomFilter;
use crate::protocol::FilterAnnounce;
use crate::NodeAddr;
use super::{Node, NodeError};
use std::collections::HashMap;
use tracing::debug;
use tracing::{debug, warn};
impl Node {
/// Collect inbound filters from all peers for outgoing filter computation.
@@ -78,11 +78,41 @@ impl Node {
self.stats_mut().bloom.sent += 1;
// Self-plausibility check: WARN if our own outgoing filter is
// above the antipoison cap. Independent detection signal if
// aggregation drift or an ingress-check bypass pushes us over
// despite M1. Rate-limited to once per 60s globally — outgoing
// cadence can be per-tick during churn, and we want the
// operator to see one clear message, not spam.
let max_fpr = self.config.node.bloom.max_inbound_fpr;
let out_fill = sent_filter.fill_ratio();
let out_fpr = out_fill.powi(sent_filter.hash_count() as i32);
if out_fpr > max_fpr {
let now = std::time::Instant::now();
let should_warn = self
.last_self_warn
.map(|t| now.duration_since(t) >= std::time::Duration::from_secs(60))
.unwrap_or(true);
if should_warn {
self.last_self_warn = Some(now);
warn!(
to = %self.peer_display_name(peer_addr),
fill = format_args!("{:.3}", out_fill),
fpr = format_args!("{:.4}", out_fpr),
cap = format_args!("{:.4}", max_fpr),
"Outgoing filter above FPR cap — aggregation drift or missed ingress?"
);
}
}
// Record send and store the filter for change detection
debug!(
peer = %self.peer_display_name(peer_addr),
seq = announce.sequence,
est_entries = format_args!("{:.0}", sent_filter.estimated_count()),
est_entries = match sent_filter.estimated_count(max_fpr) {
Some(n) => format!("{:.0}", n),
None => "".to_string(),
},
set_bits = sent_filter.count_ones(),
fill = format_args!("{:.1}%", sent_filter.fill_ratio() * 100.0),
tree_peer = self.is_tree_peer(peer_addr),
@@ -174,6 +204,28 @@ impl Node {
return;
}
// Antipoison FPR cap. Reject announces whose FPR exceeds
// node.bloom.max_inbound_fpr. Silent on the wire (no NACK) —
// the peer's prior accepted filter and filter_sequence stay
// untouched so the peer is not permanently silenced and an
// on-path attacker cannot weaponize a single corrupted frame
// to wipe a victim's contribution to aggregation.
let max_fpr = self.config.node.bloom.max_inbound_fpr;
let fill = announce.filter.fill_ratio();
let fpr = fill.powi(announce.filter.hash_count() as i32);
if fpr > max_fpr {
self.stats_mut().bloom.fill_exceeded += 1;
warn!(
from = %self.peer_display_name(from),
seq = announce.sequence,
fill = format_args!("{:.3}", fill),
fpr = format_args!("{:.4}", fpr),
cap = format_args!("{:.4}", max_fpr),
"FilterAnnounce above FPR cap — rejected"
);
return;
}
self.stats_mut().bloom.accepted += 1;
let now_ms = std::time::SystemTime::now()
@@ -184,7 +236,10 @@ impl Node {
debug!(
from = %self.peer_display_name(from),
seq = announce.sequence,
est_entries = format_args!("{:.0}", announce.filter.estimated_count()),
est_entries = match announce.filter.estimated_count(max_fpr) {
Some(n) => format!("{:.0}", n),
None => "".to_string(),
},
set_bits = announce.filter.count_ones(),
fill = format_args!("{:.1}%", announce.filter.fill_ratio() * 100.0),
tree_peer = self.is_tree_peer(from),

View File

@@ -0,0 +1,373 @@
//! Discovery protocol rate limiting and backoff.
//!
//! Two complementary mechanisms:
//!
//! - **`DiscoveryBackoff`** (originator-side): Exponential backoff for failed
//! lookups. After a lookup times out, suppresses re-initiation with
//! increasing delays (30s → 60s → 300s cap). Reset on topology changes
//! (parent change, new peer, first RTT, reconnection).
//!
//! - **`DiscoveryForwardRateLimiter`** (transit-side): Per-target minimum
//! interval for forwarded requests. Defense-in-depth against misbehaving
//! nodes generating fresh request_ids at high rate.
use crate::NodeAddr;
use std::collections::HashMap;
use std::time::{Duration, Instant};
// ============================================================================
// Originator-side: Discovery Backoff
// ============================================================================
/// Default base backoff after first lookup failure.
const DEFAULT_BACKOFF_BASE_SECS: u64 = 30;
/// Default maximum backoff cap.
const DEFAULT_BACKOFF_MAX_SECS: u64 = 300;
/// Backoff multiplier per consecutive failure.
const BACKOFF_MULTIPLIER: u64 = 2;
/// Exponential backoff for failed discovery lookups.
///
/// Tracks targets whose lookups have timed out and suppresses
/// re-initiation with increasing delays. Cleared on topology changes.
pub struct DiscoveryBackoff {
/// Maps target → (suppress_until, consecutive_failures).
entries: HashMap<NodeAddr, BackoffEntry>,
/// Base backoff duration (first failure).
base: Duration,
/// Maximum backoff cap.
max: Duration,
}
struct BackoffEntry {
/// Don't re-initiate until this instant.
suppress_until: Instant,
/// Consecutive failures (drives exponential backoff).
failures: u32,
}
impl DiscoveryBackoff {
/// Create with default parameters (30s base, 300s cap).
pub fn new() -> Self {
Self::with_params(DEFAULT_BACKOFF_BASE_SECS, DEFAULT_BACKOFF_MAX_SECS)
}
/// Create with custom base and max backoff in seconds.
pub fn with_params(base_secs: u64, max_secs: u64) -> Self {
Self {
entries: HashMap::new(),
base: Duration::from_secs(base_secs),
max: Duration::from_secs(max_secs),
}
}
/// Check if a lookup for this target is suppressed.
///
/// Returns true if the target is in backoff and should not be
/// looked up yet.
pub fn is_suppressed(&self, target: &NodeAddr) -> bool {
if let Some(entry) = self.entries.get(target) {
Instant::now() < entry.suppress_until
} else {
false
}
}
/// Record a lookup failure (timeout) for a target.
///
/// Increments the failure count and sets the next suppression
/// window using exponential backoff.
pub fn record_failure(&mut self, target: &NodeAddr) {
let now = Instant::now();
let failures = self.entries.get(target).map_or(0, |e| e.failures) + 1;
let backoff_secs = self
.base
.as_secs()
.saturating_mul(BACKOFF_MULTIPLIER.saturating_pow(failures.saturating_sub(1)));
let backoff = Duration::from_secs(backoff_secs.min(self.max.as_secs()));
self.entries.insert(
*target,
BackoffEntry {
suppress_until: now + backoff,
failures,
},
);
}
/// Record a successful lookup — remove backoff for this target.
pub fn record_success(&mut self, target: &NodeAddr) {
self.entries.remove(target);
}
/// Clear all backoff entries.
///
/// Called on topology changes that might make previously-unreachable
/// targets reachable (parent change, new peer, first RTT, reconnection).
pub fn reset_all(&mut self) {
self.entries.clear();
}
/// Whether any entries exist.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Current number of entries.
pub fn entry_count(&self) -> usize {
self.entries.len()
}
/// Get the failure count for a target (for logging).
pub fn failure_count(&self, target: &NodeAddr) -> u32 {
self.entries.get(target).map_or(0, |e| e.failures)
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.entries.len()
}
}
impl Default for DiscoveryBackoff {
fn default() -> Self {
Self::new()
}
}
// ============================================================================
// Transit-side: Discovery Forward Rate Limiter
// ============================================================================
/// Default minimum interval between forwarded lookups for the same target.
const DEFAULT_FORWARD_MIN_INTERVAL: Duration = Duration::from_secs(2);
/// Maximum age of entries before cleanup.
const FORWARD_MAX_AGE: Duration = Duration::from_secs(60);
/// Rate limiter for forwarded discovery requests.
///
/// Tracks the last time a LookupRequest was forwarded for each target
/// and enforces a minimum interval to prevent floods from misbehaving
/// nodes generating fresh request_ids.
pub struct DiscoveryForwardRateLimiter {
last_forwarded: HashMap<NodeAddr, Instant>,
min_interval: Duration,
max_age: Duration,
}
impl DiscoveryForwardRateLimiter {
/// Create with default parameters (2s interval).
pub fn new() -> Self {
Self {
last_forwarded: HashMap::new(),
min_interval: DEFAULT_FORWARD_MIN_INTERVAL,
max_age: FORWARD_MAX_AGE,
}
}
/// Create with a custom minimum interval.
pub fn with_interval(min_interval: Duration) -> Self {
Self {
last_forwarded: HashMap::new(),
min_interval,
max_age: FORWARD_MAX_AGE,
}
}
/// Check if we should forward a lookup for this target.
///
/// Returns true if enough time has passed since the last forward
/// for this target. Updates internal state when returning true.
pub fn should_forward(&mut self, target: &NodeAddr) -> bool {
let now = Instant::now();
if let Some(&last) = self.last_forwarded.get(target)
&& now.duration_since(last) < self.min_interval
{
return false;
}
self.last_forwarded.insert(*target, now);
self.cleanup(now);
true
}
/// Replace the minimum interval (e.g., set to zero to disable).
#[cfg(test)]
pub fn set_interval(&mut self, interval: Duration) {
self.min_interval = interval;
}
/// Remove entries older than max_age.
fn cleanup(&mut self, now: Instant) {
self.last_forwarded
.retain(|_, &mut last| now.duration_since(last) < self.max_age);
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.last_forwarded.len()
}
}
impl Default for DiscoveryForwardRateLimiter {
fn default() -> Self {
Self::new()
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
fn addr(val: u8) -> NodeAddr {
let mut bytes = [0u8; 16];
bytes[0] = val;
NodeAddr::from_bytes(bytes)
}
// --- DiscoveryBackoff tests ---
#[test]
fn test_backoff_not_suppressed_initially() {
let backoff = DiscoveryBackoff::new();
assert!(!backoff.is_suppressed(&addr(1)));
}
#[test]
fn test_backoff_suppressed_after_failure() {
let mut backoff = DiscoveryBackoff::new();
backoff.record_failure(&addr(1));
assert!(backoff.is_suppressed(&addr(1)));
// Different target not affected
assert!(!backoff.is_suppressed(&addr(2)));
}
#[test]
fn test_backoff_cleared_on_success() {
let mut backoff = DiscoveryBackoff::new();
backoff.record_failure(&addr(1));
assert!(backoff.is_suppressed(&addr(1)));
backoff.record_success(&addr(1));
assert!(!backoff.is_suppressed(&addr(1)));
}
#[test]
fn test_backoff_reset_all() {
let mut backoff = DiscoveryBackoff::new();
backoff.record_failure(&addr(1));
backoff.record_failure(&addr(2));
assert_eq!(backoff.len(), 2);
backoff.reset_all();
assert_eq!(backoff.len(), 0);
assert!(!backoff.is_suppressed(&addr(1)));
}
#[test]
fn test_backoff_exponential() {
let mut backoff = DiscoveryBackoff::with_params(1, 300);
// First failure: 1s backoff
backoff.record_failure(&addr(1));
assert_eq!(backoff.failure_count(&addr(1)), 1);
// Second failure: 2s backoff
backoff.record_failure(&addr(1));
assert_eq!(backoff.failure_count(&addr(1)), 2);
// Third failure: 4s backoff
backoff.record_failure(&addr(1));
assert_eq!(backoff.failure_count(&addr(1)), 3);
}
#[test]
fn test_backoff_expires() {
let mut backoff = DiscoveryBackoff::with_params(0, 0);
backoff.record_failure(&addr(1));
// With 0s backoff, should not be suppressed
assert!(!backoff.is_suppressed(&addr(1)));
}
#[test]
fn test_backoff_capped() {
let mut backoff = DiscoveryBackoff::with_params(1, 10);
// Record many failures
for _ in 0..20 {
backoff.record_failure(&addr(1));
}
// Backoff should be capped at max (10s), not overflow
let entry = backoff.entries.get(&addr(1)).unwrap();
let remaining = entry.suppress_until.duration_since(Instant::now());
assert!(remaining <= Duration::from_secs(11));
}
// --- DiscoveryForwardRateLimiter tests ---
#[test]
fn test_forward_first_allowed() {
let mut limiter = DiscoveryForwardRateLimiter::new();
assert!(limiter.should_forward(&addr(1)));
}
#[test]
fn test_forward_rapid_rate_limited() {
let mut limiter = DiscoveryForwardRateLimiter::new();
assert!(limiter.should_forward(&addr(1)));
assert!(!limiter.should_forward(&addr(1)));
assert!(!limiter.should_forward(&addr(1)));
}
#[test]
fn test_forward_different_targets_independent() {
let mut limiter = DiscoveryForwardRateLimiter::new();
assert!(limiter.should_forward(&addr(1)));
assert!(limiter.should_forward(&addr(2)));
assert!(!limiter.should_forward(&addr(1)));
assert!(!limiter.should_forward(&addr(2)));
}
#[test]
fn test_forward_allowed_after_interval() {
let mut limiter = DiscoveryForwardRateLimiter::with_interval(Duration::from_millis(100));
assert!(limiter.should_forward(&addr(1)));
thread::sleep(Duration::from_millis(110));
assert!(limiter.should_forward(&addr(1)));
}
#[test]
fn test_forward_cleanup_removes_old() {
let mut limiter = DiscoveryForwardRateLimiter::new();
assert!(limiter.should_forward(&addr(1)));
assert!(limiter.should_forward(&addr(2)));
assert_eq!(limiter.len(), 2);
let future = Instant::now() + Duration::from_secs(61);
limiter.cleanup(future);
assert_eq!(limiter.len(), 0);
}
#[test]
fn test_forward_cleanup_preserves_recent() {
let mut limiter = DiscoveryForwardRateLimiter::new();
assert!(limiter.should_forward(&addr(1)));
assert_eq!(limiter.len(), 1);
limiter.cleanup(Instant::now());
assert_eq!(limiter.len(), 1);
}
}

Some files were not shown because too many files have changed in this diff Show More