From 1c6992d6cb39cf67d4ca60e1af3a943950d7859d Mon Sep 17 00:00:00 2001 From: fanyang Date: Tue, 13 Oct 2026 10:04:10 +0800 Subject: [PATCH] fix(web): bind config listeners without route probes (#2660) Construct config-server TCP and UDP listeners on IPv4 and IPv6 wildcard addresses. A default route is not needed for listening, so remove the local-IP probes, URL-to-address wrapper, and optional listener pairs. Keep WebSocket on its existing IPv4 address. Propagate all native socket bind errors instead of ignoring IPv6 failures. Try address families independently and stop startup only when no listener could bind, so one unavailable family does not hide a working listener or create an unbound socket. Log every successful listener with its actual bound address, including the allocated port when the configured port is zero. Add regressions for occupied ports and unavailable IPv6 source addresses, failed listener registration, and valid ephemeral ports. Fixes #2611 Co-authored-by: KKRainbow <5665404+KKRainbow@users.noreply.github.com> --- .../src/client_manager/listener_tests.rs | 34 +++++- .../src/config_server_listener_tests.rs | 44 ++++++++ easytier-web/src/main.rs | 105 ++++++++---------- easytier/src/tunnel/common.rs | 79 +++++++++++-- 4 files changed, 196 insertions(+), 66 deletions(-) create mode 100644 easytier-web/src/config_server_listener_tests.rs diff --git a/easytier-web/src/client_manager/listener_tests.rs b/easytier-web/src/client_manager/listener_tests.rs index b4bac4dd..d298f800 100644 --- a/easytier-web/src/client_manager/listener_tests.rs +++ b/easytier-web/src/client_manager/listener_tests.rs @@ -1,7 +1,7 @@ use super::*; use easytier::proto::rpc::standalone::{ - RuntimeRpcListener, runtime_rpc_dialer, runtime_rpc_listener, + RuntimeRpcListener, runtime_rpc_dialer, runtime_rpc_listener, runtime_udp_tunnel_listener, }; use easytier_core::connectivity::protocol::raw::TunnelDialer; use std::sync::atomic::AtomicUsize; @@ -162,3 +162,35 @@ async fn completed_handshake_does_not_cancel_pending_accept() { .unwrap(); wait_until(|| manager.client_sessions.len() == 2).await; } + +#[tokio::test] +async fn failed_udp_listener_is_not_counted_as_running() { + for bind_addr in ["127.0.0.1:0", "[::1]:0"] { + let occupied = tokio::net::UdpSocket::bind(bind_addr).await.unwrap(); + let addr = occupied.local_addr().unwrap(); + let mut manager = manager().await; + manager + .add_listener(runtime_udp_tunnel_listener( + format!("udp://{addr}").parse().unwrap(), + addr, + )) + .await + .unwrap_err(); + assert!(!manager.is_running()); + } +} + +#[tokio::test] +async fn ephemeral_udp_listener_is_counted_as_running() { + let mut manager = manager().await; + let local_url = "udp://127.0.0.1:0".parse().unwrap(); + let bound_url = manager + .add_listener(runtime_udp_tunnel_listener( + local_url, + "127.0.0.1:0".parse().unwrap(), + )) + .await + .unwrap(); + assert!(bound_url.port().is_some_and(|port| port != 0)); + assert!(manager.is_running()); +} diff --git a/easytier-web/src/config_server_listener_tests.rs b/easytier-web/src/config_server_listener_tests.rs new file mode 100644 index 00000000..27f981fd --- /dev/null +++ b/easytier-web/src/config_server_listener_tests.rs @@ -0,0 +1,44 @@ +use super::get_config_server_listeners; + +#[test] +fn tcp_and_udp_candidates_do_not_depend_on_default_routes() { + for protocol in ["tcp", "udp"] { + let listeners = get_config_server_listeners(protocol, 22020).unwrap(); + assert_eq!(listeners.len(), 2); + assert_eq!( + listeners[0].local_url().as_str(), + format!("{protocol}://[::]:22020") + ); + assert_eq!( + listeners[1].local_url().as_str(), + format!("{protocol}://0.0.0.0:22020") + ); + } +} + +#[test] +fn websocket_listener_remains_ipv4_only() { + let listeners = get_config_server_listeners("ws", 22020).unwrap(); + assert_eq!(listeners.len(), 1); + assert_eq!(listeners[0].local_url().as_str(), "ws://0.0.0.0:22020/"); +} + +#[test] +fn invalid_protocol_is_rejected() { + for protocol in ["invalid", "wss", "quic"] { + assert!(get_config_server_listeners(protocol, 22020).is_err()); + } +} + +#[tokio::test] +async fn ipv4_listeners_bind_even_when_ipv6_is_unavailable() { + for protocol in ["tcp", "udp", "ws"] { + let mut ipv4 = get_config_server_listeners(protocol, 0) + .unwrap() + .pop() + .unwrap(); + // This also runs in an isolated IPv4-only network namespace without + // default routes, so construction and binding must not probe the WAN. + ipv4.listen().await.unwrap(); + } +} diff --git a/easytier-web/src/main.rs b/easytier-web/src/main.rs index 78d9dbd4..518b89e3 100644 --- a/easytier-web/src/main.rs +++ b/easytier-web/src/main.rs @@ -3,7 +3,7 @@ #[macro_use] extern crate rust_i18n; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use central_network::gateway; @@ -16,7 +16,6 @@ use easytier::{ constants::EASYTIER_VERSION, error::Error, log, - network::{local_ipv4, local_ipv6}, }, proto::rpc::standalone::{runtime_rpc_listener, runtime_udp_tunnel_listener}, utils::panic::setup_panic_handler, @@ -28,6 +27,8 @@ use mimalloc::MiMalloc; mod central_network; mod client_manager; +#[cfg(test)] +mod config_server_listener_tests; mod db; mod migrator; mod restful; @@ -247,55 +248,38 @@ impl LoggingConfigLoader for &Cli { } } -pub fn get_listener_by_url( - scheme: IpScheme, - l: &url::Url, -) -> Option>>> { - Some(match scheme { - IpScheme::Tcp => { - let addr = l.socket_addrs(|| Some(11010)).ok()?.into_iter().next()?; - Box::new(runtime_rpc_listener(addr)) - } - IpScheme::Udp => { - let addr = l.socket_addrs(|| Some(11010)).ok()?.into_iter().next()?; - Box::new(runtime_udp_tunnel_listener(l.clone(), addr)) - } - IpScheme::Ws => Box::new(WsTunnelListener::new(l.clone())), - _ => return None, - }) -} +type ConfigServerListener = Box>>; -async fn get_dual_stack_listener( +fn get_config_server_listeners( protocol: &str, port: u16, -) -> Result< - ( - Option>>>, - Option>>>, - ), - Error, -> { +) -> Result, Error> { let scheme = protocol .parse() .map_err(|_| Error::InvalidUrl(protocol.to_string()))?; - let v6_listener = - if local_ipv6().await.is_ok() && matches!(scheme, IpScheme::Tcp | IpScheme::Udp) { - get_listener_by_url( - scheme, - &format!("{protocol}://[::]:{port}").parse().unwrap(), - ) - } else { - None - }; - let v4_listener = if local_ipv4().await.is_ok() { - get_listener_by_url( - scheme, - &format!("{protocol}://0.0.0.0:{port}").parse().unwrap(), - ) - } else { - None - }; - Ok((v6_listener, v4_listener)) + // Wildcard binds do not require a default route or a public IP address. + let ipv6 = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port); + let ipv4 = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port); + match scheme { + IpScheme::Tcp => Ok(vec![ + Box::new(runtime_rpc_listener(ipv6)), + Box::new(runtime_rpc_listener(ipv4)), + ]), + IpScheme::Udp => Ok(vec![ + Box::new(runtime_udp_tunnel_listener( + format!("udp://{ipv6}").parse().unwrap(), + ipv6, + )), + Box::new(runtime_udp_tunnel_listener( + format!("udp://{ipv4}").parse().unwrap(), + ipv4, + )), + ]), + IpScheme::Ws => Ok(vec![Box::new(WsTunnelListener::new( + format!("ws://{ipv4}").parse().unwrap(), + ))]), + _ => Err(Error::InvalidUrl(protocol.to_string())), + } } #[tokio::main(flavor = "multi_thread", worker_threads = 4)] @@ -400,25 +384,34 @@ async fn main() { feature_flags.clone(), webhook_config.clone(), ); - let (v6_listener, v4_listener) = - get_dual_stack_listener(&cli.config_server_protocol, cli.config_server_port) - .await - .unwrap(); - if v4_listener.is_none() && v6_listener.is_none() { - panic!("Listen to both IPv4 and IPv6 failed"); - } - for listener in [v6_listener, v4_listener].into_iter().flatten() { - if let Some(instances) = network_instances.as_ref() { + for listener in + get_config_server_listeners(&cli.config_server_protocol, cli.config_server_port).unwrap() + { + let local_url = listener.local_url(); + let result = if let Some(instances) = network_instances.as_ref() { mgr.add_listener(gateway::listener::GatewayListener::new( listener, instances.clone(), )) .await - .unwrap(); } else { - mgr.add_listener(listener).await.unwrap(); + mgr.add_listener(listener).await + }; + match result { + Ok(local_url) => { + tracing::info!(%local_url, "Config server listener bound"); + } + Err(error) => { + tracing::warn!(%local_url, error = %format_args!("{error:#}"), "Failed to bind config server listener"); + } } } + if !mgr.is_running() { + panic!( + "Failed to bind any config server listener for {} on port {}", + cli.config_server_protocol, cli.config_server_port + ); + } let mgr = Arc::new(mgr); let central_service = Arc::new( diff --git a/easytier/src/tunnel/common.rs b/easytier/src/tunnel/common.rs index 908d2ab0..32aebc3a 100644 --- a/easytier/src/tunnel/common.rs +++ b/easytier/src/tunnel/common.rs @@ -94,13 +94,7 @@ fn setup_socket2_ext( let _ = reuse_port; } - if let Err(e) = socket2_socket.bind(&socket2::SockAddr::from(*bind_addr)) { - if bind_addr.is_ipv4() { - return Err(e.into()); - } else { - tracing::warn!(?e, "bind failed, do not return error for ipv6"); - } - } + socket2_socket.bind(&socket2::SockAddr::from(*bind_addr))?; // linux/mac does not use interface of bind_addr to send packet, so we need to bind device // win can handle this with bind correctly @@ -254,16 +248,83 @@ pub(crate) mod tests { use anyhow::Context as _; use futures::{Future, SinkExt, StreamExt}; + use tokio::net::{TcpListener, TcpSocket, UdpSocket}; use tokio_util::bytes::{BufMut, Bytes, BytesMut}; use tokio_util::task::AbortOnDropHandle; use easytier_core::{ - connectivity::protocol::raw::TunnelDialer, packet::ZCPacket, socket::SocketListener, - tunnel::Tunnel, + connectivity::protocol::raw::TunnelDialer, + packet::ZCPacket, + socket::SocketListener, + tunnel::{Tunnel, TunnelError}, }; use crate::common::netns::NetNS; + #[tokio::test] + async fn bind_rejects_occupied_ports() { + for ip in ["127.0.0.1", "::1"] { + let ip: std::net::IpAddr = ip.parse().unwrap(); + let occupied_udp = std::net::UdpSocket::bind((ip, 0)).unwrap(); + let occupied_tcp = std::net::TcpListener::bind((ip, 0)).unwrap(); + let udp_addr = occupied_udp.local_addr().unwrap(); + let tcp_addr = occupied_tcp.local_addr().unwrap(); + + for error in [ + super::bind::() + .addr(udp_addr) + .call() + .await + .unwrap_err(), + super::bind::() + .addr(tcp_addr) + .call() + .await + .unwrap_err(), + super::bind::() + .addr(tcp_addr) + .call() + .await + .unwrap_err(), + ] { + assert!(matches!( + error, + TunnelError::IOError(error) if error.kind() == std::io::ErrorKind::AddrInUse + )); + } + } + } + + #[tokio::test] + async fn bind_rejects_nonlocal_ipv6_address() { + let addr = "[2001:db8::ffff]:0".parse().unwrap(); + for error in [ + super::bind::() + .addr(addr) + .dev(super::BindDev::Disabled) + .call() + .await + .unwrap_err(), + super::bind::() + .addr(addr) + .dev(super::BindDev::Disabled) + .call() + .await + .unwrap_err(), + super::bind::() + .addr(addr) + .dev(super::BindDev::Disabled) + .call() + .await + .unwrap_err(), + ] { + assert!(matches!( + error, + TunnelError::IOError(error) if error.kind() == std::io::ErrorKind::AddrNotAvailable + )); + } + } + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] #[test] fn apply_socket_mark_none_is_noop_and_does_not_error() {