UDP transport: outbound_only mode, accept_connections, loopback validation

Three related UDP transport changes that together close a real gap in
the v0.2.x "this transport accepts inbound" assumption:

- outbound_only (default false). When true, the transport binds a
  kernel-assigned ephemeral port (0.0.0.0:0) regardless of the
  configured bind_addr, refuses inbound handshakes (Transport trait's
  accept_connections() returns false), and is never advertised on
  Nostr regardless of advertise_on_nostr. Lets a node participate in
  the mesh as a pure client — initiate outbound links without
  exposing an inbound listener on a known port. Also closes the
  "loopback bind as outbound-only workaround" trap: a UDP socket
  bound to 127.0.0.1 pins 127.0.0.1 as the source IP on outbound
  packets, and Linux refuses to deliver such packets out an external
  interface — the daemon happily reports "transport started" while
  no flow ever reaches an external peer.

- accept_connections (default true). Mirrors the existing
  Ethernet/BLE knob. Lets operators run UDP in a "client" posture
  (initiate outbound, refuse inbound msg1 from new addresses) without
  switching transport. The Node-level handshake gate already carves
  out msg1 from peers established on the transport so rekey works
  on existing sessions.

- Startup validation: reject `transports.udp[*].bind_addr` set to a
  loopback address (127.x.x.x, ::1, localhost) when at least one peer
  has a non-loopback UDP address. Replaces the silent "peer link
  won't establish" failure mode with a clear error pointing at the
  bind misconfiguration. outbound_only is exempt (it overrides
  bind_addr to 0.0.0.0:0).

The is_punch_packet-based filter from the previous commit, the
Node-level admission gate landed earlier on master, and these new
config fields together cover the three distinct ways the v0.2.x
"this transport accepts inbound" assumption could break.

Tests: validation truth table (loopback+external rejected,
loopback+loopback ok, outbound_only exempt), is_loopback_addr_str
helper, accept_connections wiring (default, explicit-false,
outbound_only-forces-false), end-to-end ephemeral-bind in the runtime.
1082 tests pass with --features nostr-discovery.
This commit is contained in:
Johnathan Corgan
2026-04-29 22:45:15 +00:00
parent 23c6609a6e
commit 674c7fe1ff
5 changed files with 322 additions and 7 deletions

View File

@@ -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 `<base>~dev+git<YYYYMMDD>.<sha>[.dirty]-1`, e.g.

View File

@@ -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.

View File

@@ -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());
}
}

View File

@@ -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<String>,
@@ -40,7 +43,7 @@ pub struct UdpConfig {
pub send_buf_size: Option<usize>,
/// 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<bool>,
@@ -51,12 +54,39 @@ pub struct UdpConfig {
/// Default: false.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public: Option<bool>,
/// 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<bool>,
/// 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<bool>,
}
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.

View File

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