From 70dddeace3b21d7b0a936bf24b9b909dc2e3db2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BC=96=E7=A8=8B=E5=B0=8F=E7=99=BD?= <138838812+Huchangzhi@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:00:05 +0800 Subject: [PATCH] Fix support for Chinese domain names (#1462) --- Cargo.lock | 1 + easytier/Cargo.toml | 1 + easytier/src/common/idn.rs | 210 +++++++++++++++++++++++++++++++++++++ easytier/src/common/mod.rs | 1 + easytier/src/launcher.rs | 33 +++--- 5 files changed, 234 insertions(+), 12 deletions(-) create mode 100644 easytier/src/common/idn.rs diff --git a/Cargo.lock b/Cargo.lock index e54b31c..2c4d6c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2152,6 +2152,7 @@ dependencies = [ "http_req", "humansize", "humantime-serde", + "idna 1.0.3", "kcp-sys", "machine-uid", "maplit", diff --git a/easytier/Cargo.toml b/easytier/Cargo.toml index 100aed8..72a1a41 100644 --- a/easytier/Cargo.toml +++ b/easytier/Cargo.toml @@ -109,6 +109,7 @@ anyhow = "1.0" url = { version = "2.5", features = ["serde"] } percent-encoding = "2.3.1" +idna = "1.0" # for tun packet byteorder = "1.5.0" diff --git a/easytier/src/common/idn.rs b/easytier/src/common/idn.rs new file mode 100644 index 0000000..3bf7dce --- /dev/null +++ b/easytier/src/common/idn.rs @@ -0,0 +1,210 @@ +use idna::domain_to_ascii; +pub fn convert_idn_to_ascii(url_str: &str) -> Result { + if !url_str.is_ascii() { + let mut url_parts = url_str.splitn(2, "://"); + let scheme = url_parts.next().unwrap_or(""); + let rest = url_parts.next().unwrap_or(url_str); + let (host_part, port_part, path_part) = { + let mut path_and_rest = rest.splitn(2, '/'); + let host_port_part = path_and_rest.next().unwrap_or(""); + let path_part = path_and_rest + .next() + .map(|s| format!("/{}", s)) + .unwrap_or_default(); + if host_port_part.starts_with('[') { + if let Some(end_bracket_pos) = host_port_part.find(']') { + let host_part = &host_port_part[..end_bracket_pos + 1]; + let remaining = &host_port_part[end_bracket_pos + 1..]; + if remaining.starts_with(':') { + if let Some(port_str) = remaining.strip_prefix(':') { + if port_str.chars().all(|c| c.is_ascii_digit()) { + (host_part, format!(":{}", port_str), path_part) + } else { + (host_part, String::new(), path_part) + } + } else { + (host_part, String::new(), path_part) + } + } else { + (host_part, String::new(), path_part) + } + } else { + (host_port_part, String::new(), path_part) + } + } else { + let (host_part, port_part) = if let Some(pos) = host_port_part.rfind(':') { + let port_str = &host_port_part[pos + 1..]; + if port_str.chars().all(|c| c.is_ascii_digit()) { + (&host_port_part[..pos], format!(":{}", port_str)) + } else { + (host_port_part, String::new()) + } + } else { + (host_port_part, String::new()) + }; + (host_part, port_part, path_part) + } + }; + + if !host_part.is_ascii() { + let ascii_host = domain_to_ascii(host_part) + .map_err(|e| format!("Failed to convert IDN to ASCII: {}", e))?; + let result = format!("{}://{}{}{}", scheme, ascii_host, port_part, path_part); + Ok(result) + } else { + Ok(url_str.to_string()) + } + } else { + Ok(url_str.to_string()) + } +} +pub fn safe_convert_idn_to_ascii(url_str: &str) -> String { + convert_idn_to_ascii(url_str).unwrap_or_else(|_| url_str.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ascii_only_urls() { + assert_eq!( + convert_idn_to_ascii("https://example.com").unwrap(), + "https://example.com" + ); + assert_eq!( + convert_idn_to_ascii("http://test.org:8080/path").unwrap(), + "http://test.org:8080/path" + ); + } + + #[test] + fn test_unicode_domains() { + assert_eq!( + convert_idn_to_ascii("https://räksmörgås.nu").unwrap(), + "https://xn--rksmrgs-5wao1o.nu" + ); + assert_eq!( + convert_idn_to_ascii("https://例子.测试").unwrap(), + "https://xn--fsqu00a.xn--0zwm56d" + ); + } + + #[test] + fn test_chinese_domains() { + assert_eq!( + convert_idn_to_ascii("https://中文.测试").unwrap(), + "https://xn--fiq228c.xn--0zwm56d" + ); + assert_eq!( + convert_idn_to_ascii("https://公司.中国").unwrap(), + "https://xn--55qx5d.xn--fiqs8s" + ); + assert_eq!( + convert_idn_to_ascii("https://网络.测试").unwrap(), + "https://xn--io0a7i.xn--0zwm56d" + ); + } + + #[test] + fn test_unicode_domains_with_port() { + assert_eq!( + convert_idn_to_ascii("https://räksmörgås.nu:8080").unwrap(), + "https://xn--rksmrgs-5wao1o.nu:8080" + ); + assert_eq!( + convert_idn_to_ascii("http://例子.测试:3000/path").unwrap(), + "http://xn--fsqu00a.xn--0zwm56d:3000/path" + ); + assert_eq!( + convert_idn_to_ascii("https://中文.测试:9000/api").unwrap(), + "https://xn--fiq228c.xn--0zwm56d:9000/api" + ); + } + + #[test] + fn test_unicode_domains_with_path() { + assert_eq!( + convert_idn_to_ascii("https://räksmörgås.nu/path/to/resource").unwrap(), + "https://xn--rksmrgs-5wao1o.nu/path/to/resource" + ); + assert_eq!( + convert_idn_to_ascii("http://例子.测试/api/v1").unwrap(), + "http://xn--fsqu00a.xn--0zwm56d/api/v1" + ); + assert_eq!( + convert_idn_to_ascii("https://中文.测试/api/users").unwrap(), + "https://xn--fiq228c.xn--0zwm56d/api/users" + ); + } + + #[test] + fn test_unicode_domains_with_port_and_path() { + assert_eq!( + convert_idn_to_ascii("https://räksmörgås.nu:8080/path/to/resource").unwrap(), + "https://xn--rksmrgs-5wao1o.nu:8080/path/to/resource" + ); + assert_eq!( + convert_idn_to_ascii("http://例子.测试:9000/api/v1/users").unwrap(), + "http://xn--fsqu00a.xn--0zwm56d:9000/api/v1/users" + ); + assert_eq!( + convert_idn_to_ascii("https://中文.测试:8000/用户/管理").unwrap(), + "https://xn--fiq228c.xn--0zwm56d:8000/用户/管理" + ); + } + + #[test] + fn test_ipv6_literals() { + assert_eq!( + convert_idn_to_ascii("https://[2001:db8::1]:8080").unwrap(), + "https://[2001:db8::1]:8080" + ); + assert_eq!( + convert_idn_to_ascii("https://[2001:db8::1]/path").unwrap(), + "https://[2001:db8::1]/path" + ); + assert_eq!( + convert_idn_to_ascii("https://[2001:db8::1]/路径/资源").unwrap(), + "https://[2001:db8::1]/路径/资源" + ); + } + + #[test] + fn test_invalid_port_format() { + let result = convert_idn_to_ascii("https://räksmörgås.nu:notaport").unwrap(); + assert!(result.contains("xn--") && result.contains(":notaport")); + } + + #[test] + fn test_safe_conversion() { + assert_eq!( + safe_convert_idn_to_ascii("https://example.com"), + "https://example.com" + ); + assert_eq!( + safe_convert_idn_to_ascii("https://中文.测试"), + "https://xn--fiq228c.xn--0zwm56d" + ); + } + + #[test] + fn test_edge_cases() { + // Without scheme '://', entire string is treated as host part + let result = convert_idn_to_ascii("räksmörgås.nu").unwrap(); + assert_eq!(result, "räksmörgås.nu://xn--rksmrgs-5wao1o.nu"); + + assert_eq!( + convert_idn_to_ascii("https://test.例子.com").unwrap(), + "https://test.xn--fsqu00a.com" + ); + } + + #[test] + fn test_ipv6_with_unicode_path() { + assert_eq!( + convert_idn_to_ascii("https://[2001:db8::1]/路径/资源").unwrap(), + "https://[2001:db8::1]/路径/资源" + ); + } +} diff --git a/easytier/src/common/mod.rs b/easytier/src/common/mod.rs index 8663206..dc2b9c4 100644 --- a/easytier/src/common/mod.rs +++ b/easytier/src/common/mod.rs @@ -18,6 +18,7 @@ pub mod defer; pub mod dns; pub mod error; pub mod global_ctx; +pub mod idn; pub mod ifcfg; pub mod netns; pub mod network; diff --git a/easytier/src/launcher.rs b/easytier/src/launcher.rs index 2b6b99c..b3268fe 100644 --- a/easytier/src/launcher.rs +++ b/easytier/src/launcher.rs @@ -10,6 +10,7 @@ use crate::{ }, constants::EASYTIER_VERSION, global_ctx::{EventBusSubscriber, GlobalCtxEvent}, + idn::safe_convert_idn_to_ascii, }, instance::instance::Instance, proto::api::instance::list_peer_route_pair, @@ -523,9 +524,13 @@ impl NetworkConfig { { NetworkingMethod::PublicServer => { let public_server_url = self.public_server_url.clone().unwrap_or_default(); + let converted_public_server_url = safe_convert_idn_to_ascii(&public_server_url); cfg.set_peers(vec![PeerConfig { - uri: public_server_url.parse().with_context(|| { - format!("failed to parse public server uri: {}", public_server_url) + uri: converted_public_server_url.parse().with_context(|| { + format!( + "failed to parse public server uri: {}", + converted_public_server_url + ) })?, }]); } @@ -535,10 +540,11 @@ impl NetworkConfig { if peer_url.is_empty() { continue; } + let converted_peer_url = safe_convert_idn_to_ascii(peer_url); peers.push(PeerConfig { - uri: peer_url - .parse() - .with_context(|| format!("failed to parse peer uri: {}", peer_url))?, + uri: converted_peer_url.parse().with_context(|| { + format!("failed to parse peer uri: {}", converted_peer_url) + })?, }); } @@ -552,11 +558,10 @@ impl NetworkConfig { if listener_url.is_empty() { continue; } - listener_urls.push( - listener_url - .parse() - .with_context(|| format!("failed to parse listener uri: {}", listener_url))?, - ); + let converted_listener_url = safe_convert_idn_to_ascii(listener_url); + listener_urls.push(converted_listener_url.parse().with_context(|| { + format!("failed to parse listener uri: {}", converted_listener_url) + })?); } cfg.set_listeners(listener_urls); @@ -650,8 +655,12 @@ impl NetworkConfig { self.mapped_listeners .iter() .map(|s| { - s.parse() - .with_context(|| format!("mapped listener is not a valid url: {}", s)) + let converted_s = safe_convert_idn_to_ascii(s); + converted_s + .parse() + .with_context(|| { + format!("mapped listener is not a valid url: {}", converted_s) + }) .unwrap() }) .map(|s: url::Url| {