CH-J Server Managerserver management over SSH
Menu
Published source

CH-J Server Manager

Browse directories and files for a specific application release.

Download source ZIP
CH-J Proprietary Software License 1.14

Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.

7,9 KB · 106 linesDownload file
1"use strict";
2const fs = require("node:fs");
3const path = require("node:path");
4const { atomicWriteJson } = require("../storage/atomicFile");
5const net = require("node:net");
6const { DiagnosticError, normalizeOptions, TOOLS } = require("./common");
7// History contains measured metadata, never response bodies, credentials or URL queries.
8function scrub(value, key = "", depth = 0) {
9 if (depth > 24) throw new DiagnosticError("IMPORT_INVALID");
10 if (/password|passphrase|privatekey|cookie|authoriz|authenticat|api.?key|secret|token/i.test(key)) return "[redacted]";
11 if (typeof value === "string") {
12 if (key === "input" || /^(?:https?|wss?):\/\//i.test(value)) {
13 try { const url = new URL(value.includes("://") ? value : `https://${value}`); url.username = ""; url.password = ""; url.search = ""; url.hash = ""; return url.href; } catch {}
14 }
15 if (key.toLowerCase() === "location") return value.split(/[?#]/)[0].slice(0, 16000);
16 return value.slice(0, 16000);
17 }
18 if (Array.isArray(value)) return value.slice(0, 500).map((v) => scrub(v, "", depth + 1));
19 if (value && typeof value === "object") {
20 const result = {};
21 for (const [k, v] of Object.entries(value)) {
22 if (["_body", "raw", "__proto__", "constructor", "prototype"].includes(k)) continue;
23 result[k] = scrub(v, k, depth + 1);
24 }
25 return result;
26 }
27 return typeof value === "number" && !Number.isFinite(value) ? null : value;
29function validateReport(report) {
30 const invalid = () => { throw new DiagnosticError("IMPORT_INVALID"); };
31 const object = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
32 const objects = (v) => Array.isArray(v) && v.length <= 500 && v.every(object);
33 if (!object(report) || !/^[a-f0-9]{32}$/.test(report.id) || !Number.isFinite(Date.parse(report.createdAt)) ||
34 !["completed", "cancelled", "failed"].includes(report.status) || !object(report.options) || !object(report.options.target) || !object(report.results)) invalid();
35 let options;
36 try { options = normalizeOptions({ ...report.options, target: report.options.target.url, ports: report.options.ports?.join(",") }); } catch { invalid(); }
37 if (options.target.host !== report.options.target.host || !objects(report.addresses) || report.addresses.some((a) => net.isIP(a.address) !== a.family)) invalid();
38 let nodes = 0;
39 const arrayKeys = new Set(["comparisons", "samples", "routes", "hops", "probes", "queries", "records", "packets", "ports", "redirects", "securityHeaders", "chain"]);
40 function walk(value, depth = 0) {
41 if (++nodes > 50000 || depth > 24) invalid();
42 if (Array.isArray(value)) { if (value.length > 500) invalid(); for (const child of value) walk(child, depth + 1); }
43 else if (object(value)) {
44 for (const [key, child] of Object.entries(value)) {
45 if (["__proto__", "constructor", "prototype"].includes(key)) invalid();
46 if (arrayKeys.has(key) && !(key === "ports" && Array.isArray(child) && child.every(Number.isInteger)) && !objects(child)) invalid();
47 if (["addresses", "errors"].includes(key) && !Array.isArray(child)) invalid();
48 walk(child, depth + 1);
49 }
50 if ("comparisons" in value && value.comparisons.some((g) => !objects(g.samples))) invalid();
51 if ("routes" in value && value.routes.some((r) => !objects(r.hops))) invalid();
52 if ("hop" in value && (!Array.isArray(value.addresses) || !value.addresses.every((a) => typeof a === "string") || !objects(value.probes))) invalid();
53 }
54 }
55 walk(report);
56 for (const [tool, result] of Object.entries(report.results)) {
57 if (!TOOLS.includes(tool)) invalid();
58 if (tool === "dns" ? !object(result) && !objects(result) : !objects(result)) invalid();
59 if (tool === "tcp" && result.some((entry) => entry.ports !== undefined && !objects(entry.ports))) invalid();
60 if (["tls", "compression"].includes(tool) && result.some((entry) => entry.results !== undefined && !objects(entry.results))) invalid();
61 }
62 if (report.progress !== undefined && !objects(report.progress)) invalid();
63 return scrub({ ...report, options });
65function rows(report) {
66 const result = [];
67 for (const [tool, entries] of Object.entries(report.results || {})) for (const entry of Array.isArray(entries) ? entries : [entries]) {
68 if (tool === "ping") for (const packet of entry.packets || []) result.push({ tool, address: entry.address, sequence: packet.sequence, status: packet.status, rttMs: packet.rttMs });
69 else if (tool === "http") for (const group of entry.comparisons || []) for (const sample of group.samples || []) result.push({ tool, address: group.address, protocol: group.protocol, sequence: sample.iteration, status: sample.status, httpStatus: sample.statusCode, dnsMs: sample.timings?.dnsMs, tcpMs: sample.timings?.tcpMs, tlsMs: sample.timings?.tlsMs, ttfbMs: sample.timings?.ttfbMs, totalMs: sample.timings?.totalMs, bytes: sample.downloadedBytes });
70 else if (tool === "tcp") for (const port of entry.ports || []) result.push({ tool, address: port.address, port: port.port, status: port.status, tcpMs: port.connectionMs });
71 else result.push({ tool, address: entry.address, status: entry.status, detail: JSON.stringify(entry) });
72 }
73 return result;
75function exportReport(report, format) {
76 const safe = scrub(report);
77 if (format === "json") return JSON.stringify({ schema: 1, report: safe }, null, 2) + "\n";
78 if (format === "txt") return `CH-J Server Diagnostics\n${safe.createdAt}\n${safe.options?.target?.host || ""}\n\n${JSON.stringify(safe, null, 2)}\n`;
79 if (format !== "csv") throw new DiagnosticError("INVALID_EXPORT_FORMAT");
80 const table = rows(safe), keys = [...new Set(table.flatMap((row) => Object.keys(row)))];
81 const cell = (value) => { let text = value === null || value === undefined ? "" : String(value); if (/^\s*[=+@-]|^[\t\r\n]/.test(text)) text = "'" + text; return '"' + text.replace(/"/g, '""') + '"'; };
82 return [keys.map(cell).join(","), ...table.map((row) => keys.map((key) => cell(row[key])).join(","))].join("\r\n") + "\r\n";
84class DiagnosticsHistory {
85 constructor(root) { this.file = root ? path.join(root, "diagnostics", "history.json") : null; this.items = []; this.loaded = false; }
86 load() {
87 if (this.loaded) return;
88 if (!this.file || !fs.existsSync(this.file)) { this.loaded = true; return; }
89 const attrs = fs.lstatSync(this.file); if (!attrs.isFile() || attrs.isSymbolicLink() || attrs.size > 12 * 1024 * 1024) throw new DiagnosticError("HISTORY_INVALID");
90 const document = JSON.parse(fs.readFileSync(this.file, "utf8")); if (document.schema !== 1 || !Array.isArray(document.items)) throw new DiagnosticError("HISTORY_INVALID");
91 this.items = document.items.slice(0, 30).map(validateReport); this.loaded = true;
92 }
93 add(report) { this.load(); this.items = this.items.filter((item) => item.id !== report.id); this.items.unshift(scrub(report)); this.items = this.items.slice(0, 30); while (Buffer.byteLength(JSON.stringify(this.items)) > 10 * 1024 * 1024) this.items.pop(); this.persist(); }
94 persist() { if (this.file) atomicWriteJson(this.file, { schema: 1, items: this.items }); }
95 list() { this.load(); return this.items.map((item) => ({ id: item.id, createdAt: item.createdAt, target: item.options.target, mode: item.options.mode, tools: item.options.tools, status: item.status })); }
96 get(id) { this.load(); const report = this.items.find((item) => item.id === id); if (!report) throw new DiagnosticError("HISTORY_NOT_FOUND"); return scrub(report); }
97 remove(id) { this.load(); this.items = id ? this.items.filter((item) => item.id !== id) : []; this.persist(); return this.list(); }
98 import(text) {
99 if (typeof text !== "string" || Buffer.byteLength(text) > 2 * 1024 * 1024) throw new DiagnosticError("IMPORT_LIMIT");
100 let value;
101 try { value = JSON.parse(text); } catch { throw new DiagnosticError("IMPORT_INVALID"); }
102 if (value?.schema !== 1) throw new DiagnosticError("IMPORT_INVALID");
103 const report = validateReport(value.report); this.add(report); return report.id;
104 }
106module.exports = { DiagnosticsHistory, scrub, exportReport, rows, validateReport };

SHA-256: bdd88081167f6505851c95eb939ba95089da0acd3cbc5d6981df5608a8addc62

Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0