From 42834b8008509450fd66fce1364be1765a7de072 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Mon, 13 Apr 2026 05:34:51 +0000 Subject: [PATCH 1/3] 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. --- src/node/handlers/dispatch.rs | 14 +++++++++++-- src/node/tests/unit.rs | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/node/handlers/dispatch.rs b/src/node/handlers/dispatch.rs index c441b5e..44707ce 100644 --- a/src/node/handlers/dispatch.rs +++ b/src/node/handlers/dispatch.rs @@ -67,8 +67,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) => { @@ -83,7 +87,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 015762e..97d1ac1 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -801,6 +801,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() { From d16acf8ceae8d78656800a61e6aac3527e31ed06 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Mon, 13 Apr 2026 05:44:23 +0000 Subject: [PATCH 2/3] 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. --- src/bin/fipsctl.rs | 108 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index 9c36e68..12a9333 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -9,6 +9,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::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -199,6 +200,50 @@ fn print_response(value: &serde_json::Value) { 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::() { + 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. @@ -275,6 +320,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", @@ -300,3 +349,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()); + } +} From 7e002a3883d13492ca676ae50f7cf3df24733ac0 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Mon, 13 Apr 2026 05:49:37 +0000 Subject: [PATCH 3/3] 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). --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f603332..4873bd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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)) ## [0.2.0] - 2026-03-22