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.

9,5 KB · 151 linesDownload file
1"use strict";
2const { EventEmitter } = require("node:events");
3const crypto = require("node:crypto");
4const net = require("node:net");
5const { normalizeOptions, DiagnosticError, checkAbort, errorResult, boundedSignal, now } = require("./common");
6const { DnsDiagnostics, reverseName } = require("./dnsDiagnostics");
7const { ping, traceroute } = require("./processTools");
8const { HttpDiagnostics } = require("./httpDiagnostics");
9const { runTls, runTcp, websocketProbe } = require("./socketDiagnostics");
10const { DiagnosticsHistory, exportReport, scrub } = require("./historyStore");
11function sameIp(a, b) {
12 if (net.isIP(a) !== net.isIP(b)) return false;
13 return a === b || net.isIP(a) === 6 && new URL(`http://[${a}]/`).hostname === new URL(`http://[${b}]/`).hostname;
15class DiagnosticsService extends EventEmitter {
16 constructor(options = {}) {
17 super();
18 this.dns = options.dns || new DnsDiagnostics();
19 this.http = options.http || new HttpDiagnostics(this.dns);
20 this.history = options.history || new DiagnosticsHistory(options.storageRoot);
21 this.jobs = new Map(); this.resolvers = new Set();
22 this.adapters = { ping, traceroute, tls: runTls, tcp: runTcp,
23 websocket: (target, address, settings, signal) => websocketProbe(target, address, settings, signal), ...options.adapters };
24 }
25 async resolve(source) {
26 if (this.resolvers.size >= 2) throw new DiagnosticError("DIAGNOSTICS_BUSY");
27 const options = normalizeOptions({ ...source, tools: ["dns"] });
28 const controller = new AbortController(), bounded = boundedSignal(controller.signal, 30000);
29 this.resolvers.add(controller);
30 try { return { target: options.target, addresses: await this.dns.resolve(options.target.host, options, bounded.signal) }; }
31 finally { bounded.close(); this.resolvers.delete(controller); }
32 }
33 start(source) {
34 if ([...this.jobs.values()].some((job) => job.report.status === "running")) throw new DiagnosticError("DIAGNOSTICS_BUSY");
35 const options = normalizeOptions(source), id = crypto.randomBytes(16).toString("hex"), controller = new AbortController();
36 const report = { id, createdAt: new Date().toISOString(), status: "running", options, results: {}, progress: [], addresses: [] };
37 const job = { report, controller, tools: new Map(), cancelledTools: new Set() };
38 this.jobs.set(id, job);
39 while (this.jobs.size > 5) this.jobs.delete(this.jobs.keys().next().value);
40 setImmediate(() => { job.promise = this._run(job).catch((error) => this._finish(job, errorResult(error))); });
41 return { id, report: scrub(report) };
42 }
43 cancel(id, tool) {
44 const job = this.jobs.get(String(id)); if (!job) throw new DiagnosticError("JOB_NOT_FOUND");
45 if (tool) {
46 if (!job.report.options.tools.includes(tool)) throw new DiagnosticError("INVALID_TOOLS");
47 job.cancelledTools.add(tool); job.tools.get(tool)?.abort();
48 } else job.controller.abort();
49 return { cancelling: true };
50 }
51 stopAll() {
52 for (const job of this.jobs.values()) if (job.report.status === "running") job.controller.abort();
53 for (const controller of this.resolvers) controller.abort();
54 }
55 get(id) { const job = this.jobs.get(id); return job ? scrub(job.report) : this.history.get(id); }
56 _emit(job, event) {
57 const value = { jobId: job.report.id, ...event };
58 if (event.kind !== "snapshot" && event.kind !== "complete") {
59 job.report.progress.push(scrub(event));
60 if (job.report.progress.length > 500) job.report.progress.shift();
61 }
62 this.emit("progress", value);
63 }
64 _finish(job, failure) {
65 if (job.report.status !== "running") return;
66 job.report.status = job.controller.signal.aborted ? "cancelled" : failure ? "failed" : "completed";
67 job.report.finishedAt = new Date().toISOString();
68 if (failure) job.report.error = failure;
69 if (job.controller.signal.reason?.code) job.report.error = errorResult(job.controller.signal.reason);
70 try { this.history.add(job.report); } catch (error) { job.report.historyError = error.code || "HISTORY_WRITE_FAILED"; }
71 this._emit(job, { kind: "complete", report: scrub(job.report) });
72 }
73 async _address(job, tool, item, signal, progress) {
74 const { report } = job, { options } = report;
75 try {
76 checkAbort(signal); let result;
77 if (tool === "http") result = await this.http.run(options.target, item.address, options, signal, progress);
78 else if (tool === "compression") result = await this.http.compression(options.target, item.address, options, signal, progress);
79 else if (tool === "tls" || tool === "websocket") result = await this.adapters[tool](options.target, item.address, options, signal, progress);
80 else result = await this.adapters[tool](item.address, options, signal, progress);
81 if (tool === "traceroute") {
82 const addresses = [...new Set(result.routes?.flatMap((route) => route.hops.flatMap((hop) => hop.addresses)) || [])].slice(0, options.maxHops);
83 result.reverseDns = {};
84 for (const address of addresses) {
85 checkAbort(signal);
86 try { const query = await this.dns.query(reverseName(address), "PTR", options, signal); result.reverseDns[address] = query.records.filter((r) => r.type === "PTR").map((r) => r.value); }
87 catch (error) { if (signal.aborted) throw error; result.reverseDns[address] = []; }
88 }
89 }
90 return { address: item.address, family: item.family, ...result };
91 } catch (error) {
92 const partial = { address: item.address, family: item.family, ...errorResult(error) };
93 if (tool === "ping") {
94 const latest = report.livePing?.[item.address];
95 if (latest) Object.assign(partial, { sent: latest.sent, received: latest.received, lossPercent: latest.lossPercent, statistics: latest.statistics });
96 partial.packets = report.progress.filter((e) => e.kind === "ping-packet" && e.address === item.address);
97 }
98 if (tool === "traceroute") partial.routes = [{ hops: report.progress.filter((e) => e.kind === "trace-hop" && e.address === item.address) }];
99 if (tool === "http") partial.comparisons = options.protocols.map((protocol) => ({ protocol, address: item.address, samples: report.progress.filter((e) => e.kind === "http-sample" && e.address === item.address && e.protocol === protocol) }));
100 return partial;
101 }
102 }
103 async _run(job) {
104 const { report, controller } = job, { options } = report, signal = controller.signal;
105 const deadline = setTimeout(() => controller.abort(new DiagnosticError("JOB_TIME_LIMIT")), options.continuous ? 30 * 60 * 1000 : 5 * 60 * 1000);
106 try {
107 checkAbort(signal); const started = now();
108 try { report.addresses = await this.dns.resolve(options.target.host, options, signal); }
109 catch (error) { if (signal.aborted) throw error; report.resolutionError = errorResult(error); }
110 report.resolvedAddresses = [...report.addresses]; report.resolutionMs = now() - started;
111 if (options.selectedIp) {
112 const selected = report.addresses.filter((item) => sameIp(item.address, options.selectedIp));
113 if (!selected.length) throw new DiagnosticError("SELECTED_IP_NOT_RESOLVED");
114 report.addresses = selected;
115 }
116 if (options.mode === "ipv4") report.addresses = report.addresses.filter((a) => a.family === 4);
117 if (options.mode === "ipv6") report.addresses = report.addresses.filter((a) => a.family === 6);
118 if (options.mode === "auto") report.addresses = [report.addresses.find((a) => a.family === 4) || report.addresses[0]].filter(Boolean);
119 if (options.mode === "both") report.addresses = [report.addresses.find((a) => a.family === 4), report.addresses.find((a) => a.family === 6)].filter(Boolean);
120 this._emit(job, { kind: "resolved", addresses: report.addresses, resolvedAddresses: report.resolvedAddresses, resolutionMs: report.resolutionMs, error: report.resolutionError });
121 // Two tools at a time; at most two IP families per tool. All workers settle before completion.
122 const queue = [...options.tools]; let cursor = 0;
123 await Promise.all(Array.from({ length: Math.min(2, queue.length) }, async () => {
124 while (cursor < queue.length && !signal.aborted) {
125 const tool = queue[cursor++], sub = new AbortController(); job.tools.set(tool, sub);
126 const abort = () => sub.abort(); signal.addEventListener("abort", abort, { once: true });
127 if (signal.aborted || job.cancelledTools.has(tool)) sub.abort();
128 this._emit(job, { kind: "tool-start", tool });
129 const progress = (event) => {
130 if (event.kind === "ping-packet") { report.livePing ||= {}; report.livePing[event.address] = event; }
131 this._emit(job, { tool, ...event });
132 };
133 try {
134 checkAbort(sub.signal);
135 if (tool === "dns") report.results.dns = await this.dns.run(options.target.host, options, sub.signal, progress);
136 else if (!report.addresses.length) report.results[tool] = [{ status: "not-tested", code: "NO_ADDRESS" }];
137 else report.results[tool] = await Promise.all(report.addresses.map((item) => this._address(job, tool, item, sub.signal, progress)));
138 } catch (error) { report.results[tool] = [{ ...errorResult(error) }]; }
139 finally {
140 this._emit(job, { kind: "tool-complete", tool, result: scrub(report.results[tool]) });
141 signal.removeEventListener("abort", abort); job.tools.delete(tool);
142 }
143 }
144 }));
145 this._finish(job);
146 } catch (error) { this._finish(job, errorResult(error)); }
147 finally { clearTimeout(deadline); }
148 }
149 export(id, format) { return exportReport(this.get(id), format); }
151module.exports = { DiagnosticsService, sameIp };

SHA-256: abcc87d72e54bbff5583c62d1ac0a754c0fe4b9f3f4137059bffd13f96c97779

Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0