diff --git a/CHANGELOG.md b/CHANGELOG.md index 65abb1f4..036a0197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 source address, or twenty garbage frames carrying a sniffed index from any bound transport, could move or tear down a peering. +- `transports.udp.interface` binds a UDP instance, and the per-peer + connected sockets under it, to one interface, so two instances bound to + two interfaces are two distinct routes to a peer reachable over both. + Linux binds both directions (`SO_BINDTODEVICE`); macOS binds egress only + (`IP_BOUND_IF`), so inbound on a wildcard `bind_addr` still arrives from + any interface there, and naming an interface elsewhere is an error at + start. The interface must exist when the daemon starts: unlike an + Ethernet transport, an interface-bound UDP instance is not retried when + its interface appears later. + - Dynamic interface binding for the Ethernet transport. An interface-bound transport is now a long-lived object that is *sometimes bound*: the interface it names need not exist when the daemon starts, may appear minutes later, and diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8e1db983..6c49376e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -656,6 +656,7 @@ adding entries and the precedence rules: | `transports.udp.advertise_on_nostr` | bool | `false` | Include this UDP transport in Nostr endpoint adverts. Implicitly forced false when `outbound_only: true`. | | `transports.udp.public` | bool | `false` | If advertised: `true` publishes direct `host:port`; `false` publishes `udp:nat` rendezvous | | `transports.udp.external_addr` | string | *(none)* | Explicit advertise-as override. Bare IP (`"203.0.113.45"` — bind port is appended) or full `host:port`. Takes precedence over the bound address and STUN autodiscovery. Useful when the public IP isn't on a local interface (cloud 1:1 NAT, EIP) or to skip STUN for a deterministic value. | +| `transports.udp.interface` | string | *(none)* | Bind the socket to one interface (e.g. `en0`), making this instance one path. Two instances bound to two interfaces give a peer reachable over both two paths. Linux binds both directions (`SO_BINDTODEVICE`); macOS binds egress only (`IP_BOUND_IF`), so inbound on a wildcard `bind_addr` still arrives from any interface there. Unsupported elsewhere (fails to start). The interface must exist when the daemon starts: unlike an Ethernet transport, an interface-bound UDP instance is not retried when its interface appears later, and it gets no presence or carrier signal while running (see `node.path.*` above). | | `transports.udp.outbound_only` | bool | `false` | Pure-client posture. When `true`, the transport binds to `0.0.0.0:0` (kernel-assigned ephemeral port) regardless of `bind_addr`, refuses inbound handshake msg1, and is never advertised on Nostr regardless of `advertise_on_nostr`. | | `transports.udp.accept_connections` | bool | `true` | Accept inbound handshake msg1 from new peers. Combine with `outbound_only: false` and `accept_connections: false` (plus `auto_connect` on peer entries) for a node that initiates outbound links but rejects fresh inbound handshakes. The handshake handler carves out msg1 from peers already established on this transport so rekey continues to work. | diff --git a/src/config/transport.rs b/src/config/transport.rs index 5850fb17..d9c7cd17 100644 --- a/src/config/transport.rs +++ b/src/config/transport.rs @@ -45,6 +45,15 @@ const DEFAULT_UDP_SEND_BUF: usize = 2 * 1024 * 1024; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct UdpConfig { + /// Bind the socket to one interface (`interface: en0`). Makes this UDP + /// instance one path: with several instances each bound to an + /// interface, a peer reachable over two of them holds two paths. Linux + /// binds both directions (`SO_BINDTODEVICE`); macOS binds egress only + /// (`IP_BOUND_IF`), so inbound on a wildcard `bind_addr` still arrives + /// from any interface there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interface: Option, + /// Bind address (`bind_addr`). Defaults to "0.0.0.0:2121". /// /// When `outbound_only = true`, this field is ignored and the transport diff --git a/src/node/dataplane/connected_udp.rs b/src/node/dataplane/connected_udp.rs index b22419f8..4ee50dae 100644 --- a/src/node/dataplane/connected_udp.rs +++ b/src/node/dataplane/connected_udp.rs @@ -119,7 +119,7 @@ impl Node { // the UDP transport's DNS cache. This may await on a DNS // lookup the very first time we see a hostname; subsequent // calls hit the cache. - let (peer_socket_addr, local_addr, recv_buf, send_buf, packet_tx) = { + let (peer_socket_addr, local_addr, recv_buf, send_buf, interface, packet_tx) = { let Some(transport) = self.transports.get(&transport_id) else { return Ok(()); }; @@ -136,8 +136,9 @@ impl Node { .ok_or_else(|| "udp transport not started".to_string())?; let recv_buf = udp.recv_buf_size(); let send_buf = udp.send_buf_size(); + let interface = udp.interface().map(str::to_string); let tx = udp.clone_packet_tx(); - (peer_sa, local, recv_buf, send_buf, tx) + (peer_sa, local, recv_buf, send_buf, interface, tx) }; // Open the connected socket on the kernel side, then adopt the @@ -147,6 +148,7 @@ impl Node { peer_socket_addr, recv_buf, send_buf, + interface.as_deref(), ) .map_err(|e| format!("open_connected_fd: {e}"))?; let socket = std::sync::Arc::new(crate::transport::udp::ConnectedPeerSocket::from_fd( diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index eb42d1bb..1cd0394a 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -75,7 +75,7 @@ pub(super) fn install_connected_udp( let local: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap(); let peer_sa: std::net::SocketAddr = "127.0.0.1:9".parse().unwrap(); - let owned = crate::transport::udp::open_connected_fd(local, peer_sa, 65_536, 65_536) + let owned = crate::transport::udp::open_connected_fd(local, peer_sa, 65_536, 65_536, None) .expect("open a connected UDP socket"); let bound = crate::transport::udp::ConnectedPeerSocket::from_fd(owned, peer_sa, local); let socket = std::sync::Arc::new(bound); diff --git a/src/node/tests/multi_path.rs b/src/node/tests/multi_path.rs index 9c27e25d..0b6880b1 100644 --- a/src/node/tests/multi_path.rs +++ b/src/node/tests/multi_path.rs @@ -248,3 +248,29 @@ fn a_promoted_peer_holds_one_path_and_rebind_repoints_it() { assert_eq!(peer.paths().len(), 1); assert_eq!(peer.transport_id(), Some(wifi)); } + +// ============================================================================ +// UDP `interface:` binding +// ============================================================================ + +#[test] +fn udp_interface_config_parses() { + let cfg: crate::config::UdpConfig = serde_yaml::from_str("interface: en0\n").unwrap(); + assert_eq!(cfg.interface.as_deref(), Some("en0")); + let cfg: crate::config::UdpConfig = serde_yaml::from_str("bind_addr: 0.0.0.0:1\n").unwrap(); + assert!(cfg.interface.is_none()); +} + +#[test] +fn binding_udp_to_a_missing_interface_fails_to_start() { + use crate::transport::udp::io::UdpRawSocket; + let err = UdpRawSocket::open_on_interface( + "127.0.0.1:0".parse().unwrap(), + 65_536, + 65_536, + Some("fips-absent-x0"), + ) + .err() + .expect("an absent interface cannot be bound"); + assert!(err.to_string().contains("fips-absent-x0"), "{err}"); +} diff --git a/src/transport/udp/io/bind_device.rs b/src/transport/udp/io/bind_device.rs new file mode 100644 index 00000000..aaaa188f --- /dev/null +++ b/src/transport/udp/io/bind_device.rs @@ -0,0 +1,87 @@ +//! Binding a UDP socket to one interface (`udp.interface`), the one way +//! for both the listen socket and the per-peer connected sockets. +//! +//! Linux: `SO_BINDTODEVICE`, both directions. macOS: `IP_BOUND_IF` / +//! `IPV6_BOUND_IF`, egress only — inbound still arrives from any +//! interface, which is why the interface-bound transport's paths are +//! detected by unreachable-on-send and the echo timeout, not by presence. +//! Elsewhere naming an interface is an error rather than a silent no-op. + +use std::io; +use std::os::unix::io::RawFd; + +/// Bind `fd` to the interface named `name`. `v4` selects the IPv4 or IPv6 +/// option where the platform has one per family. +#[cfg(target_os = "linux")] +pub(super) fn bind_to_interface(fd: RawFd, name: &str, _v4: bool) -> io::Result<()> { + // SAFETY: `fd` is an open socket owned by the caller; the option value + // is `name`'s bytes with the length passed alongside, and the kernel + // copies them for the duration of the call. + let r = unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_BINDTODEVICE, + name.as_ptr() as *const libc::c_void, + name.len() as libc::socklen_t, + ) + }; + if r < 0 { + let err = io::Error::last_os_error(); + return Err(io::Error::new( + err.kind(), + format!("bind to interface {name}: {err}"), + )); + } + Ok(()) +} + +/// Bind `fd` to the interface named `name`. `v4` selects the IPv4 or IPv6 +/// option where the platform has one per family. +#[cfg(target_os = "macos")] +pub(super) fn bind_to_interface(fd: RawFd, name: &str, v4: bool) -> io::Result<()> { + let c_name = std::ffi::CString::new(name) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid interface name"))?; + // SAFETY: `c_name` is a valid NUL-terminated string for the call's duration. + let index = unsafe { libc::if_nametoindex(c_name.as_ptr()) }; + if index == 0 { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("interface {name} not found"), + )); + } + let (level, opt) = if v4 { + (libc::IPPROTO_IP, libc::IP_BOUND_IF) + } else { + (libc::IPPROTO_IPV6, libc::IPV6_BOUND_IF) + }; + let value = index as libc::c_int; + // SAFETY: `fd` is an open socket owned by the caller; the option value + // is a `c_int` on the stack whose size is passed alongside. + let r = unsafe { + libc::setsockopt( + fd, + level, + opt, + &value as *const libc::c_int as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if r < 0 { + let err = io::Error::last_os_error(); + return Err(io::Error::new( + err.kind(), + format!("bind to interface {name}: {err}"), + )); + } + Ok(()) +} + +/// Bind `fd` to the interface named `name`: not supported on this platform. +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub(super) fn bind_to_interface(_fd: RawFd, name: &str, _v4: bool) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("udp.interface ({name}) is supported on Linux and macOS only"), + )) +} diff --git a/src/transport/udp/io/connected/drain.rs b/src/transport/udp/io/connected/drain.rs index de276509..761a63ce 100644 --- a/src/transport/udp/io/connected/drain.rs +++ b/src/transport/udp/io/connected/drain.rs @@ -463,7 +463,7 @@ mod tests { // don't conflict with anything else on the test host. let local_addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); let owned = - crate::transport::udp::open_connected_fd(local_addr, peer_addr, 1 << 20, 1 << 20) + crate::transport::udp::open_connected_fd(local_addr, peer_addr, 1 << 20, 1 << 20, None) .expect("open_connected_fd"); let socket = Arc::new(ConnectedPeerSocket::from_fd(owned, peer_addr, local_addr)); diff --git a/src/transport/udp/io/connected/fd.rs b/src/transport/udp/io/connected/fd.rs index c8f6a81c..fa899842 100644 --- a/src/transport/udp/io/connected/fd.rs +++ b/src/transport/udp/io/connected/fd.rs @@ -33,12 +33,16 @@ use super::super::macos as sys; /// sizes, applied best-effort: on Linux with `SO_*BUFFORCE` first, /// falling back to the normal `SO_*BUF` if the process can't bypass the /// kernel ceiling; on macOS with `SO_*BUF` alone, which has no force -/// variant. +/// variant. `interface`, if named, binds the socket to that interface the +/// way the listen socket is (`udp.interface`): without it the connected +/// socket would route by the kernel's table and an interface-bound +/// transport's data could leave by another NIC, making the path a lie. pub(crate) fn open_connected_fd( local_addr: SocketAddr, peer_addr: SocketAddr, recv_buf: usize, send_buf: usize, + interface: Option<&str>, ) -> io::Result { // Family must match between local and peer. if local_addr.is_ipv4() != peer_addr.is_ipv4() { @@ -77,6 +81,10 @@ pub(crate) fn open_connected_fd( // Buffer sizes — best effort; see the per-platform implementation. sys::set_buf_sizes(raw, recv_buf, send_buf); + if let Some(name) = interface { + super::super::bind_device::bind_to_interface(raw, name, local_addr.is_ipv4())?; + } + // Bind to the wildcard local address (same port as listen socket). let local_sa: socket2::SockAddr = local_addr.into(); let bind_r = unsafe { @@ -149,7 +157,7 @@ mod tests { let holder = UdpSocket::bind("127.0.0.1:0").expect("holder bind"); let holder_addr = holder.local_addr().expect("holder addr"); - let err = open_connected_fd(holder_addr, "127.0.0.1:9".parse().unwrap(), BUF, BUF) + let err = open_connected_fd(holder_addr, "127.0.0.1:9".parse().unwrap(), BUF, BUF, None) .expect_err("bind must fail against a non-reuseport holder"); assert_eq!(err.kind(), io::ErrorKind::AddrInUse, "{err}"); @@ -168,6 +176,7 @@ mod tests { "255.255.255.255:9999".parse().unwrap(), BUF, BUF, + None, ) .expect_err("connect to broadcast without SO_BROADCAST must fail"); diff --git a/src/transport/udp/io/connected/socket.rs b/src/transport/udp/io/connected/socket.rs index ac540443..0f71c735 100644 --- a/src/transport/udp/io/connected/socket.rs +++ b/src/transport/udp/io/connected/socket.rs @@ -122,8 +122,9 @@ mod tests { recv_buf: usize, send_buf: usize, ) -> std::io::Result { - let fd = - crate::transport::udp::open_connected_fd(local_addr, peer_addr, recv_buf, send_buf)?; + let fd = crate::transport::udp::open_connected_fd( + local_addr, peer_addr, recv_buf, send_buf, None, + )?; Ok(ConnectedPeerSocket::from_fd(fd, peer_addr, local_addr)) } diff --git a/src/transport/udp/io/mod.rs b/src/transport/udp/io/mod.rs index 64a25cd3..b34cad03 100644 --- a/src/transport/udp/io/mod.rs +++ b/src/transport/udp/io/mod.rs @@ -21,6 +21,8 @@ //! //! Follows the pattern established by `transport/ethernet/socket.rs`. +#[cfg(unix)] +mod bind_device; #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "macos")] diff --git a/src/transport/udp/io/unix.rs b/src/transport/udp/io/unix.rs index 32341810..197835bd 100644 --- a/src/transport/udp/io/unix.rs +++ b/src/transport/udp/io/unix.rs @@ -41,10 +41,25 @@ impl UdpRawSocket { /// /// Enables `SO_RXQ_OVFL` for kernel drop counting (non-fatal if /// unsupported). Sets non-blocking mode for async integration. + #[cfg(test)] pub fn open( bind_addr: SocketAddr, recv_buf_size: usize, send_buf_size: usize, + ) -> Result { + Self::open_on_interface(bind_addr, recv_buf_size, send_buf_size, None) + } + + /// [`open`](Self::open), bound to `interface` if one is named. + /// + /// Linux: `SO_BINDTODEVICE`, both directions. macOS: `IP_BOUND_IF` / + /// `IPV6_BOUND_IF`, egress only. Elsewhere naming an interface is an + /// error rather than a silent no-op. + pub fn open_on_interface( + bind_addr: SocketAddr, + recv_buf_size: usize, + send_buf_size: usize, + interface: Option<&str>, ) -> Result { let domain = if bind_addr.is_ipv4() { Domain::IPV4 @@ -57,6 +72,17 @@ impl UdpRawSocket { sock.set_nonblocking(true) .map_err(|e| TransportError::StartFailed(format!("set nonblocking failed: {}", e)))?; + if let Some(name) = interface { + super::bind_device::bind_to_interface(sock.as_raw_fd(), name, bind_addr.is_ipv4()) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::Unsupported { + TransportError::NotSupported(e.to_string()) + } else { + TransportError::StartFailed(e.to_string()) + } + })?; + } + sock.bind(&bind_addr.into()) .map_err(|e| TransportError::StartFailed(format!("bind failed: {}", e)))?; diff --git a/src/transport/udp/io/windows.rs b/src/transport/udp/io/windows.rs index bf71de98..daffddc0 100644 --- a/src/transport/udp/io/windows.rs +++ b/src/transport/udp/io/windows.rs @@ -21,6 +21,21 @@ pub struct UdpRawSocket { } impl UdpRawSocket { + /// [`open`](Self::open); naming an interface is not supported here. + pub fn open_on_interface( + bind_addr: SocketAddr, + recv_buf_size: usize, + send_buf_size: usize, + interface: Option<&str>, + ) -> Result { + if let Some(name) = interface { + return Err(TransportError::NotSupported(format!( + "udp.interface ({name}) is supported on Linux and macOS only" + ))); + } + Self::open(bind_addr, recv_buf_size, send_buf_size) + } + /// Create, bind, and configure a UDP socket. /// /// Sets non-blocking mode and configures buffer sizes. The socket diff --git a/src/transport/udp/mod.rs b/src/transport/udp/mod.rs index 9ea1a3f8..9c708c05 100644 --- a/src/transport/udp/mod.rs +++ b/src/transport/udp/mod.rs @@ -141,6 +141,13 @@ impl UdpTransport { self.socket.as_ref().map(|socket| socket.as_raw_fd()) } + /// The interface this instance is bound to (`udp.interface`), if any. + /// Per-peer `ConnectedPeerSocket`s bind to it too, or their traffic + /// would route by the kernel's table and not by the path. + pub fn interface(&self) -> Option<&str> { + self.config.interface.as_deref() + } + /// Configured recv buffer size — used when opening per-peer /// `ConnectedPeerSocket`s so they get the same buffer ceiling as /// the wildcard listen socket. @@ -256,10 +263,11 @@ impl UdpTransport { .map_err(|e| TransportError::StartFailed(format!("invalid bind address: {}", e)))?; // Create, bind, and configure UDP socket - let raw_socket = UdpRawSocket::open( + let raw_socket = UdpRawSocket::open_on_interface( bind_addr, self.config.recv_buf_size(), self.config.send_buf_size(), + self.config.interface.as_deref(), )?; let actual_recv = raw_socket.recv_buffer_size()?;