diff --git a/easytier/src/common/config.rs b/easytier/src/common/config.rs index be959587..3b102919 100644 --- a/easytier/src/common/config.rs +++ b/easytier/src/common/config.rs @@ -21,7 +21,7 @@ use crate::{ api::manage::ConfigSource as RpcConfigSource, common::{CompressionAlgoPb, PortForwardConfigPb, SecureModeConfig, SocketType}, }, - tunnel::generate_digest_from_str, + tunnel::{IpScheme, TunnelScheme, generate_digest_from_str}, }; use super::env_parser; @@ -74,6 +74,36 @@ pub fn gen_default_flags() -> Flags { } } +fn mapped_listener_allows_implicit_port(url: &url::Url) -> bool { + TunnelScheme::try_from(url) + .ok() + .and_then(|scheme| IpScheme::try_from(scheme).ok()) + .is_some() +} + +pub fn validate_mapped_listener_url(url: &url::Url) -> Result<(), anyhow::Error> { + if url.port().is_none() && !mapped_listener_allows_implicit_port(url) { + anyhow::bail!("mapped listener port is missing: {}", url); + } + + Ok(()) +} + +pub fn parse_mapped_listener_urls( + mapped_listeners: &[String], +) -> Result, anyhow::Error> { + mapped_listeners + .iter() + .map(|s| { + let url: url::Url = s + .parse() + .with_context(|| format!("mapped listener is not a valid url: {}", s))?; + validate_mapped_listener_url(&url)?; + Ok(url) + }) + .collect() +} + #[derive(Debug, Clone, PartialEq, Eq, Display, EnumString, VariantArray)] #[strum(ascii_case_insensitive)] pub enum EncryptionAlgorithm { @@ -1226,6 +1256,37 @@ stun_servers = [ assert_eq!(loaded.get_network_config_source(), ConfigSource::Webhook); } + #[test] + fn test_parse_mapped_listener_urls_allows_ws_without_port() { + let parsed = parse_mapped_listener_urls(&[ + "ws://example.com".to_string(), + "wss://example.com/path".to_string(), + ]) + .unwrap(); + + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].scheme(), "ws"); + assert_eq!(parsed[0].port(), None); + assert_eq!(parsed[1].scheme(), "wss"); + assert_eq!(parsed[1].port(), None); + } + + #[test] + fn test_parse_mapped_listener_urls_allows_tcp_without_port() { + let parsed = parse_mapped_listener_urls(&["tcp://127.0.0.1".to_string()]).unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].scheme(), "tcp"); + assert_eq!(parsed[0].port(), None); + } + + #[test] + fn test_parse_mapped_listener_urls_requires_port_for_non_ip_scheme() { + let err = parse_mapped_listener_urls(&["ring://peer-id".to_string()]).unwrap_err(); + + assert!(err.to_string().contains("mapped listener port is missing")); + } + #[test] fn test_network_config_source_user_is_implicit() { let config = TomlConfigLoader::default(); diff --git a/easytier/src/connector/direct.rs b/easytier/src/connector/direct.rs index 452128f5..c9265092 100644 --- a/easytier/src/connector/direct.rs +++ b/easytier/src/connector/direct.rs @@ -51,6 +51,19 @@ pub const DIRECT_CONNECTOR_BLACKLIST_TIMEOUT_SEC: u64 = 300; static TESTING: AtomicBool = AtomicBool::new(false); +fn mapped_listener_port(url: &url::Url) -> Option { + url.port().or_else(|| { + TunnelScheme::try_from(url) + .ok() + .and_then(|scheme| IpScheme::try_from(scheme).ok()) + .map(IpScheme::default_port) + }) +} + +async fn resolve_mapped_listener_addrs(listener: &url::Url) -> Result, Error> { + socket_addrs(listener, || mapped_listener_port(listener)).await +} + #[async_trait::async_trait] pub trait PeerManagerForDirectConnector { async fn list_peers(&self) -> Vec; @@ -132,7 +145,7 @@ impl DirectConnectorManagerData { } let global_ctx = self.peer_manager.get_global_ctx(); - let listener_port = remote_url.port().ok_or(anyhow::anyhow!( + let listener_port = mapped_listener_port(remote_url).ok_or(anyhow::anyhow!( "failed to parse port from remote url: {}", remote_url ))?; @@ -382,7 +395,7 @@ impl DirectConnectorManagerData { listener: &url::Url, tasks: &mut JoinSet>, ) { - let Ok(mut addrs) = socket_addrs(listener, || None).await else { + let Ok(mut addrs) = resolve_mapped_listener_addrs(listener).await else { tracing::error!(?listener, "failed to parse socket address from listener"); return; }; @@ -536,7 +549,7 @@ impl DirectConnectorManagerData { .into_iter() .map(Into::::into) .filter_map(|l| if l.scheme() != "ring" { Some(l) } else { None }) - .filter(|l| l.port().is_some() && l.host().is_some()) + .filter(|l| mapped_listener_port(l).is_some() && l.host().is_some()) .filter(|l| enable_ipv6 || !matches!(l.host().unwrap().to_owned(), Host::Ipv6(_))) .collect::>(); @@ -762,6 +775,17 @@ impl DirectConnectorManager { pub fn run_as_client(&mut self) { self.client.start(); } + + #[cfg(test)] + pub(crate) async fn try_direct_connect_with_ip_list( + &self, + dst_peer_id: PeerId, + ip_list: GetIpListResponse, + ) -> Result<(), Error> { + self.data + .do_try_direct_connect_internal(dst_peer_id, ip_list) + .await + } } #[cfg(test)] @@ -780,10 +804,53 @@ mod tests { proto::peer_rpc::GetIpListResponse, }; - use super::TESTING; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use super::{TESTING, mapped_listener_port, resolve_mapped_listener_addrs}; + + #[test] + fn mapped_listener_port_uses_ip_scheme_defaults() { + assert_eq!( + mapped_listener_port(&"ws://example.com".parse().unwrap()), + Some(80) + ); + assert_eq!( + mapped_listener_port(&"wss://example.com".parse().unwrap()), + Some(443) + ); + assert_eq!( + mapped_listener_port(&"tcp://127.0.0.1".parse().unwrap()), + Some(11010) + ); + assert_eq!( + mapped_listener_port(&"udp://127.0.0.1".parse().unwrap()), + Some(11010) + ); + } #[tokio::test] - async fn direct_connector_mapped_listener() { + async fn resolve_mapped_listener_addrs_uses_default_ports() { + let wss_addrs = resolve_mapped_listener_addrs(&"wss://127.0.0.1".parse().unwrap()) + .await + .unwrap(); + assert_eq!( + wss_addrs, + vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443)] + ); + + let tcp_addrs = resolve_mapped_listener_addrs(&"tcp://127.0.0.1".parse().unwrap()) + .await + .unwrap(); + assert_eq!( + tcp_addrs, + vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 11010)] + ); + } + + async fn run_direct_connector_mapped_listener_test( + mapped_listener: &str, + target_listener: &str, + ) { TESTING.store(true, std::sync::atomic::Ordering::Relaxed); let p_a = create_mock_peer_manager().await; let p_b = create_mock_peer_manager().await; @@ -802,11 +869,11 @@ mod tests { p_c.get_global_ctx() .config - .set_mapped_listeners(Some(vec!["tcp://127.0.0.1:11334".parse().unwrap()])); + .set_mapped_listeners(Some(vec![mapped_listener.parse().unwrap()])); p_x.get_global_ctx() .config - .set_listeners(vec!["tcp://0.0.0.0:11334".parse().unwrap()]); + .set_listeners(vec![target_listener.parse().unwrap()]); let mut lis_x = ListenerManager::new(p_x.get_global_ctx(), p_x.clone()); lis_x.prepare_listeners().await.unwrap(); lis_x.run().await.unwrap(); @@ -823,6 +890,12 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn direct_connector_mapped_listener() { + run_direct_connector_mapped_listener_test("tcp://127.0.0.1:11334", "tcp://0.0.0.0:11334") + .await; + } + #[rstest::rstest] #[tokio::test] async fn direct_connector_basic_test( diff --git a/easytier/src/core.rs b/easytier/src/core.rs index 96e3df75..6614f1ff 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -6,7 +6,8 @@ use crate::{ config::{ ConfigFileControl, ConfigLoader, ConsoleLoggerConfig, EncryptionAlgorithm, FileLoggerConfig, LoggingConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, - TomlConfigLoader, VpnPortalConfig, load_config_from_file, process_secure_mode_cfg, + TomlConfigLoader, VpnPortalConfig, load_config_from_file, parse_mapped_listener_urls, + process_secure_mode_cfg, }, constants::EASYTIER_VERSION, log, @@ -906,32 +907,7 @@ impl NetworkOptions { } if !self.mapped_listeners.is_empty() { - let mut errs = Vec::new(); - cfg.set_mapped_listeners(Some( - self.mapped_listeners - .iter() - .map(|s| { - s.parse() - .with_context(|| format!("mapped listener is not a valid url: {}", s)) - .unwrap() - }) - .map(|s: url::Url| { - if s.port().is_none() { - errs.push(anyhow::anyhow!("mapped listener port is missing: {}", s)); - } - s - }) - .collect::>(), - )); - if !errs.is_empty() { - return Err(anyhow::anyhow!( - "{}", - errs.iter() - .map(|x| format!("{}", x)) - .collect::>() - .join("\n") - )); - } + cfg.set_mapped_listeners(Some(parse_mapped_listener_urls(&self.mapped_listeners)?)); } for n in self.proxy_networks.iter() { diff --git a/easytier/src/launcher.rs b/easytier/src/launcher.rs index 6fbc67c6..3ae785f1 100644 --- a/easytier/src/launcher.rs +++ b/easytier/src/launcher.rs @@ -1,5 +1,6 @@ use crate::common::config::{ - ConfigFileControl, ConfigSource, PortForwardConfig, process_secure_mode_cfg, + ConfigFileControl, ConfigSource, PortForwardConfig, parse_mapped_listener_urls, + process_secure_mode_cfg, }; use crate::proto::api::{self, manage}; use crate::proto::rpc_types::controller::BaseController; @@ -671,22 +672,8 @@ impl NetworkConfig { } if !self.mapped_listeners.is_empty() { - cfg.set_mapped_listeners(Some( - self.mapped_listeners - .iter() - .map(|s| { - s.parse() - .with_context(|| format!("mapped listener is not a valid url: {}", s)) - .unwrap() - }) - .map(|s: url::Url| { - if s.port().is_none() { - panic!("mapped listener port is missing: {}", s); - } - s - }) - .collect(), - )); + let mapped_listeners = parse_mapped_listener_urls(&self.mapped_listeners)?; + cfg.set_mapped_listeners(Some(mapped_listeners)); } if let Some(credential_file) = self diff --git a/easytier/src/tests/mod.rs b/easytier/src/tests/mod.rs index 893befaf..3d5385b5 100644 --- a/easytier/src/tests/mod.rs +++ b/easytier/src/tests/mod.rs @@ -108,6 +108,58 @@ pub fn create_netns(name: &str, ipv4: &str, ipv6: &str) { } } +pub struct TestNetnsGuard { + name: String, + host_ipv4: Option, +} + +impl TestNetnsGuard { + fn run_ip(args: &[&str]) { + let status = std::process::Command::new("ip") + .args(args) + .status() + .unwrap(); + assert!(status.success(), "ip command failed: {:?}", args); + } + + pub fn new(name: &str, guest_ipv4: &str, guest_ipv6: &str) -> Self { + del_netns(name); + create_netns(name, guest_ipv4, guest_ipv6); + Self { + name: name.to_string(), + host_ipv4: None, + } + } + + pub fn set_host_ipv4(&mut self, host_ipv4: &str) { + Self::run_ip(&[ + "addr", + "add", + host_ipv4, + "dev", + get_host_veth_name(&self.name), + ]); + self.host_ipv4 = Some(host_ipv4.to_string()); + } +} + +impl Drop for TestNetnsGuard { + fn drop(&mut self) { + if let Some(host_ipv4) = self.host_ipv4.as_deref() { + let _ = std::process::Command::new("ip") + .args([ + "addr", + "del", + host_ipv4, + "dev", + get_host_veth_name(&self.name), + ]) + .status(); + } + del_netns(&self.name); + } +} + pub fn prepare_bridge(name: &str) { // del bridge with brctl let _ = std::process::Command::new("brctl") diff --git a/easytier/src/tests/three_node.rs b/easytier/src/tests/three_node.rs index 0b12c46b..849e1440 100644 --- a/easytier/src/tests/three_node.rs +++ b/easytier/src/tests/three_node.rs @@ -258,6 +258,102 @@ pub async fn drop_insts(insts: Vec) { while set.join_next().await.is_some() {} } +mod direct_connector_mapped_listener_tests { + use std::sync::Arc; + + use crate::{ + common::{ + config::{ConfigLoader, TomlConfigLoader}, + global_ctx::GlobalCtx, + stun::MockStunInfoCollector, + }, + connector::direct::DirectConnectorManager, + instance::listeners::ListenerManager, + peers::{ + create_packet_recv_chan, + peer_manager::{PeerManager, RouteAlgoType}, + tests::{ + connect_peer_manager, create_mock_peer_manager, wait_route_appear, + wait_route_appear_with_cost, + }, + }, + proto::{common::NatType, peer_rpc::GetIpListResponse}, + tests::TestNetnsGuard, + }; + + async fn create_mock_peer_manager_in_netns(netns: &str) -> Arc { + let (s, _r) = create_packet_recv_chan(); + let config = TomlConfigLoader::default(); + config.set_netns(Some(netns.to_owned())); + let global_ctx = Arc::new(GlobalCtx::new(config)); + global_ctx.replace_stun_info_collector(Box::new(MockStunInfoCollector { + udp_nat_type: NatType::Unknown, + })); + + let peer_mgr = Arc::new(PeerManager::new(RouteAlgoType::Ospf, global_ctx, s)); + peer_mgr.run().await.unwrap(); + peer_mgr + } + + async fn run_direct_connector_mapped_listener_without_port_test( + mapped_listener: &str, + listener: &str, + ) { + let ns_name = "dmlp"; + let mut _ns = TestNetnsGuard::new(ns_name, "10.199.0.2/24", "fd99::2/64"); + _ns.set_host_ipv4("10.199.0.1/24"); + + let p_a = create_mock_peer_manager().await; + let p_b = create_mock_peer_manager().await; + let p_c = create_mock_peer_manager_in_netns(ns_name).await; + connect_peer_manager(p_a.clone(), p_b.clone()).await; + connect_peer_manager(p_b.clone(), p_c.clone()).await; + + wait_route_appear(p_a.clone(), p_c.clone()).await.unwrap(); + + let mut f = p_a.get_global_ctx().get_flags(); + f.bind_device = false; + p_a.get_global_ctx().set_flags(f); + + p_c.get_global_ctx() + .config + .set_mapped_listeners(Some(vec![mapped_listener.parse().unwrap()])); + + p_c.get_global_ctx() + .config + .set_listeners(vec![listener.parse().unwrap()]); + let mut lis_c = ListenerManager::new(p_c.get_global_ctx(), p_c.clone()); + lis_c.prepare_listeners().await.unwrap(); + lis_c.run().await.unwrap(); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let dm_a = DirectConnectorManager::new(p_a.get_global_ctx(), p_a.clone()); + let mut ip_list = GetIpListResponse::default(); + ip_list.listeners.push(mapped_listener.parse().unwrap()); + dm_a.try_direct_connect_with_ip_list(p_c.my_peer_id(), ip_list) + .await + .unwrap(); + + wait_route_appear_with_cost(p_a.clone(), p_c.my_peer_id(), Some(1)) + .await + .unwrap(); + } + + #[rstest::rstest] + #[tokio::test] + #[serial_test::serial] + async fn direct_connector_mapped_listener_without_port( + #[values( + ("tcp://10.199.0.2", "tcp://0.0.0.0:11010"), + ("ws://10.199.0.2", "ws://0.0.0.0:80"), + ("wss://10.199.0.2", "wss://0.0.0.0:443") + )] + case: (&str, &str), + ) { + run_direct_connector_mapped_listener_without_port_test(case.0, case.1).await; + } +} + async fn ping_test(from_netns: &str, target_ip: &str, payload_size: Option) -> bool { let _g = NetNS::new(Some(ROOT_NETNS_NAME.to_owned())).guard(); let code = tokio::process::Command::new("ip")