diff --git a/easytier/src/socket/udp.rs b/easytier/src/socket/udp.rs index 0f413c78..15519094 100644 --- a/easytier/src/socket/udp.rs +++ b/easytier/src/socket/udp.rs @@ -157,16 +157,6 @@ impl RuntimeUdpSocketFactory { BindDev::Disabled } - - fn reuse_addr_for(&self, options: &UdpBindOptions) -> bool { - options.reuse_addr - || (matches!( - options.purpose, - UdpSocketPurpose::PortBoundListener - | UdpSocketPurpose::ProxyNat - | UdpSocketPurpose::PortForward - ) && !cfg!(target_os = "windows")) - } } /// Creates a host-owned UDP socket, including any required protection, before @@ -181,7 +171,9 @@ pub(crate) async fn create_udp_socket(options: &UdpBindOptions) -> anyhow::Resul .dev(factory.bind_device_for(options)) .net_ns(NetNS::from_socket_context(&options.context)) .only_v6(options.only_v6) - .reuse_addr(factory.reuse_addr_for(options)) + // Independent UDP receivers must not share a bound endpoint, including + // kernel-assigned ports. Only an explicit request may enable reuse. + .reuse_addr(options.reuse_addr) .reuse_port(options.reuse_port) .maybe_socket_mark(options.context.socket_mark) .need_protect(options.need_protect) @@ -216,6 +208,78 @@ mod tests { use super::*; + #[cfg(target_os = "linux")] + #[tokio::test] + async fn runtime_udp_bind_rejects_occupied_port() { + for local_addr in ["0.0.0.0:0", "[::]:0"] { + let local_addr = local_addr.parse().unwrap(); + for bind in [ + UdpBindOptions::port_bound_listener(local_addr), + UdpBindOptions::proxy_nat().with_local_addr(Some(local_addr)), + UdpBindOptions::port_forward(local_addr), + ] { + let bind = bind.with_only_v6(true); + let listener = create_udp_socket(&bind).await.unwrap(); + let occupied = bind.with_local_addr(Some(listener.local_addr().unwrap())); + + for reuse_addr in [false, true] { + let error = create_udp_socket(&occupied.clone().with_reuse_addr(reuse_addr)) + .await + .expect_err("another socket must not share a bound receive endpoint"); + assert!(matches!( + error.downcast_ref::(), + Some(easytier_core::tunnel::TunnelError::IOError(error)) + if error.kind() == std::io::ErrorKind::AddrInUse + )); + } + + drop(listener); + create_udp_socket(&occupied).await.unwrap(); + } + } + } + + #[tokio::test] + async fn runtime_udp_listeners_keep_ephemeral_ports_and_packets_separate() { + for (bind_addr, loopback) in [ + ("0.0.0.0:0", "127.0.0.1".parse::().unwrap()), + ("[::]:0", "::1".parse::().unwrap()), + ] { + let bind_addr = bind_addr.parse().unwrap(); + let binds = [ + UdpBindOptions::port_bound_listener(bind_addr), + UdpBindOptions::proxy_nat().with_local_addr(Some(bind_addr)), + UdpBindOptions::port_forward(bind_addr), + ]; + let mut listeners = Vec::new(); + let mut ports = std::collections::HashSet::new(); + for index in 0..128 { + let bind = binds[index % binds.len()].clone().with_only_v6(true); + let listener = create_udp_socket(&bind).await.unwrap(); + assert!(ports.insert(listener.local_addr().unwrap().port())); + listeners.push(listener); + } + + let sender = UdpSocket::bind(SocketAddr::new(loopback, 0)).await.unwrap(); + for (index, listener) in listeners.iter().enumerate() { + let destination = SocketAddr::new(loopback, listener.local_addr().unwrap().port()); + sender.send_to(&[index as u8], destination).await.unwrap(); + } + for (index, listener) in listeners.iter().enumerate() { + let mut packet = [0; 16]; + let (len, source) = tokio::time::timeout( + std::time::Duration::from_secs(2), + listener.recv_from(&mut packet), + ) + .await + .expect("listener did not receive its packet") + .unwrap(); + assert_eq!(&packet[..len], &[index as u8]); + assert_eq!(source, sender.local_addr().unwrap()); + } + } + } + #[cfg(any(target_os = "linux", target_os = "android"))] #[tokio::test] async fn runtime_udp_socket_reports_ipv4_destination_ip() { @@ -360,7 +424,7 @@ mod tests { } #[test] - fn factory_interprets_bind_defaults_by_purpose() { + fn factory_interprets_bind_device_by_purpose() { let listener_addr = SocketAddr::from(([0, 0, 0, 0], 11010)); let factory = RuntimeUdpSocketFactory::new(); @@ -384,20 +448,6 @@ mod tests { factory.bind_device_for(&UdpBindOptions::hole_punch_control()), BindDev::Disabled )); - assert_eq!( - factory.reuse_addr_for(&UdpBindOptions::port_bound_listener(listener_addr)), - !cfg!(target_os = "windows") - ); - assert!(!factory.reuse_addr_for(&UdpBindOptions::hole_punch_control())); - assert_eq!( - factory.reuse_addr_for(&UdpBindOptions::proxy_nat()), - !cfg!(target_os = "windows") - ); - assert_eq!( - factory.reuse_addr_for(&UdpBindOptions::port_forward(listener_addr)), - !cfg!(target_os = "windows") - ); - assert!(!factory.reuse_addr_for(&UdpBindOptions::port_lease(listener_addr))); } #[test] diff --git a/easytier/src/tunnel/wireguard.rs b/easytier/src/tunnel/wireguard.rs index 8336a529..d8d80eee 100644 --- a/easytier/src/tunnel/wireguard.rs +++ b/easytier/src/tunnel/wireguard.rs @@ -843,13 +843,32 @@ pub mod tests { } #[tokio::test] - async fn bind_same_port() { - let server_cfg = test_wg_config(); - let mut listener = WgTunnelListener::new("wg://[::1]:31015".parse().unwrap(), server_cfg); - let server_cfg = test_wg_config(); - let mut listener2 = WgTunnelListener::new("wg://[::1]:31015".parse().unwrap(), server_cfg); + async fn bind_ipv4_and_ipv6_same_port() { + let mut listener = WgTunnelListener::new("wg://[::]:0".parse().unwrap(), test_wg_config()); listener.listen().await.unwrap(); + let port = listener.local_url().port().unwrap(); + + let mut listener2 = WgTunnelListener::new( + format!("wg://0.0.0.0:{port}").parse().unwrap(), + test_wg_config(), + ); listener2.listen().await.unwrap(); + assert_eq!(listener2.local_url().port(), Some(port)); + } + + #[tokio::test] + async fn bind_same_port_is_rejected() { + let server_cfg = test_wg_config(); + let mut listener = WgTunnelListener::new("wg://[::1]:0".parse().unwrap(), server_cfg); + listener.listen().await.unwrap(); + + let mut listener2 = WgTunnelListener::new(listener.local_url(), test_wg_config()); + let error = listener2.listen().await.unwrap_err(); + assert!(error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::AddrInUse) + })); } #[tokio::test]