From 006c3314adac03d4bc52a409c45b7a02073b64bf Mon Sep 17 00:00:00 2001 From: Arjen <18398758+Origami74@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:56:59 +0100 Subject: [PATCH] feat(node): expose the UDP transport fd for app-owned socket binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UDP transport opens one wildcard socket and selects the egress path per destination address. That assumes the host routes by destination alone — true on an ordinary Unix box, not true everywhere. On hosts that associate each socket with one "network" and steer traffic by that association, a peer reachable only over a secondary network is unreachable in a way FIPS cannot see or fix: the address is well-formed, the send succeeds, the peer receives our handshake and replies, and the reply is discarded by the host before it reaches our socket. The link just retries msg1 forever. Nothing in FIPS can correct that, because the correction is a socket option on a socket the transport keeps private. So hand the embedder the fd: let rx = node.enable_app_owned_udp_fd(); // before start() node.start().await?; let fd = rx.recv()?; // once the transport is up This follows the existing `enable_app_owned_tun` / `enable_app_owned_dns` contract — call after `Node::new` and before `start()`, get a channel back — and stays deliberately narrow: FIPS keeps owning the socket, and the fd carries no promise beyond "this is the transport's socket, now open". If no UDP transport is configured, or it fails to start, nothing is ever sent. Also plumbs `raw_fd()` through `TransportHandle` and `UdpTransport`. Unix only, since `RawFd` is a unix concept; non-UDP transports return None. --- src/node/lifecycle.rs | 6 ++++++ src/node/mod.rs | 39 +++++++++++++++++++++++++++++++++++++++ src/transport/mod.rs | 18 ++++++++++++++++++ src/transport/udp/mod.rs | 11 +++++++++++ 4 files changed, 74 insertions(+) diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 08d8c73..8c07145 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -1242,6 +1242,12 @@ impl Node { match handle.start().await { Ok(()) => { + #[cfg(unix)] + if transport_type == "udp" + && let (Some(tx), Some(fd)) = (&self.udp_bind_tx, handle.raw_fd()) + { + let _ = tx.send(fd); + } self.transports.insert(transport_id, handle); } Err(e) => { diff --git a/src/node/mod.rs b/src/node/mod.rs index 04ec487..340fdcb 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -440,6 +440,13 @@ pub struct Node { /// DNS responder task handle. dns_task: Option>, + // === App-owned UDP socket binding === + /// Fires once with the UDP transport's raw fd right after it starts, so an + /// embedder can apply host-specific socket options (e.g. pinning it to one + /// of several OS networks). See [`Self::enable_app_owned_udp_fd`]. + #[cfg(unix)] + udp_bind_tx: Option>, + // === Index-Based Session Dispatch === /// Allocator for session indices. index_allocator: IndexAllocator, @@ -699,6 +706,8 @@ impl Node { tun_shutdown_fd: None, dns_identity_rx: None, dns_task: None, + #[cfg(unix)] + udp_bind_tx: None, index_allocator: IndexAllocator::new(), peers_by_index: HashMap::new(), pending_outbound: HashMap::new(), @@ -860,6 +869,8 @@ impl Node { tun_shutdown_fd: None, dns_identity_rx: None, dns_task: None, + #[cfg(unix)] + udp_bind_tx: None, index_allocator: IndexAllocator::new(), peers_by_index: HashMap::new(), pending_outbound: HashMap::new(), @@ -2930,6 +2941,34 @@ impl Node { identity_tx } + /// Set up an **app-owned UDP socket binding**: the returned receiver gets + /// the UDP transport's raw socket fd once, right after the transport opens + /// inside [`Self::start`], so an embedder can apply a socket option FIPS + /// itself has no way to choose — in particular pinning the socket to one + /// of several networks the host OS offers. + /// + /// FIPS opens a single wildcard UDP socket and picks the egress path per + /// destination address, which assumes the OS routes by destination alone. + /// Some hosts don't: where the OS binds each socket to one "network" and + /// steers replies by that association, a peer reachable only over a + /// secondary network (a link-local address on an interface that is not + /// the default route) can receive our handshake and have its reply + /// discarded before it reaches the socket. The fd is the only handle that + /// lets the embedder correct this, and it is otherwise private to the + /// transport. + /// + /// Call after [`Node::new`] and before [`Self::start`], like + /// [`Self::enable_app_owned_tun`]. Nothing is ever sent if no UDP + /// transport is configured or it fails to start. + #[cfg(unix)] + pub fn enable_app_owned_udp_fd( + &mut self, + ) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + self.udp_bind_tx = Some(tx); + rx + } + // === Sending === /// Encrypt and send a link-layer message to an authenticated peer. diff --git a/src/transport/mod.rs b/src/transport/mod.rs index b5da1b0..2b0ea92 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -1095,6 +1095,24 @@ impl TransportHandle { } } + /// Raw fd of the bound socket (UDP only, returns None for other + /// transports). See [`crate::transport::udp::UdpTransport::raw_fd`]. + #[cfg(unix)] + pub fn raw_fd(&self) -> Option { + match self { + TransportHandle::Udp(t) => t.raw_fd(), + #[cfg(any(target_os = "linux", target_os = "macos"))] + TransportHandle::Ethernet(_) => None, + TransportHandle::Tcp(_) => None, + TransportHandle::Tor(_) => None, + TransportHandle::Nym(_) => None, + #[cfg(ble_available)] + TransportHandle::Ble(_) => None, + #[cfg(test)] + TransportHandle::Loopback(_) => None, + } + } + /// Get the interface name (Ethernet only, returns None for other transports). pub fn interface_name(&self) -> Option<&str> { match self { diff --git a/src/transport/udp/mod.rs b/src/transport/udp/mod.rs index 3728fb9..7a500c1 100644 --- a/src/transport/udp/mod.rs +++ b/src/transport/udp/mod.rs @@ -89,6 +89,17 @@ impl UdpTransport { self.local_addr } + /// Raw fd of the bound socket, so an embedder can apply socket options + /// this transport does not manage itself — notably binding it to one of + /// several networks a host OS offers, when destination-based routing alone + /// does not reach a peer (see [`crate::Node::enable_app_owned_udp_fd`]). + /// `None` before start. + #[cfg(unix)] + pub fn raw_fd(&self) -> Option { + use std::os::unix::io::AsRawFd; + self.socket.as_ref().map(|s| s.as_raw_fd()) + } + /// Configured recv buffer size — used when opening per-peer /// `ConnectedPeerSocket`s so they get the same buffer ceiling as /// the wildcard listen socket.