diff --git a/CHANGELOG.md b/CHANGELOG.md index 908708e..787b04e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,46 @@ with v0.2.x peers. ### Added +#### Platform Support + +- Windows platform support: wintun TUN device, TCP control socket on + `localhost:21210` (in place of the Unix domain socket), Windows + Service lifecycle (`--install-service`, `--uninstall-service`, + `--service`), ZIP packaging with PowerShell install/uninstall scripts, + and CI build/test matrix entry + ([#45](https://github.com/jmcorgan/fips/pull/45)) +- macOS platform support: native `utun` TUN interface management, raw + Ethernet transport via BPF, `.pkg` packaging with launchd plist and + uninstall script, x86_64 cross-compile from arm64, and CI build/unit + test jobs +- `gateway` Cargo feature flag gates the optional Linux-only + `rustables` dependency so macOS and Windows builds never pull in + nftables bindings + +#### Outbound LAN Gateway + +- New `fips-gateway` binary that lets unmodified LAN hosts reach FIPS + mesh destinations via DNS-allocated virtual IPs and kernel nftables + NAT. Virtual-IP pool (`fd01::/112` by default) with state-machine + lifecycle and TTL-based reclamation; conntrack-backed session + tracking; proxy NDP on the LAN interface; control socket at + `/run/fips/gateway.sock` with `show_gateway` and `show_mappings`; + fipstop Gateway tab with pool gauge and mappings table; design doc + at `docs/design/fips-gateway.md`; integration test harness +- Gateway packaging: systemd service unit with `After=fips.service`, + Debian and AUR package entries, OpenWrt procd init with dnsmasq + forwarding, proxy NDP, RA route advertisements, and IPv6 forwarding + sysctls. Gateway enabled by default on OpenWrt + +#### Examples + +- macOS WireGuard sidecar: run FIPS in a local Docker container and + route `.fips` traffic from the macOS host through a WireGuard tunnel + to the container's `fips0` interface. Only traffic destined for + `fd00::/8` transits the sidecar; regular internet traffic continues + to use the host network + ([#51](https://github.com/jmcorgan/fips/pull/51)) + #### Bluetooth Transport - Bluetooth Low Energy (BLE) L2CAP Connection-Oriented Channel transport @@ -96,6 +136,31 @@ with v0.2.x peers. - Cross-probe tie-breaker using deterministic NodeAddr comparison - Connection pool with configurable capacity and eviction +#### DNS + +- Multi-backend `.fips` DNS configuration: a detection script + configures whichever resolver is available, in priority order: + systemd dns-delegate (systemd >= 258), systemd-resolved via + `resolvectl`, standalone dnsmasq, NetworkManager with the dnsmasq + plugin. Teardown reads the recorded backend from + `/run/fips/dns-backend` and reverses only what was applied + ([#58](https://github.com/jmcorgan/fips/pull/58), + fixes [#52](https://github.com/jmcorgan/fips/issues/52)) + +#### Operator Configuration + +- `node.log_level` config field (case-insensitive, default `info`) + replaces the hardcoded `RUST_LOG=info` previously baked into + systemd units and the OpenWrt procd init script. The daemon now + loads config before initializing tracing so the configured level + takes effect; `RUST_LOG` still overrides when set + +#### Documentation + +- Pre-implementation proposal for NAT traversal using Nostr relays + as the signaling channel and STUN for reflexive address discovery + (`docs/proposals/`) + #### Packaging and Deployment - Linux release artifact workflow: builds x86_64 and aarch64 tarballs @@ -106,6 +171,20 @@ with v0.2.x peers. ([#21](https://github.com/jmcorgan/fips/pull/21), [@dskvr](https://github.com/dskvr)) +### Changed + +- MMP link-layer report intervals retuned for constrained transports: + steady-state floor raised from 100ms to 1000ms, ceiling from 2000ms + to 5000ms. Cold-start uses a 200ms floor for the first 5 SRTT samples + before switching to steady-state. Reduces BLE overhead ~10× while + keeping reports well above the EWMA convergence threshold. + Session-layer intervals unchanged +- 35 info-level log messages demoted to debug (handshake + cross-connection mechanics, periodic MMP telemetry, TUN/transport + shutdown, retry scheduling). Info output now focuses on + operator-relevant state changes: lifecycle events, peer promotions, + session establishment, parent switches, transport start/stop + ### Fixed - Control socket path detection in fipsctl and fipstop now checks for @@ -118,6 +197,37 @@ with v0.2.x peers. 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)) +- Rekey msg1 on non-accepting transports (e.g. UDP holepunch) was + rejected at the top of `handle_msg1()`, which broke rekey handshakes + on established links and produced repeated "dual rekey initiation" + log floods. The gate now only blocks truly new inbound handshakes + from unknown addresses; rekey and restart msg1s for established + peers are processed normally + ([#47](https://github.com/jmcorgan/fips/issues/47), + [#49](https://github.com/jmcorgan/fips/pull/49)) +- `fipstop` now uses `ratatui::try_init()` instead of `ratatui::init()`, + so terminal initialization failures (e.g. Docker on macOS Sequoia, + or environments without a usable tty) produce a clean error message + instead of a hard crash ## [0.2.0] - 2026-03-22 diff --git a/README.md b/README.md index 263c3f1..bbfd742 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,12 @@ endpoints. forward secrecy and protocol negotiation in the handshake - **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`) +- **IPv6 adaptation** — TUN interface maps npubs to fd00::/8 addresses + for unmodified IP applications; built-in `.fips` DNS resolver with + optional static hostname mapping (`/etc/fips/hosts`) +- **Outbound LAN gateway** — optional `fips-gateway` daemon lets + unmodified LAN hosts reach `.fips` destinations via a + DNS-allocated virtual IP pool and kernel nftables NAT - **Metrics Measurement Protocol** — per-link RTT, loss, jitter, and goodput measurement with mesh size estimation - **ECN congestion signaling** — hop-by-hop CE flag relay with RFC 3168 IPv6 @@ -69,18 +73,21 @@ supported (see transport matrix below). ### Transport support by platform -| Transport | Linux | macOS | Windows | -|-----------|:-----:|:-----:|:-------:| -| UDP | ✅ | ✅ | ❌ | -| TCP | ✅ | ✅ | ❌ | -| Ethernet | ✅ | ✅ | ❌ | -| Tor | ✅ | ✅ | ❌ | -| BLE | ✅ | ❌ | ❌ | +| Transport | Linux | macOS | Windows | OpenWrt | +|-----------|:-----:|:-----:|:-------:|:-------:| +| UDP | ✅ | ✅ | ✅ | ✅ | +| TCP | ✅ | ✅ | ✅ | ✅ | +| Ethernet | ✅ | ✅ | ❌ | ✅ | +| Tor | ✅ | ✅ | ✅ | ✅ | +| BLE | ✅ | ❌ | ❌ | ❌ | On **Linux**, the BLE transport requires BlueZ and libdbus. On Debian/Ubuntu: `sudo apt install bluez libdbus-1-dev`. Then build with BLE enabled: `cargo build --release --features ble`. +On **OpenWrt**, BLE is disabled because libdbus is not available on +the target. All other transports work and ship in the default ipk. + ## Installation After building, choose one of the following methods to install. @@ -262,7 +269,11 @@ for the full reference. FIPS includes a DNS resolver (enabled by default, port 5354) that maps `.fips` names to fd00::/8 IPv6 addresses. -**Linux** (systemd-resolved): +**Linux**: The `.deb` package auto-detects and configures whichever +resolver is present (systemd dns-delegate, systemd-resolved, dnsmasq, +or NetworkManager with dnsmasq); no manual setup is needed. For +manual or tarball installs, point your resolver at `127.0.0.1:5354` +for the `fips` domain — e.g., with systemd-resolved: ```bash sudo resolvectl dns fips0 127.0.0.1:5354 @@ -323,13 +334,20 @@ including static topology tests and stochastic chaos simulation. ## Examples -- [examples/wireguard-sidecar-macos/](examples/wireguard-sidecar-macos/) - - Run a local WireGuard sidecar on macOS so `.fips` traffic can reach the mesh - through Docker. - -The macOS WireGuard sidecar only forwards FIPS IPv6 traffic destined for -`fd00::/8` from `wg0` to `fips0`. Regular internet traffic does not transit the -sidecar and continues to use the host network normally. +- [examples/sidecar-nostr-relay/](examples/sidecar-nostr-relay/) — + Run 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. +- [examples/k8s-sidecar/](examples/k8s-sidecar/) — Run FIPS as a + Kubernetes Pod sidecar. The sidecar creates `fips0` in the Pod's + shared network namespace so every other container in the Pod gets + mesh access without modification. +- [examples/wireguard-sidecar-macos/](examples/wireguard-sidecar-macos/) — + Reach the FIPS mesh from a macOS host through a local Docker + container over a WireGuard tunnel. Only traffic destined for + `fd00::/8` transits the sidecar; regular internet traffic continues + to use the host network. ## Documentation @@ -346,8 +364,9 @@ If you want to contribute, start with: ## Project Structure ```text -src/ Rust source (library + fips/fipsctl/fipstop binaries) -packaging/ Debian, systemd tarball, and shared packaging files +src/ Rust source (library + fips/fipsctl/fipstop/fips-gateway binaries) +packaging/ Debian, macOS .pkg, Windows ZIP, OpenWrt ipk, AUR, systemd tarball +examples/ Deployment examples (Nostr relay, K8s sidecar, macOS WireGuard) docs/design/ Protocol design specifications testing/ Docker-based integration test harnesses ``` @@ -364,14 +383,17 @@ Ethernet, Tor, and Bluetooth (BLE) with a small live mesh of deployed nodes. - Noise XX encryption at both layers with protocol negotiation - 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 +- IPv6 TUN adapter with built-in `.fips` DNS resolver and multi-backend + auto-configuration (systemd dns-delegate, systemd-resolved, dnsmasq, + NetworkManager) - 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, Ethernet, Tor, and BLE transports (BLE via L2CAP CoC with per-link MTU negotiation) +- Outbound LAN gateway for unmodified hosts via DNS-allocated virtual IPs and nftables NAT - Runtime inspection and peer management via `fipsctl` and `fipstop` - Reproducible builds with toolchain pinning and SOURCE_DATE_EPOCH -- Linux (Debian, systemd tarball, OpenWrt, AUR) and macOS packaging +- Linux (Debian, systemd tarball, OpenWrt, AUR), macOS (`.pkg`), and Windows (ZIP, service) packaging - Docker-based integration and chaos testing ### Near-term priorities diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index f649932..fb28146 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -12,6 +12,7 @@ use fips::upper::hosts::HostMap; use fips::version; use fips::{Identity, encode_nsec}; use std::io::{BufRead, BufReader, Write}; +use std::net::{Ipv6Addr, SocketAddrV6}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -249,6 +250,50 @@ fn default_key_dir() -> PathBuf { } } +/// 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::() { + return is_ula(&a); + } + if let Ok(sa) = address.parse::() { + 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::() { + 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. @@ -328,6 +373,10 @@ fn main() { 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", @@ -353,3 +402,62 @@ fn main() { } } } + +#[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()); + } +} diff --git a/src/node/handlers/dispatch.rs b/src/node/handlers/dispatch.rs index dd2a62d..44e4af4 100644 --- a/src/node/handlers/dispatch.rs +++ b/src/node/handlers/dispatch.rs @@ -71,8 +71,12 @@ impl Node { /// Handle a Disconnect notification from a peer. /// /// The peer is signaling an orderly departure. We immediately remove - /// them from all state rather than waiting for timeout detection. - fn handle_disconnect(&mut self, from: &NodeAddr, payload: &[u8]) { + /// them from all state rather than waiting for timeout detection, and + /// schedule a reconnect if the peer is configured as auto-connect. + /// Without this, a graceful upstream shutdown orphans auto-connect + /// entries — other removal paths (link-dead, decrypt failure, peer + /// restart) all schedule reconnect. + pub(in crate::node) fn handle_disconnect(&mut self, from: &NodeAddr, payload: &[u8]) { let disconnect = match crate::protocol::Disconnect::decode(payload) { Ok(msg) => msg, Err(e) => { @@ -87,7 +91,13 @@ impl Node { "Peer sent disconnect notification" ); + let addr = *from; self.remove_active_peer(from); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + self.schedule_reconnect(addr, now_ms); } /// Remove an active peer and clean up all associated state. diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index e683e08..8d0583c 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -804,6 +804,43 @@ fn test_schedule_reconnect_fresh_state() { assert_eq!(state.retry_after_ms, 1_000 + expected_delay); } +/// Test that a graceful Disconnect from an auto-connect peer schedules reconnect. +/// +/// Regression test for issue #60: `handle_disconnect` previously called +/// `remove_active_peer` without `schedule_reconnect`, orphaning auto-connect +/// entries on a clean upstream shutdown. Other peer-removal paths (link-dead, +/// decrypt failure, peer restart) all schedule reconnect. +#[test] +fn test_disconnect_schedules_reconnect() { + use crate::protocol::{Disconnect, DisconnectReason}; + + let peer_identity = Identity::generate(); + let peer_npub = peer_identity.npub(); + let peer_node_addr = *PeerIdentity::from_npub(&peer_npub).unwrap().node_addr(); + + let mut config = Config::new(); + config.peers.push(crate::config::PeerConfig::new( + peer_npub, + "udp", + "10.0.0.2:2121", + )); + + let mut node = Node::new(config).unwrap(); + + let payload = Disconnect::new(DisconnectReason::Shutdown).encode(); + node.handle_disconnect(&peer_node_addr, &payload); + + let state = node + .retry_pending + .get(&peer_node_addr) + .expect("handle_disconnect should schedule reconnect for auto-connect peer"); + assert!(state.reconnect, "Entry should be marked as reconnect"); + assert_eq!( + state.retry_count, 0, + "Fresh reconnect after disconnect should start at count=0" + ); +} + /// Test that promote_connection clears retry_pending. #[test] fn test_promote_clears_retry_pending() {