//! Shared diagnostics helpers (port of `diagnostics/common.js`): target/option //! normalization, statistics, error results and bounded concurrency. use crate::core::error::CoreError; use once_cell::sync::Lazy; use regex::Regex; use serde_json::{json, Map, Value}; use std::future::Future; use std::net::IpAddr; use std::time::Instant; pub const PING_INTERVAL_MIN_MS: u64 = 50; pub const PING_DURATION_LIMIT_MS: u64 = 30 * 60 * 1000; pub const TOOLS: [&str; 8] = ["dns", "ping", "traceroute", "http", "compression", "tls", "tcp", "websocket"]; static LABEL: Lazy = Lazy::new(|| Regex::new(r"(?i)^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$").unwrap()); static START: Lazy = Lazy::new(Instant::now); pub fn now_ms() -> f64 { START.elapsed().as_secs_f64() * 1000.0 } pub fn service_name(port: u16) -> Option<&'static str> { Some(match port { 21 => "FTP", 22 => "SSH", 25 => "SMTP", 53 => "DNS TCP", 80 => "HTTP", 110 => "POP3", 143 => "IMAP", 443 => "HTTPS", 445 => "SMB", 587 => "SMTP Submission", 993 => "IMAPS", 995 => "POP3S", 3306 => "MySQL", 3389 => "RDP", 5432 => "PostgreSQL", 6379 => "Redis", _ => return None, }) } #[derive(Debug, Clone)] pub struct DiagError { pub code: String, pub message: String, } impl DiagError { pub fn new(code: &str) -> Self { Self { code: code.into(), message: code.into() } } pub fn with_message(code: &str, message: impl Into) -> Self { Self { code: code.into(), message: message.into() } } pub fn from_io(error: std::io::Error) -> Self { let code = match error.kind() { std::io::ErrorKind::ConnectionRefused => "ECONNREFUSED", std::io::ErrorKind::TimedOut => "ETIMEDOUT", std::io::ErrorKind::NotFound => "ENOENT", std::io::ErrorKind::ConnectionReset => "ECONNRESET", std::io::ErrorKind::PermissionDenied => "EACCES", _ => match error.raw_os_error() { Some(65) | Some(113) | Some(10065) => "EHOSTUNREACH", Some(51) | Some(101) | Some(10051) => "ENETUNREACH", _ => "EIO", }, }; Self { code: code.into(), message: error.to_string() } } } impl From for CoreError { fn from(error: DiagError) -> Self { CoreError::new(error.code.clone(), error.message.chars().take(400).collect::()) } } pub fn error_result(error: &DiagError) -> Value { json!({ "status": if error.code == "CANCELLED" { "cancelled" } else { "error" }, "code": if error.code.is_empty() { "DIAGNOSTIC_ERROR" } else { error.code.as_str() }, "reason": error.message.chars().take(400).collect::() }) } /// Host normalization: IP literals verbatim, names via IDNA to lower-case ASCII. pub fn hostname(raw: &str) -> Result { let value = raw.trim_start_matches('[').trim_end_matches(']'); if value.parse::().is_ok() { return Ok(value.to_string()); } let trimmed = value.strip_suffix('.').unwrap_or(value); let ascii = idna::domain_to_ascii(trimmed).map_err(|_| DiagError::new("INVALID_HOST"))?.to_lowercase(); if ascii.is_empty() || ascii.len() > 253 || !ascii.split('.').all(|label| LABEL.is_match(label)) { return Err(DiagError::new("INVALID_HOST")); } Ok(ascii) } #[derive(Debug, Clone, Default)] pub struct Target { pub input: String, pub host: String, pub url: String, pub port: u16, pub kind: String, } impl Target { pub fn to_json(&self) -> Value { json!({ "input": self.input, "host": self.host, "url": self.url, "port": self.port, "type": self.kind }) } } fn ip_family(value: &str) -> u8 { match value.parse::() { Ok(IpAddr::V4(_)) => 4, Ok(IpAddr::V6(_)) => 6, Err(_) => 0, } } pub fn parse_target(raw: &str) -> Result { let input = raw.trim(); if input.is_empty() || input.chars().count() > 2048 || input.chars().any(|c| c as u32 <= 0x20 || c as u32 == 0x7f || c == '\\') { return Err(DiagError::new("INVALID_TARGET")); } let stripped = input.trim_start_matches('[').trim_end_matches(']'); let literal = ip_family(stripped); let has_scheme = Regex::new(r"(?i)^[a-z][a-z0-9+.-]*://").unwrap().is_match(input); let candidate = if literal == 6 { format!("https://[{}]/", hostname(input)?) } else if has_scheme { input.to_string() } else { format!("https://{input}") }; let mut url = url::Url::parse(&candidate).map_err(|_| DiagError::new("INVALID_URL"))?; if !["http", "https", "ws", "wss"].contains(&url.scheme()) || !url.username().is_empty() || url.password().is_some() { return Err(DiagError::new("URL_AUTH_OR_SCHEME_FORBIDDEN")); } let host = hostname(url.host_str().ok_or_else(|| DiagError::new("INVALID_URL"))?)?; url.set_fragment(None); let secure = matches!(url.scheme(), "https" | "wss"); let family = ip_family(&host); Ok(Target { input: input.to_string(), port: url.port().unwrap_or(if secure { 443 } else { 80 }), url: url.to_string(), kind: if family > 0 { format!("ipv{family}") } else if input.contains("://") { "url".into() } else { "hostname".into() }, host, }) } fn integer(value: Option<&Value>, fallback: Option, min: i64, max: i64) -> Result, DiagError> { let invalid = || DiagError::new("INVALID_PARAMETER"); let number = match value { None => return Ok(fallback), Some(Value::String(s)) if s.is_empty() => return Ok(fallback), Some(Value::Number(n)) => n.as_f64().ok_or_else(invalid)?, Some(Value::String(s)) => s.trim().parse::().map_err(|_| invalid())?, Some(Value::Bool(b)) => if *b { 1.0 } else { 0.0 }, Some(Value::Null) => 0.0, _ => return Err(invalid()), }; if number.fract() != 0.0 || number < min as f64 || number > max as f64 { return Err(invalid()); } Ok(Some(number as i64)) } #[derive(Debug, Clone, Default)] pub struct Options { pub target: Target, pub manual_addresses: Option>, pub tools: Vec, pub mode: String, pub selected_ip: String, pub resolver: String, pub custom_dns: String, pub timeout_ms: u64, pub count: u64, pub interval_ms: u64, pub ping_timeout_ms: Option, pub packet_size: Option, pub ttl: Option, pub dont_fragment: bool, pub repetitions: u64, pub max_hops: u64, pub continuous: bool, pub warm: bool, pub protocols: Vec, pub ports: Vec, pub compare_schemes: bool, pub json: Value, } fn manual_pair(source: &Value) -> Result>, DiagError> { let list: Option> = if let Some(Value::Array(items)) = source.get("manualAddresses") { Some(items.iter().map(|e| e.get("address").and_then(Value::as_str).or(e.as_str()).map(str::to_string).unwrap_or_else(|| e.to_string())).collect()) } else if let Some(target) = source.get("target").and_then(Value::as_str) { let trimmed = target.trim(); if Regex::new(r"^[^/]*[\s,;][^/]*$").unwrap().is_match(trimmed) { Some(Regex::new(r"[\s,;]+").unwrap().split(trimmed).filter(|s| !s.is_empty()).map(str::to_string).collect()) } else { None } } else { None }; let Some(list) = list else { return Ok(None) }; let addresses: Vec = list.iter().map(|v| v.trim_start_matches('[').trim_end_matches(']').to_string()).collect(); if addresses.len() != 2 || addresses.iter().any(|a| ip_family(a) == 0) || ip_family(&addresses[0]) == ip_family(&addresses[1]) { return Err(DiagError::new("INVALID_MANUAL_ADDRESSES")); } let mut pair: Vec<(String, u8)> = addresses.iter().map(|a| Ok((hostname(a)?, ip_family(a)))).collect::>()?; pair.sort_by_key(|(_, family)| *family); Ok(Some(pair)) } fn string_list(value: Option<&Value>) -> Option> { value.and_then(Value::as_array).map(|items| items.iter().map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).collect()) } pub fn normalize_options(source: &Value) -> Result { let manual = manual_pair(source)?; let target_raw = match (&manual, source.get("manualAddresses")) { (Some(pair), None) | (Some(pair), Some(Value::Null)) => pair[0].0.clone(), _ => source.get("target").and_then(Value::as_str).unwrap_or("").to_string(), }; let target = parse_target(&target_raw)?; let mode = source.get("mode").and_then(Value::as_str).filter(|m| !m.is_empty()).unwrap_or("auto").to_string(); if !["auto", "ipv4", "ipv6", "both"].contains(&mode.as_str()) { return Err(DiagError::new("INVALID_IP_MODE")); } let tools = match source.get("tools") { None | Some(Value::Null) => TOOLS.iter().filter(|t| **t != "websocket").map(|t| t.to_string()).collect(), Some(_) => string_list(source.get("tools")).ok_or_else(|| DiagError::new("INVALID_TOOLS"))?, }; if tools.is_empty() || tools.len() > TOOLS.len() || tools.iter().any(|t| !TOOLS.contains(&t.as_str())) { return Err(DiagError::new("INVALID_TOOLS")); } let ports_text = match source.get("ports") { None | Some(Value::Null) => "22,80,443".to_string(), Some(Value::String(s)) if s.is_empty() => "22,80,443".to_string(), Some(Value::String(s)) => s.clone(), Some(other) => other.to_string(), }; if ports_text.chars().count() > 512 { return Err(DiagError::new("PORT_LIMIT")); } let mut ports: Vec = Vec::new(); for port in Regex::new(r"[,;\s]+").unwrap().split(&ports_text).filter(|s| !s.is_empty()) { let value = integer(Some(&Value::String(port.into())), Some(443), 1, 65535)?.unwrap_or(443) as u16; if !ports.contains(&value) { ports.push(value); } } if ports.is_empty() || ports.len() > 16 { return Err(DiagError::new("PORT_LIMIT")); } let resolver = source.get("resolver").and_then(Value::as_str).filter(|r| !r.is_empty()).unwrap_or("system").to_string(); if !["system", "custom", "cloudflare", "google", "quad9"].contains(&resolver.as_str()) { return Err(DiagError::new("INVALID_RESOLVER")); } let custom_dns = source.get("customDns").and_then(Value::as_str).unwrap_or("").to_string(); if resolver == "custom" && ip_family(&custom_dns) == 0 { return Err(DiagError::new("INVALID_DNS_SERVER")); } let selected_ip = source.get("selectedIp").and_then(Value::as_str).unwrap_or("").to_string(); if !selected_ip.is_empty() && ip_family(&selected_ip) == 0 { return Err(DiagError::new("INVALID_IP")); } let protocols = match source.get("protocols") { None | Some(Value::Null) => vec!["1.1".to_string(), "2".into(), "3".into()], Some(_) => string_list(source.get("protocols")).ok_or_else(|| DiagError::new("INVALID_PROTOCOL"))?, }; if protocols.is_empty() || protocols.len() > 3 || protocols.iter().any(|p| !["1.1", "2", "3"].contains(&p.as_str())) { return Err(DiagError::new("INVALID_PROTOCOL")); } let optional = |key: &str, min: i64, max: i64| -> Result, DiagError> { match source.get(key) { None | Some(Value::Null) => Ok(None), Some(Value::String(s)) if s.is_empty() => Ok(None), value => integer(value, None, min, max), } }; let count = integer(source.get("count"), Some(4), 1, 100_000)?.unwrap_or(4) as u64; let repetitions = integer(source.get("repetitions"), Some(1), 1, 20)?.unwrap_or(1) as u64; let interval_ms = integer(source.get("intervalMs"), Some(1000), PING_INTERVAL_MIN_MS as i64, 60_000)?.unwrap_or(1000) as u64; let continuous = source.get("continuous") == Some(&Value::Bool(true)); if !continuous && count * repetitions * interval_ms > PING_DURATION_LIMIT_MS { return Err(DiagError::new("PING_DURATION_LIMIT")); } let mut unique_tools: Vec = Vec::new(); for tool in tools { if !unique_tools.contains(&tool) { unique_tools.push(tool); } } let mut unique_protocols: Vec = Vec::new(); for protocol in protocols { if !unique_protocols.contains(&protocol) { unique_protocols.push(protocol); } } let mut options = Options { target, manual_addresses: manual, tools: unique_tools, mode, selected_ip, resolver, custom_dns, timeout_ms: integer(source.get("timeoutMs"), Some(5000), 250, 30_000)?.unwrap_or(5000) as u64, count, interval_ms, ping_timeout_ms: optional("pingTimeoutMs", 100, 30_000)?.map(|v| v as u64), packet_size: optional("packetSize", 0, 65_500)?.map(|v| v as u64), ttl: optional("ttl", 1, 255)?.map(|v| v as u64), dont_fragment: source.get("dontFragment") == Some(&Value::Bool(true)), repetitions, max_hops: integer(source.get("maxHops"), Some(20), 1, 40)?.unwrap_or(20) as u64, continuous, warm: source.get("warm") == Some(&Value::Bool(true)), protocols: unique_protocols, ports, compare_schemes: source.get("compareSchemes") == Some(&Value::Bool(true)), json: Value::Null, }; options.json = options_json(&options); Ok(options) } pub fn options_json(o: &Options) -> Value { let mut map = Map::new(); map.insert("target".into(), o.target.to_json()); if let Some(manual) = &o.manual_addresses { map.insert("manualAddresses".into(), Value::Array(manual.iter().map(|(a, f)| json!({ "address": a, "family": f })).collect())); } let extra = json!({ "tools": o.tools, "mode": o.mode, "selectedIp": o.selected_ip, "resolver": o.resolver, "customDns": o.custom_dns, "timeoutMs": o.timeout_ms, "count": o.count, "intervalMs": o.interval_ms, "pingTimeoutMs": o.ping_timeout_ms, "packetSize": o.packet_size, "ttl": o.ttl, "dontFragment": o.dont_fragment, "repetitions": o.repetitions, "maxHops": o.max_hops, "continuous": o.continuous, "warm": o.warm, "protocols": o.protocols, "ports": o.ports, "compareSchemes": o.compare_schemes }); if let Value::Object(extra) = extra { map.extend(extra); } Value::Object(map) } pub fn stats(values: &[Option]) -> Value { let sequence: Vec = values.iter().flatten().copied().filter(|v| v.is_finite()).collect(); if sequence.is_empty() { return json!({ "min": null, "average": null, "median": null, "max": null, "p95": null, "stddev": null, "jitter": null }); } let mut list = sequence.clone(); list.sort_by(|a, b| a.partial_cmp(b).unwrap()); let n = list.len(); let average = list.iter().sum::() / n as f64; let median = if n % 2 == 1 { list[(n - 1) / 2] } else { (list[n / 2 - 1] + list[n / 2]) / 2.0 }; let p95 = list[((n as f64 * 0.95).ceil() as usize).saturating_sub(1).min(n - 1)]; let stddev = (list.iter().map(|v| (v - average).powi(2)).sum::() / n as f64).sqrt(); let jitter = if sequence.len() < 2 { Value::Null } else { json!(sequence.windows(2).map(|w| (w[1] - w[0]).abs()).sum::() / (sequence.len() - 1) as f64) }; json!({ "min": list[0], "average": average, "median": median, "max": list[n - 1], "p95": p95, "stddev": stddev, "jitter": jitter }) } /// Runs `f` over `items` with at most `limit` in flight, preserving order. pub async fn map_limit(items: Vec, limit: usize, f: F) -> Result, DiagError> where F: Fn(T) -> Fut, Fut: Future>, { use futures::stream::{self, StreamExt}; let results: Vec> = stream::iter(items.into_iter().map(f)).buffered(limit.max(1)).collect().await; results.into_iter().collect() } #[cfg(test)] mod tests { use super::*; #[test] fn targets() { let t = parse_target("example.com").unwrap(); assert_eq!((t.host.as_str(), t.port, t.kind.as_str(), t.url.as_str()), ("example.com", 443, "hostname", "https://example.com/")); let t = parse_target("http://Bücher.example:8080/a?b#c").unwrap(); assert_eq!(t.host, "xn--bcher-kva.example"); assert_eq!(t.port, 8080); assert!(!t.url.contains('#')); assert_eq!(parse_target("2001:db8::1").unwrap().kind, "ipv6"); assert_eq!(parse_target("ftp://x").unwrap_err().code, "URL_AUTH_OR_SCHEME_FORBIDDEN"); assert_eq!(parse_target("https://u:p@x.example").unwrap_err().code, "URL_AUTH_OR_SCHEME_FORBIDDEN"); assert_eq!(parse_target("a b").unwrap_err().code, "INVALID_TARGET"); } #[test] fn options() { let o = normalize_options(&json!({"target": "example.com"})).unwrap(); assert_eq!(o.tools.len(), 7); assert_eq!(o.ports, vec![22, 80, 443]); assert_eq!(normalize_options(&json!({"target": "x.example", "count": 100000, "intervalMs": 1000})).unwrap_err().code, "PING_DURATION_LIMIT"); assert!(normalize_options(&json!({"target": "x.example", "count": 100000, "intervalMs": 1000, "continuous": true})).is_ok()); let pair = normalize_options(&json!({"target": "2001:db8::1, 192.0.2.1"})).unwrap(); assert_eq!(pair.manual_addresses.unwrap()[0].1, 4); assert_eq!(normalize_options(&json!({"target": "x.example", "ports": "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17"})).unwrap_err().code, "PORT_LIMIT"); } #[test] fn statistics() { let s = stats(&[Some(1.0), Some(3.0), None, Some(2.0)]); assert_eq!(s["median"], 2.0); assert_eq!(s["jitter"], 1.5); assert_eq!(stats(&[None])["min"], Value::Null); } }