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);