CH-J Toolsquản lý máy chủ qua SSH
Menu
Mã nguồn đã công bố

CH-J Tools

Duyệt thư mục và tệp của một bản phát hành ứng dụng cụ thể.

Tải mã nguồn dạng ZIP
CH-J / Server Manager 1704 tệp
0.1.2-beta Ngày thêm: · Ngày dựng:
history.rs Ngày thêm: Chưa ghi nhận ngày 18,2 KB · 417 dòng
Mã Tải tệp xuống
1//! Diagnostics history and export (port of `historyStore.js`): schema 1 file
2//! `diagnostics/history.json`, max 30 reports / 10 MiB, scrubbed of bodies, credentials,
3//! URL queries and secrets; strict import validation; JSON/CSV/TXT export with CSV formula guard.
5use crate::core::diagnostics::common::{normalize_options, DiagError, TOOLS};
6use crate::core::storage::atomic_write_json;
7use once_cell::sync::Lazy;
8use parking_lot::Mutex;
9use regex::Regex;
10use serde_json::{json, Map, Value};
11use std::net::IpAddr;
12use std::path::{Path, PathBuf};
14static SECRET_KEY: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)password|passphrase|privatekey|cookie|authoriz|authenticat|api.?key|secret|token").unwrap());
15static URLISH: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^(?:https?|wss?)://").unwrap());
17fn truncate(value: &str, max: usize) -> String {
18 value.chars().take(max).collect()
21pub fn scrub(value: &Value, key: &str, depth: usize) -> Result<Value, DiagError> {
22 if depth > 24 {
23 return Err(DiagError::new("IMPORT_INVALID"));
24 }
25 if SECRET_KEY.is_match(key) {
26 return Ok(json!("[redacted]"));
27 }
28 Ok(match value {
29 Value::String(text) => {
30 if key == "input" || URLISH.is_match(text) {
31 let candidate = if text.contains("://") { text.clone() } else { format!("https://{text}") };
32 if let Ok(mut url) = url::Url::parse(&candidate) {
33 let _ = url.set_username("");
34 let _ = url.set_password(None);
35 url.set_query(None);
36 url.set_fragment(None);
37 return Ok(json!(url.to_string()));
38 }
39 }
40 if key.eq_ignore_ascii_case("location") {
41 return Ok(json!(truncate(text.split(['?', '#']).next().unwrap_or(""), 16000)));
42 }
43 json!(truncate(text, 16000))
44 }
45 Value::Array(items) => Value::Array(items.iter().take(500).map(|v| scrub(v, "", depth + 1)).collect::<Result<_, _>>()?),
46 Value::Object(map) => {
47 let mut result = Map::new();
48 for (k, v) in map {
49 if ["_body", "raw", "__proto__", "constructor", "prototype"].contains(&k.as_str()) {
50 continue;
51 }
52 result.insert(k.clone(), scrub(v, k, depth + 1)?);
53 }
54 Value::Object(result)
55 }
56 Value::Number(n) if n.as_f64().map(|f| !f.is_finite()).unwrap_or(false) => Value::Null,
57 other => other.clone(),
58 })
61pub fn scrubbed(value: &Value) -> Value {
62 scrub(value, "", 0).unwrap_or(Value::Null)
65fn invalid() -> DiagError {
66 DiagError::new("IMPORT_INVALID")
69fn is_object(v: &Value) -> bool {
70 v.is_object()
73fn objects(v: &Value) -> bool {
74 v.as_array().map(|a| a.len() <= 500 && a.iter().all(is_object)).unwrap_or(false)
77pub fn validate_report(report: &Value) -> Result<Value, DiagError> {
78 let id_ok = report["id"].as_str().map(|s| s.len() == 32 && s.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))).unwrap_or(false);
79 let created_ok = report["createdAt"].as_str().map(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok()).unwrap_or(false);
80 if !is_object(report) || !id_ok || !created_ok || !["completed", "cancelled", "failed"].contains(&report["status"].as_str().unwrap_or("")) || !is_object(&report["options"]) || !is_object(&report["options"]["target"]) || !is_object(&report["results"]) {
81 return Err(invalid());
82 }
83 let mut source = report["options"].clone();
84 source["target"] = report["options"]["target"]["url"].clone();
85 if let Some(ports) = report["options"]["ports"].as_array() {
86 source["ports"] = json!(ports.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(","));
87 }
88 let options = normalize_options(&source).map_err(|_| invalid())?;
89 let addresses_ok = report["addresses"].as_array().map(|list| {
90 list.len() <= 500
91 && list.iter().all(|a| {
92 let family = a["family"].as_u64();
93 match a["address"].as_str().and_then(|s| s.parse::<IpAddr>().ok()) {
94 Some(IpAddr::V4(_)) => family == Some(4),
95 Some(IpAddr::V6(_)) => family == Some(6),
96 None => false,
97 }
98 })
99 });
100 if options.target.host != report["options"]["target"]["host"].as_str().unwrap_or("") || addresses_ok != Some(true) {
101 return Err(invalid());
102 }
103 let array_keys = ["comparisons", "samples", "routes", "hops", "probes", "queries", "records", "packets", "ports", "redirects", "securityHeaders", "chain"];
104 let mut nodes = 0usize;
105 fn walk(value: &Value, depth: usize, nodes: &mut usize, array_keys: &[&str]) -> Result<(), DiagError> {
106 *nodes += 1;
107 if *nodes > 50_000 || depth > 24 {
108 return Err(invalid());
109 }
110 match value {
111 Value::Array(items) => {
112 if items.len() > 500 {
113 return Err(invalid());
114 }
115 for child in items {
116 walk(child, depth + 1, nodes, array_keys)?;
117 }
118 }
119 Value::Object(map) => {
120 for (key, child) in map {
121 if ["__proto__", "constructor", "prototype"].contains(&key.as_str()) {
122 return Err(invalid());
123 }
124 let ports_ok = key == "ports" && child.as_array().map(|a| a.iter().all(|p| p.as_i64().is_some())).unwrap_or(false);
125 if array_keys.contains(&key.as_str()) && !ports_ok && !objects(child) {
126 return Err(invalid());
127 }
128 if (key == "addresses" || key == "errors") && !child.is_array() {
129 return Err(invalid());
130 }
131 walk(child, depth + 1, nodes, array_keys)?;
132 }
133 if let Some(groups) = map.get("comparisons").and_then(Value::as_array) {
134 if groups.iter().any(|g| !objects(&g["samples"])) {
135 return Err(invalid());
136 }
137 }
138 if let Some(routes) = map.get("routes").and_then(Value::as_array) {
139 if routes.iter().any(|r| !objects(&r["hops"])) {
140 return Err(invalid());
141 }
142 }
143 if map.contains_key("hop") {
144 let addresses_ok = map.get("addresses").and_then(Value::as_array).map(|a| a.iter().all(Value::is_string)).unwrap_or(false);
145 if !addresses_ok || !objects(map.get("probes").unwrap_or(&Value::Null)) {
146 return Err(invalid());
147 }
148 }
149 }
150 _ => {}
151 }
152 Ok(())
153 }
154 walk(report, 0, &mut nodes, &array_keys)?;
155 for (tool, result) in report["results"].as_object().into_iter().flatten() {
156 if !TOOLS.contains(&tool.as_str()) {
157 return Err(invalid());
158 }
159 let ok = if tool == "dns" { is_object(result) || objects(result) } else { objects(result) };
160 if !ok {
161 return Err(invalid());
162 }
163 let entries = result.as_array().cloned().unwrap_or_default();
164 if tool == "tcp" && entries.iter().any(|e| !e["ports"].is_null() && !objects(&e["ports"])) {
165 return Err(invalid());
166 }
167 if (tool == "tls" || tool == "compression") && entries.iter().any(|e| !e["results"].is_null() && !objects(&e["results"])) {
168 return Err(invalid());
169 }
170 }
171 if !report["progress"].is_null() && !objects(&report["progress"]) {
172 return Err(invalid());
173 }
174 let mut copy = report.clone();
175 copy["options"] = options.json;
176 scrub(&copy, "", 0)
179fn rows(report: &Value) -> Vec<Map<String, Value>> {
180 let mut result = Vec::new();
181 for (tool, entries) in report["results"].as_object().into_iter().flatten() {
182 let list = if entries.is_array() { entries.as_array().cloned().unwrap_or_default() } else { vec![entries.clone()] };
183 for entry in list {
184 let mut push = |pairs: Vec<(&str, Value)>| {
185 let mut row = Map::new();
186 for (k, v) in pairs {
187 row.insert(k.to_string(), v);
188 }
189 result.push(row);
190 };
191 match tool.as_str() {
192 "ping" => {
193 for packet in entry["packets"].as_array().cloned().unwrap_or_default() {
194 push(vec![("tool", json!(tool)), ("address", entry["address"].clone()), ("sequence", packet["sequence"].clone()), ("status", packet["status"].clone()), ("rttMs", packet["rttMs"].clone())]);
195 }
196 }
197 "http" => {
198 for group in entry["comparisons"].as_array().cloned().unwrap_or_default() {
199 for sample in group["samples"].as_array().cloned().unwrap_or_default() {
200 let t = &sample["timings"];
201 let c = &sample["capability"];
202 push(vec![
203 ("tool", json!(tool)), ("address", group["address"].clone()), ("protocol", group["protocol"].clone()), ("sequence", sample["iteration"].clone()),
204 ("status", sample["status"].clone()), ("httpStatus", sample["statusCode"].clone()), ("dnsMs", t["dnsMs"].clone()), ("tcpMs", t["tcpMs"].clone()),
205 ("tlsMs", t["tlsMs"].clone()), ("quicHandshakeMs", t["quicHandshakeMs"].clone()), ("code", sample["code"].clone()), ("clientVersion", c["version"].clone()),
206 ("http2", c["http2"].clone()), ("http3", c["http3"].clone()), ("tlsBackend", c["tlsBackend"].clone()), ("tlsVerified", sample["tls"]["verified"].clone()),
207 ("ttfbMs", t["ttfbMs"].clone()), ("totalMs", t["totalMs"].clone()), ("bytes", sample["downloadedBytes"].clone()),
208 ]);
209 }
210 }
211 }
212 "tcp" => {
213 for port in entry["ports"].as_array().cloned().unwrap_or_default() {
214 push(vec![("tool", json!(tool)), ("address", port["address"].clone()), ("port", port["port"].clone()), ("status", port["status"].clone()), ("tcpMs", port["connectionMs"].clone())]);
215 }
216 }
217 _ => push(vec![("tool", json!(tool)), ("address", entry["address"].clone()), ("status", entry["status"].clone()), ("detail", json!(entry.to_string()))]),
218 }
219 }
220 }
221 result
224fn js_string(value: &Value) -> String {
225 match value {
226 Value::Null => String::new(),
227 Value::String(s) => s.clone(),
228 other => other.to_string(),
229 }
232pub fn export_report(report: &Value, format: &str) -> Result<String, DiagError> {
233 let safe = scrubbed(report);
234 match format {
235 "json" => Ok(serde_json::to_string_pretty(&json!({ "schema": 1, "report": safe })).unwrap_or_default() + "\n"),
236 "txt" => Ok(format!(
237 "CH-J Server Diagnostics\n{}\n{}\n\n{}\n",
238 js_string(&safe["createdAt"]),
239 safe["options"]["target"]["host"].as_str().unwrap_or(""),
240 serde_json::to_string_pretty(&safe).unwrap_or_default()
241 )),
242 "csv" => {
243 let table = rows(&safe);
244 let mut keys: Vec<String> = Vec::new();
245 for row in &table {
246 for key in row.keys() {
247 if !keys.contains(key) {
248 keys.push(key.clone());
249 }
250 }
251 }
252 let formula = Regex::new(r"^\s*[=+@-]|^[\t\r\n]").unwrap();
253 let cell = |value: &Value| {
254 let mut text = js_string(value);
255 if formula.is_match(&text) {
256 text = format!("'{text}");
257 }
258 format!("\"{}\"", text.replace('"', "\"\""))
259 };
260 let mut lines = vec![keys.iter().map(|k| cell(&json!(k))).collect::<Vec<_>>().join(",")];
261 for row in &table {
262 lines.push(keys.iter().map(|k| cell(row.get(k).unwrap_or(&Value::Null))).collect::<Vec<_>>().join(","));
263 }
264 Ok(lines.join("\r\n") + "\r\n")
265 }
266 _ => Err(DiagError::new("INVALID_EXPORT_FORMAT")),
267 }
270pub struct DiagnosticsHistory {
271 file: Option<PathBuf>,
272 items: Mutex<Option<Vec<Value>>>,
275impl DiagnosticsHistory {
276 pub fn new(root: Option<&Path>) -> Self {
277 Self { file: root.map(|r| r.join("diagnostics").join("history.json")), items: Mutex::new(None) }
278 }
280 fn load(&self) -> Result<(), DiagError> {
281 let mut guard = self.items.lock();
282 if guard.is_some() {
283 return Ok(());
284 }
285 let Some(file) = &self.file else {
286 *guard = Some(Vec::new());
287 return Ok(());
288 };
289 let meta = match std::fs::symlink_metadata(file) {
290 Ok(meta) => meta,
291 Err(_) => {
292 *guard = Some(Vec::new());
293 return Ok(());
294 }
295 };
296 if !meta.is_file() || meta.len() > 12 * 1024 * 1024 {
297 return Err(DiagError::new("HISTORY_INVALID"));
298 }
299 let document: Value = serde_json::from_str(&std::fs::read_to_string(file).map_err(|_| DiagError::new("HISTORY_INVALID"))?).map_err(|_| DiagError::new("HISTORY_INVALID"))?;
300 if document["schema"] != 1 || !document["items"].is_array() {
301 return Err(DiagError::new("HISTORY_INVALID"));
302 }
303 let items = document["items"].as_array().cloned().unwrap_or_default().iter().take(30).map(validate_report).collect::<Result<Vec<_>, _>>()?;
304 *guard = Some(items);
305 Ok(())
306 }
308 fn persist(&self, items: &[Value]) -> Result<(), DiagError> {
309 if let Some(file) = &self.file {
310 atomic_write_json(file, &json!({ "schema": 1, "items": items })).map_err(|e| DiagError::with_message("HISTORY_WRITE_FAILED", e.message))?;
311 }
312 Ok(())
313 }
315 pub fn add(&self, report: &Value) -> Result<(), DiagError> {
316 self.load()?;
317 let mut guard = self.items.lock();
318 let items = guard.as_mut().expect("loaded");
319 items.retain(|i| i["id"] != report["id"]);
320 items.insert(0, scrubbed(report));
321 items.truncate(30);
322 while serde_json::to_vec(&*items).map(|v| v.len()).unwrap_or(0) > 10 * 1024 * 1024 {
323 items.pop();
324 }
325 let snapshot = items.clone();
326 drop(guard);
327 self.persist(&snapshot)
328 }
330 pub fn list(&self) -> Result<Value, DiagError> {
331 self.load()?;
332 let guard = self.items.lock();
333 Ok(Value::Array(
334 guard
335 .as_ref()
336 .expect("loaded")
337 .iter()
338 .map(|i| json!({ "id": i["id"], "createdAt": i["createdAt"], "target": i["options"]["target"], "mode": i["options"]["mode"], "tools": i["options"]["tools"], "status": i["status"] }))
339 .collect(),
340 ))
341 }
343 pub fn get(&self, id: &str) -> Result<Value, DiagError> {
344 self.load()?;
345 let guard = self.items.lock();
346 guard.as_ref().expect("loaded").iter().find(|i| i["id"] == id).map(scrubbed).ok_or_else(|| DiagError::new("HISTORY_NOT_FOUND"))
347 }
349 pub fn remove(&self, id: Option<&str>) -> Result<Value, DiagError> {
350 self.load()?;
351 let snapshot = {
352 let mut guard = self.items.lock();
353 let items = guard.as_mut().expect("loaded");
354 match id {
355 Some(id) if !id.is_empty() => items.retain(|i| i["id"] != id),
356 _ => items.clear(),
357 }
358 items.clone()
359 };
360 self.persist(&snapshot)?;
361 self.list()
362 }
364 pub fn import(&self, text: &str) -> Result<String, DiagError> {
365 if text.len() > 2 * 1024 * 1024 {
366 return Err(DiagError::new("IMPORT_LIMIT"));
367 }
368 let value: Value = serde_json::from_str(text).map_err(|_| invalid())?;
369 if value["schema"] != 1 {
370 return Err(invalid());
371 }
372 let report = validate_report(&value["report"])?;
373 self.add(&report)?;
374 Ok(report["id"].as_str().unwrap_or("").to_string())
375 }
378#[cfg(test)]
379mod tests {
380 use super::*;
382 fn report() -> Value {
383 let options = normalize_options(&json!({"target": "https://user:pw@example.com/path?token=x"})).map(|o| o.json);
384 let options = options.unwrap_or_else(|_| normalize_options(&json!({"target": "https://example.com/path?token=x"})).unwrap().json);
385 json!({
386 "id": "a".repeat(32), "createdAt": "2026-10-24T10:00:00.000Z", "status": "completed", "options": options,
387 "addresses": [{"address": "93.184.216.34", "family": 4}],
388 "results": {"tcp": [{"address": "93.184.216.34", "ports": [{"address": "93.184.216.34", "port": 443, "status": "connected", "connectionMs": 1.5}]}], "http": [{"comparisons": [{"protocol": "3", "address": "93.184.216.34", "samples": [{"iteration": 0, "status": "success", "_body": "secret", "headers": {"authorization": "x"}}]}]}]},
389 "progress": []
390 })
391 }
393 #[test]
394 fn scrub_and_round_trip() {
395 let safe = scrubbed(&report());
396 assert!(!safe.to_string().contains("token=x"));
397 assert!(!safe.to_string().contains("\"_body\""));
398 assert_eq!(safe["results"]["http"][0]["comparisons"][0]["samples"][0]["headers"]["authorization"], "[redacted]");
399 let dir = tempfile::tempdir().unwrap();
400 let history = DiagnosticsHistory::new(Some(dir.path()));
401 let exported = export_report(&safe, "json").unwrap();
402 let id = history.import(&exported).unwrap();
403 assert_eq!(id, "a".repeat(32));
404 assert_eq!(history.list().unwrap().as_array().unwrap().len(), 1);
405 let reloaded = DiagnosticsHistory::new(Some(dir.path()));
406 assert_eq!(reloaded.get(&id).unwrap()["status"], "completed");
407 assert!(history.import(r#"{"schema":1,"report":{"id":"x"}}"#).is_err());
408 }
410 #[test]
411 fn csv_formula_guard() {
412 let mut r = report();
413 r["results"] = json!({"tcp": [{"ports": [{"address": "=cmd()", "port": 1, "status": "x"}]}]});
414 let csv = export_report(&r, "csv").unwrap();
415 assert!(csv.contains("\"'=cmd()\""));
416 }
SHA-2569bdee129aa42adcc8b87d6ad60cfb45630c3fbd6a206e4b94adb146ec93ffff1

CH-J Proprietary Software License 1.14 · Mã nguồn được cung cấp theo CH-J Proprietary Software License 1.14. Việc công khai mã nguồn không thay đổi các điều khoản giấy phép và không cấp thêm quyền nào.

SHA-256 của tệp nénac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Tệp