From ffddc517e1a3baab0a2f007e2d01513ac5c5d3f5 Mon Sep 17 00:00:00 2001 From: Luna Yao <40349250+ZnqbuZ@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:45:22 +0200 Subject: [PATCH] fix: listener parsing (#2143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a CLI listener parsing regression where url crate special-casing for ws/wss could misinterpret inputs like ws:11011, and adds coverage to prevent future regressions. Changes: Refactors listener parsing to avoid url::Url parsing for proto:port forms and to support additional shorthand inputs (port-only / IP-only / SocketAddr). Centralizes “expand to all IpScheme variants” logic in a helper (gen_listeners) while preserving the “port=0 is dynamic” behavior. Adds unit tests covering valid/invalid listener inputs and expansion behavior. --- easytier/src/core.rs | 199 ++++++++++++++++++++++++++++++++----------- 1 file changed, 150 insertions(+), 49 deletions(-) diff --git a/easytier/src/core.rs b/easytier/src/core.rs index ada771ba..4f89a843 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -1,12 +1,5 @@ #![allow(dead_code)] -use std::{ - net::{IpAddr, SocketAddr}, - path::PathBuf, - process::ExitCode, - sync::{Arc, atomic::AtomicBool}, -}; - use crate::{ ShellType, common::{ @@ -30,6 +23,12 @@ use anyhow::Context; use cidr::IpCidr; use clap::{CommandFactory, Parser}; use rust_i18n::t; +use std::{ + net::{IpAddr, SocketAddr}, + path::PathBuf, + process::ExitCode, + sync::{Arc, atomic::AtomicBool}, +}; use strum::VariantArray; use tokio::io::AsyncReadExt; @@ -744,55 +743,69 @@ struct RpcPortalOptions { } impl Cli { + fn gen_listeners(addr: SocketAddr) -> impl Iterator { + let dynamic = addr.port() == 0; + IpScheme::VARIANTS.iter().map(move |proto| { + let mut addr = addr; + if !dynamic { + addr.set_port(addr.port() + proto.port_offset()); + } + format!("{}://{}", proto, addr) + }) + } + fn parse_listeners(no_listener: bool, listeners: Vec) -> anyhow::Result> { if no_listener || listeners.is_empty() { return Ok(vec![]); } - if listeners.len() == 1 - && let Ok(port) = listeners[0].parse::() - { - let listeners = IpScheme::VARIANTS - .iter() - .map(|proto| { - format!( - "{}://0.0.0.0:{}", - proto, - if port == 0 { - 0 - } else { - port + proto.port_offset() - } - ) - }) - .collect(); - return Ok(listeners); + let mut parsed = vec![]; + + for l in listeners.into_iter() { + if let Ok(port) = l.parse::() { + parsed.extend(Self::gen_listeners(SocketAddr::new( + "0.0.0.0".parse()?, + port, + ))); + continue; + } + + if let Ok(ip) = l.trim_matches(|c| c == '[' || c == ']').parse::() { + parsed.extend(Self::gen_listeners(SocketAddr::new(ip, 11010))); + continue; + } + + if let Ok(addr) = l.parse::() { + parsed.extend(Self::gen_listeners(addr)); + continue; + } + + let (scheme, rest) = l.split_once(':').unwrap_or((&l, "")); + let Ok(scheme) = scheme.parse::() else { + anyhow::bail!("invalid listener: {}", l); + }; + + if rest.is_empty() { + parsed.push(format!( + "{}://0.0.0.0:{}", + scheme, + 11010 + scheme.port_offset() + )); + continue; + } + + if let Ok(port) = rest.parse::() { + parsed.push(format!("{}://0.0.0.0:{}", scheme, port)); + continue; + } + + if !l.parse::()?.has_authority() { + anyhow::bail!("invalid listener: {}", l); + } + parsed.push(l); } - listeners - .into_iter() - .map(|l| { - let l = l - .parse::() - .or_else(|_| url::Url::parse(&format!("{}:", l)))?; - - if l.has_authority() { - return Ok(l.to_string()); - } - - let scheme: IpScheme = l.scheme().parse()?; - let port = { - let port = l.path(); - if port.is_empty() { - 11010 + scheme.port_offset() - } else { - port.parse::() - .with_context(|| format!("invalid port: {}", port))? - } - }; - Ok(format!("{}://0.0.0.0:{}", scheme, port)) - }) - .collect() + Ok(parsed) } } @@ -1594,3 +1607,91 @@ async fn validate_config(cli: &Cli) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_listeners() { + type IpSchemeMap = fn(&IpScheme) -> String; + + let cases: [(&str, IpSchemeMap); _] = [ + ("0", |s| format!("{}://0.0.0.0:0", s)), + ("11010", |s| { + format!("{}://0.0.0.0:{}", s, 11010 + s.port_offset()) + }), + ("1.1.1.1", |s| { + format!("{}://1.1.1.1:{}", s, 11010 + s.port_offset()) + }), + ("1.1.1.1:50000", |s| { + format!("{}://1.1.1.1:{}", s, 50000 + s.port_offset()) + }), + ("[::1]", |s| { + format!("{}://[::1]:{}", s, 11010 + s.port_offset()) + }), + ("[::1]:50000", |s| { + format!("{}://[::1]:{}", s, 50000 + s.port_offset()) + }), + ]; + + for (input, output) in cases { + assert_eq!( + Cli::parse_listeners(false, vec![input.to_string()]).unwrap(), + IpScheme::VARIANTS.iter().map(output).collect::>() + ); + } + + let input = cases.iter().map(|(i, _)| i.to_string()).collect::>(); + let output = cases + .iter() + .flat_map(|(_, o)| IpScheme::VARIANTS.iter().map(o)) + .collect::>(); + assert_eq!(Cli::parse_listeners(false, input).unwrap(), output); + + let cases: [(IpSchemeMap, IpSchemeMap); _] = [ + ( + |s| format!("{}", s), + |s| format!("{}://0.0.0.0:{}", s, 11010 + s.port_offset()), + ), + ( + |s| format!("{}:50000", s), + |s| format!("{}://0.0.0.0:50000", s), + ), + ( + |s| format!("{}://1.1.1.1:50000", s), + |s| format!("{}://1.1.1.1:50000", s), + ), + ]; + + for (input, output) in cases { + assert_eq!( + Cli::parse_listeners( + false, + IpScheme::VARIANTS.iter().map(input).collect::>(), + ) + .unwrap(), + IpScheme::VARIANTS.iter().map(output).collect::>() + ); + } + + let input = cases + .iter() + .flat_map(|(i, _)| IpScheme::VARIANTS.iter().map(i)) + .collect::>(); + let output = cases + .iter() + .flat_map(|(_, o)| IpScheme::VARIANTS.iter().map(o)) + .collect::>(); + assert_eq!(Cli::parse_listeners(false, input).unwrap(), output); + + let cases = ["tcp://[::1", "xxx", "tcp:/abc", "tcp:abc"]; + for input in cases { + assert!( + Cli::parse_listeners(false, vec![input.to_string()]).is_err(), + "input: {}", + input + ); + } + } +}