CH-J Server Managerspráva serverů přes SSH
Menu
Vydané zdroje

CH-J Server Manager

Procházejte adresáře a soubory konkrétního vydání aplikace.

Stáhnout zdroje ZIP
CH-J / Server Manager 368 souborů
core-20261017T201717Z-057348c4a70f Přidáno: Datum není evidováno · Sestaveno:
sessionManager.js Přidáno: Datum není evidováno 58,8 KB · 1143 řádků
Kód Stáhnout soubor
1"use strict";
3const crypto = require("node:crypto");
4const fs = require("node:fs");
5const dns = require("node:dns").promises;
6const net = require("node:net");
7const { EventEmitter } = require("node:events");
8const { StringDecoder } = require("node:string_decoder");
9const { performance } = require("node:perf_hooks");
10const { Client, utils: sshUtils } = require("ssh2");
11const path = require("node:path");
12const { LatencyMonitor } = require("./latencyMonitor");
13const REMOTE_EDITOR = fs.readFileSync(path.join(__dirname, "../files/remoteEditor.py"), "utf8");
14const EDITOR_STARTED = "\x1eCHJ_EDITOR_START\n";
16const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]{1,80}$/;
17const MAX_INPUT_LENGTH = 64 * 1024;
18const MAX_KEY_BYTES = 2 * 1024 * 1024;
19const MAX_COMMAND_OUTPUT = 512 * 1024;
20const MAX_NGINX_CONFIG_BYTES = 512 * 1024;
21const USERNAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
22const METRICS_COMMAND = String.raw`LC_ALL=C
23platform=$(uname -s 2>/dev/null || echo unknown)
24kernel=$(uname -r 2>/dev/null || echo unknown)
25host=$(hostname 2>/dev/null || echo unknown)
26cpu_arch=$(uname -m 2>/dev/null || echo unknown)
27cpu_usage=0
28cpu_logical=0
29cpu_physical=0
30cpu_sockets=0
31cpu_model=unknown
32cpu_core_types=
33network_default=
35if [ -r /proc/uptime ]; then
36 uptime_s=$(awk '{print int($1)}' /proc/uptime)
37else
38 boot_epoch=$(sysctl -n kern.boottime 2>/dev/null | awk -F'[=,]' '{gsub(/[^0-9]/,"",$2); print $2}')
39 now_epoch=$(date +%s 2>/dev/null || echo 0)
40 if [ -n "$boot_epoch" ] && [ "$now_epoch" -ge "$boot_epoch" ] 2>/dev/null; then uptime_s=$((now_epoch-boot_epoch)); else uptime_s=0; fi
41fi
42if [ -r /proc/loadavg ]; then load1=$(awk '{print $1}' /proc/loadavg); else load1=$(uptime | sed -E 's/.*load averages?:[[:space:]]*([0-9.]+).*/\1/'); fi
44if [ -r /proc/meminfo ]; then
45 mem_total=$(awk '/^MemTotal:/{print $2*1024}' /proc/meminfo)
46 mem_avail=$(awk '/^MemAvailable:/{print $2*1024}' /proc/meminfo)
47 swap_total=$(awk '/^SwapTotal:/{print $2*1024}' /proc/meminfo)
48 swap_free=$(awk '/^SwapFree:/{print $2*1024}' /proc/meminfo)
49 cpu_first=$(awk '/^cpu /{idle=$5+$6; total=0; for(i=2;i<=9 && i<=NF;i++)total+=$i; printf "%.0f:%.0f",idle,total; exit}' /proc/stat)
50 sleep 0.2
51 cpu_second=$(awk '/^cpu /{idle=$5+$6; total=0; for(i=2;i<=9 && i<=NF;i++)total+=$i; printf "%.0f:%.0f",idle,total; exit}' /proc/stat)
52 cpu_usage=$(awk -v first="$cpu_first" -v second="$cpu_second" 'BEGIN{split(first,a,":");split(second,b,":");total=b[2]-a[2];idle=b[1]-a[1];if(total>0)printf "%.2f",100*(total-idle)/total;else print 0}')
53 cpu_logical=$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || awk '/^processor[[:space:]]*:/{n++}END{print n+0}' /proc/cpuinfo)
54 cpu_model=$(awk -F: '/^(model name|Processor|Hardware)[[:space:]]*:/{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2);if($2!=""){print $2;exit}}' /proc/cpuinfo)
55 if command -v lscpu >/dev/null 2>&1; then
56 cpu_physical=$(lscpu -p=Core,Socket 2>/dev/null | awk -F, '!/^#/&&$1~/^[0-9]+$/{seen[$1":"$2]=1}END{for(k in seen)n++;print n+0}')
57 cpu_sockets=$(lscpu -p=Socket 2>/dev/null | awk -F, '!/^#/&&$1~/^[0-9]+$/{seen[$1]=1}END{for(k in seen)n++;print n+0}')
58 fi
59 [ "$cpu_physical" -gt 0 ] 2>/dev/null || cpu_physical=$cpu_logical
60 [ "$cpu_sockets" -gt 0 ] 2>/dev/null || cpu_sockets=1
61 if ls /sys/devices/system/cpu/cpu*/topology/core_type >/dev/null 2>&1; then
62 core_type_records=$(for type_file in /sys/devices/system/cpu/cpu*/topology/core_type; do topology=$(dirname "$type_file"); type=$(cat "$type_file" 2>/dev/null); core=$(cat "$topology/core_id" 2>/dev/null); package=$(cat "$topology/physical_package_id" 2>/dev/null); printf '%s:%s:%s\n' "$type" "$package" "$core"; done | sort -u)
63 efficiency_cores=$(printf '%s\n' "$core_type_records" | awk -F: '$1==1{n++}END{print n+0}')
64 performance_cores=$(printf '%s\n' "$core_type_records" | awk -F: '$1==2{n++}END{print n+0}')
65 [ "$performance_cores" -gt 0 ] && cpu_core_types="P-core:$performance_cores"
66 if [ "$efficiency_cores" -gt 0 ]; then [ -n "$cpu_core_types" ] && separator=, || separator=; cpu_core_types=$(printf '%s%sE-core:%s' "$cpu_core_types" "$separator" "$efficiency_cores"); fi
67 fi
68 if command -v ip >/dev/null 2>&1; then network_default=$(ip route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++)if($i=="dev"){print $(i+1);exit}}'); fi
69else
70 mem_total=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
71 page_size=$(vm_stat 2>/dev/null | awk '/page size of/{print $8}')
72 [ -n "$page_size" ] || page_size=4096
73 mem_avail=$(vm_stat 2>/dev/null | awk -v page="$page_size" '/Pages (free|inactive|speculative):/{gsub(/\./,"",$3);pages+=$3}END{printf "%.0f",pages*page}')
74 swap_total=0
75 swap_free=0
76 cpu_logical=$(sysctl -n hw.logicalcpu 2>/dev/null || echo 0)
77 cpu_physical=$(sysctl -n hw.physicalcpu 2>/dev/null || echo "$cpu_logical")
78 cpu_sockets=1
79 cpu_model=$(sysctl -n machdep.cpu.brand_string 2>/dev/null || sysctl -n hw.model 2>/dev/null || echo unknown)
80 cpu_usage=$(top -l 1 -n 0 2>/dev/null | awk '/CPU usage:/{idle=$7;gsub(/%/,"",idle)}END{if(idle!="")printf "%.2f",100-idle;else print 0}')
81 for level in 0 1 2; do
82 count=$(sysctl -n "hw.perflevel$level.physicalcpu" 2>/dev/null || echo 0)
83 if [ "$count" -gt 0 ] 2>/dev/null; then
84 name=$(sysctl -n "hw.perflevel$level.name" 2>/dev/null || echo "Cluster $level")
85 [ -n "$cpu_core_types" ] && separator=, || separator=
86 cpu_core_types=$(printf '%s%s%s:%s' "$cpu_core_types" "$separator" "$name" "$count")
87 fi
88 done
89 network_default=$(route -n get default 2>/dev/null | awk '/interface:/{print $2;exit}')
90fi
92[ -n "$cpu_model" ] || cpu_model=unknown
93cpu_model=$(printf '%s' "$cpu_model" | tr '\r\n|\036' ' ')
94cpu_core_types=$(printf '%s' "$cpu_core_types" | tr '\r\n|\036' ' ')
95disk=$(df -Pk / 2>/dev/null | awk 'NR==2{print $2*1024":"$3*1024":"$4*1024}')
96printf 'platform=%s\nkernel=%s\nhostname=%s\nuptimeSeconds=%s\nload1=%s\nmemoryTotal=%s\nmemoryAvailable=%s\nswapTotal=%s\nswapFree=%s\ndisk=%s\ncpuUsage=%s\ncpuArchitecture=%s\ncpuModel=%s\ncpuLogical=%s\ncpuPhysical=%s\ncpuSockets=%s\ncpuCoreTypes=%s\nnetworkDefault=%s\n' "$platform" "$kernel" "$host" "$uptime_s" "$load1" "$mem_total" "$mem_avail" "$swap_total" "$swap_free" "$disk" "$cpu_usage" "$cpu_arch" "$cpu_model" "$cpu_logical" "$cpu_physical" "$cpu_sockets" "$cpu_core_types" "$network_default"
98# Detailed memory and swap for the "Memory and virtual memory" section (kB fields converted to bytes).
99if [ -r /proc/meminfo ]; then
100 awk '$1 ~ /^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapCached|Active|Inactive|Slab|SReclaimable|SUnreclaim|Shmem|KernelStack|PageTables|VmallocUsed|Percpu|Dirty|Writeback|SwapTotal|SwapFree|Zswap|Zswapped|CommitLimit|Committed_AS):$/ { key=$1; sub(/:$/, "", key); value=($3=="kB") ? $2*1024 : $2; printf "meminfo=%s|%.0f\n", key, value }' /proc/meminfo
101fi
102if [ -r /proc/swaps ]; then
103 awk 'NR>1 { printf "swapArea=%s|%s|%.0f|%.0f|%s\n", $1, $2, $3*1024, $4*1024, $5 }' /proc/swaps
104fi
105if [ -r /proc/vmstat ]; then
106 awk '$1=="pswpin" || $1=="pswpout" || $1=="pgmajfault" { printf "vmstat=%s|%s\n", $1, $2 }' /proc/vmstat
107fi
108for zram in /sys/block/zram*; do
109 [ -r "$zram/mm_stat" ] || continue
110 printf 'zram=%s|%s|%s|%s\n' "$(basename "$zram")" "$(cat "$zram/disksize" 2>/dev/null)" "$(cat "$zram/mm_stat" 2>/dev/null | tr -s ' ' ',')" "$(cat "$zram/comp_algorithm" 2>/dev/null | sed -n 's/.*\[\([^]]*\)\].*/\1/p')"
111done
112if [ -r /sys/module/zswap/parameters/enabled ]; then
113 printf 'zswap=%s|%s|%s\n' "$(cat /sys/module/zswap/parameters/enabled)" "$(cat /sys/module/zswap/parameters/compressor 2>/dev/null)" "$(cat /sys/module/zswap/parameters/max_pool_percent 2>/dev/null)"
114fi
115printf 'pageSize=%s\n' "$(getconf PAGESIZE 2>/dev/null || echo 4096)"
117if [ -d /sys/class/net ]; then
118 for iface_path in /sys/class/net/*; do
119 [ -e "$iface_path" ] || continue
120 iface=$(basename "$iface_path")
121 [ "$iface" = lo ] && continue
122 state=$(cat "$iface_path/operstate" 2>/dev/null || echo unknown)
123 mac=$(cat "$iface_path/address" 2>/dev/null || true)
124 rx=$(cat "$iface_path/statistics/rx_bytes" 2>/dev/null || echo 0)
125 tx=$(cat "$iface_path/statistics/tx_bytes" 2>/dev/null || echo 0)
126 if command -v ip >/dev/null 2>&1; then addresses=$(ip -o addr show dev "$iface" scope global 2>/dev/null | awk '{print $4}' | paste -sd, -); else addresses=; fi
127 printf 'networkInterface=%s|%s|%s|%s|%s|%s\n' "$iface" "$state" "$mac" "$rx" "$tx" "$addresses"
128 done
129elif command -v ifconfig >/dev/null 2>&1; then
130 for iface in $(ifconfig -l 2>/dev/null); do
131 [ "$iface" = lo0 ] && continue
132 state=$(ifconfig "$iface" 2>/dev/null | awk '/status:/{print $2;exit}')
133 mac=$(ifconfig "$iface" 2>/dev/null | awk '/ether /{print $2;exit}')
134 addresses=$(ifconfig "$iface" 2>/dev/null | awk '/^[[:space:]]+inet /{print $2}/^[[:space:]]+inet6 /{print $2}' | paste -sd, -)
135 counters=$(netstat -ibn -I "$iface" 2>/dev/null | awk 'NR==2{print $7":"$10;exit}')
136 rx=$(printf '%s' "$counters" | cut -d: -f1); tx=$(printf '%s' "$counters" | cut -d: -f2)
137 [ -n "$state" ] || state=unknown; [ -n "$rx" ] || rx=0; [ -n "$tx" ] || tx=0
138 printf 'networkInterface=%s|%s|%s|%s|%s|%s\n' "$iface" "$state" "$mac" "$rx" "$tx" "$addresses"
139 done
140fi`;
141const NGINX_INSPECT_COMMAND = String.raw`LC_ALL=C
142nginx_bin=$(command -v nginx 2>/dev/null || true)
143if [ -z "$nginx_bin" ]; then printf 'installed=0\n'; exit 0; fi
144printf 'installed=1\n'
145printf 'binary=%s\n' "$nginx_bin"
146version=$($nginx_bin -v 2>&1 | sed 's/^nginx version: //')
147build=$($nginx_bin -V 2>&1 | tr '\n' ' ')
148conf_path=$(printf '%s' "$build" | sed -n 's/.*--conf-path=\([^ ]*\).*/\1/p')
149[ -n "$conf_path" ] || conf_path=/etc/nginx/nginx.conf
150if command -v systemctl >/dev/null 2>&1; then service_state=$(systemctl is-active nginx 2>/dev/null || true); else service_state=$(pgrep -x nginx >/dev/null 2>&1 && echo active || echo inactive); fi
151[ -n "$service_state" ] || service_state=unknown
152printf 'version=%s\nconfigPath=%s\nserviceState=%s\n' "$version" "$conf_path" "$service_state"
153for directory in /etc/nginx /etc/nginx/conf.d /etc/nginx/snippets /etc/nginx/sites-available /etc/nginx/sites-enabled /etc/nginx/modules-available /etc/nginx/modules-enabled /etc/nginx/stream-conf.d /usr/local/etc/nginx /usr/local/etc/nginx/conf.d /opt/homebrew/etc/nginx /opt/homebrew/etc/nginx/servers; do
154 [ -d "$directory" ] || continue
155 find "$directory" -maxdepth 1 \( -type f -o -type l \) ! -name '*.chj-backup-*' -print 2>/dev/null | while IFS= read -r file; do
156 [ -L "$file" ] && kind=symlink || kind=file
157 printf 'config=%s|%s\n' "$kind" "$file"
158 done
159done`;
161const NGINX_CONFIG_PATH_PATTERN = /^\/(?:etc\/nginx\/(?:nginx\.conf|(?:conf\.d|snippets|modules-(?:available|enabled)|stream-conf\.d)\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}|sites-(?:available|enabled)\/[A-Za-z0-9][A-Za-z0-9._-]{0,127})|usr\/local\/etc\/nginx\/(?:nginx\.conf|conf\.d\/[A-Za-z0-9][A-Za-z0-9._-]{0,127})|opt\/homebrew\/etc\/nginx\/(?:nginx\.conf|servers\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}))$/;
163function normalizeNginxConfigPath(value) {
164 const path = String(value || "").trim();
165 if (!NGINX_CONFIG_PATH_PATTERN.test(path) || path.includes(".chj-backup-")) {
166 throw new SessionError("NGINX_CONFIG_PATH_INVALID", "The NGINX configuration path is outside the managed directories.");
167 }
168 return path;
171function shellQuote(value) {
172 return `'${String(value).replace(/'/g, `'"'"'`)}'`;
175function openExec(client, command, callback) {
176 try { client.exec(command, callback); }
177 catch (error) { callback(error); }
180// SHA256 fingerprint of the session's public key, used only to refuse removing the key in use.
181function publicKeyFingerprintForPrivateKey(privateKeyPath) {
182 let blob = null;
183 try {
184 const parsed = sshUtils.parseKey(fs.readFileSync(privateKeyPath));
185 if (!(parsed instanceof Error)) blob = (Array.isArray(parsed) ? parsed[0] : parsed).getPublicSSH();
186 } catch {}
187 if (!blob) {
188 try { blob = Buffer.from(fs.readFileSync(`${privateKeyPath}.pub`, "utf8").trim().split(/\s+/)[1] || "", "base64"); } catch {}
189 }
190 return blob?.length ? `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}` : null;
193function normalizeSudoPassword(value) {
194 const password = String(value || "");
195 if (password.length > 1024 || /[\0\r\n]/.test(password)) throw new SessionError("SUDO_PASSWORD_INVALID", "Invalid sudo password.");
196 return password;
199function parseNginxInspectionOutput(stdout) {
200 const result = { installed: false, binary: "", version: "", configPath: "", serviceState: "unknown", configs: [] };
201 const seen = new Set();
202 for (const line of String(stdout || "").split(/\r?\n/)) {
203 if (line.startsWith("config=")) {
204 const separator = line.indexOf("|", 7);
205 if (separator < 0) continue;
206 const kind = line.slice(7, separator) === "symlink" ? "symlink" : "file";
207 try {
208 const path = normalizeNginxConfigPath(line.slice(separator + 1));
209 if (!seen.has(path)) { seen.add(path); result.configs.push({ path, kind, writable: kind === "file" }); }
210 } catch {}
211 continue;
212 }
213 const separator = line.indexOf("=");
214 if (separator < 1) continue;
215 const key = line.slice(0, separator);
216 const value = line.slice(separator + 1).replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 512);
217 if (key === "installed") result.installed = value === "1";
218 else if (["binary", "version", "configPath", "serviceState"].includes(key)) result[key] = value;
219 }
220 result.configs.sort((left, right) => left.path.localeCompare(right.path));
221 return result;
224class SessionError extends Error {
225 constructor(code, message, details = {}) {
226 super(message);
227 this.name = "SessionError";
228 this.code = code;
229 Object.assign(this, details);
230 }
232 toJSON() {
233 const result = { code: this.code, message: this.message };
234 for (const key of ["sessionId", "profileId", "host", "port", "fingerprint", "knownFingerprint"]) {
235 if (this[key] !== undefined) result[key] = this[key];
236 }
237 return result;
238 }
241function normalizeTerminalSize(value, fallback, min, max) {
242 const number = Number(value);
243 return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback;
246function metricNumber(value, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
247 const number = Number(value);
248 return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : minimum;
251function metricText(value, fallback = "unknown", maximumLength = 240) {
252 const normalized = String(value ?? "")
253 .replace(/[\x00-\x1f\x7f|]/g, " ")
254 .replace(/\s+/g, " ")
255 .trim()
256 .slice(0, maximumLength);
257 return normalized || fallback;
260function parseCpuCoreTypes(value) {
261 return String(value || "").split(",").slice(0, 16).map((entry) => {
262 const separator = entry.lastIndexOf(":");
263 if (separator <= 0) return null;
264 const name = metricText(entry.slice(0, separator), "", 80);
265 const count = Math.floor(metricNumber(entry.slice(separator + 1), 0, 4096));
266 return name && count > 0 ? { name, count } : null;
267 }).filter(Boolean);
270function parseNetworkInterfaces(lines, defaultInterface) {
271 return lines.filter((line) => line.startsWith("networkInterface=")).slice(0, 64).map((line) => {
272 const fields = line.slice("networkInterface=".length).split("|");
273 const name = metricText(fields[0], "unknown", 64);
274 const state = metricText(fields[1], "unknown", 32).toLowerCase();
275 const addresses = String(fields[5] || "").split(",").slice(0, 16)
276 .map((address) => metricText(address, "", 128)).filter(Boolean);
277 return {
278 name,
279 state,
280 mac: metricText(fields[2], "", 64),
281 rxBytes: metricNumber(fields[3]),
282 txBytes: metricNumber(fields[4]),
283 addresses,
284 isDefault: name === defaultInterface
285 };
286 });
289const LIST_PREFIXES = ["networkInterface=", "meminfo=", "swapArea=", "vmstat=", "zram=", "zswap="];
291function parseLinuxMemoryDetail(lines) {
292 const meminfo = {};
293 for (const line of lines.filter((entry) => entry.startsWith("meminfo="))) {
294 const [key, value] = line.slice(8).split("|");
295 if (/^[A-Za-z_()]+$/.test(key) && Number.isFinite(Number(value))) meminfo[key] = Number(value);
296 }
297 if (!meminfo.MemTotal) return null;
298 const get = (key) => (key in meminfo ? meminfo[key] : null);
299 const total = meminfo.MemTotal;
300 const free = get("MemFree") ?? 0;
301 const buffers = get("Buffers") ?? 0;
302 const cached = get("Cached") ?? 0;
303 const reclaimableSlab = get("SReclaimable") ?? 0;
304 // Non-overlapping slices: Cached already contains Shmem; SwapCached is reported separately (not a slice).
305 const used = Math.max(0, total - free - buffers - cached - reclaimableSlab);
306 const vmstat = Object.fromEntries(lines.filter((entry) => entry.startsWith("vmstat=")).map((line) => line.slice(7).split("|")).map(([key, value]) => [key, Number(value)]));
307 const swapAreas = lines.filter((entry) => entry.startsWith("swapArea=")).slice(0, 32).map((line) => {
308 const [name = "", type = "", size = "", usedBytes = "", priority = ""] = line.slice(9).split("|");
309 const path = name.replace(/\\040/g, " ");
310 return { name: metricText(path, "", 255), type: /^\/dev\/zram\d+$/.test(path) ? "zram" : metricText(type, "unknown", 16), size: metricNumber(size), used: metricNumber(usedBytes), priority: Number.isFinite(Number(priority)) ? Number(priority) : null };
311 });
312 const zram = lines.filter((entry) => entry.startsWith("zram=")).slice(0, 16).map((line) => {
313 const [device = "", disksize = "", mmStat = "", algorithm = ""] = line.slice(5).split("|");
314 const stat = mmStat.split(",").filter(Boolean).map(Number);
315 return { device: metricText(device, "", 32), diskSize: metricNumber(disksize), originalData: stat[0] ?? null, compressedData: stat[1] ?? null, memoryUsed: stat[2] ?? null, memoryLimit: stat[3] || null, algorithm: metricText(algorithm, "", 32) || null };
316 });
317 const zswapLine = lines.find((entry) => entry.startsWith("zswap="));
318 const [zswapEnabled = "", zswapCompressor = "", zswapPool = ""] = zswapLine ? zswapLine.slice(6).split("|") : [];
319 return {
320 source: "/proc/meminfo",
321 total,
322 free,
323 available: get("MemAvailable"),
324 used,
325 buffers,
326 cached,
327 shmem: get("Shmem"),
328 swapCached: get("SwapCached"),
329 slab: get("Slab"),
330 slabReclaimable: get("SReclaimable"),
331 slabUnreclaimable: get("SUnreclaim"),
332 kernelStack: get("KernelStack"),
333 pageTables: get("PageTables"),
334 vmallocUsed: get("VmallocUsed"),
335 dirty: get("Dirty"),
336 writeback: get("Writeback"),
337 commit: { committed: get("Committed_AS"), limit: get("CommitLimit") },
338 swapTotal: get("SwapTotal"),
339 swapFree: get("SwapFree"),
340 zswapPool: get("Zswap"),
341 zswapStored: get("Zswapped"),
342 paging: { swapInPages: Number.isFinite(vmstat.pswpin) ? vmstat.pswpin : null, swapOutPages: Number.isFinite(vmstat.pswpout) ? vmstat.pswpout : null, majorFaults: Number.isFinite(vmstat.pgmajfault) ? vmstat.pgmajfault : null },
343 swapAreas,
344 zram,
345 zswap: zswapLine ? { enabled: /^[Yy1]/.test(zswapEnabled), compressor: metricText(zswapCompressor, "", 32) || null, maxPoolPercent: Number(zswapPool) || null } : null
346 };
349function parseSystemMetricsOutput(stdout, fallbackHostname = "unknown") {
350 const lines = String(stdout || "").split(/\r?\n/).filter(Boolean);
351 const values = Object.fromEntries(lines.filter((line) => !LIST_PREFIXES.some((prefix) => line.startsWith(prefix))).map((line) => {
352 const index = line.indexOf("=");
353 return index > 0 ? [line.slice(0, index), line.slice(index + 1)] : [line, ""];
354 }));
355 const disk = String(values.disk || "0:0:0").split(":").map((value) => metricNumber(value));
356 const memoryTotal = metricNumber(values.memoryTotal);
357 const memoryAvailable = metricNumber(values.memoryAvailable, 0, memoryTotal);
358 const swapTotal = metricNumber(values.swapTotal);
359 const swapFree = metricNumber(values.swapFree, 0, swapTotal);
360 const defaultInterface = metricText(values.networkDefault, "", 64);
361 const memoryDetail = parseLinuxMemoryDetail(lines);
362 return {
363 os: "linux",
364 loadAverageSupported: true,
365 pageSize: metricNumber(values.pageSize, 0, 1 << 30) || null,
366 memoryDetail,
367 swapAreas: memoryDetail?.swapAreas || [],
368 platform: metricText(values.platform),
369 kernel: metricText(values.kernel),
370 hostname: metricText(values.hostname, fallbackHostname),
371 uptimeSeconds: metricNumber(values.uptimeSeconds),
372 load1: metricNumber(values.load1),
373 memory: { total: memoryTotal, available: memoryAvailable },
374 swap: { total: swapTotal, used: Math.max(0, swapTotal - swapFree), free: swapFree },
375 disk: {
376 mount: "/",
377 total: disk[0] || 0,
378 used: Math.min(disk[0] || 0, disk[1] || 0),
379 available: Math.min(disk[0] || 0, disk[2] || 0)
380 },
381 cpu: {
382 usagePercent: metricNumber(values.cpuUsage, 0, 100),
383 architecture: metricText(values.cpuArchitecture),
384 model: metricText(values.cpuModel),
385 logicalCores: Math.floor(metricNumber(values.cpuLogical, 0, 4096)),
386 physicalCores: Math.floor(metricNumber(values.cpuPhysical, 0, 4096)),
387 sockets: Math.floor(metricNumber(values.cpuSockets, 0, 256)),
388 coreTypes: parseCpuCoreTypes(values.cpuCoreTypes)
389 },
390 network: {
391 defaultInterface,
392 interfaces: parseNetworkInterfaces(lines, defaultInterface)
393 }
394 };
397class SessionManager extends EventEmitter {
398 constructor(options) {
399 super();
400 this.profileService = options.profileService;
401 this.logger = options.logger;
402 this.clientFactory = options.clientFactory || (() => new Client());
403 this.lookupHost = options.lookupHost || dns.lookup;
404 this.resolve4 = options.resolve4 || dns.resolve4;
405 this.resolve6 = options.resolve6 || dns.resolve6;
406 this.latencyMonitor = options.latencyMonitor || new LatencyMonitor();
407 this.sessions = new Map();
408 this.pendingTrust = new Map();
409 }
411 list() {
412 return [...this.sessions.values()].map((record) => this._publicStatus(record));
413 }
415 async connect(options = {}) {
416 const sessionId = String(options.sessionId || "").trim();
417 if (!SESSION_ID_PATTERN.test(sessionId)) throw new SessionError("INVALID_SESSION_ID", "Invalid terminal session ID.");
418 await this.disconnect(sessionId, "reconnect");
419 const profile = this.profileService.get(String(options.profileId || ""));
420 const config = this._buildConnectConfig(profile, options);
421 const resolvedHost = await this._resolveConnectionAddress(profile.host);
422 config.host = resolvedHost.address;
423 const knownFingerprint = this.profileService.getHostKey(profile.host, profile.port);
424 const client = this.clientFactory();
425 const record = {
426 sessionId,
427 profileId: profile.id,
428 label: profile.label,
429 host: profile.host,
430 address: resolvedHost.address,
431 port: profile.port,
432 username: profile.username,
433 authMethod: profile.authMethod,
434 state: "connecting",
435 client,
436 stream: null,
437 decoder: new StringDecoder("utf8"),
438 finalized: false
439 };
440 this.sessions.set(sessionId, record);
441 this.emit("state", this._publicStatus(record));
443 let hostKeyIssue = null;
444 config.hostHash = "sha256";
445 config.hostVerifier = (fingerprint) => {
446 const normalized = String(fingerprint || "").toLowerCase();
447 if (!knownFingerprint) {
448 hostKeyIssue = { code: "HOST_KEY_UNKNOWN", fingerprint: normalized };
449 this.pendingTrust.set(sessionId, {
450 sessionId,
451 profileId: profile.id,
452 host: profile.host,
453 port: profile.port,
454 fingerprint: normalized,
455 knownFingerprint: null,
456 expiresAt: Date.now() + 5 * 60 * 1000
457 });
458 return false;
459 }
460 if (knownFingerprint !== normalized) {
461 hostKeyIssue = { code: "HOST_KEY_MISMATCH", fingerprint: normalized, knownFingerprint };
462 this.pendingTrust.set(sessionId, {
463 sessionId,
464 profileId: profile.id,
465 host: profile.host,
466 port: profile.port,
467 fingerprint: normalized,
468 knownFingerprint,
469 expiresAt: Date.now() + 5 * 60 * 1000
470 });
471 return false;
472 }
473 return true;
474 };
476 return new Promise((resolve, reject) => {
477 let settled = false;
478 const fail = (error) => {
479 if (settled) {
480 const normalized = this._normalizeConnectionError(error, record);
481 this.emit("sessionError", normalized.toJSON());
482 this._finalize(record, normalized.code);
483 return;
484 }
485 settled = true;
486 const normalized = hostKeyIssue
487 ? new SessionError(hostKeyIssue.code, hostKeyIssue.code === "HOST_KEY_UNKNOWN" ? "The SSH host key is not trusted yet." : "The SSH host key changed.", {
488 sessionId,
489 profileId: profile.id,
490 host: profile.host,
491 port: profile.port,
492 ...hostKeyIssue
493 })
494 : this._normalizeConnectionError(error, record);
495 this.logger?.warn("SSH connection failed.", { sessionId, code: normalized.code, message: normalized.message, address: record.address, port: record.port });
496 this._finalize(record, normalized.code);
497 reject(normalized);
498 };
500 client.once("ready", () => {
501 const cols = normalizeTerminalSize(options.cols, 100, 20, 500);
502 const rows = normalizeTerminalSize(options.rows, 30, 5, 300);
503 client.shell({ term: "xterm-256color", cols, rows }, (error, stream) => {
504 if (error) { fail(error); return; }
505 if (settled) { try { stream.close(); } catch {} return; }
506 settled = true;
507 record.stream = stream;
508 record.state = "connected";
509 this.pendingTrust.delete(sessionId);
510 this._bindShell(record);
511 this.profileService.markUsed(profile.id);
512 this.logger?.info("SSH session connected.", {
513 sessionId,
514 profileId: profile.id,
515 host: profile.host,
516 port: profile.port,
517 username: profile.username,
518 authMethod: profile.authMethod
519 });
520 const status = this._publicStatus(record);
521 this.emit("state", status);
522 record.stopLatency = this.latencyMonitor.start(record,
523 () => this._sshRoundTrip(record),
524 (latency) => {
525 if (record.finalized || this.sessions.get(sessionId) !== record) return;
526 record.latency = latency;
527 this.emit("state", this._publicStatus(record));
528 });
529 resolve(status);
530 });
531 });
532 client.on("error", fail);
533 client.once("close", () => {
534 if (!settled) fail(new Error("SSH connection closed before it became ready."));
535 else this._finalize(record, "remote-close");
536 });
537 try { client.connect(config); } catch (error) { fail(error); }
538 });
539 }
541 trustPending(options = {}) {
542 const sessionId = String(options.sessionId || "");
543 const pending = this.pendingTrust.get(sessionId);
544 const fingerprint = String(options.fingerprint || "").toLowerCase();
545 if (!pending || pending.expiresAt < Date.now()) {
546 this.pendingTrust.delete(sessionId);
547 throw new SessionError("HOST_KEY_CONFIRMATION_EXPIRED", "The host-key confirmation expired.");
548 }
549 if (pending.profileId !== options.profileId || pending.fingerprint !== fingerprint) {
550 throw new SessionError("HOST_KEY_CONFIRMATION_INVALID", "The host-key confirmation does not match the pending connection.");
551 }
552 if (pending.knownFingerprint) {
553 const confirmedKnownFingerprint = String(options.knownFingerprint || "").toLowerCase();
554 if (options.replaceKnown !== true || confirmedKnownFingerprint !== pending.knownFingerprint) {
555 throw new SessionError("HOST_KEY_REPLACEMENT_CONFIRMATION_REQUIRED", "Replacing a changed SSH host key requires explicit confirmation of both fingerprints.");
556 }
557 if (this.profileService.getHostKey(pending.host, pending.port) !== pending.knownFingerprint) {
558 this.pendingTrust.delete(sessionId);
559 throw new SessionError("HOST_KEY_REPLACEMENT_STALE", "The stored SSH host key changed while confirmation was pending.");
560 }
561 } else if (options.replaceKnown === true) {
562 throw new SessionError("HOST_KEY_CONFIRMATION_INVALID", "A new host key cannot be confirmed as a replacement.");
563 }
564 const result = this.profileService.trustHostKey(pending.host, pending.port, pending.fingerprint);
565 this.pendingTrust.delete(sessionId);
566 const details = { host: pending.host, port: pending.port, fingerprint: pending.fingerprint, replacedFingerprint: pending.knownFingerprint };
567 if (pending.knownFingerprint) this.logger?.warn("Changed SSH host key replaced by user confirmation.", details);
568 else this.logger?.info("SSH host key trusted by user.", details);
569 return result;
570 }
572 write(sessionId, data) {
573 const record = this._requireConnected(sessionId);
574 const text = String(data ?? "");
575 if (Buffer.byteLength(text, "utf8") > MAX_INPUT_LENGTH) throw new SessionError("TERMINAL_INPUT_TOO_LARGE", "Terminal input is too large.");
576 record.stream.write(text);
577 }
579 resize(sessionId, cols, rows) {
580 const record = this._requireConnected(sessionId);
581 const normalizedCols = normalizeTerminalSize(cols, 100, 20, 500);
582 const normalizedRows = normalizeTerminalSize(rows, 30, 5, 300);
583 record.stream.setWindow(normalizedRows, normalizedCols, 0, 0);
584 return { cols: normalizedCols, rows: normalizedRows };
585 }
587 async readSystemMetrics(sessionId) {
588 const record = this._requireConnected(sessionId);
589 const platform = await this._platform(record);
590 if (platform.os === "windows") return this._readWindowsMetrics(record);
591 if (platform.os === "unknown") throw new SessionError("METRICS_PLATFORM_UNSUPPORTED", `The remote operating system could not be identified: ${platform.reason || "no POSIX shell and no Windows PowerShell"}.`);
592 const output = await this._execFixed(record, METRICS_COMMAND, 10000);
593 return {
594 sessionId: record.sessionId,
595 ...parseSystemMetricsOutput(output.stdout, record.host),
596 collectedAt: new Date().toISOString()
597 };
598 }
600 // Remote OS of a live session (cached). Required lazily to avoid a require cycle.
601 _platform(record) {
602 if (record.platform) return Promise.resolve(record.platform);
603 return require("./platformDetect").detectPlatform(this, record.sessionId);
604 }
606 async _assertPosix(record, feature) {
607 const platform = await this._platform(record);
608 if (platform.os === "windows") throw new SessionError("FEATURE_PLATFORM_UNSUPPORTED", `${feature} is available only on Linux/POSIX servers; this server runs ${platform.caption || "Windows"}.`);
609 }
611 // Runs a fixed PowerShell script on a Windows session (short encoded bootstrap; script and JSON on stdin).
612 async _runWindowsScript(record, script, args, timeoutMs = 30000) {
613 const { BOOTSTRAP, bootstrapInput, encodedCommand, parseWindowsOutput } = require("./windowsPrivilegedExec");
614 const result = await this._execStream(record, encodedCommand(BOOTSTRAP), {
615 timeoutMs,
616 outputLimit: 4 * 1024 * 1024,
617 onUpdate: (controller, kind) => { if (kind === "open") { controller.write(bootstrapInput(script, args)); controller.end(); } }
618 }).done;
619 return parseWindowsOutput(result.stdout, result.stderr, result.code, "METRICS_");
620 }
622 async _readWindowsMetrics(record) {
623 const { METRICS_SCRIPT, normalizeWindowsMetrics } = require("./windowsMetrics");
624 record.metricsState ||= {};
625 const state = record.metricsState;
626 const raw = await this._runWindowsScript(record, METRICS_SCRIPT, { static: !state.static }, 30000);
627 if (raw.static) state.static = raw.static;
628 const metrics = normalizeWindowsMetrics(raw, state.static, state, record.host);
629 if (raw.cpuRaw) state.cpuRaw = raw.cpuRaw;
630 if (raw.memoryCounters) state.memoryCounters = raw.memoryCounters;
631 return { sessionId: record.sessionId, ...metrics, collectedAt: new Date().toISOString() };
632 }
634 openSftp(sessionId) {
635 const record = this._requireConnected(sessionId);
636 return new Promise((resolve, reject) => record.client.sftp((error, sftp) => error ? reject(new SessionError("SFTP_OPEN_FAILED", error.message)) : resolve(sftp)));
637 }
639 // Internal fixed protocol only. Neither renderer nor plugin can supply code.
640 async remoteEditor(sessionId, request, authorization = {}) {
641 const record = this._requireConnected(sessionId);
642 await this._assertPosix(record, "The remote text editor");
643 let password;
644 try { password = normalizeSudoPassword(authorization.sudoPassword); }
645 catch { throw new SessionError("FILE_SUDO_PASSWORD_INVALID", "Invalid sudo password."); }
646 if (password && authorization.sudo !== true) throw new SessionError("FILE_SUDO_AUTHORIZATION_REQUIRED", "Explicit sudo authorization is required.");
647 const command = `printf '\\036CHJ_EDITOR_START\\n'; exec python3 -I -B -c ${shellQuote(REMOTE_EDITOR)} ${shellQuote(JSON.stringify(request))}`;
648 let output;
649 try {
650 output = authorization.sudo === true
651 ? await this._execSudo(record, command, password, 60000, 36 * 1024 * 1024)
652 : await this._execFixed(record, command, 60000, 36 * 1024 * 1024);
653 } catch (error) {
654 // Remote stderr and sudo diagnostics never become editor errors or logs.
655 const definitiveSudoFailure = authorization.sudo === true && error.remoteExitCode === 1 && error.editorStarted === false;
656 throw new SessionError(definitiveSudoFailure ? "FILE_SUDO_FAILED" : error.remoteExitCode === 127 ? "FILE_DEPENDENCY_UNAVAILABLE" : request.operation === "finalize" ? "FILE_RESULT_UNKNOWN" : "FILE_EXEC_FAILED",
657 "Remote editor operation was not confirmed. Inspect recovery before retrying.");
658 }
659 let response;
660 try {
661 if (!output.stdout.startsWith(EDITOR_STARTED)) throw new Error("Missing editor marker");
662 response = JSON.parse(output.stdout.slice(EDITOR_STARTED.length));
663 }
664 catch { throw new SessionError("FILE_RESULT_UNKNOWN", "Remote editor result could not be verified."); }
665 if (!response || response.ok !== true || !response.value || typeof response.value !== "object") {
666 const code = /^FILE_[A-Z_]+$/.test(response?.code) ? response.code : "FILE_PROTOCOL_ERROR";
667 throw new SessionError(code, `${code}: Remote editor operation stopped; recoverable copies were retained.`);
668 }
669 return response.value;
670 }
672 // Identity facts for lockout protection. Secrets are never returned.
673 sessionAuthInfo(sessionId) {
674 const record = this._requireConnected(sessionId);
675 let publicKeyFingerprint = null;
676 if (record.authMethod === "privateKey") {
677 try {
678 const profile = this.profileService.get(record.profileId);
679 publicKeyFingerprint = publicKeyFingerprintForPrivateKey(profile.privateKeyPath);
680 } catch {}
681 }
682 return { username: record.username, authMethod: record.authMethod, address: record.address, publicKeyFingerprint };
683 }
685 // Reports whether a fresh login can be attempted without asking the user for more secrets.
686 probeLoginRequirements(sessionId, credentials = {}) {
687 const record = this._requireConnected(sessionId);
688 let profile;
689 try { profile = this.profileService.get(record.profileId); }
690 catch { return { ready: false, missing: "profile" }; }
691 if (profile.authMethod === "privateKey") {
692 let data;
693 try { data = fs.readFileSync(profile.privateKeyPath); } catch { return { ready: false, missing: "privateKey" }; }
694 const parsed = sshUtils.parseKey(data, credentials.passphrase ? String(credentials.passphrase) : undefined);
695 return parsed instanceof Error ? { ready: false, missing: "passphrase" } : { ready: true, missing: null };
696 }
697 const password = String(credentials.password || this.profileService.getStoredPassword?.(profile.id) || "");
698 return password ? { ready: true, missing: null } : { ready: false, missing: "password" };
699 }
701 // Profile and address of a live session, kept by callers that must reconnect after sshd restarts.
702 connectionTarget(sessionId) {
703 const record = this._requireConnected(sessionId);
704 return { sessionId: record.sessionId, profileId: record.profileId, address: record.address, host: record.host, port: record.port };
705 }
707 // Opens a separate SSH connection with the session profile to prove that new logins still work.
708 // The host key must match the trusted fingerprint; nothing is stored or trusted implicitly.
709 async probeLogin(sessionId, credentials = {}) {
710 const result = await this.freshExec(this.connectionTarget(sessionId), credentials, { command: "true", acceptAnyExit: true });
711 return { ok: true, address: result.address, checkedAt: result.checkedAt };
712 }
714 // Runs one fixed command on a new, short-lived connection (used to confirm configuration changes
715 // even when the original session did not survive an sshd restart). Output is returned, not logged.
716 async freshExec(target, credentials = {}, { command, input = "", timeoutMs = 60000, acceptAnyExit = false } = {}) {
717 const profile = this.profileService.get(target.profileId);
718 const config = this._buildConnectConfig(profile, credentials);
719 config.host = target.address;
720 config.readyTimeout = 15000;
721 config.keepaliveInterval = 0;
722 const knownFingerprint = this.profileService.getHostKey(profile.host, profile.port);
723 config.hostHash = "sha256";
724 config.hostVerifier = (fingerprint) => Boolean(knownFingerprint) && String(fingerprint || "").toLowerCase() === knownFingerprint;
725 const client = this.clientFactory();
726 const reference = { sessionId: target.sessionId, profileId: target.profileId, host: profile.host, port: profile.port };
727 return new Promise((resolve, reject) => {
728 let settled = false;
729 let stdout = "";
730 let stderr = "";
731 const done = (error, code) => {
732 if (settled) return;
733 settled = true;
734 clearTimeout(timer);
735 try { client.end(); } catch {}
736 if (error) {
737 const normalized = this._normalizeConnectionError(error, reference);
738 reject(new SessionError("SSH_PROBE_FAILED", `A new SSH login was not confirmed: ${normalized.message}`, { causeCode: normalized.code }));
739 } else resolve({ ok: true, code, stdout, stderr, address: target.address, checkedAt: new Date().toISOString() });
740 };
741 const timer = setTimeout(() => done(new Error("Login probe timed out.")), timeoutMs);
742 timer.unref?.();
743 client.once("ready", () => openExec(client, command, (error, stream) => {
744 if (error) { done(error); return; }
745 stream.on("data", (chunk) => { if (stdout.length < MAX_COMMAND_OUTPUT) stdout += chunk.toString("utf8"); });
746 stream.stderr?.on("data", (chunk) => { if (stderr.length < MAX_COMMAND_OUTPUT) stderr += chunk.toString("utf8"); });
747 stream.once("close", (code) => {
748 if (acceptAnyExit || code === 0 || code === undefined || code === null) done(null, code);
749 else done(new Error(`Probe command exited with ${code}.`));
750 });
751 try { stream.end(input); } catch {}
752 }));
753 client.on("error", done);
754 client.once("close", () => done(new Error("SSH connection closed before the login was confirmed.")));
755 try { client.connect(config); } catch (error) { done(error); }
756 });
757 }
759 async inspectNginx(sessionId) {
760 const record = this._requireConnected(sessionId);
761 await this._assertPosix(record, "NGINX management");
762 const output = await this._execFixed(record, NGINX_INSPECT_COMMAND, 10000);
763 return { sessionId: record.sessionId, ...parseNginxInspectionOutput(output.stdout), collectedAt: new Date().toISOString() };
764 }
766 async readNginxConfig(sessionId, payload = {}) {
767 const record = this._requireConnected(sessionId);
768 const path = normalizeNginxConfigPath(payload.path);
769 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
770 const command = `target=${shellQuote(path)}; [ -f "$target" ] || { echo 'NGINX configuration file was not found.' >&2; exit 41; }; size=$(wc -c < "$target"); [ "$size" -le ${MAX_NGINX_CONFIG_BYTES} ] || { echo 'NGINX configuration exceeds the 512 KiB editor limit.' >&2; exit 42; }; cat -- "$target"`;
771 const output = sudoPassword
772 ? await this._execSudo(record, command, sudoPassword, 10000)
773 : await this._execFixed(record, command, 10000);
774 return { path, text: output.stdout, size: Buffer.byteLength(output.stdout, "utf8") };
775 }
777 async dumpNginxConfig(sessionId, payload = {}) {
778 const record = this._requireConnected(sessionId);
779 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
780 const output = await this._execSudo(record, "nginx -T 2>&1", sudoPassword, 20000);
781 return { text: output.stdout, generatedAt: new Date().toISOString() };
782 }
784 async testNginxConfig(sessionId, payload = {}) {
785 const record = this._requireConnected(sessionId);
786 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
787 const command = "set +e; output=$(nginx -t 2>&1); code=$?; printf '%s\\n' \"$output\"; printf '\\036CHJ_EXIT=%s\\n' \"$code\"; exit 0";
788 const output = await this._execSudo(record, command, sudoPassword, 20000);
789 const marker = output.stdout.match(/\x1eCHJ_EXIT=(\d+)\s*$/);
790 const code = marker ? Number(marker[1]) : 1;
791 return { ok: code === 0, output: output.stdout.replace(/\x1eCHJ_EXIT=\d+\s*$/, "").trim(), testedAt: new Date().toISOString() };
792 }
794 async saveNginxConfig(sessionId, payload = {}) {
795 const record = this._requireConnected(sessionId);
796 const path = normalizeNginxConfigPath(payload.path);
797 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
798 const text = String(payload.text ?? "");
799 const bytes = Buffer.byteLength(text, "utf8");
800 if (bytes <= 0 || bytes > MAX_NGINX_CONFIG_BYTES || text.includes("\0")) {
801 throw new SessionError("NGINX_CONFIG_INVALID", "NGINX configuration must be UTF-8 text between 1 byte and 512 KiB.");
802 }
803 const encoded = Buffer.from(text, "utf8").toString("base64");
804 const command = `target=${shellQuote(path)}; data=${shellQuote(encoded)}; [ -f "$target" ] || { echo 'NGINX configuration file was not found.' >&2; exit 41; }; [ ! -L "$target" ] || { echo 'Edit the corresponding sites-available file instead of replacing an enabled symlink.' >&2; exit 42; }; backup="$target.chj-backup-$(date +%Y%m%dT%H%M%S)"; temporary=$(mktemp "$target.chj-tmp.XXXXXX") || exit 43; cleanup(){ [ -z "$temporary" ] || rm -f -- "$temporary"; }; trap cleanup EXIT HUP INT TERM; if ! printf '%s' "$data" | base64 --decode > "$temporary" 2>/dev/null; then printf '%s' "$data" | base64 -d > "$temporary" || exit 44; fi; chmod --reference="$target" "$temporary"; chown --reference="$target" "$temporary"; cp -p -- "$target" "$backup"; mv -f -- "$temporary" "$target"; temporary=; test_output=$(nginx -t 2>&1); test_code=$?; if [ "$test_code" -ne 0 ]; then cp -p -- "$backup" "$target"; printf '%s\\nConfiguration was rolled back from %s.\\n' "$test_output" "$backup" >&2; exit 45; fi; printf '%s\\n\\036CHJ_BACKUP=%s\\n' "$test_output" "$backup"`;
805 const output = await this._execSudo(record, command, sudoPassword, 30000);
806 const marker = output.stdout.match(/\x1eCHJ_BACKUP=([^\r\n]+)\s*$/);
807 const backupPath = marker ? marker[1].trim() : "";
808 this.logger?.warn("Remote NGINX configuration saved after successful validation.", { sessionId: record.sessionId, host: record.host, path, backupPath });
809 return { path, backupPath, size: bytes, testOutput: output.stdout.replace(/\x1eCHJ_BACKUP=[^\r\n]+\s*$/, "").trim(), savedAt: new Date().toISOString() };
810 }
812 async reloadNginx(sessionId, payload = {}) {
813 const record = this._requireConnected(sessionId);
814 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
815 if (payload.confirm !== true) throw new SessionError("NGINX_RELOAD_CONFIRMATION_REQUIRED", "Reloading NGINX requires explicit confirmation.");
816 const command = "test_output=$(nginx -t 2>&1) || { printf '%s\\n' \"$test_output\" >&2; exit 41; }; if command -v systemctl >/dev/null 2>&1; then systemctl reload nginx; elif command -v service >/dev/null 2>&1; then service nginx reload; else nginx -s reload; fi; printf '%s\\nNGINX configuration reloaded gracefully.\\n' \"$test_output\"";
817 const output = await this._execSudo(record, command, sudoPassword, 30000);
818 this.logger?.warn("Remote NGINX graceful reload completed.", { sessionId: record.sessionId, host: record.host });
819 return { ok: true, output: output.stdout.trim(), reloadedAt: new Date().toISOString() };
820 }
822 async disconnect(sessionId, reason = "user") {
823 const record = this.sessions.get(String(sessionId || ""));
824 this.pendingTrust.delete(String(sessionId || ""));
825 if (!record) return { disconnected: false };
826 this._finalize(record, reason);
827 return { disconnected: true };
828 }
830 async disconnectAll(reason = "lock") {
831 const ids = [...this.sessions.keys()];
832 for (const id of ids) await this.disconnect(id, reason);
833 this.pendingTrust.clear();
834 return { disconnected: ids.length };
835 }
837 _buildConnectConfig(profile, options) {
838 const config = {
839 host: profile.host,
840 port: profile.port,
841 username: profile.username,
842 readyTimeout: 15000,
843 keepaliveInterval: 15000,
844 keepaliveCountMax: 3
845 };
846 if (profile.authMethod === "privateKey") {
847 if (profile.privateKeyPath.toLowerCase().endsWith(".pub")) throw new SessionError("PUBLIC_KEY_SELECTED", "Select a private key, not a .pub file.");
848 let stat;
849 try { stat = fs.statSync(profile.privateKeyPath); } catch { throw new SessionError("PRIVATE_KEY_NOT_FOUND", "The private key file cannot be read."); }
850 if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_KEY_BYTES) throw new SessionError("PRIVATE_KEY_INVALID", "The private key file is invalid or too large.");
851 config.privateKey = fs.readFileSync(profile.privateKeyPath);
852 const passphrase = String(options.passphrase || "");
853 if (passphrase) config.passphrase = passphrase;
854 } else {
855 const password = String(options.password || this.profileService.getStoredPassword?.(profile.id) || "");
856 if (!password) throw new SessionError("PASSWORD_REQUIRED", "Enter the SSH password.");
857 config.password = password;
858 }
859 return config;
860 }
862 _bindShell(record) {
863 record.stream.on("data", (chunk) => this.emit("data", { sessionId: record.sessionId, data: record.decoder.write(chunk) }));
864 record.stream.stderr?.on("data", (chunk) => this.emit("data", { sessionId: record.sessionId, data: record.decoder.write(chunk) }));
865 record.stream.once("close", () => this._finalize(record, "shell-close"));
866 record.stream.once("error", (error) => {
867 this.emit("sessionError", { sessionId: record.sessionId, message: error?.message || String(error) });
868 this._finalize(record, "shell-error");
869 });
870 }
872 _requireConnected(sessionId) {
873 const record = this.sessions.get(String(sessionId || ""));
874 if (!record || record.state !== "connected" || !record.stream) throw new SessionError("SESSION_NOT_CONNECTED", "The terminal session is not connected.");
875 return record;
876 }
878 _normalizeConnectionError(error, record) {
879 const message = error?.message || String(error || "SSH connection failed.");
880 let code = "SSH_CONNECTION_FAILED";
881 if (process.platform === "darwin" && ["EPERM", "EACCES"].includes(error?.code)) code = "SSH_LOCAL_NETWORK_DENIED";
882 else if (process.platform === "darwin" && ["EHOSTUNREACH", "ENETUNREACH"].includes(error?.code)) code = "SSH_MAC_NETWORK_UNREACHABLE";
883 else if (error?.code === "ENOTFOUND" || error?.code === "EAI_AGAIN" || /getaddrinfo|name or service not known|nodename nor servname/i.test(message)) code = "SSH_DNS_RESOLUTION_FAILED";
884 else if (/authentication/i.test(message)) code = "SSH_AUTHENTICATION_FAILED";
885 else if (/timed?\s*out/i.test(message)) code = "SSH_TIMEOUT";
886 else if (/refused/i.test(message)) code = "SSH_CONNECTION_REFUSED";
887 return new SessionError(code, message, { sessionId: record.sessionId, profileId: record.profileId, host: record.host, port: record.port });
888 }
890 async _resolveConnectionAddress(host) {
891 const literalFamily = net.isIP(host);
892 if (literalFamily) return { address: host, family: literalFamily, source: "literal" };
893 let addresses = [];
894 let lookupError = null;
895 try {
896 addresses = await this.lookupHost(host, { all: true });
897 } catch (error) {
898 lookupError = error;
899 }
900 let normalized = (Array.isArray(addresses) ? addresses : [addresses]).filter((entry) => entry && net.isIP(entry.address) === Number(entry.family));
901 let source = "system";
902 if (!normalized.length) {
903 const [ipv4, ipv6] = await Promise.allSettled([this.resolve4(host), this.resolve6(host)]);
904 normalized = [
905 ...(ipv4.status === "fulfilled" ? ipv4.value.map((address) => ({ address, family: 4 })) : []),
906 ...(ipv6.status === "fulfilled" ? ipv6.value.map((address) => ({ address, family: 6 })) : [])
907 ].filter((entry) => net.isIP(entry.address) === entry.family);
908 source = "dns";
909 }
910 if (!normalized.length) {
911 throw new SessionError("SSH_DNS_RESOLUTION_FAILED", `DNS name "${host}" could not be resolved.`, { host, causeCode: lookupError?.code });
912 }
913 const selected = normalized.find((entry) => entry.family === 4) || normalized[0];
914 this.logger?.info("SSH hostname resolved.", { host, address: selected.address, addressFamily: selected.family, addressCount: normalized.length, source });
915 return { ...selected, source };
916 }
918 _execFixed(record, command, timeoutMs, outputLimit = MAX_COMMAND_OUTPUT) {
919 return new Promise((resolve, reject) => {
920 let settled = false;
921 let stdout = "";
922 let stderr = "";
923 let channel;
924 const cancel = () => finish(new SessionError("SESSION_NOT_CONNECTED", "The SSH connection was lost."));
925 record.pendingExec ||= new Set();
926 record.pendingExec.add(cancel);
927 const timer = setTimeout(() => {
928 finish(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote command timed out."));
929 try { channel?.close(); } catch {}
930 }, timeoutMs);
931 timer.unref?.();
932 const finish = (error, result) => {
933 if (settled) return;
934 settled = true;
935 clearTimeout(timer);
936 record.pendingExec.delete(cancel);
937 if (error) reject(error); else resolve(result);
938 };
939 openExec(record.client, command, (error, stream) => {
940 if (settled) { try { stream?.close(); } catch {} return; }
941 if (error) { finish(new SessionError("REMOTE_COMMAND_FAILED", error.message)); return; }
942 channel = stream;
943 const append = (target, chunk) => {
944 const next = target + chunk.toString("utf8");
945 if (Buffer.byteLength(next, "utf8") > outputLimit) {
946 try { stream.close(); } catch {}
947 finish(new SessionError("REMOTE_OUTPUT_TOO_LARGE", "The system metrics response is too large."));
948 return target;
949 }
950 return next;
951 };
952 stream.on("data", (chunk) => { stdout = append(stdout, chunk); });
953 stream.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk); });
954 stream.once("error", (streamError) => finish(new SessionError("REMOTE_COMMAND_FAILED", streamError.message)));
955 stream.once("close", (code) => {
956 if (!Number.isInteger(code) || code !== 0) finish(new SessionError("REMOTE_COMMAND_FAILED", stderr.trim() || `Remote command exited with ${code}.`, { remoteExitCode: code, editorStarted: stdout.startsWith(EDITOR_STARTED) }));
957 else finish(null, { stdout, stderr });
958 });
959 });
960 });
961 }
963 // SSH round trip over the existing transport: a keepalive@openssh.com global request with want-reply.
964 // SUCCESS and FAILURE replies both complete a client->server->client round trip; no channel, no command
965 // and no new authentication. Falls back to the no-op "exit 0" (valid in sh, cmd.exe and PowerShell),
966 // which is labelled as a command round trip including remote process start.
967 _sshRoundTrip(record, timeoutMs = 5000) {
968 const client = record.client;
969 if (client && Array.isArray(client._callbacks) && typeof client._protocol?.ping === "function") {
970 return new Promise((resolve, reject) => {
971 let done = false;
972 const started = performance.now();
973 const timer = setTimeout(() => { if (!done) { done = true; reject(new SessionError("SSH_RTT_TIMEOUT", "The SSH server did not answer the round-trip request in time.")); } }, timeoutMs);
974 timer.unref?.();
975 // Replies are matched to callbacks in order, so the callback stays queued even after a timeout.
976 client._callbacks.push(() => {
977 if (done) return;
978 done = true;
979 clearTimeout(timer);
980 resolve({ ms: performance.now() - started, method: "transport" });
981 });
982 try { client._protocol.ping(); }
983 catch (error) { if (!done) { done = true; clearTimeout(timer); reject(new SessionError("SSH_RTT_FAILED", error.message)); } }
984 });
985 }
986 return this._execFixed(record, "exit 0", timeoutMs).then(() => ({ method: "command" }));
987 }
989 // Streaming exec channel with stdin access. Resolves with the exit code instead of rejecting on
990 // non-zero exits so callers can classify sudo and protocol failures themselves.
991 _execStream(record, command, { timeoutMs = 30000, outputLimit = MAX_COMMAND_OUTPUT, onUpdate } = {}) {
992 let settled = false;
993 let channel = null;
994 let received = 0;
995 let resolveDone; let rejectDone;
996 const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
997 const controller = {
998 stdout: "",
999 stderr: "",
1000 done: new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; }),
1001 write(text) { if (!settled && channel) { try { channel.write(text); } catch {} } },
1002 end() { if (channel) { try { channel.end(); } catch {} } },
1003 close() { if (channel) { try { channel.close(); } catch {} } }
1004 };
1005 const finish = (error, result) => {
1006 if (settled) return;
1007 settled = true;
1008 clearTimeout(timer);
1009 record.pendingExec?.delete(cancel);
1010 if (error) rejectDone(error); else resolveDone(result);
1011 };
1012 const cancel = () => finish(new SessionError("SESSION_NOT_CONNECTED", "The SSH connection was lost."));
1013 record.pendingExec ||= new Set();
1014 record.pendingExec.add(cancel);
1015 const timer = setTimeout(() => {
1016 finish(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote command timed out."));
1017 controller.close();
1018 }, timeoutMs);
1019 timer.unref?.();
1020 const notify = (kind) => {
1021 try { onUpdate?.(controller, kind); }
1022 catch (error) { finish(error); controller.close(); }
1023 };
1024 openExec(record.client, command, (error, stream) => {
1025 if (settled) { try { stream?.close(); } catch {} return; }
1026 if (error) { finish(new SessionError("REMOTE_COMMAND_FAILED", error.message)); return; }
1027 channel = stream;
1028 const append = (kind, chunk) => {
1029 if (settled) return;
1030 received += chunk.length;
1031 if (received > outputLimit) {
1032 finish(new SessionError("REMOTE_OUTPUT_TOO_LARGE", "The remote response is too large."));
1033 controller.close();
1034 return;
1036 controller[kind] += decoders[kind].write(chunk);
1037 notify(kind);
1038 };
1039 stream.on("data", (chunk) => append("stdout", chunk));
1040 stream.stderr?.on("data", (chunk) => append("stderr", chunk));
1041 stream.once("error", (streamError) => finish(new SessionError("REMOTE_COMMAND_FAILED", streamError.message)));
1042 stream.once("close", (code, signal) => {
1043 controller.stdout += decoders.stdout.end();
1044 controller.stderr += decoders.stderr.end();
1045 finish(null, { code: Number.isInteger(code) ? code : null, signal: signal || null, stdout: controller.stdout, stderr: controller.stderr });
1046 });
1047 notify("open");
1048 });
1049 return controller;
1052 _execSudo(record, command, sudoPassword, timeoutMs, outputLimit = MAX_COMMAND_OUTPUT) {
1053 return new Promise((resolve, reject) => {
1054 let settled = false; let stdout = ""; let stderr = "";
1055 let channel;
1056 const cancel = () => finish(new SessionError("SESSION_NOT_CONNECTED", "The SSH connection was lost."));
1057 record.pendingExec ||= new Set();
1058 record.pendingExec.add(cancel);
1059 const timer = setTimeout(() => {
1060 finish(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote command timed out."));
1061 try { channel?.close(); } catch {}
1062 }, timeoutMs);
1063 timer.unref?.();
1064 const finish = (error, result) => {
1065 if (settled) return;
1066 settled = true; clearTimeout(timer);
1067 record.pendingExec.delete(cancel);
1068 if (error) reject(error); else resolve(result);
1069 };
1070 const isRootSession = record.username === "root";
1071 const prefix = isRootSession ? "sh -c " : (sudoPassword ? "sudo -S -p '' -- sh -c " : "sudo -n -- sh -c ");
1072 const quoted = `'${String(command).replace(/'/g, `'"'"'`)}'`;
1073 openExec(record.client, prefix + quoted, (error, stream) => {
1074 if (settled) { try { stream?.close(); } catch {} return; }
1075 if (error) { finish(new SessionError("USER_ACTION_FAILED", error.message)); return; }
1076 channel = stream;
1077 const append = (target, chunk) => {
1078 const next = target + chunk.toString("utf8");
1079 if (Buffer.byteLength(next, "utf8") > outputLimit) {
1080 try { stream.close(); } catch {}
1081 finish(new SessionError("REMOTE_OUTPUT_TOO_LARGE", "The user-management response is too large."));
1082 return target;
1084 return next;
1085 };
1086 stream.on("data", (chunk) => { stdout = append(stdout, chunk); });
1087 stream.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk); });
1088 stream.once("error", (streamError) => finish(new SessionError("USER_ACTION_FAILED", streamError.message)));
1089 stream.once("close", (code) => {
1090 if (!Number.isInteger(code) || code !== 0) finish(new SessionError("USER_ACTION_FAILED", stderr.trim() || `User command exited with ${code}.`, { remoteExitCode: code, editorStarted: stdout.startsWith(EDITOR_STARTED) }));
1091 else finish(null, { stdout, stderr });
1092 });
1093 if (!isRootSession && sudoPassword) stream.end(`${sudoPassword}\n`); else stream.end();
1094 });
1095 });
1098 _finalize(record, reason) {
1099 if (record.finalized) return;
1100 record.finalized = true;
1101 record.state = "disconnected";
1102 record.stopLatency?.();
1103 record.latency = null;
1104 for (const cancel of record.pendingExec || []) cancel();
1105 if (this.sessions.get(record.sessionId) === record) this.sessions.delete(record.sessionId);
1106 try { record.stream?.end(); } catch {}
1107 try { record.client?.end(); } catch {}
1108 const trailing = record.decoder.end();
1109 if (trailing) this.emit("data", { sessionId: record.sessionId, data: trailing });
1110 const status = { ...this._publicStatus(record), reason };
1111 this.emit("state", status);
1112 this.logger?.info("SSH session disconnected.", { sessionId: record.sessionId, profileId: record.profileId, reason });
1115 _publicStatus(record) {
1116 return {
1117 sessionId: record.sessionId,
1118 profileId: record.profileId,
1119 label: record.label,
1120 host: record.host,
1121 port: record.port,
1122 username: record.username,
1123 authMethod: record.authMethod,
1124 state: record.state,
1125 latency: record.latency || { pingMs: null, sshRttMs: null, measuredAt: null }
1126 };
1130module.exports = {
1131 MAX_NGINX_CONFIG_BYTES,
1132 METRICS_COMMAND,
1133 NGINX_INSPECT_COMMAND,
1134 SessionError,
1135 SessionManager,
1136 USERNAME_PATTERN,
1137 normalizeNginxConfigPath,
1138 normalizeTerminalSize,
1139 openExec,
1140 parseNginxInspectionOutput,
1141 shellQuote,
1142 parseSystemMetricsOutput
SHA-25656f43b462689d2cdf1e66929cc443595c8df31e3c4c58288f861f7d0cb2ace5d

CH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.

SHA-256 archivucf4455b698f0f28e456a37b1834976a0194d8486acb7101052d59a838745cca3
Soubory