Sumber yang diterbitkan
Unduh ZIP sumber
CH-J Tools
Jelajahi direktori dan file untuk rilis aplikasi tertentu.
CH-J / Server Manager
1704 file
0.1.2-beta
Ditambahkan: · Dibuat:
1
//! Shared diagnostics helpers (port of `diagnostics/common.js`): target/option2
//! normalization, statistics, error results and bounded concurrency.4
use crate::core::error::CoreError;5
use once_cell::sync::Lazy;6
use regex::Regex;7
use serde_json::{json, Map, Value};8
use std::future::Future;9
use std::net::IpAddr;10
use std::time::Instant;12
pub const PING_INTERVAL_MIN_MS: u64 = 50;13
pub const PING_DURATION_LIMIT_MS: u64 = 30 * 60 * 1000;14
pub const TOOLS: [&str; 8] = ["dns", "ping", "traceroute", "http", "compression", "tls", "tcp", "websocket"];16
static LABEL: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$").unwrap());17
static START: Lazy<Instant> = Lazy::new(Instant::now);19
pub fn now_ms() -> f64 {20
START.elapsed().as_secs_f64() * 1000.021
}23
pub fn service_name(port: u16) -> Option<&'static str> {24
Some(match port {25
21 => "FTP",26
22 => "SSH",27
25 => "SMTP",28
53 => "DNS TCP",29
80 => "HTTP",30
110 => "POP3",31
143 => "IMAP",32
443 => "HTTPS",33
445 => "SMB",34
587 => "SMTP Submission",35
993 => "IMAPS",36
995 => "POP3S",37
3306 => "MySQL",38
3389 => "RDP",39
5432 => "PostgreSQL",40
6379 => "Redis",41
_ => return None,42
})43
}45
#[derive(Debug, Clone)]46
pub struct DiagError {47
pub code: String,48
pub message: String,49
}51
impl DiagError {52
pub fn new(code: &str) -> Self {53
Self { code: code.into(), message: code.into() }54
}56
pub fn with_message(code: &str, message: impl Into<String>) -> Self {57
Self { code: code.into(), message: message.into() }58
}60
pub fn from_io(error: std::io::Error) -> Self {61
let code = match error.kind() {62
std::io::ErrorKind::ConnectionRefused => "ECONNREFUSED",63
std::io::ErrorKind::TimedOut => "ETIMEDOUT",64
std::io::ErrorKind::NotFound => "ENOENT",65
std::io::ErrorKind::ConnectionReset => "ECONNRESET",66
std::io::ErrorKind::PermissionDenied => "EACCES",67
_ => match error.raw_os_error() {68
Some(65) | Some(113) | Some(10065) => "EHOSTUNREACH",69
Some(51) | Some(101) | Some(10051) => "ENETUNREACH",70
_ => "EIO",71
},72
};73
Self { code: code.into(), message: error.to_string() }74
}75
}77
impl From<DiagError> for CoreError {78
fn from(error: DiagError) -> Self {79
CoreError::new(error.code.clone(), error.message.chars().take(400).collect::<String>())80
}81
}83
pub fn error_result(error: &DiagError) -> Value {84
json!({85
"status": if error.code == "CANCELLED" { "cancelled" } else { "error" },86
"code": if error.code.is_empty() { "DIAGNOSTIC_ERROR" } else { error.code.as_str() },87
"reason": error.message.chars().take(400).collect::<String>()88
})89
}91
/// Host normalization: IP literals verbatim, names via IDNA to lower-case ASCII.92
pub fn hostname(raw: &str) -> Result<String, DiagError> {93
let value = raw.trim_start_matches('[').trim_end_matches(']');94
if value.parse::<IpAddr>().is_ok() {95
return Ok(value.to_string());96
}97
let trimmed = value.strip_suffix('.').unwrap_or(value);98
let ascii = idna::domain_to_ascii(trimmed).map_err(|_| DiagError::new("INVALID_HOST"))?.to_lowercase();99
if ascii.is_empty() || ascii.len() > 253 || !ascii.split('.').all(|label| LABEL.is_match(label)) {100
return Err(DiagError::new("INVALID_HOST"));101
}102
Ok(ascii)103
}105
#[derive(Debug, Clone, Default)]106
pub struct Target {107
pub input: String,108
pub host: String,109
pub url: String,110
pub port: u16,111
pub kind: String,112
}114
impl Target {115
pub fn to_json(&self) -> Value {116
json!({ "input": self.input, "host": self.host, "url": self.url, "port": self.port, "type": self.kind })117
}118
}120
fn ip_family(value: &str) -> u8 {121
match value.parse::<IpAddr>() {122
Ok(IpAddr::V4(_)) => 4,123
Ok(IpAddr::V6(_)) => 6,124
Err(_) => 0,125
}126
}128
pub fn parse_target(raw: &str) -> Result<Target, DiagError> {129
let input = raw.trim();130
if input.is_empty() || input.chars().count() > 2048 || input.chars().any(|c| c as u32 <= 0x20 || c as u32 == 0x7f || c == '\\') {131
return Err(DiagError::new("INVALID_TARGET"));132
}133
let stripped = input.trim_start_matches('[').trim_end_matches(']');134
let literal = ip_family(stripped);135
let has_scheme = Regex::new(r"(?i)^[a-z][a-z0-9+.-]*://").unwrap().is_match(input);136
let candidate = if literal == 6 { format!("https://[{}]/", hostname(input)?) } else if has_scheme { input.to_string() } else { format!("https://{input}") };137
let mut url = url::Url::parse(&candidate).map_err(|_| DiagError::new("INVALID_URL"))?;138
if !["http", "https", "ws", "wss"].contains(&url.scheme()) || !url.username().is_empty() || url.password().is_some() {139
return Err(DiagError::new("URL_AUTH_OR_SCHEME_FORBIDDEN"));140
}141
let host = hostname(url.host_str().ok_or_else(|| DiagError::new("INVALID_URL"))?)?;142
url.set_fragment(None);143
let secure = matches!(url.scheme(), "https" | "wss");144
let family = ip_family(&host);145
Ok(Target {146
input: input.to_string(),147
port: url.port().unwrap_or(if secure { 443 } else { 80 }),148
url: url.to_string(),149
kind: if family > 0 { format!("ipv{family}") } else if input.contains("://") { "url".into() } else { "hostname".into() },150
host,151
})152
}154
fn integer(value: Option<&Value>, fallback: Option<i64>, min: i64, max: i64) -> Result<Option<i64>, DiagError> {155
let invalid = || DiagError::new("INVALID_PARAMETER");156
let number = match value {157
None => return Ok(fallback),158
Some(Value::String(s)) if s.is_empty() => return Ok(fallback),159
Some(Value::Number(n)) => n.as_f64().ok_or_else(invalid)?,160
Some(Value::String(s)) => s.trim().parse::<f64>().map_err(|_| invalid())?,161
Some(Value::Bool(b)) => if *b { 1.0 } else { 0.0 },162
Some(Value::Null) => 0.0,163
_ => return Err(invalid()),164
};165
if number.fract() != 0.0 || number < min as f64 || number > max as f64 {166
return Err(invalid());167
}168
Ok(Some(number as i64))169
}171
#[derive(Debug, Clone, Default)]172
pub struct Options {173
pub target: Target,174
pub manual_addresses: Option<Vec<(String, u8)>>,175
pub tools: Vec<String>,176
pub mode: String,177
pub selected_ip: String,178
pub resolver: String,179
pub custom_dns: String,180
pub timeout_ms: u64,181
pub count: u64,182
pub interval_ms: u64,183
pub ping_timeout_ms: Option<u64>,184
pub packet_size: Option<u64>,185
pub ttl: Option<u64>,186
pub dont_fragment: bool,187
pub repetitions: u64,188
pub max_hops: u64,189
pub continuous: bool,190
pub warm: bool,191
pub protocols: Vec<String>,192
pub ports: Vec<u16>,193
pub compare_schemes: bool,194
pub json: Value,195
}197
fn manual_pair(source: &Value) -> Result<Option<Vec<(String, u8)>>, DiagError> {198
let list: Option<Vec<String>> = if let Some(Value::Array(items)) = source.get("manualAddresses") {199
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())200
} else if let Some(target) = source.get("target").and_then(Value::as_str) {201
let trimmed = target.trim();202
if Regex::new(r"^[^/]*[\s,;][^/]*$").unwrap().is_match(trimmed) {203
Some(Regex::new(r"[\s,;]+").unwrap().split(trimmed).filter(|s| !s.is_empty()).map(str::to_string).collect())204
} else {205
None206
}207
} else {208
None209
};210
let Some(list) = list else { return Ok(None) };211
let addresses: Vec<String> = list.iter().map(|v| v.trim_start_matches('[').trim_end_matches(']').to_string()).collect();212
if addresses.len() != 2 || addresses.iter().any(|a| ip_family(a) == 0) || ip_family(&addresses[0]) == ip_family(&addresses[1]) {213
return Err(DiagError::new("INVALID_MANUAL_ADDRESSES"));214
}215
let mut pair: Vec<(String, u8)> = addresses.iter().map(|a| Ok((hostname(a)?, ip_family(a)))).collect::<Result<_, DiagError>>()?;216
pair.sort_by_key(|(_, family)| *family);217
Ok(Some(pair))218
}220
fn string_list(value: Option<&Value>) -> Option<Vec<String>> {221
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())222
}224
pub fn normalize_options(source: &Value) -> Result<Options, DiagError> {225
let manual = manual_pair(source)?;226
let target_raw = match (&manual, source.get("manualAddresses")) {227
(Some(pair), None) | (Some(pair), Some(Value::Null)) => pair[0].0.clone(),228
_ => source.get("target").and_then(Value::as_str).unwrap_or("").to_string(),229
};230
let target = parse_target(&target_raw)?;231
let mode = source.get("mode").and_then(Value::as_str).filter(|m| !m.is_empty()).unwrap_or("auto").to_string();232
if !["auto", "ipv4", "ipv6", "both"].contains(&mode.as_str()) {233
return Err(DiagError::new("INVALID_IP_MODE"));234
}235
let tools = match source.get("tools") {236
None | Some(Value::Null) => TOOLS.iter().filter(|t| **t != "websocket").map(|t| t.to_string()).collect(),237
Some(_) => string_list(source.get("tools")).ok_or_else(|| DiagError::new("INVALID_TOOLS"))?,238
};239
if tools.is_empty() || tools.len() > TOOLS.len() || tools.iter().any(|t| !TOOLS.contains(&t.as_str())) {240
return Err(DiagError::new("INVALID_TOOLS"));241
}242
let ports_text = match source.get("ports") {243
None | Some(Value::Null) => "22,80,443".to_string(),244
Some(Value::String(s)) if s.is_empty() => "22,80,443".to_string(),245
Some(Value::String(s)) => s.clone(),246
Some(other) => other.to_string(),247
};248
if ports_text.chars().count() > 512 {249
return Err(DiagError::new("PORT_LIMIT"));250
}251
let mut ports: Vec<u16> = Vec::new();252
for port in Regex::new(r"[,;\s]+").unwrap().split(&ports_text).filter(|s| !s.is_empty()) {253
let value = integer(Some(&Value::String(port.into())), Some(443), 1, 65535)?.unwrap_or(443) as u16;254
if !ports.contains(&value) {255
ports.push(value);256
}257
}258
if ports.is_empty() || ports.len() > 16 {259
return Err(DiagError::new("PORT_LIMIT"));260
}261
let resolver = source.get("resolver").and_then(Value::as_str).filter(|r| !r.is_empty()).unwrap_or("system").to_string();262
if !["system", "custom", "cloudflare", "google", "quad9"].contains(&resolver.as_str()) {263
return Err(DiagError::new("INVALID_RESOLVER"));264
}265
let custom_dns = source.get("customDns").and_then(Value::as_str).unwrap_or("").to_string();266
if resolver == "custom" && ip_family(&custom_dns) == 0 {267
return Err(DiagError::new("INVALID_DNS_SERVER"));268
}269
let selected_ip = source.get("selectedIp").and_then(Value::as_str).unwrap_or("").to_string();270
if !selected_ip.is_empty() && ip_family(&selected_ip) == 0 {271
return Err(DiagError::new("INVALID_IP"));272
}273
let protocols = match source.get("protocols") {274
None | Some(Value::Null) => vec!["1.1".to_string(), "2".into(), "3".into()],275
Some(_) => string_list(source.get("protocols")).ok_or_else(|| DiagError::new("INVALID_PROTOCOL"))?,276
};277
if protocols.is_empty() || protocols.len() > 3 || protocols.iter().any(|p| !["1.1", "2", "3"].contains(&p.as_str())) {278
return Err(DiagError::new("INVALID_PROTOCOL"));279
}280
let optional = |key: &str, min: i64, max: i64| -> Result<Option<i64>, DiagError> {281
match source.get(key) {282
None | Some(Value::Null) => Ok(None),283
Some(Value::String(s)) if s.is_empty() => Ok(None),284
value => integer(value, None, min, max),285
}286
};287
let count = integer(source.get("count"), Some(4), 1, 100_000)?.unwrap_or(4) as u64;288
let repetitions = integer(source.get("repetitions"), Some(1), 1, 20)?.unwrap_or(1) as u64;289
let interval_ms = integer(source.get("intervalMs"), Some(1000), PING_INTERVAL_MIN_MS as i64, 60_000)?.unwrap_or(1000) as u64;290
let continuous = source.get("continuous") == Some(&Value::Bool(true));291
if !continuous && count * repetitions * interval_ms > PING_DURATION_LIMIT_MS {292
return Err(DiagError::new("PING_DURATION_LIMIT"));293
}294
let mut unique_tools: Vec<String> = Vec::new();295
for tool in tools {296
if !unique_tools.contains(&tool) {297
unique_tools.push(tool);298
}299
}300
let mut unique_protocols: Vec<String> = Vec::new();301
for protocol in protocols {302
if !unique_protocols.contains(&protocol) {303
unique_protocols.push(protocol);304
}305
}306
let mut options = Options {307
target,308
manual_addresses: manual,309
tools: unique_tools,310
mode,311
selected_ip,312
resolver,313
custom_dns,314
timeout_ms: integer(source.get("timeoutMs"), Some(5000), 250, 30_000)?.unwrap_or(5000) as u64,315
count,316
interval_ms,317
ping_timeout_ms: optional("pingTimeoutMs", 100, 30_000)?.map(|v| v as u64),318
packet_size: optional("packetSize", 0, 65_500)?.map(|v| v as u64),319
ttl: optional("ttl", 1, 255)?.map(|v| v as u64),320
dont_fragment: source.get("dontFragment") == Some(&Value::Bool(true)),321
repetitions,322
max_hops: integer(source.get("maxHops"), Some(20), 1, 40)?.unwrap_or(20) as u64,323
continuous,324
warm: source.get("warm") == Some(&Value::Bool(true)),325
protocols: unique_protocols,326
ports,327
compare_schemes: source.get("compareSchemes") == Some(&Value::Bool(true)),328
json: Value::Null,329
};330
options.json = options_json(&options);331
Ok(options)332
}334
pub fn options_json(o: &Options) -> Value {335
let mut map = Map::new();336
map.insert("target".into(), o.target.to_json());337
if let Some(manual) = &o.manual_addresses {338
map.insert("manualAddresses".into(), Value::Array(manual.iter().map(|(a, f)| json!({ "address": a, "family": f })).collect()));339
}340
let extra = json!({341
"tools": o.tools, "mode": o.mode, "selectedIp": o.selected_ip, "resolver": o.resolver, "customDns": o.custom_dns,342
"timeoutMs": o.timeout_ms, "count": o.count, "intervalMs": o.interval_ms,343
"pingTimeoutMs": o.ping_timeout_ms, "packetSize": o.packet_size, "ttl": o.ttl, "dontFragment": o.dont_fragment,344
"repetitions": o.repetitions, "maxHops": o.max_hops, "continuous": o.continuous, "warm": o.warm,345
"protocols": o.protocols, "ports": o.ports, "compareSchemes": o.compare_schemes346
});347
if let Value::Object(extra) = extra {348
map.extend(extra);349
}350
Value::Object(map)351
}353
pub fn stats(values: &[Option<f64>]) -> Value {354
let sequence: Vec<f64> = values.iter().flatten().copied().filter(|v| v.is_finite()).collect();355
if sequence.is_empty() {356
return json!({ "min": null, "average": null, "median": null, "max": null, "p95": null, "stddev": null, "jitter": null });357
}358
let mut list = sequence.clone();359
list.sort_by(|a, b| a.partial_cmp(b).unwrap());360
let n = list.len();361
let average = list.iter().sum::<f64>() / n as f64;362
let median = if n % 2 == 1 { list[(n - 1) / 2] } else { (list[n / 2 - 1] + list[n / 2]) / 2.0 };363
let p95 = list[((n as f64 * 0.95).ceil() as usize).saturating_sub(1).min(n - 1)];364
let stddev = (list.iter().map(|v| (v - average).powi(2)).sum::<f64>() / n as f64).sqrt();365
let jitter = if sequence.len() < 2 { Value::Null } else { json!(sequence.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f64>() / (sequence.len() - 1) as f64) };366
json!({ "min": list[0], "average": average, "median": median, "max": list[n - 1], "p95": p95, "stddev": stddev, "jitter": jitter })367
}369
/// Runs `f` over `items` with at most `limit` in flight, preserving order.370
pub async fn map_limit<T, R, F, Fut>(items: Vec<T>, limit: usize, f: F) -> Result<Vec<R>, DiagError>371
where372
F: Fn(T) -> Fut,373
Fut: Future<Output = Result<R, DiagError>>,374
{375
use futures::stream::{self, StreamExt};376
let results: Vec<Result<R, DiagError>> = stream::iter(items.into_iter().map(f)).buffered(limit.max(1)).collect().await;377
results.into_iter().collect()378
}380
#[cfg(test)]381
mod tests {382
use super::*;384
#[test]385
fn targets() {386
let t = parse_target("example.com").unwrap();387
assert_eq!((t.host.as_str(), t.port, t.kind.as_str(), t.url.as_str()), ("example.com", 443, "hostname", "https://example.com/"));388
let t = parse_target("http://Bücher.example:8080/a?b#c").unwrap();389
assert_eq!(t.host, "xn--bcher-kva.example");390
assert_eq!(t.port, 8080);391
assert!(!t.url.contains('#'));392
assert_eq!(parse_target("2001:db8::1").unwrap().kind, "ipv6");393
assert_eq!(parse_target("ftp://x").unwrap_err().code, "URL_AUTH_OR_SCHEME_FORBIDDEN");394
assert_eq!(parse_target("https://u:p@x.example").unwrap_err().code, "URL_AUTH_OR_SCHEME_FORBIDDEN");395
assert_eq!(parse_target("a b").unwrap_err().code, "INVALID_TARGET");396
}398
#[test]399
fn options() {400
let o = normalize_options(&json!({"target": "example.com"})).unwrap();401
assert_eq!(o.tools.len(), 7);402
assert_eq!(o.ports, vec![22, 80, 443]);403
assert_eq!(normalize_options(&json!({"target": "x.example", "count": 100000, "intervalMs": 1000})).unwrap_err().code, "PING_DURATION_LIMIT");404
assert!(normalize_options(&json!({"target": "x.example", "count": 100000, "intervalMs": 1000, "continuous": true})).is_ok());405
let pair = normalize_options(&json!({"target": "2001:db8::1, 192.0.2.1"})).unwrap();406
assert_eq!(pair.manual_addresses.unwrap()[0].1, 4);407
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");408
}410
#[test]411
fn statistics() {412
let s = stats(&[Some(1.0), Some(3.0), None, Some(2.0)]);413
assert_eq!(s["median"], 2.0);414
assert_eq!(s["jitter"], 1.5);415
assert_eq!(stats(&[None])["min"], Value::Null);416
}417
}SHA-256
298bad1cf116f7ea1452a8db8afe11db88b7783f5e97d95e4c7b6e4024ba30f1CH-J Proprietary Software License 1.14 · Kode sumber disediakan berdasarkan CH-J Proprietary Software License 1.14. Ketersediaannya tidak mengubah ketentuan lisensi atau memberikan hak tambahan.
SHA-256 arsip
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f