diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a93634..bfc854f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (development) packages with sysusers.d/tmpfiles.d integration ([#21](https://github.com/jmcorgan/fips/pull/21), [@dskvr](https://github.com/dskvr)) +- `transports.udp.outbound_only` (default `false`). When true, the UDP + transport binds a kernel-assigned ephemeral port (`0.0.0.0:0`) instead + of the configured `bind_addr`, refuses inbound handshakes, and is + never advertised on Nostr regardless of `advertise_on_nostr`. Use + this to participate in the mesh as a pure client — initiate outbound + links without exposing an inbound listener on a known port. + Implements the long-form fix for `udp.bind_addr: "127.0.0.1:..."` + not actually working as a workaround (Linux pins the loopback source + IP, dropping outbound flows to external peers at the routing layer) +- `transports.udp.accept_connections` (default `true`). Mirrors the + Ethernet/BLE knob; setting to `false` produces a "client" posture + (initiate outbound, refuse inbound msg1 from new addresses). The + Node-level handshake gate carves out msg1 from peers already + established on this transport so rekey continues to work + (ISSUE-2026-0004). Affects every transport via the `Transport` trait +- Startup validation now rejects `transports.udp[*].bind_addr` set to a + loopback address when at least one peer has a non-loopback UDP + address. Replaces the silent "peer link won't establish" failure + mode where Linux's source-address routing check dropped outbound + flows from the loopback-bound socket. `outbound_only: true` is + exempt from the check (it overrides `bind_addr` to `0.0.0.0:0`) - `packaging/debian/build-deb.sh` now auto-derives a per-commit Debian Version field for dev builds (Cargo.toml version ending in `-dev`) using the form `~dev+git.[.dirty]-1`, e.g. diff --git a/packaging/common/fips.yaml b/packaging/common/fips.yaml index 209d33a..a3b8b3c 100644 --- a/packaging/common/fips.yaml +++ b/packaging/common/fips.yaml @@ -59,6 +59,11 @@ transports: bind_addr: "0.0.0.0:2121" # advertise_on_nostr: true # public: false # false => advertise udp:nat; true => advertise bound host:port + # accept_connections: true # default; refuse inbound msg1 when false + # outbound_only: false # true => bind ephemeral, no listener on a + # # known port. Forces advertise_on_nostr=false + # # and accept_connections=false. Pure-client + # # posture; bind_addr is ignored. tcp: # Accepts inbound connections. No static outbound peers. diff --git a/src/config/mod.rs b/src/config/mod.rs index 1c911b1..3adbda2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -52,6 +52,31 @@ const KEY_FILENAME: &str = "fips.key"; /// Default public key filename, placed alongside the key file. const PUB_FILENAME: &str = "fips.pub"; +/// Returns true if the textual `host:port` form refers to a loopback host. +/// Recognizes IPv4 `127.x.x.x`, IPv6 `::1` (with or without brackets), and +/// the literal string `localhost`. Hostnames are conservatively assumed to +/// be non-loopback. Used by `Config::validate()` to reject misconfigured +/// loopback UDP binds combined with non-loopback peer addresses (see +/// ISSUE-2026-0005). +fn is_loopback_addr_str(addr: &str) -> bool { + // Bracketed IPv6: `[::1]:port` + if let Some(rest) = addr.strip_prefix('[') + && let Some(end) = rest.find(']') + { + let host = &rest[..end]; + return host == "::1"; + } + // Plain `host:port` — split on the rightmost ':'. + let host = match addr.rsplit_once(':') { + Some((h, _)) => h, + None => addr, + }; + host == "localhost" + || host == "::1" + || host == "0:0:0:0:0:0:0:1" + || host.starts_with("127.") +} + /// Derive the key file path from a config file path. pub fn key_file_path(config_path: &Path) -> PathBuf { config_path @@ -600,6 +625,34 @@ impl Config { } } + // Reject loopback UDP bind combined with non-loopback peer addresses. + // Linux pins the source IP to a loopback-bound socket, so packets + // sent from such a socket to external peers are dropped at the + // routing layer with no clear error in the daemon log. See + // ISSUE-2026-0005. Outbound-only mode is exempt because it + // overrides bind_addr to 0.0.0.0:0 (kernel-picked source). + for (name, cfg) in self.transports.udp.iter() { + if cfg.outbound_only() { + continue; + } + if is_loopback_addr_str(cfg.bind_addr()) { + let any_external_peer = self.peers.iter().any(|peer| { + peer.addresses + .iter() + .any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr)) + }); + if any_external_peer { + let label = name.unwrap_or("(unnamed)"); + return Err(ConfigError::Validation(format!( + "transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \ + fips cannot reach external peers from a loopback-bound socket. \ + Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.", + cfg.bind_addr() + ))); + } + } + } + Ok(()) } @@ -1293,4 +1346,106 @@ peers: let err = config.validate().expect_err("validation should fail"); assert!(err.to_string().contains("stun_servers")); } + + #[test] + fn test_is_loopback_addr_str() { + assert!(is_loopback_addr_str("127.0.0.1:2121")); + assert!(is_loopback_addr_str("127.0.0.5:9999")); + assert!(is_loopback_addr_str("[::1]:2121")); + assert!(is_loopback_addr_str("::1:2121")); + assert!(is_loopback_addr_str("localhost:80")); + assert!(!is_loopback_addr_str("0.0.0.0:2121")); + assert!(!is_loopback_addr_str("192.168.1.1:2121")); + assert!(!is_loopback_addr_str("[fd00::1]:2121")); + assert!(!is_loopback_addr_str("core-vm.tail65015.ts.net:2121")); + assert!(!is_loopback_addr_str("example.com:443")); + } + + #[test] + fn test_validate_loopback_bind_with_external_peer_rejected() { + use crate::config::PeerAddress; + let mut config = Config::default(); + config.transports.udp = TransportInstances::Single(UdpConfig { + bind_addr: Some("127.0.0.1:2121".to_string()), + ..Default::default() + }); + config.peers = vec![PeerConfig { + npub: "npub1peer".to_string(), + addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")], + ..Default::default() + }]; + + let err = config.validate().expect_err("validation should fail"); + let msg = err.to_string(); + assert!(msg.contains("loopback"), "got: {msg}"); + assert!(msg.contains("non-loopback"), "got: {msg}"); + } + + #[test] + fn test_validate_loopback_bind_with_loopback_peer_ok() { + use crate::config::PeerAddress; + let mut config = Config::default(); + config.transports.udp = TransportInstances::Single(UdpConfig { + bind_addr: Some("127.0.0.1:2121".to_string()), + ..Default::default() + }); + config.peers = vec![PeerConfig { + npub: "npub1peer".to_string(), + addresses: vec![PeerAddress::new("udp", "127.0.0.2:2121")], + ..Default::default() + }]; + + config + .validate() + .expect("loopback peer with loopback bind should validate"); + } + + #[test] + fn test_validate_outbound_only_exempt_from_loopback_check() { + use crate::config::PeerAddress; + let mut config = Config::default(); + // outbound_only overrides bind_addr → 0.0.0.0:0; the loopback + // check must skip this transport entirely. + config.transports.udp = TransportInstances::Single(UdpConfig { + bind_addr: Some("127.0.0.1:2121".to_string()), + outbound_only: Some(true), + ..Default::default() + }); + config.peers = vec![PeerConfig { + npub: "npub1peer".to_string(), + addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")], + ..Default::default() + }]; + + config + .validate() + .expect("outbound_only should be exempt from the loopback check"); + } + + #[test] + fn test_outbound_only_forces_ephemeral_bind() { + let cfg = UdpConfig { + bind_addr: Some("127.0.0.1:2121".to_string()), + outbound_only: Some(true), + ..Default::default() + }; + assert_eq!(cfg.bind_addr(), "0.0.0.0:0"); + assert!(cfg.outbound_only()); + } + + #[test] + fn test_outbound_only_forces_advertise_off() { + let cfg = UdpConfig { + advertise_on_nostr: Some(true), + outbound_only: Some(true), + ..Default::default() + }; + assert!(!cfg.advertise_on_nostr()); + } + + #[test] + fn test_udp_accept_connections_default_true() { + let cfg = UdpConfig::default(); + assert!(cfg.accept_connections()); + } } diff --git a/src/config/transport.rs b/src/config/transport.rs index 8257159..ac2a150 100644 --- a/src/config/transport.rs +++ b/src/config/transport.rs @@ -24,6 +24,9 @@ const DEFAULT_UDP_SEND_BUF: usize = 2 * 1024 * 1024; #[serde(deny_unknown_fields)] pub struct UdpConfig { /// Bind address (`bind_addr`). Defaults to "0.0.0.0:2121". + /// + /// When `outbound_only = true`, this field is ignored and the transport + /// binds to `0.0.0.0:0` (kernel-assigned ephemeral port) regardless. #[serde(default, skip_serializing_if = "Option::is_none")] pub bind_addr: Option, @@ -40,7 +43,7 @@ pub struct UdpConfig { pub send_buf_size: Option, /// Whether this transport should be advertised on Nostr overlay discovery. - /// Default: false. + /// Default: false. Implicitly forced false when `outbound_only = true`. #[serde(default, skip_serializing_if = "Option::is_none")] pub advertise_on_nostr: Option, @@ -51,12 +54,39 @@ pub struct UdpConfig { /// Default: false. #[serde(default, skip_serializing_if = "Option::is_none")] pub public: Option, + /// Outbound-only mode. When true, the transport binds to a kernel- + /// assigned ephemeral port (`0.0.0.0:0`) instead of the configured + /// `bind_addr`, refuses inbound handshake msg1, and is never + /// advertised on Nostr regardless of `advertise_on_nostr`. Use this + /// to participate in the mesh as a pure client — initiate outbound + /// links without exposing an inbound listener on a known port. + /// Default: false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outbound_only: Option, + + /// Accept inbound handshake msg1 from new peers. Default: true. + /// Setting this to false combined with `auto_connect: true` on + /// peer-side configurations gives a "client" posture: this node + /// initiates outbound links but refuses inbound handshakes from + /// unfamiliar addresses. The Node-level gate at + /// `src/node/handlers/handshake.rs` carves out msg1 from peers + /// already established on this transport (so rekey continues to + /// work) — see ISSUE-2026-0004. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accept_connections: Option, } impl UdpConfig { /// Get the bind address, using default if not configured. + /// + /// When `outbound_only = true`, returns `0.0.0.0:0` so the kernel picks + /// an ephemeral source port and no listener is exposed on a known port. pub fn bind_addr(&self) -> &str { - self.bind_addr.as_deref().unwrap_or(DEFAULT_UDP_BIND_ADDR) + if self.outbound_only() { + "0.0.0.0:0" + } else { + self.bind_addr.as_deref().unwrap_or(DEFAULT_UDP_BIND_ADDR) + } } /// Get the UDP MTU, using default if not configured. @@ -75,14 +105,29 @@ impl UdpConfig { } /// Whether this UDP transport should be advertised on Nostr discovery. + /// Always false when `outbound_only = true`. pub fn advertise_on_nostr(&self) -> bool { - self.advertise_on_nostr.unwrap_or(false) + if self.outbound_only() { + false + } else { + self.advertise_on_nostr.unwrap_or(false) + } } /// Whether this UDP transport should be advertised as directly reachable. pub fn is_public(&self) -> bool { self.public.unwrap_or(false) } + + /// Whether this transport runs in outbound-only mode. Default: false. + pub fn outbound_only(&self) -> bool { + self.outbound_only.unwrap_or(false) + } + + /// Whether this transport accepts inbound handshakes. Default: true. + pub fn accept_connections(&self) -> bool { + self.accept_connections.unwrap_or(true) + } } /// Transport instances - either a single config or named instances. diff --git a/src/transport/udp/mod.rs b/src/transport/udp/mod.rs index a5b2482..c86dbe7 100644 --- a/src/transport/udp/mod.rs +++ b/src/transport/udp/mod.rs @@ -144,6 +144,13 @@ impl UdpTransport { self.state = TransportState::Starting; + if self.config.outbound_only() && self.config.bind_addr.is_some() { + warn!( + configured_bind_addr = ?self.config.bind_addr, + "udp.outbound_only = true; configured bind_addr is ignored, binding to 0.0.0.0:0" + ); + } + // Parse bind address let bind_addr: SocketAddr = self .config @@ -367,6 +374,20 @@ impl Transport for UdpTransport { // Peer configuration is handled at the node level, not transport level Ok(Vec::new()) } + + /// Whether the transport accepts inbound handshake initiations. + /// `outbound_only` mode forces this to false; otherwise reflects the + /// `accept_connections` config field (default: true). Note that the + /// hard gate is at the Node level (see ISSUE-2026-0004 fix in + /// `src/node/handlers/handshake.rs`); this method is what that gate + /// consults for transports that lack runtime-state-based filtering. + fn accept_connections(&self) -> bool { + if self.config.outbound_only() { + false + } else { + self.config.accept_connections() + } + } } impl Drop for UdpTransport { @@ -474,10 +495,7 @@ mod tests { UdpConfig { bind_addr: Some(format!("127.0.0.1:{}", port)), mtu: Some(1280), - recv_buf_size: None, - send_buf_size: None, - advertise_on_nostr: None, - public: None, + ..Default::default() } } @@ -705,6 +723,77 @@ mod tests { assert_eq!(cong.recv_drops, Some(0)); } + #[test] + fn test_accept_connections_default_true() { + let (tx, _rx) = packet_channel(100); + let transport = UdpTransport::new(TransportId::new(1), None, make_config(0), tx); + // Default UdpConfig has accept_connections unset → true. + assert!(transport.accept_connections()); + } + + #[test] + fn test_accept_connections_false_when_configured() { + let (tx, _rx) = packet_channel(100); + let transport = UdpTransport::new( + TransportId::new(1), + None, + UdpConfig { + bind_addr: Some("127.0.0.1:0".to_string()), + accept_connections: Some(false), + ..Default::default() + }, + tx, + ); + assert!(!transport.accept_connections()); + } + + #[test] + fn test_accept_connections_forced_false_in_outbound_only() { + let (tx, _rx) = packet_channel(100); + let transport = UdpTransport::new( + TransportId::new(1), + None, + UdpConfig { + outbound_only: Some(true), + accept_connections: Some(true), // explicit true; outbound_only wins + ..Default::default() + }, + tx, + ); + assert!(!transport.accept_connections()); + } + + #[tokio::test] + async fn test_outbound_only_binds_ephemeral() { + // outbound_only=true must override bind_addr to 0.0.0.0:0 so the + // kernel picks a source port and there is no listener on a known + // port. The runtime should bind successfully even if `bind_addr` + // is explicitly set in the config (a warn fires; not asserted + // here). + let (tx, _rx) = packet_channel(100); + let mut transport = UdpTransport::new( + TransportId::new(1), + None, + UdpConfig { + bind_addr: Some("127.0.0.1:65535".to_string()), + outbound_only: Some(true), + ..Default::default() + }, + tx, + ); + + transport.start_async().await.unwrap(); + let local = transport.local_addr().unwrap(); + // Ephemeral port: kernel-assigned, non-zero, never matches the + // configured 65535 (since outbound_only ignored bind_addr). + assert_ne!(local.port(), 65535); + assert!(local.port() > 0); + // Source IP picked by the kernel; v4 INADDR_ANY before binding, + // resolves to 0.0.0.0 on the local end. + assert!(local.ip().is_unspecified()); + transport.stop_async().await.unwrap(); + } + #[tokio::test] async fn test_punch_probe_dropped() { let (tx_recv, mut rx_recv) = packet_channel(100);