Avaldatud allikas
Laadi alla allikas ZIP
CH-J Tools
Vaata katalooge ja faile konkreetse rakenduse vabastamiseks.
CH-J / Server Manager
1704 failid
0.1.2-beta
Lisatud: · Ehitatud:
1
//! Local OS tools for diagnostics (port of `diagnostics/processTools.js`): single-packet2
//! ping probes with a bounded overlap (no root/flood needed) and traceroute/tracert.3
//! Arguments are passed without a shell; output is size-limited.5
use crate::core::diagnostics::common::{now_ms, stats, DiagError, Options};6
use crate::core::ssh::latency::parse_ping_replies;7
use once_cell::sync::Lazy;8
use regex::Regex;9
use serde_json::{json, Value};10
use std::net::IpAddr;11
use std::process::Stdio;12
use std::sync::Arc;13
use std::time::Duration;14
use tokio::io::AsyncReadExt;15
use tokio_util::sync::CancellationToken;17
static CONFIG_ERROR: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)not permitted|permission denied|invalid option|unknown option|invalid argument|out of range|illegal|usage:").unwrap());18
static TRANSIENT_ERROR: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)network is unreachable|no route to host|general failure|transmit failed|no buffer space|resource temporarily unavailable|host is down").unwrap());19
static EXPLICIT_TIMEOUT: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)Request timed out|Request timeout|Zeitüberschreitung|Vypršel.*časov[ýy].*limit").unwrap());20
const MAX_PING_IN_FLIGHT: u64 = 32;21
const MAX_CONSECUTIVE_PING_ERRORS: u64 = 50;23
pub struct ProcessResult {24
pub code: Option<i32>,25
pub stdout: Vec<u8>,26
pub stderr: String,27
}29
pub fn clean_env(command: &mut tokio::process::Command, inherit: bool) {30
if !inherit {31
command.env_clear();32
for name in ["SystemRoot", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "TMPDIR"] {33
if let Ok(value) = std::env::var(name) {34
command.env(name, value);35
}36
}37
}38
command.env("LC_ALL", "C").env("LANG", "C");39
}41
pub async fn run_process(42
executable: &str,43
args: &[String],44
timeout_ms: u64,45
cancel: &CancellationToken,46
max_bytes: usize,47
on_line: Option<&(dyn Fn(&str) + Send + Sync)>,48
curated_env: bool,49
) -> Result<ProcessResult, DiagError> {50
if cancel.is_cancelled() {51
return Err(DiagError::new("CANCELLED"));52
}53
let mut command = tokio::process::Command::new(executable);54
command.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);55
clean_env(&mut command, !curated_env);56
#[cfg(windows)]57
command.creation_flags(0x0800_0000);58
let mut child = command.spawn().map_err(|e| if e.kind() == std::io::ErrorKind::NotFound { DiagError::with_message("ENOENT", e.to_string()) } else { DiagError::from_io(e) })?;59
let mut stdout = child.stdout.take().expect("stdout");60
let mut stderr = child.stderr.take().expect("stderr");61
let work = async {62
let mut out = Vec::new();63
let mut err = Vec::new();64
let mut pending = String::new();65
let mut buffer = [0u8; 8192];66
let mut err_buffer = [0u8; 4096];67
let mut out_open = true;68
let mut err_open = true;69
while out_open || err_open {70
tokio::select! {71
n = stdout.read(&mut buffer), if out_open => {72
let n = n.map_err(DiagError::from_io)?;73
if n == 0 { out_open = false; continue; }74
if out.len() + err.len() + n > max_bytes { return Err(DiagError::new("OUTPUT_LIMIT")); }75
out.extend_from_slice(&buffer[..n]);76
if let Some(callback) = on_line {77
pending.push_str(&String::from_utf8_lossy(&buffer[..n]));78
while let Some(index) = pending.find('\n') {79
let line: String = pending.drain(..=index).collect();80
callback(line.trim_end_matches(['\r', '\n']));81
}82
}83
}84
n = stderr.read(&mut err_buffer), if err_open => {85
let n = n.map_err(DiagError::from_io)?;86
if n == 0 { err_open = false; continue; }87
if out.len() + err.len() + n > max_bytes { return Err(DiagError::new("OUTPUT_LIMIT")); }88
err.extend_from_slice(&err_buffer[..n]);89
}90
}91
}92
if let Some(callback) = on_line {93
if !pending.is_empty() {94
callback(pending.trim_end_matches('\r'));95
}96
}97
let status = child.wait().await.map_err(DiagError::from_io)?;98
Ok(ProcessResult { code: status.code(), stdout: out, stderr: String::from_utf8_lossy(&err).to_string() })99
};100
tokio::select! {101
result = tokio::time::timeout(Duration::from_millis(timeout_ms), work) => result.map_err(|_| DiagError::new("TIMEOUT"))?,102
_ = cancel.cancelled() => Err(DiagError::new("CANCELLED")),103
}104
}106
pub fn ping_command(address: &str, timeout_ms: u64, options: &Options) -> Result<(String, Vec<String>), DiagError> {107
let ip: IpAddr = address.parse().map_err(|_| DiagError::new("INVALID_IP"))?;108
let v6 = ip.is_ipv6();109
let size = |flag: &str| options.packet_size.map(|s| vec![flag.to_string(), s.to_string()]).unwrap_or_default();110
let hops = |flag: &str| options.ttl.map(|t| vec![flag.to_string(), t.to_string()]).unwrap_or_default();111
let df = |flags: &[&str]| if options.dont_fragment && !v6 { flags.iter().map(|f| f.to_string()).collect() } else { Vec::new() };112
let mut args: Vec<String>;113
let executable: &str;114
if cfg!(windows) {115
executable = "ping.exe";116
args = vec![if v6 { "-6" } else { "-4" }.into(), "-n".into(), "1".into(), "-w".into(), timeout_ms.to_string()];117
args.extend(size("-l"));118
args.extend(hops("-i"));119
args.extend(df(&["-f"]));120
} else if cfg!(target_os = "macos") {121
executable = if v6 { "/sbin/ping6" } else { "/sbin/ping" };122
args = vec!["-n".into(), "-c".into(), "1".into()];123
if !v6 {124
args.extend(["-W".to_string(), timeout_ms.to_string()]);125
}126
args.extend(size("-s"));127
args.extend(hops(if v6 { "-h" } else { "-m" }));128
args.extend(df(&["-D"]));129
} else if cfg!(target_os = "linux") {130
executable = "ping";131
args = vec![if v6 { "-6" } else { "-4" }.into(), "-n".into(), "-c".into(), "1".into(), "-W".into(), timeout_ms.div_ceil(1000).to_string()];132
args.extend(size("-s"));133
args.extend(hops("-t"));134
args.extend(df(&["-M", "do"]));135
} else {136
return Err(DiagError::new("PLATFORM_UNSUPPORTED"));137
}138
args.push(address.to_string());139
Ok((executable.to_string(), args))140
}142
pub fn parse_ping_reply(output: &str) -> Value {143
let exact = parse_ping_replies(output).into_iter().find(|r| !r.less_than && r.ms > 0.0).map(|r| r.ms);144
let upper = Regex::new(r"(?i)<\s*(\d+(?:[.,]\d+)?)\s*ms").unwrap().captures(output).and_then(|c| c[1].replace(',', ".").parse::<f64>().ok());145
let ttl = Regex::new(r"(?i)(?:ttl|hlim|hop limit)[=:\s]+(\d+)").unwrap().captures(output).and_then(|c| c[1].parse::<u64>().ok());146
json!({ "received": exact.is_some() || upper.is_some(), "rttMs": exact, "upperBoundMs": upper, "ttl": ttl })147
}149
struct PingState {150
packets: Vec<Value>,151
rtts: Vec<Option<f64>>,152
sent: u64,153
received: u64,154
error_packets: u64,155
consecutive_errors: u64,156
failure: Option<Value>,157
}159
fn summary(state: &PingState) -> Value {160
json!({161
"sent": state.sent,162
"received": state.received,163
"errorPackets": state.error_packets,164
"lossPercent": if state.sent > 0 { json!(100.0 * (state.sent - state.received) as f64 / state.sent as f64) } else { Value::Null },165
"statistics": stats(&state.rtts)166
})167
}169
fn merge(mut base: Value, extra: Value) -> Value {170
if let (Some(target), Value::Object(fields)) = (base.as_object_mut(), extra) {171
target.extend(fields);172
}173
base174
}176
pub async fn ping(address: &str, options: &Options, cancel: &CancellationToken, progress: Arc<dyn Fn(Value) + Send + Sync>) -> Result<Value, DiagError> {177
let interval = options.interval_ms.max(1);178
let timeout = options.ping_timeout_ms.unwrap_or(options.timeout_ms);179
let total = if options.continuous { u64::MAX } else { options.count * options.repetitions };180
let max_in_flight = (timeout + 500).div_ceil(interval).saturating_add(1).clamp(1, MAX_PING_IN_FLIGHT);181
let state = Arc::new(parking_lot::Mutex::new(PingState { packets: Vec::new(), rtts: Vec::new(), sent: 0, received: 0, error_packets: 0, consecutive_errors: 0, failure: None }));182
let (executable, _) = ping_command(address, timeout, options)?;183
let _ = executable;184
let mut in_flight = tokio::task::JoinSet::new();185
let mut launched = 0u64;186
let mut throttled = 0u64;187
let started = now_ms();188
let mut next_at = started;189
let mut last_launch = started;190
let base_options = options.clone();191
while launched < total && state.lock().failure.is_none() {192
if cancel.is_cancelled() {193
in_flight.abort_all();194
return Err(DiagError::new("CANCELLED"));195
}196
if in_flight.len() as u64 >= max_in_flight {197
throttled += 1;198
tokio::select! {199
_ = in_flight.join_next() => {},200
_ = cancel.cancelled() => { in_flight.abort_all(); return Err(DiagError::new("CANCELLED")); }201
}202
continue;203
}204
launched += 1;205
let sequence = launched;206
let address = address.to_string();207
let state = state.clone();208
let progress = progress.clone();209
let cancel_probe = cancel.clone();210
let probe_options = base_options.clone();211
in_flight.spawn(async move {212
let (exe, args) = match ping_command(&address, timeout, &probe_options) {213
Ok(c) => c,214
Err(e) => {215
state.lock().failure = Some(json!({ "sequence": sequence, "address": address, "received": false, "rttMs": null, "status": "error", "code": e.code, "raw": e.message }));216
return;217
}218
};219
let mut packet = match run_process(&exe, &args, timeout + 500, &cancel_probe, 256 * 1024, None, false).await {220
Ok(result) => {221
let raw = String::from_utf8_lossy(&result.stdout).to_string();222
let reply = parse_ping_reply(&raw);223
let received = reply["received"] == true;224
let status = if received { "success" } else if EXPLICIT_TIMEOUT.is_match(&raw) { "timeout" } else { "no-reply" };225
let combined = format!("{raw}{}", result.stderr);226
let mut packet = merge(json!({ "sequence": sequence, "address": address }), reply);227
packet["status"] = json!(status);228
packet["raw"] = json!(combined.chars().take(if received { 300 } else { 4096 }).collect::<String>());229
let threshold = if cfg!(target_os = "macos") { 2 } else { 1 };230
if !received && (result.code.map(|c| c > threshold).unwrap_or(false) || CONFIG_ERROR.is_match(&result.stderr) || TRANSIENT_ERROR.is_match(&result.stderr)) {231
packet["status"] = json!("error");232
packet["fatal"] = json!(CONFIG_ERROR.is_match(&result.stderr));233
}234
packet235
}236
Err(error) => {237
if cancel_probe.is_cancelled() {238
return;239
}240
let status = match error.code.as_str() { "ENOENT" => "unavailable", "TIMEOUT" => "timeout", _ => "error" };241
json!({ "sequence": sequence, "address": address, "received": false, "rttMs": null, "status": status, "code": error.code, "raw": error.message.chars().take(400).collect::<String>() })242
}243
};244
if cancel_probe.is_cancelled() {245
return;246
}247
let mut guard = state.lock();248
if guard.failure.is_some() {249
return;250
}251
packet["measuredAt"] = json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true));252
let is_error = packet["status"] == "error";253
let fatal = packet["status"] == "unavailable" || (is_error && (packet["fatal"] == true || guard.sent == 0 || guard.consecutive_errors + 1 >= MAX_CONSECUTIVE_PING_ERRORS));254
if let Some(object) = packet.as_object_mut() {255
object.remove("fatal");256
}257
if fatal {258
if is_error && guard.sent > 0 && packet.get("code").is_none() {259
packet["code"] = json!("PING_REPEATED_ERRORS");260
}261
guard.packets.push(packet.clone());262
guard.failure = Some(packet);263
return;264
}265
guard.consecutive_errors = if is_error { guard.consecutive_errors + 1 } else { 0 };266
if is_error {267
guard.error_packets += 1;268
}269
guard.sent += 1;270
if packet["received"] == true {271
guard.received += 1;272
let index = (sequence - 1) as usize;273
if guard.rtts.len() <= index {274
guard.rtts.resize(index + 1, None);275
}276
guard.rtts[index] = packet["rttMs"].as_f64();277
}278
guard.packets.push(packet.clone());279
if guard.packets.len() > 500 {280
guard.packets.remove(0);281
}282
let event = merge(merge(json!({ "kind": "ping-packet" }), packet), summary(&guard));283
drop(guard);284
progress(event);285
});286
last_launch = now_ms();287
next_at = (next_at + interval as f64).max(last_launch - interval as f64 / 2.0);288
let wait = next_at - now_ms();289
if launched < total && wait > 0.0 {290
tokio::select! {291
_ = tokio::time::sleep(Duration::from_secs_f64(wait / 1000.0)) => {},292
_ = cancel.cancelled() => { in_flight.abort_all(); return Err(DiagError::new("CANCELLED")); }293
}294
}295
}296
while in_flight.join_next().await.is_some() {}297
if cancel.is_cancelled() {298
return Err(DiagError::new("CANCELLED"));299
}300
let mut guard = state.lock();301
guard.packets.sort_by_key(|p| p["sequence"].as_u64().unwrap_or(0));302
let timing = json!({303
"intervalMs": interval,304
"effectiveIntervalMs": if launched > 1 { json!((last_launch - started) / (launched - 1) as f64) } else { Value::Null },305
"timeoutMs": timeout,306
"maxInFlight": max_in_flight,307
"throttled": throttled,308
"packetSize": options.packet_size,309
"ttl": options.ttl,310
"dontFragment": options.dont_fragment311
});312
let sum = summary(&guard);313
if let Some(failure) = guard.failure.clone() {314
progress(merge(merge(json!({ "kind": "ping-packet" }), failure.clone()), sum.clone()));315
return Ok(merge(316
json!({ "status": failure["status"], "code": failure.get("code").cloned().unwrap_or(json!("PING_TOOL_ERROR")), "reason": failure["raw"], "address": address, "packets": guard.packets }),317
merge(sum, json!({ "timing": timing, "note": "ICMP failure does not establish server unavailability or prove a firewall." })),318
));319
}320
Ok(merge(321
json!({ "status": if guard.received > 0 { "success" } else { "warning" }, "address": address, "packets": std::mem::take(&mut guard.packets) }),322
merge(sum, json!({ "timing": timing, "note": "No ICMP reply may mean filtering, ICMP rate limiting or loss; it does not establish server unavailability." })),323
))324
}326
pub fn traceroute_command(address: &str, options: &Options) -> Result<(String, Vec<String>), DiagError> {327
let ip: IpAddr = address.parse().map_err(|_| DiagError::new("INVALID_IP"))?;328
let v6 = ip.is_ipv6();329
let hops = options.max_hops.to_string();330
if cfg!(windows) {331
Ok(("tracert.exe".into(), vec![if v6 { "-6" } else { "-4" }.into(), "-d".into(), "-h".into(), hops, "-w".into(), options.timeout_ms.to_string(), address.into()]))332
} else if cfg!(target_os = "macos") {333
Ok(((if v6 { "/usr/sbin/traceroute6" } else { "/usr/sbin/traceroute" }).into(), vec!["-n".into(), "-m".into(), hops, "-w".into(), options.timeout_ms.div_ceil(1000).to_string(), address.into()]))334
} else if cfg!(target_os = "linux") {335
let wait = options.timeout_ms as f64 / 1000.0;336
Ok(("traceroute".into(), vec![if v6 { "-6" } else { "-4" }.into(), "-n".into(), "-m".into(), hops, "-w".into(), if wait.fract() == 0.0 { format!("{}", wait as u64) } else { wait.to_string() }, address.into()]))337
} else {338
Err(DiagError::new("PLATFORM_UNSUPPORTED"))339
}340
}342
pub fn parse_hop(line: &str) -> Option<Value> {343
let caps = Regex::new(r"^\s*(\d{1,2})\s+(.+)").unwrap().captures(line)?;344
let rest = caps[2].to_string();345
let mut addresses: Vec<String> = Vec::new();346
for token in rest.split_whitespace() {347
let cleaned: String = token.chars().filter(|c| !"()[],".contains(*c)).collect();348
if cleaned.parse::<IpAddr>().is_ok() && !addresses.contains(&cleaned) {349
addresses.push(cleaned);350
}351
}352
let probe_re = Regex::new(r"(?i)(<\s*)?(\d+(?:[.,]\d+)?)\s*ms|\*").unwrap();353
let probes: Vec<Value> = probe_re354
.captures_iter(&rest)355
.map(|m| {356
if &m[0] == "*" {357
json!({ "status": "timeout", "rttMs": null })358
} else {359
let value = m[2].replace(',', ".").parse::<f64>().unwrap_or(0.0);360
if m.get(1).is_some() { json!({ "status": "success", "rttMs": null, "upperBoundMs": value }) } else { json!({ "status": "success", "rttMs": value, "upperBoundMs": null }) }361
}362
})363
.collect();364
if probes.is_empty() && addresses.is_empty() {365
return None;366
}367
let rtts: Vec<Option<f64>> = probes.iter().map(|p| p["rttMs"].as_f64()).collect();368
let timeouts = probes.iter().filter(|p| p["status"] == "timeout").count();369
Some(json!({370
"hop": caps[1].parse::<u64>().unwrap_or(0),371
"addresses": addresses,372
"probes": probes,373
"averageMs": stats(&rtts)["average"],374
"lossPercent": if probes.is_empty() { Value::Null } else { json!(100.0 * timeouts as f64 / probes.len() as f64) },375
"raw": line.chars().take(1024).collect::<String>()376
}))377
}379
fn same_ip(a: &str, b: &str) -> bool {380
match (a.parse::<IpAddr>(), b.parse::<IpAddr>()) {381
(Ok(x), Ok(y)) => x == y,382
_ => a == b,383
}384
}386
pub async fn traceroute(address: &str, options: &Options, cancel: &CancellationToken, progress: Arc<dyn Fn(Value) + Send + Sync>) -> Result<Value, DiagError> {387
let mut routes: Vec<Value> = Vec::new();388
for iteration in 0..options.repetitions {389
if cancel.is_cancelled() {390
return Err(DiagError::new("CANCELLED"));391
}392
let (exe, args) = traceroute_command(address, options)?;393
let hops = Arc::new(parking_lot::Mutex::new(Vec::<Value>::new()));394
let hops_cb = hops.clone();395
let progress_cb = progress.clone();396
let address_owned = address.to_string();397
let on_line = move |line: &str| {398
if let Some(hop) = parse_hop(line) {399
hops_cb.lock().push(hop.clone());400
progress_cb(merge(json!({ "kind": "trace-hop", "iteration": iteration, "address": address_owned }), hop));401
}402
};403
let timeout = (options.max_hops * options.timeout_ms.div_ceil(1000) * 1000 * 3 + 2000).min(180_000);404
match run_process(&exe, &args, timeout, cancel, 256 * 1024, Some(&on_line), false).await {405
Ok(result) => {406
let hops = hops.lock().clone();407
routes.push(json!({ "iteration": iteration, "hops": hops, "exitCode": result.code, "raw": String::from_utf8_lossy(&result.stdout).chars().take(32000).collect::<String>(), "reason": result.stderr.chars().take(1000).collect::<String>() }));408
}409
Err(error) => {410
if cancel.is_cancelled() {411
return Err(error);412
}413
let hops = hops.lock().clone();414
if !hops.is_empty() {415
routes.push(json!({ "iteration": iteration, "hops": hops, "incomplete": true, "reason": error.message }));416
}417
return Ok(json!({ "status": if error.code == "ENOENT" { "unavailable" } else { "error" }, "code": error.code, "reason": error.message, "address": address, "routes": routes }));418
}419
}420
}421
let reached = routes.iter().any(|r| r["hops"].as_array().map(|h| h.iter().any(|hop| hop["addresses"].as_array().map(|a| a.iter().any(|ip| same_ip(ip.as_str().unwrap_or(""), address))).unwrap_or(false))).unwrap_or(false));422
let all_failed = routes.iter().all(|r| r["exitCode"] != 0 && r["hops"].as_array().map(|h| h.is_empty()).unwrap_or(true));423
let addresses_of = |route: &Value| route["hops"].as_array().map(|h| h.iter().map(|hop| hop["addresses"].clone()).collect::<Vec<_>>()).unwrap_or_default();424
let changed = if routes.len() > 1 { json!(routes.iter().any(|r| addresses_of(r) != addresses_of(&routes[0]))) } else { Value::Null };425
Ok(json!({426
"status": if reached { "success" } else if all_failed { "error" } else { "warning" },427
"address": address,428
"routes": routes,429
"effectiveProbeTimeoutMs": if cfg!(target_os = "macos") { options.timeout_ms.div_ceil(1000) * 1000 } else { options.timeout_ms },430
"routeChanged": changed,431
"method": if cfg!(windows) { "ICMP tracert" } else { "System UDP traceroute" },432
"note": "Intermediate timeouts do not prove a broken route. Loss is the fraction of unanswered displayed probes, not end-to-end packet loss."433
}))434
}436
#[cfg(test)]437
mod tests {438
use super::*;440
#[test]441
fn hop_parsing() {442
let hop = parse_hop(" 3 10.0.0.1 1.234 ms * <1 ms").unwrap();443
assert_eq!(hop["hop"], 3);444
assert_eq!(hop["addresses"][0], "10.0.0.1");445
assert_eq!(hop["probes"].as_array().unwrap().len(), 3);446
assert_eq!(hop["probes"][2]["upperBoundMs"], 1.0);447
assert!(parse_hop("traceroute to x").is_none());448
}450
#[test]451
fn ping_reply_parsing() {452
let reply = parse_ping_reply("64 bytes from 1.1.1.1: icmp_seq=0 ttl=57 time=12.5 ms");453
assert_eq!(reply["rttMs"], 12.5);454
assert_eq!(reply["ttl"], 57);455
let windows = parse_ping_reply("Reply from 10.0.0.1: bytes=32 time<1ms TTL=128");456
assert_eq!(windows["received"], true);457
assert_eq!(windows["upperBoundMs"], 1.0);458
}460
#[tokio::test]461
async fn process_limits_and_cancel() {462
if cfg!(windows) {463
return;464
}465
let cancel = CancellationToken::new();466
let result = run_process("/bin/sh", &["-c".into(), "echo hi; echo err >&2".into()], 5000, &cancel, 1024, None, false).await.unwrap();467
assert_eq!(String::from_utf8_lossy(&result.stdout), "hi\n");468
assert_eq!(result.stderr, "err\n");469
let limited = run_process("/bin/sh", &["-c".into(), "yes".into()], 5000, &cancel, 1024, None, false).await;470
assert_eq!(limited.err().unwrap().code, "OUTPUT_LIMIT");471
let timed = run_process("/bin/sh", &["-c".into(), "sleep 5".into()], 200, &cancel, 1024, None, false).await;472
assert_eq!(timed.err().unwrap().code, "TIMEOUT");473
}474
}SHA-256
23353fe1be096dd1ff396be8a10b5126125647d1b107b88e7545692670a625e4CH-J Proprietary Software License 1.14 · Allikas on esitatud CH-J Proprietary Software License 1.14 all. Selle kättesaadavus ei muuda litsentsitingimusi ega anna täiendavaid õigusi.
Arhiiv SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f