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() {