mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
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:
21
CHANGELOG.md
21
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 `<base>~dev+git<YYYYMMDD>.<sha>[.dirty]-1`, e.g.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user