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.

45,6 KB · 882 linesDownload file
1"use strict";
3const fs = require("node:fs");
4const dns = require("node:dns").promises;
5const net = require("node:net");
6const { EventEmitter } = require("node:events");
7const { StringDecoder } = require("node:string_decoder");
8const { Client } = require("ssh2");
9const path = require("node:path");
10const { LatencyMonitor } = require("./latencyMonitor");
11const REMOTE_EDITOR = fs.readFileSync(path.join(__dirname, "../files/remoteEditor.py"), "utf8");
12const EDITOR_STARTED = "\x1eCHJ_EDITOR_START\n";
14const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]{1,80}$/;
15const MAX_INPUT_LENGTH = 64 * 1024;
16const MAX_KEY_BYTES = 2 * 1024 * 1024;
17const MAX_COMMAND_OUTPUT = 512 * 1024;
18const MAX_NGINX_CONFIG_BYTES = 512 * 1024;
19const USERNAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
20const USER_ACTIONS = new Set(["create", "delete", "lock", "unlock", "grantAdmin", "revokeAdmin"]);
21const METRICS_COMMAND = String.raw`LC_ALL=C
22platform=$(uname -s 2>/dev/null || echo unknown)
23kernel=$(uname -r 2>/dev/null || echo unknown)
24host=$(hostname 2>/dev/null || echo unknown)
25cpu_arch=$(uname -m 2>/dev/null || echo unknown)
26cpu_usage=0
27cpu_logical=0
28cpu_physical=0
29cpu_sockets=0
30cpu_model=unknown
31cpu_core_types=
32network_default=
34if [ -r /proc/uptime ]; then
35 uptime_s=$(awk '{print int($1)}' /proc/uptime)
36else
37 boot_epoch=$(sysctl -n kern.boottime 2>/dev/null | awk -F'[=,]' '{gsub(/[^0-9]/,"",$2); print $2}')
38 now_epoch=$(date +%s 2>/dev/null || echo 0)
39 if [ -n "$boot_epoch" ] && [ "$now_epoch" -ge "$boot_epoch" ] 2>/dev/null; then uptime_s=$((now_epoch-boot_epoch)); else uptime_s=0; fi
40fi
41if [ -r /proc/loadavg ]; then load1=$(awk '{print $1}' /proc/loadavg); else load1=$(uptime | sed -E 's/.*load averages?:[[:space:]]*([0-9.]+).*/\1/'); fi
43if [ -r /proc/meminfo ]; then
44 mem_total=$(awk '/^MemTotal:/{print $2*1024}' /proc/meminfo)
45 mem_avail=$(awk '/^MemAvailable:/{print $2*1024}' /proc/meminfo)
46 swap_total=$(awk '/^SwapTotal:/{print $2*1024}' /proc/meminfo)
47 swap_free=$(awk '/^SwapFree:/{print $2*1024}' /proc/meminfo)
48 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)
49 sleep 0.2
50 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)
51 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}')
52 cpu_logical=$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || awk '/^processor[[:space:]]*:/{n++}END{print n+0}' /proc/cpuinfo)
53 cpu_model=$(awk -F: '/^(model name|Processor|Hardware)[[:space:]]*:/{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2);if($2!=""){print $2;exit}}' /proc/cpuinfo)
54 if command -v lscpu >/dev/null 2>&1; then
55 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}')
56 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}')
57 fi
58 [ "$cpu_physical" -gt 0 ] 2>/dev/null || cpu_physical=$cpu_logical
59 [ "$cpu_sockets" -gt 0 ] 2>/dev/null || cpu_sockets=1
60 if ls /sys/devices/system/cpu/cpu*/topology/core_type >/dev/null 2>&1; then
61 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)
62 efficiency_cores=$(printf '%s\n' "$core_type_records" | awk -F: '$1==1{n++}END{print n+0}')
63 performance_cores=$(printf '%s\n' "$core_type_records" | awk -F: '$1==2{n++}END{print n+0}')
64 [ "$performance_cores" -gt 0 ] && cpu_core_types="P-core:$performance_cores"
65 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
66 fi
67 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
68else
69 mem_total=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
70 page_size=$(vm_stat 2>/dev/null | awk '/page size of/{print $8}')
71 [ -n "$page_size" ] || page_size=4096
72 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}')
73 swap_total=0
74 swap_free=0
75 cpu_logical=$(sysctl -n hw.logicalcpu 2>/dev/null || echo 0)
76 cpu_physical=$(sysctl -n hw.physicalcpu 2>/dev/null || echo "$cpu_logical")
77 cpu_sockets=1
78 cpu_model=$(sysctl -n machdep.cpu.brand_string 2>/dev/null || sysctl -n hw.model 2>/dev/null || echo unknown)
79 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}')
80 for level in 0 1 2; do
81 count=$(sysctl -n "hw.perflevel$level.physicalcpu" 2>/dev/null || echo 0)
82 if [ "$count" -gt 0 ] 2>/dev/null; then
83 name=$(sysctl -n "hw.perflevel$level.name" 2>/dev/null || echo "Cluster $level")
84 [ -n "$cpu_core_types" ] && separator=, || separator=
85 cpu_core_types=$(printf '%s%s%s:%s' "$cpu_core_types" "$separator" "$name" "$count")
86 fi
87 done
88 network_default=$(route -n get default 2>/dev/null | awk '/interface:/{print $2;exit}')
89fi
91[ -n "$cpu_model" ] || cpu_model=unknown
92cpu_model=$(printf '%s' "$cpu_model" | tr '\r\n|\036' ' ')
93cpu_core_types=$(printf '%s' "$cpu_core_types" | tr '\r\n|\036' ' ')
94disk=$(df -Pk / 2>/dev/null | awk 'NR==2{print $2*1024":"$3*1024":"$4*1024}')
95printf '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"
97if [ -d /sys/class/net ]; then
98 for iface_path in /sys/class/net/*; do
99 [ -e "$iface_path" ] || continue
100 iface=$(basename "$iface_path")
101 [ "$iface" = lo ] && continue
102 state=$(cat "$iface_path/operstate" 2>/dev/null || echo unknown)
103 mac=$(cat "$iface_path/address" 2>/dev/null || true)
104 rx=$(cat "$iface_path/statistics/rx_bytes" 2>/dev/null || echo 0)
105 tx=$(cat "$iface_path/statistics/tx_bytes" 2>/dev/null || echo 0)
106 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
107 printf 'networkInterface=%s|%s|%s|%s|%s|%s\n' "$iface" "$state" "$mac" "$rx" "$tx" "$addresses"
108 done
109elif command -v ifconfig >/dev/null 2>&1; then
110 for iface in $(ifconfig -l 2>/dev/null); do
111 [ "$iface" = lo0 ] && continue
112 state=$(ifconfig "$iface" 2>/dev/null | awk '/status:/{print $2;exit}')
113 mac=$(ifconfig "$iface" 2>/dev/null | awk '/ether /{print $2;exit}')
114 addresses=$(ifconfig "$iface" 2>/dev/null | awk '/^[[:space:]]+inet /{print $2}/^[[:space:]]+inet6 /{print $2}' | paste -sd, -)
115 counters=$(netstat -ibn -I "$iface" 2>/dev/null | awk 'NR==2{print $7":"$10;exit}')
116 rx=$(printf '%s' "$counters" | cut -d: -f1); tx=$(printf '%s' "$counters" | cut -d: -f2)
117 [ -n "$state" ] || state=unknown; [ -n "$rx" ] || rx=0; [ -n "$tx" ] || tx=0
118 printf 'networkInterface=%s|%s|%s|%s|%s|%s\n' "$iface" "$state" "$mac" "$rx" "$tx" "$addresses"
119 done
120fi`;
121const USERS_COMMAND = String.raw`LC_ALL=C; getent passwd | awk -F: '$1=="root" || ($3 >= 1000 && $3 != 65534)'; printf '\036CHJ_GROUPS\n'; getent group sudo 2>/dev/null || true; getent group wheel 2>/dev/null || true; printf '\036CHJ_STATUS\n'; for name in $(getent passwd | awk -F: '$1=="root" || ($3 >= 1000 && $3 != 65534) {print $1}'); do passwd -S "$name" 2>/dev/null || true; done`;
122const NGINX_INSPECT_COMMAND = String.raw`LC_ALL=C
123nginx_bin=$(command -v nginx 2>/dev/null || true)
124if [ -z "$nginx_bin" ]; then printf 'installed=0\n'; exit 0; fi
125printf 'installed=1\n'
126printf 'binary=%s\n' "$nginx_bin"
127version=$($nginx_bin -v 2>&1 | sed 's/^nginx version: //')
128build=$($nginx_bin -V 2>&1 | tr '\n' ' ')
129conf_path=$(printf '%s' "$build" | sed -n 's/.*--conf-path=\([^ ]*\).*/\1/p')
130[ -n "$conf_path" ] || conf_path=/etc/nginx/nginx.conf
131if 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
132[ -n "$service_state" ] || service_state=unknown
133printf 'version=%s\nconfigPath=%s\nserviceState=%s\n' "$version" "$conf_path" "$service_state"
134for 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
135 [ -d "$directory" ] || continue
136 find "$directory" -maxdepth 1 \( -type f -o -type l \) ! -name '*.chj-backup-*' -print 2>/dev/null | while IFS= read -r file; do
137 [ -L "$file" ] && kind=symlink || kind=file
138 printf 'config=%s|%s\n' "$kind" "$file"
139 done
140done`;
142const 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}))$/;
144function normalizeNginxConfigPath(value) {
145 const path = String(value || "").trim();
146 if (!NGINX_CONFIG_PATH_PATTERN.test(path) || path.includes(".chj-backup-")) {
147 throw new SessionError("NGINX_CONFIG_PATH_INVALID", "The NGINX configuration path is outside the managed directories.");
148 }
149 return path;
152function shellQuote(value) {
153 return `'${String(value).replace(/'/g, `'"'"'`)}'`;
156function openExec(client, command, callback) {
157 try { client.exec(command, callback); }
158 catch (error) { callback(error); }
161function normalizeSudoPassword(value) {
162 const password = String(value || "");
163 if (password.length > 1024 || /[\0\r\n]/.test(password)) throw new SessionError("SUDO_PASSWORD_INVALID", "Invalid sudo password.");
164 return password;
167function parseNginxInspectionOutput(stdout) {
168 const result = { installed: false, binary: "", version: "", configPath: "", serviceState: "unknown", configs: [] };
169 const seen = new Set();
170 for (const line of String(stdout || "").split(/\r?\n/)) {
171 if (line.startsWith("config=")) {
172 const separator = line.indexOf("|", 7);
173 if (separator < 0) continue;
174 const kind = line.slice(7, separator) === "symlink" ? "symlink" : "file";
175 try {
176 const path = normalizeNginxConfigPath(line.slice(separator + 1));
177 if (!seen.has(path)) { seen.add(path); result.configs.push({ path, kind, writable: kind === "file" }); }
178 } catch {}
179 continue;
180 }
181 const separator = line.indexOf("=");
182 if (separator < 1) continue;
183 const key = line.slice(0, separator);
184 const value = line.slice(separator + 1).replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 512);
185 if (key === "installed") result.installed = value === "1";
186 else if (["binary", "version", "configPath", "serviceState"].includes(key)) result[key] = value;
187 }
188 result.configs.sort((left, right) => left.path.localeCompare(right.path));
189 return result;
192class SessionError extends Error {
193 constructor(code, message, details = {}) {
194 super(message);
195 this.name = "SessionError";
196 this.code = code;
197 Object.assign(this, details);
198 }
200 toJSON() {
201 const result = { code: this.code, message: this.message };
202 for (const key of ["sessionId", "profileId", "host", "port", "fingerprint", "knownFingerprint"]) {
203 if (this[key] !== undefined) result[key] = this[key];
204 }
205 return result;
206 }
209function normalizeTerminalSize(value, fallback, min, max) {
210 const number = Number(value);
211 return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback;
214function metricNumber(value, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
215 const number = Number(value);
216 return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : minimum;
219function metricText(value, fallback = "unknown", maximumLength = 240) {
220 const normalized = String(value ?? "")
221 .replace(/[\x00-\x1f\x7f|]/g, " ")
222 .replace(/\s+/g, " ")
223 .trim()
224 .slice(0, maximumLength);
225 return normalized || fallback;
228function parseCpuCoreTypes(value) {
229 return String(value || "").split(",").slice(0, 16).map((entry) => {
230 const separator = entry.lastIndexOf(":");
231 if (separator <= 0) return null;
232 const name = metricText(entry.slice(0, separator), "", 80);
233 const count = Math.floor(metricNumber(entry.slice(separator + 1), 0, 4096));
234 return name && count > 0 ? { name, count } : null;
235 }).filter(Boolean);
238function parseNetworkInterfaces(lines, defaultInterface) {
239 return lines.filter((line) => line.startsWith("networkInterface=")).slice(0, 64).map((line) => {
240 const fields = line.slice("networkInterface=".length).split("|");
241 const name = metricText(fields[0], "unknown", 64);
242 const state = metricText(fields[1], "unknown", 32).toLowerCase();
243 const addresses = String(fields[5] || "").split(",").slice(0, 16)
244 .map((address) => metricText(address, "", 128)).filter(Boolean);
245 return {
246 name,
247 state,
248 mac: metricText(fields[2], "", 64),
249 rxBytes: metricNumber(fields[3]),
250 txBytes: metricNumber(fields[4]),
251 addresses,
252 isDefault: name === defaultInterface
253 };
254 });
257function parseSystemMetricsOutput(stdout, fallbackHostname = "unknown") {
258 const lines = String(stdout || "").split(/\r?\n/).filter(Boolean);
259 const values = Object.fromEntries(lines.filter((line) => !line.startsWith("networkInterface=")).map((line) => {
260 const index = line.indexOf("=");
261 return index > 0 ? [line.slice(0, index), line.slice(index + 1)] : [line, ""];
262 }));
263 const disk = String(values.disk || "0:0:0").split(":").map((value) => metricNumber(value));
264 const memoryTotal = metricNumber(values.memoryTotal);
265 const memoryAvailable = metricNumber(values.memoryAvailable, 0, memoryTotal);
266 const swapTotal = metricNumber(values.swapTotal);
267 const swapFree = metricNumber(values.swapFree, 0, swapTotal);
268 const defaultInterface = metricText(values.networkDefault, "", 64);
269 return {
270 platform: metricText(values.platform),
271 kernel: metricText(values.kernel),
272 hostname: metricText(values.hostname, fallbackHostname),
273 uptimeSeconds: metricNumber(values.uptimeSeconds),
274 load1: metricNumber(values.load1),
275 memory: { total: memoryTotal, available: memoryAvailable },
276 swap: { total: swapTotal, used: Math.max(0, swapTotal - swapFree), free: swapFree },
277 disk: {
278 total: disk[0] || 0,
279 used: Math.min(disk[0] || 0, disk[1] || 0),
280 available: Math.min(disk[0] || 0, disk[2] || 0)
281 },
282 cpu: {
283 usagePercent: metricNumber(values.cpuUsage, 0, 100),
284 architecture: metricText(values.cpuArchitecture),
285 model: metricText(values.cpuModel),
286 logicalCores: Math.floor(metricNumber(values.cpuLogical, 0, 4096)),
287 physicalCores: Math.floor(metricNumber(values.cpuPhysical, 0, 4096)),
288 sockets: Math.floor(metricNumber(values.cpuSockets, 0, 256)),
289 coreTypes: parseCpuCoreTypes(values.cpuCoreTypes)
290 },
291 network: {
292 defaultInterface,
293 interfaces: parseNetworkInterfaces(lines, defaultInterface)
294 }
295 };
298class SessionManager extends EventEmitter {
299 constructor(options) {
300 super();
301 this.profileService = options.profileService;
302 this.logger = options.logger;
303 this.clientFactory = options.clientFactory || (() => new Client());
304 this.lookupHost = options.lookupHost || dns.lookup;
305 this.resolve4 = options.resolve4 || dns.resolve4;
306 this.resolve6 = options.resolve6 || dns.resolve6;
307 this.latencyMonitor = options.latencyMonitor || new LatencyMonitor();
308 this.sessions = new Map();
309 this.pendingTrust = new Map();
310 }
312 list() {
313 return [...this.sessions.values()].map((record) => this._publicStatus(record));
314 }
316 async connect(options = {}) {
317 const sessionId = String(options.sessionId || "").trim();
318 if (!SESSION_ID_PATTERN.test(sessionId)) throw new SessionError("INVALID_SESSION_ID", "Invalid terminal session ID.");
319 await this.disconnect(sessionId, "reconnect");
320 const profile = this.profileService.get(String(options.profileId || ""));
321 const config = this._buildConnectConfig(profile, options);
322 const resolvedHost = await this._resolveConnectionAddress(profile.host);
323 config.host = resolvedHost.address;
324 const knownFingerprint = this.profileService.getHostKey(profile.host, profile.port);
325 const client = this.clientFactory();
326 const record = {
327 sessionId,
328 profileId: profile.id,
329 label: profile.label,
330 host: profile.host,
331 address: resolvedHost.address,
332 port: profile.port,
333 username: profile.username,
334 authMethod: profile.authMethod,
335 state: "connecting",
336 client,
337 stream: null,
338 decoder: new StringDecoder("utf8"),
339 finalized: false
340 };
341 this.sessions.set(sessionId, record);
342 this.emit("state", this._publicStatus(record));
344 let hostKeyIssue = null;
345 config.hostHash = "sha256";
346 config.hostVerifier = (fingerprint) => {
347 const normalized = String(fingerprint || "").toLowerCase();
348 if (!knownFingerprint) {
349 hostKeyIssue = { code: "HOST_KEY_UNKNOWN", fingerprint: normalized };
350 this.pendingTrust.set(sessionId, {
351 sessionId,
352 profileId: profile.id,
353 host: profile.host,
354 port: profile.port,
355 fingerprint: normalized,
356 knownFingerprint: null,
357 expiresAt: Date.now() + 5 * 60 * 1000
358 });
359 return false;
360 }
361 if (knownFingerprint !== normalized) {
362 hostKeyIssue = { code: "HOST_KEY_MISMATCH", fingerprint: normalized, knownFingerprint };
363 this.pendingTrust.set(sessionId, {
364 sessionId,
365 profileId: profile.id,
366 host: profile.host,
367 port: profile.port,
368 fingerprint: normalized,
369 knownFingerprint,
370 expiresAt: Date.now() + 5 * 60 * 1000
371 });
372 return false;
373 }
374 return true;
375 };
377 return new Promise((resolve, reject) => {
378 let settled = false;
379 const fail = (error) => {
380 if (settled) {
381 const normalized = this._normalizeConnectionError(error, record);
382 this.emit("sessionError", normalized.toJSON());
383 this._finalize(record, normalized.code);
384 return;
385 }
386 settled = true;
387 const normalized = hostKeyIssue
388 ? new SessionError(hostKeyIssue.code, hostKeyIssue.code === "HOST_KEY_UNKNOWN" ? "The SSH host key is not trusted yet." : "The SSH host key changed.", {
389 sessionId,
390 profileId: profile.id,
391 host: profile.host,
392 port: profile.port,
393 ...hostKeyIssue
394 })
395 : this._normalizeConnectionError(error, record);
396 this.logger?.warn("SSH connection failed.", { sessionId, code: normalized.code, message: normalized.message, address: record.address, port: record.port });
397 this._finalize(record, normalized.code);
398 reject(normalized);
399 };
401 client.once("ready", () => {
402 const cols = normalizeTerminalSize(options.cols, 100, 20, 500);
403 const rows = normalizeTerminalSize(options.rows, 30, 5, 300);
404 client.shell({ term: "xterm-256color", cols, rows }, (error, stream) => {
405 if (error) { fail(error); return; }
406 if (settled) { try { stream.close(); } catch {} return; }
407 settled = true;
408 record.stream = stream;
409 record.state = "connected";
410 this.pendingTrust.delete(sessionId);
411 this._bindShell(record);
412 this.profileService.markUsed(profile.id);
413 this.logger?.info("SSH session connected.", {
414 sessionId,
415 profileId: profile.id,
416 host: profile.host,
417 port: profile.port,
418 username: profile.username,
419 authMethod: profile.authMethod
420 });
421 const status = this._publicStatus(record);
422 this.emit("state", status);
423 record.stopLatency = this.latencyMonitor.start(record,
424 () => this._execFixed(record, "true", 5000),
425 (latency) => {
426 if (record.finalized || this.sessions.get(sessionId) !== record) return;
427 record.latency = latency;
428 this.emit("state", this._publicStatus(record));
429 });
430 resolve(status);
431 });
432 });
433 client.on("error", fail);
434 client.once("close", () => {
435 if (!settled) fail(new Error("SSH connection closed before it became ready."));
436 else this._finalize(record, "remote-close");
437 });
438 try { client.connect(config); } catch (error) { fail(error); }
439 });
440 }
442 trustPending(options = {}) {
443 const sessionId = String(options.sessionId || "");
444 const pending = this.pendingTrust.get(sessionId);
445 const fingerprint = String(options.fingerprint || "").toLowerCase();
446 if (!pending || pending.expiresAt < Date.now()) {
447 this.pendingTrust.delete(sessionId);
448 throw new SessionError("HOST_KEY_CONFIRMATION_EXPIRED", "The host-key confirmation expired.");
449 }
450 if (pending.profileId !== options.profileId || pending.fingerprint !== fingerprint) {
451 throw new SessionError("HOST_KEY_CONFIRMATION_INVALID", "The host-key confirmation does not match the pending connection.");
452 }
453 if (pending.knownFingerprint) {
454 const confirmedKnownFingerprint = String(options.knownFingerprint || "").toLowerCase();
455 if (options.replaceKnown !== true || confirmedKnownFingerprint !== pending.knownFingerprint) {
456 throw new SessionError("HOST_KEY_REPLACEMENT_CONFIRMATION_REQUIRED", "Replacing a changed SSH host key requires explicit confirmation of both fingerprints.");
457 }
458 if (this.profileService.getHostKey(pending.host, pending.port) !== pending.knownFingerprint) {
459 this.pendingTrust.delete(sessionId);
460 throw new SessionError("HOST_KEY_REPLACEMENT_STALE", "The stored SSH host key changed while confirmation was pending.");
461 }
462 } else if (options.replaceKnown === true) {
463 throw new SessionError("HOST_KEY_CONFIRMATION_INVALID", "A new host key cannot be confirmed as a replacement.");
464 }
465 const result = this.profileService.trustHostKey(pending.host, pending.port, pending.fingerprint);
466 this.pendingTrust.delete(sessionId);
467 const details = { host: pending.host, port: pending.port, fingerprint: pending.fingerprint, replacedFingerprint: pending.knownFingerprint };
468 if (pending.knownFingerprint) this.logger?.warn("Changed SSH host key replaced by user confirmation.", details);
469 else this.logger?.info("SSH host key trusted by user.", details);
470 return result;
471 }
473 write(sessionId, data) {
474 const record = this._requireConnected(sessionId);
475 const text = String(data ?? "");
476 if (Buffer.byteLength(text, "utf8") > MAX_INPUT_LENGTH) throw new SessionError("TERMINAL_INPUT_TOO_LARGE", "Terminal input is too large.");
477 record.stream.write(text);
478 }
480 resize(sessionId, cols, rows) {
481 const record = this._requireConnected(sessionId);
482 const normalizedCols = normalizeTerminalSize(cols, 100, 20, 500);
483 const normalizedRows = normalizeTerminalSize(rows, 30, 5, 300);
484 record.stream.setWindow(normalizedRows, normalizedCols, 0, 0);
485 return { cols: normalizedCols, rows: normalizedRows };
486 }
488 async readSystemMetrics(sessionId) {
489 const record = this._requireConnected(sessionId);
490 const output = await this._execFixed(record, METRICS_COMMAND, 10000);
491 return {
492 sessionId: record.sessionId,
493 ...parseSystemMetricsOutput(output.stdout, record.host),
494 collectedAt: new Date().toISOString()
495 };
496 }
498 openSftp(sessionId) {
499 const record = this._requireConnected(sessionId);
500 return new Promise((resolve, reject) => record.client.sftp((error, sftp) => error ? reject(new SessionError("SFTP_OPEN_FAILED", error.message)) : resolve(sftp)));
501 }
503 // Internal fixed protocol only. Neither renderer nor plugin can supply code.
504 async remoteEditor(sessionId, request, authorization = {}) {
505 const record = this._requireConnected(sessionId);
506 let password;
507 try { password = normalizeSudoPassword(authorization.sudoPassword); }
508 catch { throw new SessionError("FILE_SUDO_PASSWORD_INVALID", "Invalid sudo password."); }
509 if (password && authorization.sudo !== true) throw new SessionError("FILE_SUDO_AUTHORIZATION_REQUIRED", "Explicit sudo authorization is required.");
510 const command = `printf '\\036CHJ_EDITOR_START\\n'; exec python3 -I -B -c ${shellQuote(REMOTE_EDITOR)} ${shellQuote(JSON.stringify(request))}`;
511 let output;
512 try {
513 output = authorization.sudo === true
514 ? await this._execSudo(record, command, password, 60000, 36 * 1024 * 1024)
515 : await this._execFixed(record, command, 60000, 36 * 1024 * 1024);
516 } catch (error) {
517 // Remote stderr and sudo diagnostics never become editor errors or logs.
518 const definitiveSudoFailure = authorization.sudo === true && error.remoteExitCode === 1 && error.editorStarted === false;
519 throw new SessionError(definitiveSudoFailure ? "FILE_SUDO_FAILED" : error.remoteExitCode === 127 ? "FILE_DEPENDENCY_UNAVAILABLE" : request.operation === "finalize" ? "FILE_RESULT_UNKNOWN" : "FILE_EXEC_FAILED",
520 "Remote editor operation was not confirmed. Inspect recovery before retrying.");
521 }
522 let response;
523 try {
524 if (!output.stdout.startsWith(EDITOR_STARTED)) throw new Error("Missing editor marker");
525 response = JSON.parse(output.stdout.slice(EDITOR_STARTED.length));
526 }
527 catch { throw new SessionError("FILE_RESULT_UNKNOWN", "Remote editor result could not be verified."); }
528 if (!response || response.ok !== true || !response.value || typeof response.value !== "object") {
529 const code = /^FILE_[A-Z_]+$/.test(response?.code) ? response.code : "FILE_PROTOCOL_ERROR";
530 throw new SessionError(code, `${code}: Remote editor operation stopped; recoverable copies were retained.`);
531 }
532 return response.value;
533 }
535 async readUsers(sessionId) {
536 const record = this._requireConnected(sessionId);
537 const output = await this._execFixed(record, USERS_COMMAND, 10000);
538 const [passwdText = "", groupText = "", statusText = ""] = output.stdout.split(/\x1eCHJ_(?:GROUPS|STATUS)\r?\n/);
539 const adminMembers = new Set(["root"]);
540 for (const line of groupText.split(/\r?\n/).filter(Boolean)) {
541 const fields = line.split(":");
542 if (fields[0] !== "sudo" && fields[0] !== "wheel") continue;
543 for (const username of String(fields[3] || "").split(",").filter(Boolean)) adminMembers.add(username);
544 }
545 const statuses = new Map(statusText.split(/\r?\n/).filter(Boolean).map((line) => {
546 const [username, state = ""] = line.trim().split(/\s+/, 3);
547 return [username, state];
548 }));
549 return passwdText.split(/\r?\n/).filter(Boolean).map((line) => {
550 const [username, , uid, gid, displayName, home, shell] = line.split(":");
551 const passwordState = statuses.get(username) || "";
552 return {
553 username,
554 uid: Number(uid) || 0,
555 gid: Number(gid) || 0,
556 displayName: String(displayName || "").split(",")[0],
557 home: String(home || ""),
558 shell: String(shell || ""),
559 admin: adminMembers.has(username),
560 locked: passwordState === "L" || passwordState === "LK",
561 passwordState
562 };
563 }).sort((left, right) => left.uid - right.uid || left.username.localeCompare(right.username));
564 }
566 async manageUser(sessionId, payload = {}) {
567 const record = this._requireConnected(sessionId);
568 const action = String(payload.action || "");
569 const username = String(payload.username || "").trim();
570 if (!USER_ACTIONS.has(action)) throw new SessionError("USER_ACTION_INVALID", "Unsupported user-management action.");
571 if (!USERNAME_PATTERN.test(username)) throw new SessionError("USERNAME_INVALID", "Username must use lowercase letters, numbers, underscore or hyphen.");
572 if (username === "root" || username === record.username) {
573 throw new SessionError("PROTECTED_USER", "Core will not modify root or the account used by the active SSH session.");
574 }
575 const sudoPassword = String(payload.sudoPassword || "");
576 if (sudoPassword.length > 1024 || /[\0\r\n]/.test(sudoPassword)) throw new SessionError("SUDO_PASSWORD_INVALID", "Invalid sudo password.");
577 const commands = {
578 create: `useradd --create-home --shell /bin/bash -- ${username}${payload.admin === true ? ` && group=$(getent group sudo >/dev/null && echo sudo || echo wheel) && usermod --append --groups "$group" -- ${username}` : ""}`,
579 delete: `userdel ${payload.removeHome === true ? "--remove " : ""}-- ${username}`,
580 lock: `usermod --lock -- ${username}`,
581 unlock: `usermod --unlock -- ${username}`,
582 grantAdmin: `group=$(getent group sudo >/dev/null && echo sudo || echo wheel); usermod --append --groups "$group" -- ${username}`,
583 revokeAdmin: `group=$(getent group sudo >/dev/null && echo sudo || echo wheel); gpasswd --delete ${username} "$group"`
584 };
585 await this._execSudo(record, commands[action], sudoPassword, 30000);
586 this.logger?.warn("Remote user-management action completed.", { sessionId: record.sessionId, host: record.host, action, username });
587 return { action, username, users: await this.readUsers(sessionId) };
588 }
590 async inspectNginx(sessionId) {
591 const record = this._requireConnected(sessionId);
592 const output = await this._execFixed(record, NGINX_INSPECT_COMMAND, 10000);
593 return { sessionId: record.sessionId, ...parseNginxInspectionOutput(output.stdout), collectedAt: new Date().toISOString() };
594 }
596 async readNginxConfig(sessionId, payload = {}) {
597 const record = this._requireConnected(sessionId);
598 const path = normalizeNginxConfigPath(payload.path);
599 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
600 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"`;
601 const output = sudoPassword
602 ? await this._execSudo(record, command, sudoPassword, 10000)
603 : await this._execFixed(record, command, 10000);
604 return { path, text: output.stdout, size: Buffer.byteLength(output.stdout, "utf8") };
605 }
607 async dumpNginxConfig(sessionId, payload = {}) {
608 const record = this._requireConnected(sessionId);
609 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
610 const output = await this._execSudo(record, "nginx -T 2>&1", sudoPassword, 20000);
611 return { text: output.stdout, generatedAt: new Date().toISOString() };
612 }
614 async testNginxConfig(sessionId, payload = {}) {
615 const record = this._requireConnected(sessionId);
616 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
617 const command = "set +e; output=$(nginx -t 2>&1); code=$?; printf '%s\\n' \"$output\"; printf '\\036CHJ_EXIT=%s\\n' \"$code\"; exit 0";
618 const output = await this._execSudo(record, command, sudoPassword, 20000);
619 const marker = output.stdout.match(/\x1eCHJ_EXIT=(\d+)\s*$/);
620 const code = marker ? Number(marker[1]) : 1;
621 return { ok: code === 0, output: output.stdout.replace(/\x1eCHJ_EXIT=\d+\s*$/, "").trim(), testedAt: new Date().toISOString() };
622 }
624 async saveNginxConfig(sessionId, payload = {}) {
625 const record = this._requireConnected(sessionId);
626 const path = normalizeNginxConfigPath(payload.path);
627 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
628 const text = String(payload.text ?? "");
629 const bytes = Buffer.byteLength(text, "utf8");
630 if (bytes <= 0 || bytes > MAX_NGINX_CONFIG_BYTES || text.includes("\0")) {
631 throw new SessionError("NGINX_CONFIG_INVALID", "NGINX configuration must be UTF-8 text between 1 byte and 512 KiB.");
632 }
633 const encoded = Buffer.from(text, "utf8").toString("base64");
634 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"`;
635 const output = await this._execSudo(record, command, sudoPassword, 30000);
636 const marker = output.stdout.match(/\x1eCHJ_BACKUP=([^\r\n]+)\s*$/);
637 const backupPath = marker ? marker[1].trim() : "";
638 this.logger?.warn("Remote NGINX configuration saved after successful validation.", { sessionId: record.sessionId, host: record.host, path, backupPath });
639 return { path, backupPath, size: bytes, testOutput: output.stdout.replace(/\x1eCHJ_BACKUP=[^\r\n]+\s*$/, "").trim(), savedAt: new Date().toISOString() };
640 }
642 async reloadNginx(sessionId, payload = {}) {
643 const record = this._requireConnected(sessionId);
644 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
645 if (payload.confirm !== true) throw new SessionError("NGINX_RELOAD_CONFIRMATION_REQUIRED", "Reloading NGINX requires explicit confirmation.");
646 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\"";
647 const output = await this._execSudo(record, command, sudoPassword, 30000);
648 this.logger?.warn("Remote NGINX graceful reload completed.", { sessionId: record.sessionId, host: record.host });
649 return { ok: true, output: output.stdout.trim(), reloadedAt: new Date().toISOString() };
650 }
652 async disconnect(sessionId, reason = "user") {
653 const record = this.sessions.get(String(sessionId || ""));
654 this.pendingTrust.delete(String(sessionId || ""));
655 if (!record) return { disconnected: false };
656 this._finalize(record, reason);
657 return { disconnected: true };
658 }
660 async disconnectAll(reason = "lock") {
661 const ids = [...this.sessions.keys()];
662 for (const id of ids) await this.disconnect(id, reason);
663 this.pendingTrust.clear();
664 return { disconnected: ids.length };
665 }
667 _buildConnectConfig(profile, options) {
668 const config = {
669 host: profile.host,
670 port: profile.port,
671 username: profile.username,
672 readyTimeout: 15000,
673 keepaliveInterval: 15000,
674 keepaliveCountMax: 3
675 };
676 if (profile.authMethod === "privateKey") {
677 if (profile.privateKeyPath.toLowerCase().endsWith(".pub")) throw new SessionError("PUBLIC_KEY_SELECTED", "Select a private key, not a .pub file.");
678 let stat;
679 try { stat = fs.statSync(profile.privateKeyPath); } catch { throw new SessionError("PRIVATE_KEY_NOT_FOUND", "The private key file cannot be read."); }
680 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.");
681 config.privateKey = fs.readFileSync(profile.privateKeyPath);
682 const passphrase = String(options.passphrase || "");
683 if (passphrase) config.passphrase = passphrase;
684 } else {
685 const password = String(options.password || this.profileService.getStoredPassword?.(profile.id) || "");
686 if (!password) throw new SessionError("PASSWORD_REQUIRED", "Enter the SSH password.");
687 config.password = password;
688 }
689 return config;
690 }
692 _bindShell(record) {
693 record.stream.on("data", (chunk) => this.emit("data", { sessionId: record.sessionId, data: record.decoder.write(chunk) }));
694 record.stream.stderr?.on("data", (chunk) => this.emit("data", { sessionId: record.sessionId, data: record.decoder.write(chunk) }));
695 record.stream.once("close", () => this._finalize(record, "shell-close"));
696 record.stream.once("error", (error) => {
697 this.emit("sessionError", { sessionId: record.sessionId, message: error?.message || String(error) });
698 this._finalize(record, "shell-error");
699 });
700 }
702 _requireConnected(sessionId) {
703 const record = this.sessions.get(String(sessionId || ""));
704 if (!record || record.state !== "connected" || !record.stream) throw new SessionError("SESSION_NOT_CONNECTED", "The terminal session is not connected.");
705 return record;
706 }
708 _normalizeConnectionError(error, record) {
709 const message = error?.message || String(error || "SSH connection failed.");
710 let code = "SSH_CONNECTION_FAILED";
711 if (process.platform === "darwin" && ["EPERM", "EACCES"].includes(error?.code)) code = "SSH_LOCAL_NETWORK_DENIED";
712 else if (process.platform === "darwin" && ["EHOSTUNREACH", "ENETUNREACH"].includes(error?.code)) code = "SSH_MAC_NETWORK_UNREACHABLE";
713 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";
714 else if (/authentication/i.test(message)) code = "SSH_AUTHENTICATION_FAILED";
715 else if (/timed?\s*out/i.test(message)) code = "SSH_TIMEOUT";
716 else if (/refused/i.test(message)) code = "SSH_CONNECTION_REFUSED";
717 return new SessionError(code, message, { sessionId: record.sessionId, profileId: record.profileId, host: record.host, port: record.port });
718 }
720 async _resolveConnectionAddress(host) {
721 const literalFamily = net.isIP(host);
722 if (literalFamily) return { address: host, family: literalFamily, source: "literal" };
723 let addresses = [];
724 let lookupError = null;
725 try {
726 addresses = await this.lookupHost(host, { all: true });
727 } catch (error) {
728 lookupError = error;
729 }
730 let normalized = (Array.isArray(addresses) ? addresses : [addresses]).filter((entry) => entry && net.isIP(entry.address) === Number(entry.family));
731 let source = "system";
732 if (!normalized.length) {
733 const [ipv4, ipv6] = await Promise.allSettled([this.resolve4(host), this.resolve6(host)]);
734 normalized = [
735 ...(ipv4.status === "fulfilled" ? ipv4.value.map((address) => ({ address, family: 4 })) : []),
736 ...(ipv6.status === "fulfilled" ? ipv6.value.map((address) => ({ address, family: 6 })) : [])
737 ].filter((entry) => net.isIP(entry.address) === entry.family);
738 source = "dns";
739 }
740 if (!normalized.length) {
741 throw new SessionError("SSH_DNS_RESOLUTION_FAILED", `DNS name "${host}" could not be resolved.`, { host, causeCode: lookupError?.code });
742 }
743 const selected = normalized.find((entry) => entry.family === 4) || normalized[0];
744 this.logger?.info("SSH hostname resolved.", { host, address: selected.address, addressFamily: selected.family, addressCount: normalized.length, source });
745 return { ...selected, source };
746 }
748 _execFixed(record, command, timeoutMs, outputLimit = MAX_COMMAND_OUTPUT) {
749 return new Promise((resolve, reject) => {
750 let settled = false;
751 let stdout = "";
752 let stderr = "";
753 let channel;
754 const cancel = () => finish(new SessionError("SESSION_NOT_CONNECTED", "The SSH connection was lost."));
755 record.pendingExec ||= new Set();
756 record.pendingExec.add(cancel);
757 const timer = setTimeout(() => {
758 finish(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote command timed out."));
759 try { channel?.close(); } catch {}
760 }, timeoutMs);
761 timer.unref?.();
762 const finish = (error, result) => {
763 if (settled) return;
764 settled = true;
765 clearTimeout(timer);
766 record.pendingExec.delete(cancel);
767 if (error) reject(error); else resolve(result);
768 };
769 openExec(record.client, command, (error, stream) => {
770 if (settled) { try { stream?.close(); } catch {} return; }
771 if (error) { finish(new SessionError("REMOTE_COMMAND_FAILED", error.message)); return; }
772 channel = stream;
773 const append = (target, chunk) => {
774 const next = target + chunk.toString("utf8");
775 if (Buffer.byteLength(next, "utf8") > outputLimit) {
776 try { stream.close(); } catch {}
777 finish(new SessionError("REMOTE_OUTPUT_TOO_LARGE", "The system metrics response is too large."));
778 return target;
779 }
780 return next;
781 };
782 stream.on("data", (chunk) => { stdout = append(stdout, chunk); });
783 stream.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk); });
784 stream.once("error", (streamError) => finish(new SessionError("REMOTE_COMMAND_FAILED", streamError.message)));
785 stream.once("close", (code) => {
786 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) }));
787 else finish(null, { stdout, stderr });
788 });
789 });
790 });
791 }
793 _execSudo(record, command, sudoPassword, timeoutMs, outputLimit = MAX_COMMAND_OUTPUT) {
794 return new Promise((resolve, reject) => {
795 let settled = false; let stdout = ""; let stderr = "";
796 let channel;
797 const cancel = () => finish(new SessionError("SESSION_NOT_CONNECTED", "The SSH connection was lost."));
798 record.pendingExec ||= new Set();
799 record.pendingExec.add(cancel);
800 const timer = setTimeout(() => {
801 finish(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote command timed out."));
802 try { channel?.close(); } catch {}
803 }, timeoutMs);
804 timer.unref?.();
805 const finish = (error, result) => {
806 if (settled) return;
807 settled = true; clearTimeout(timer);
808 record.pendingExec.delete(cancel);
809 if (error) reject(error); else resolve(result);
810 };
811 const isRootSession = record.username === "root";
812 const prefix = isRootSession ? "sh -c " : (sudoPassword ? "sudo -S -p '' -- sh -c " : "sudo -n -- sh -c ");
813 const quoted = `'${String(command).replace(/'/g, `'"'"'`)}'`;
814 openExec(record.client, prefix + quoted, (error, stream) => {
815 if (settled) { try { stream?.close(); } catch {} return; }
816 if (error) { finish(new SessionError("USER_ACTION_FAILED", error.message)); return; }
817 channel = stream;
818 const append = (target, chunk) => {
819 const next = target + chunk.toString("utf8");
820 if (Buffer.byteLength(next, "utf8") > outputLimit) {
821 try { stream.close(); } catch {}
822 finish(new SessionError("REMOTE_OUTPUT_TOO_LARGE", "The user-management response is too large."));
823 return target;
824 }
825 return next;
826 };
827 stream.on("data", (chunk) => { stdout = append(stdout, chunk); });
828 stream.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk); });
829 stream.once("error", (streamError) => finish(new SessionError("USER_ACTION_FAILED", streamError.message)));
830 stream.once("close", (code) => {
831 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) }));
832 else finish(null, { stdout, stderr });
833 });
834 if (!isRootSession && sudoPassword) stream.end(`${sudoPassword}\n`); else stream.end();
835 });
836 });
837 }
839 _finalize(record, reason) {
840 if (record.finalized) return;
841 record.finalized = true;
842 record.state = "disconnected";
843 record.stopLatency?.();
844 record.latency = null;
845 for (const cancel of record.pendingExec || []) cancel();
846 if (this.sessions.get(record.sessionId) === record) this.sessions.delete(record.sessionId);
847 try { record.stream?.end(); } catch {}
848 try { record.client?.end(); } catch {}
849 const trailing = record.decoder.end();
850 if (trailing) this.emit("data", { sessionId: record.sessionId, data: trailing });
851 const status = { ...this._publicStatus(record), reason };
852 this.emit("state", status);
853 this.logger?.info("SSH session disconnected.", { sessionId: record.sessionId, profileId: record.profileId, reason });
854 }
856 _publicStatus(record) {
857 return {
858 sessionId: record.sessionId,
859 profileId: record.profileId,
860 label: record.label,
861 host: record.host,
862 port: record.port,
863 username: record.username,
864 authMethod: record.authMethod,
865 state: record.state,
866 latency: record.latency || { pingMs: null, sshRttMs: null, measuredAt: null }
867 };
868 }
871module.exports = {
872 MAX_NGINX_CONFIG_BYTES,
873 METRICS_COMMAND,
874 NGINX_INSPECT_COMMAND,
875 SessionError,
876 SessionManager,
877 USERNAME_PATTERN,
878 normalizeNginxConfigPath,
879 normalizeTerminalSize,
880 parseNginxInspectionOutput,
881 parseSystemMetricsOutput
882};

SHA-256: f47f26a6ba6f9a2711ee6ce479bf64d0682dc00bcbbcf8124bb3b13f37a0e171

Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0