"use strict"; const { execFile } = require("node:child_process"); const net = require("node:net"); const { performance } = require("node:perf_hooks"); const PING_COUNT = 3; // Reply lines only (they carry TTL/hlim); statistics lines such as "Minimum = 5ms" are ignored. const REPLY_LINE = /\b(?:ttl|hlim)\s*=\s*\d+/i; const REPLY_TIME = /([=<])\s*(\d+(?:[.,]\d+)?)\s*ms\b/i; function median(values) { const sorted = [...values].sort((left, right) => left - right); if (!sorted.length) return null; const middle = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; } // Parses per-reply times from macOS, Linux (iputils) and localized Windows ping output. // "<1ms" (Windows) is an upper bound and is reported as 1 ms with lessThan=true. function parsePingReplies(stdout) { const replies = []; for (const line of String(stdout || "").split(/\r?\n/)) { if (!REPLY_LINE.test(line)) continue; const match = line.match(REPLY_TIME); if (!match) continue; const value = Number(match[2].replace(",", ".")); if (Number.isFinite(value) && value >= 0) replies.push({ ms: match[1] === "<" ? Math.max(value, 1) : value, lessThan: match[1] === "<" }); } return replies; } // First exact reply time (kept for callers that need a single value). function parsePing(stdout) { const reply = parsePingReplies(stdout).find((entry) => !entry.lessThan && entry.ms > 0); return reply ? reply.ms : null; } function classifyFailure(error, output) { const text = `${output || ""}\n${error?.message || ""}`; if (error?.code === "ENOENT") return { status: "unsupported", reason: "The ping utility is not available on this computer." }; if (/Operation not permitted|Permission denied|Access is denied|not allowed/i.test(text)) return { status: "denied", reason: "The operating system denied sending ICMP (for example the macOS Local Network permission or a security policy)." }; if (/unknown host|cannot resolve|could not find host|Name or service not known/i.test(text)) return { status: "error", reason: "The address could not be resolved for ping." }; if (/No route to host|Network is unreachable|Destination (host|net) unreachable|unreachable|nedosažiteln|nicht erreichbar/i.test(text)) return { status: "unreachable", reason: "The target is unreachable for ICMP (no route or an unreachable reply)." }; return { status: "timeout", reason: "No ICMP echo reply before the timeout; the server or a firewall may block ICMP." }; } function pingCommand(address, platform) { const ipv6 = net.isIP(address) === 6; if (platform === "win32") return { executable: "ping.exe", args: [ipv6 ? "-6" : "-4", "-n", String(PING_COUNT), "-w", "2000", address] }; if (platform === "darwin") return { executable: ipv6 ? "/sbin/ping6" : "/sbin/ping", args: ["-n", "-c", String(PING_COUNT), ...(ipv6 ? [] : ["-W", "2000"]), address] }; return { executable: "ping", args: [ipv6 ? "-6" : "-4", "-n", "-c", String(PING_COUNT), "-W", "2", address] }; } // Local ICMP echo from the CH-J computer to the address actually used by the SSH connection (IPv4 or IPv6). // Arguments are passed separately without a shell; no root or sudo is needed on macOS, Linux or Windows. function measurePing(address, { signal, platform = process.platform, run = execFile } = {}) { const family = net.isIP(address); if (!family) return Promise.resolve({ ms: null, status: "error", reason: "No IP address is known for this session.", family: null, sent: 0, received: 0, loss: null }); if (!["win32", "darwin", "linux"].includes(platform)) return Promise.resolve({ ms: null, status: "unsupported", reason: `Ping is not supported on ${platform}.`, family, sent: 0, received: 0, loss: null }); const { executable, args } = pingCommand(address, platform); return new Promise((resolve) => { run(executable, args, { shell: false, windowsHide: true, timeout: 9000, maxBuffer: 64 * 1024, signal, env: { ...process.env, LC_ALL: "C", LANG: "C" } }, (error, stdout, stderr) => { const replies = parsePingReplies(stdout); const received = Math.min(replies.length, PING_COUNT); if (received) { resolve({ ms: median(replies.map((reply) => reply.ms)), lessThan: replies.every((reply) => reply.lessThan), status: "ok", reason: "", family, sent: PING_COUNT, received, loss: (PING_COUNT - received) / PING_COUNT }); return; } if (error?.name === "AbortError") { resolve({ ms: null, status: "aborted", reason: "", family, sent: 0, received: 0, loss: null }); return; } resolve({ ms: null, ...classifyFailure(error, `${stdout || ""}\n${stderr || ""}`), family, sent: PING_COUNT, received: 0, loss: error?.killed ? null : 1 }); }); }); } class LatencyMonitor { constructor({ ping = measurePing, now = () => performance.now(), intervalMs = 10000, sshSamples = 3 } = {}) { this.ping = ping; this.now = now; this.intervalMs = intervalMs; this.sshSamples = sshSamples; } // request() performs one SSH round trip. It may resolve with { ms, method }; otherwise the elapsed // monotonic time around the call is used. Samples run sequentially so channels are never flooded. async _ssh(request) { const samples = []; let method = null; let error = null; for (let index = 0; index < this.sshSamples; index += 1) { const started = this.now(); try { const result = await request(); const ms = typeof result?.ms === "number" ? result.ms : this.now() - started; method = result?.method || method || "command"; if (Number.isFinite(ms) && ms > 0) samples.push(ms); } catch (cause) { error = cause; break; } } return { ms: median(samples), method, samples: samples.length, error: samples.length ? null : (error?.message || "SSH round trip failed") }; } start(record, request, publish) { const controller = new AbortController(); let timer; let stopped = false; const sample = async () => { // ICMP and SSH are independent: a blocked ping never hides the SSH measurement and vice versa. const [ping, ssh] = await Promise.all([ Promise.resolve().then(() => this.ping(record.address, { signal: controller.signal })).catch((error) => ({ ms: null, status: "error", reason: error?.message || String(error) })), this._ssh(request) ]); if (stopped) return; const pingResult = typeof ping === "number" ? { ms: ping, status: "ok" } : (ping || { ms: null, status: "timeout" }); const valid = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; publish({ pingMs: valid(pingResult.ms), pingStatus: pingResult.status || "error", pingReason: pingResult.reason || "", pingLoss: typeof pingResult.loss === "number" ? pingResult.loss : null, pingFamily: pingResult.family || net.isIP(record.address) || null, pingLessThan: pingResult.lessThan === true, sshRttMs: valid(ssh.ms), sshRttMethod: ssh.method, sshRttSamples: ssh.samples, sshRttError: ssh.error, measuredAt: new Date().toISOString() }); // Schedule only after both finish: no overlapping probes or intervals. timer = setTimeout(sample, this.intervalMs); timer.unref?.(); }; void sample(); return () => { stopped = true; clearTimeout(timer); controller.abort(); }; } } module.exports = { LatencyMonitor, PING_COUNT, measurePing, median, parsePing, parsePingReplies, pingCommand };