mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 19:18:25 +00:00
feat(udp): bind a UDP instance to one interface
`transports.udp.<name>.interface` binds the instance's listen socket, and the per-peer connected sockets under it, to the named interface: Linux `SO_BINDTODEVICE` in both directions, macOS `IP_BOUND_IF` / `IPV6_BOUND_IF` for egress only (inbound on a wildcard bind still arrives from any interface there). Elsewhere naming an interface is an error at start rather than a silent no-op. One fd-based helper in `io/bind_device.rs` serves both sockets, so the two cannot drift. Without the connected-socket half, an interface-bound instance's data routed by the kernel's table and could leave by another NIC, which made the binding a lie; with it, two instances bound to two interfaces are two distinct paths to a peer reachable over both. 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. That is documented rather than fixed here.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// Bind address (`bind_addr`). Defaults to "0.0.0.0:2121".
|
||||
///
|
||||
/// When `outbound_only = true`, this field is ignored and the transport
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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::<libc::c_int>() 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"),
|
||||
))
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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<OwnedFd> {
|
||||
// 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");
|
||||
|
||||
|
||||
@@ -122,8 +122,9 @@ mod tests {
|
||||
recv_buf: usize,
|
||||
send_buf: usize,
|
||||
) -> std::io::Result<ConnectedPeerSocket> {
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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, TransportError> {
|
||||
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<Self, TransportError> {
|
||||
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)))?;
|
||||
|
||||
|
||||
@@ -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<Self, TransportError> {
|
||||
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
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user