CH-J Toolspamamahala ng server sa pamamagitan ng SSH
Menu
Nailathalang source

CH-J Tools

I-browse ang mga direktoryo at file ng isang partikular na release ng application.

I-download ang source ZIP
CH-J / Server Manager 1704 file
0.1.2-beta Idinagdag: · Binuo:
common.rs Idinagdag: Hindi naitala ang petsa 17.6 KB · 417 linya
Code I-download ang file
1//! Shared diagnostics helpers (port of `diagnostics/common.js`): target/option
2//! normalization, statistics, error results and bounded concurrency.
4use crate::core::error::CoreError;
5use once_cell::sync::Lazy;
6use regex::Regex;
7use serde_json::{json, Map, Value};
8use std::future::Future;
9use std::net::IpAddr;
10use std::time::Instant;
12pub const PING_INTERVAL_MIN_MS: u64 = 50;
13pub const PING_DURATION_LIMIT_MS: u64 = 30 * 60 * 1000;
14pub const TOOLS: [&str; 8] = ["dns", "ping", "traceroute", "http", "compression", "tls", "tcp", "websocket"];
16static LABEL: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$").unwrap());
17static START: Lazy<Instant> = Lazy::new(Instant::now);
19pub fn now_ms() -> f64 {
20 START.elapsed().as_secs_f64() * 1000.0
23pub 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 })
45#[derive(Debug, Clone)]
46pub struct DiagError {
47 pub code: String,
48 pub message: String,
51impl 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 }
77impl From<DiagError> for CoreError {
78 fn from(error: DiagError) -> Self {
79 CoreError::new(error.code.clone(), error.message.chars().take(400).collect::<String>())
80 }
83pub 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 })
91/// Host normalization: IP literals verbatim, names via IDNA to lower-case ASCII.
92pub 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)
105#[derive(Debug, Clone, Default)]
106pub struct Target {
107 pub input: String,
108 pub host: String,
109 pub url: String,
110 pub port: u16,
111 pub kind: String,
114impl 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 }
120fn ip_family(value: &str) -> u8 {
121 match value.parse::<IpAddr>() {
122 Ok(IpAddr::V4(_)) => 4,
123 Ok(IpAddr::V6(_)) => 6,
124 Err(_) => 0,
125 }
128pub 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 })
154fn 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))
171#[derive(Debug, Clone, Default)]
172pub 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,
197fn 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 None
206 }
207 } else {
208 None
209 };
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))
220fn 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())
224pub 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)
334pub 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_schemes
346 });
347 if let Value::Object(extra) = extra {
348 map.extend(extra);
349 }
350 Value::Object(map)
353pub 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 })
369/// Runs `f` over `items` with at most `limit` in flight, preserving order.
370pub async fn map_limit<T, R, F, Fut>(items: Vec<T>, limit: usize, f: F) -> Result<Vec<R>, DiagError>
371where
372 F: Fn(T) -> Fut,
373 Fut: Future<Output = Result<R, DiagError>>,
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()
380#[cfg(test)]
381mod 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 }
SHA-256298bad1cf116f7ea1452a8db8afe11db88b7783f5e97d95e4c7b6e4024ba30f1

CH-J Proprietary Software License 1.14 · Ibinibigay ang source sa ilalim ng CH-J Proprietary Software License 1.14. Hindi binabago ng pagiging available nito ang mga tuntunin ng lisensya at hindi ito nagbibigay ng karagdagang karapatan.

SHA-256 ng archiveac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Mga file