CH-J Server Managerการจัดการเซิร์ฟเวอร์ผ่าน SSH
เมนู
ซอร์สที่เผยแพร่

CH-J Server Manager

เรียกดูไดเรกทอรีและไฟล์ของรุ่นแอปพลิเคชันที่ระบุ

ดาวน์โหลด ZIP ของซอร์ส
CH-J / Server Manager 377 ไฟล์
core-20261019T111616Z-160a00bfcddc วันที่เพิ่ม: · วันที่บิลด์:
sessionManager.js วันที่เพิ่ม: ไม่ได้บันทึกวันที่ 62.7 KB · 1201 บรรทัด
โค้ด ดาวน์โหลดไฟล์
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";
15// Privileged helper runs go through the shared PrivilegedExecutor protocol (sudo prompt marker,
16// READY, then the request as a stdin frame) instead of a separate sudo implementation.
17const EDITOR_PRIVILEGED_SCRIPT = String.raw`PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH
18printf '\036CHJ_READY\n'
19command -v python3 >/dev/null 2>&1 || { printf '\036CHJ_ERR=DEPENDENCY_UNAVAILABLE\n'; exit 127; }
20printf '\036CHJ_EDITOR_START\n'
21exec python3 -I -B -c "$1"`;
22// Operations whose effect is unknown once the request was delivered but the reply was lost.
23const EDITOR_MUTATIONS = new Set(["finalize", "publish", "rename", "remove", "mkdir", "create", "cleanup", "stage-out", "transfer-remove"]);
24const EDITOR_OUTPUT_LIMIT = 36 * 1024 * 1024;
25const WINDOWS_HASH_SCRIPT = String.raw`param([string]$ArgsJson)
26$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'
27$A = $ArgsJson | ConvertFrom-Json
28$p = [string]$A.path
29if ($p -match '^/[A-Za-z]:') { $p = $p.Substring(1) }
30$h = (Get-FileHash -Algorithm SHA256 -LiteralPath $p).Hash.ToLowerInvariant()
31[Console]::Out.WriteLine('CHJ_RESULT:' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ hash = $h } -Compress))))`;
33const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]{1,80}$/;
34const MAX_INPUT_LENGTH = 64 * 1024;
35const MAX_KEY_BYTES = 2 * 1024 * 1024;
36const MAX_COMMAND_OUTPUT = 512 * 1024;
37const MAX_NGINX_CONFIG_BYTES = 512 * 1024;
38const USERNAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
39const METRICS_COMMAND = String.raw`LC_ALL=C
40platform=$(uname -s 2>/dev/null || echo unknown)
41kernel=$(uname -r 2>/dev/null || echo unknown)
42host=$(hostname 2>/dev/null || echo unknown)
43cpu_arch=$(uname -m 2>/dev/null || echo unknown)
44cpu_usage=0
45cpu_logical=0
46cpu_physical=0
47cpu_sockets=0
48cpu_model=unknown
49cpu_core_types=
50network_default=
52if [ -r /proc/uptime ]; then
53 uptime_s=$(awk '{print int($1)}' /proc/uptime)
54else
55 boot_epoch=$(sysctl -n kern.boottime 2>/dev/null | awk -F'[=,]' '{gsub(/[^0-9]/,"",$2); print $2}')
56 now_epoch=$(date +%s 2>/dev/null || echo 0)
57 if [ -n "$boot_epoch" ] && [ "$now_epoch" -ge "$boot_epoch" ] 2>/dev/null; then uptime_s=$((now_epoch-boot_epoch)); else uptime_s=0; fi
58fi
59if [ -r /proc/loadavg ]; then load1=$(awk '{print $1}' /proc/loadavg); else load1=$(uptime | sed -E 's/.*load averages?:[[:space:]]*([0-9.]+).*/\1/'); fi
61if [ -r /proc/meminfo ]; then
62 mem_total=$(awk '/^MemTotal:/{print $2*1024}' /proc/meminfo)
63 mem_avail=$(awk '/^MemAvailable:/{print $2*1024}' /proc/meminfo)
64 swap_total=$(awk '/^SwapTotal:/{print $2*1024}' /proc/meminfo)
65 swap_free=$(awk '/^SwapFree:/{print $2*1024}' /proc/meminfo)
66 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)
67 sleep 0.2
68 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)
69 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}')
70 cpu_logical=$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || awk '/^processor[[:space:]]*:/{n++}END{print n+0}' /proc/cpuinfo)
71 cpu_model=$(awk -F: '/^(model name|Processor|Hardware)[[:space:]]*:/{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2);if($2!=""){print $2;exit}}' /proc/cpuinfo)
72 if command -v lscpu >/dev/null 2>&1; then
73 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}')
74 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}')
75 fi
76 [ "$cpu_physical" -gt 0 ] 2>/dev/null || cpu_physical=$cpu_logical
77 [ "$cpu_sockets" -gt 0 ] 2>/dev/null || cpu_sockets=1
78 if ls /sys/devices/system/cpu/cpu*/topology/core_type >/dev/null 2>&1; then
79 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)
80 efficiency_cores=$(printf '%s\n' "$core_type_records" | awk -F: '$1==1{n++}END{print n+0}')
81 performance_cores=$(printf '%s\n' "$core_type_records" | awk -F: '$1==2{n++}END{print n+0}')
82 [ "$performance_cores" -gt 0 ] && cpu_core_types="P-core:$performance_cores"
83 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
84 fi
85 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
86else
87 mem_total=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
88 page_size=$(vm_stat 2>/dev/null | awk '/page size of/{print $8}')
89 [ -n "$page_size" ] || page_size=4096
90 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}')
91 swap_total=0
92 swap_free=0
93 cpu_logical=$(sysctl -n hw.logicalcpu 2>/dev/null || echo 0)
94 cpu_physical=$(sysctl -n hw.physicalcpu 2>/dev/null || echo "$cpu_logical")
95 cpu_sockets=1
96 cpu_model=$(sysctl -n machdep.cpu.brand_string 2>/dev/null || sysctl -n hw.model 2>/dev/null || echo unknown)
97 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}')
98 for level in 0 1 2; do
99 count=$(sysctl -n "hw.perflevel$level.physicalcpu" 2>/dev/null || echo 0)
100 if [ "$count" -gt 0 ] 2>/dev/null; then
101 name=$(sysctl -n "hw.perflevel$level.name" 2>/dev/null || echo "Cluster $level")
102 [ -n "$cpu_core_types" ] && separator=, || separator=
103 cpu_core_types=$(printf '%s%s%s:%s' "$cpu_core_types" "$separator" "$name" "$count")
104 fi
105 done
106 network_default=$(route -n get default 2>/dev/null | awk '/interface:/{print $2;exit}')
107fi
109[ -n "$cpu_model" ] || cpu_model=unknown
110cpu_model=$(printf '%s' "$cpu_model" | tr '\r\n|\036' ' ')
111cpu_core_types=$(printf '%s' "$cpu_core_types" | tr '\r\n|\036' ' ')
112disk=$(df -Pk / 2>/dev/null | awk 'NR==2{print $2*1024":"$3*1024":"$4*1024}')
113printf '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"
115# Detailed memory and swap for the "Memory and virtual memory" section (kB fields converted to bytes).
116if [ -r /proc/meminfo ]; then
117 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
118fi
119if [ -r /proc/swaps ]; then
120 awk 'NR>1 { printf "swapArea=%s|%s|%.0f|%.0f|%s\n", $1, $2, $3*1024, $4*1024, $5 }' /proc/swaps
121fi
122if [ -r /proc/vmstat ]; then
123 awk '$1=="pswpin" || $1=="pswpout" || $1=="pgmajfault" { printf "vmstat=%s|%s\n", $1, $2 }' /proc/vmstat
124fi
125for zram in /sys/block/zram*; do
126 [ -r "$zram/mm_stat" ] || continue
127 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')"
128done
129if [ -r /sys/module/zswap/parameters/enabled ]; then
130 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)"
131fi
132printf 'pageSize=%s\n' "$(getconf PAGESIZE 2>/dev/null || echo 4096)"
134if [ -d /sys/class/net ]; then
135 for iface_path in /sys/class/net/*; do
136 [ -e "$iface_path" ] || continue
137 iface=$(basename "$iface_path")
138 [ "$iface" = lo ] && continue
139 state=$(cat "$iface_path/operstate" 2>/dev/null || echo unknown)
140 mac=$(cat "$iface_path/address" 2>/dev/null || true)
141 rx=$(cat "$iface_path/statistics/rx_bytes" 2>/dev/null || echo 0)
142 tx=$(cat "$iface_path/statistics/tx_bytes" 2>/dev/null || echo 0)
143 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
144 printf 'networkInterface=%s|%s|%s|%s|%s|%s\n' "$iface" "$state" "$mac" "$rx" "$tx" "$addresses"
145 done
146elif command -v ifconfig >/dev/null 2>&1; then
147 for iface in $(ifconfig -l 2>/dev/null); do
148 [ "$iface" = lo0 ] && continue
149 state=$(ifconfig "$iface" 2>/dev/null | awk '/status:/{print $2;exit}')
150 mac=$(ifconfig "$iface" 2>/dev/null | awk '/ether /{print $2;exit}')
151 addresses=$(ifconfig "$iface" 2>/dev/null | awk '/^[[:space:]]+inet /{print $2}/^[[:space:]]+inet6 /{print $2}' | paste -sd, -)
152 counters=$(netstat -ibn -I "$iface" 2>/dev/null | awk 'NR==2{print $7":"$10;exit}')
153 rx=$(printf '%s' "$counters" | cut -d: -f1); tx=$(printf '%s' "$counters" | cut -d: -f2)
154 [ -n "$state" ] || state=unknown; [ -n "$rx" ] || rx=0; [ -n "$tx" ] || tx=0
155 printf 'networkInterface=%s|%s|%s|%s|%s|%s\n' "$iface" "$state" "$mac" "$rx" "$tx" "$addresses"
156 done
157fi`;
158const NGINX_INSPECT_COMMAND = String.raw`LC_ALL=C
159nginx_bin=$(command -v nginx 2>/dev/null || true)
160if [ -z "$nginx_bin" ]; then printf 'installed=0\n'; exit 0; fi
161printf 'installed=1\n'
162printf 'binary=%s\n' "$nginx_bin"
163version=$($nginx_bin -v 2>&1 | sed 's/^nginx version: //')
164build=$($nginx_bin -V 2>&1 | tr '\n' ' ')
165conf_path=$(printf '%s' "$build" | sed -n 's/.*--conf-path=\([^ ]*\).*/\1/p')
166[ -n "$conf_path" ] || conf_path=/etc/nginx/nginx.conf
167if 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
168[ -n "$service_state" ] || service_state=unknown
169printf 'version=%s\nconfigPath=%s\nserviceState=%s\n' "$version" "$conf_path" "$service_state"
170for 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
171 [ -d "$directory" ] || continue
172 find "$directory" -maxdepth 1 \( -type f -o -type l \) ! -name '*.chj-backup-*' -print 2>/dev/null | while IFS= read -r file; do
173 [ -L "$file" ] && kind=symlink || kind=file
174 printf 'config=%s|%s\n' "$kind" "$file"
175 done
176done`;
178const 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}))$/;
180function normalizeNginxConfigPath(value) {
181 const path = String(value || "").trim();
182 if (!NGINX_CONFIG_PATH_PATTERN.test(path) || path.includes(".chj-backup-")) {
183 throw new SessionError("NGINX_CONFIG_PATH_INVALID", "The NGINX configuration path is outside the managed directories.");
184 }
185 return path;
188function shellQuote(value) {
189 return `'${String(value).replace(/'/g, `'"'"'`)}'`;
192function openExec(client, command, callback) {
193 try { client.exec(command, callback); }
194 catch (error) { callback(error); }
197// SHA256 fingerprint of the session's public key, used only to refuse removing the key in use.
198function publicKeyFingerprintForPrivateKey(privateKeyPath) {
199 let blob = null;
200 try {
201 const parsed = sshUtils.parseKey(fs.readFileSync(privateKeyPath));
202 if (!(parsed instanceof Error)) blob = (Array.isArray(parsed) ? parsed[0] : parsed).getPublicSSH();
203 } catch {}
204 if (!blob) {
205 try { blob = Buffer.from(fs.readFileSync(`${privateKeyPath}.pub`, "utf8").trim().split(/\s+/)[1] || "", "base64"); } catch {}
206 }
207 return blob?.length ? `SHA256:${crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}` : null;
210function normalizeSudoPassword(value) {
211 const password = String(value || "");
212 if (password.length > 1024 || /[\0\r\n]/.test(password)) throw new SessionError("SUDO_PASSWORD_INVALID", "Invalid sudo password.");
213 return password;
216function parseNginxInspectionOutput(stdout) {
217 const result = { installed: false, binary: "", version: "", configPath: "", serviceState: "unknown", configs: [] };
218 const seen = new Set();
219 for (const line of String(stdout || "").split(/\r?\n/)) {
220 if (line.startsWith("config=")) {
221 const separator = line.indexOf("|", 7);
222 if (separator < 0) continue;
223 const kind = line.slice(7, separator) === "symlink" ? "symlink" : "file";
224 try {
225 const path = normalizeNginxConfigPath(line.slice(separator + 1));
226 if (!seen.has(path)) { seen.add(path); result.configs.push({ path, kind, writable: kind === "file" }); }
227 } catch {}
228 continue;
229 }
230 const separator = line.indexOf("=");
231 if (separator < 1) continue;
232 const key = line.slice(0, separator);
233 const value = line.slice(separator + 1).replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 512);
234 if (key === "installed") result.installed = value === "1";
235 else if (["binary", "version", "configPath", "serviceState"].includes(key)) result[key] = value;
236 }
237 result.configs.sort((left, right) => left.path.localeCompare(right.path));
238 return result;
241class SessionError extends Error {
242 constructor(code, message, details = {}) {
243 super(message);
244 this.name = "SessionError";
245 this.code = code;
246 Object.assign(this, details);
247 }
249 toJSON() {
250 const result = { code: this.code, message: this.message };
251 for (const key of ["sessionId", "profileId", "host", "port", "fingerprint", "knownFingerprint"]) {
252 if (this[key] !== undefined) result[key] = this[key];
253 }
254 return result;
255 }
258function normalizeTerminalSize(value, fallback, min, max) {
259 const number = Number(value);
260 return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback;
263function metricNumber(value, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
264 const number = Number(value);
265 return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : minimum;
268function metricText(value, fallback = "unknown", maximumLength = 240) {
269 const normalized = String(value ?? "")
270 .replace(/[\x00-\x1f\x7f|]/g, " ")
271 .replace(/\s+/g, " ")
272 .trim()
273 .slice(0, maximumLength);
274 return normalized || fallback;
277function parseCpuCoreTypes(value) {
278 return String(value || "").split(",").slice(0, 16).map((entry) => {
279 const separator = entry.lastIndexOf(":");
280 if (separator <= 0) return null;
281 const name = metricText(entry.slice(0, separator), "", 80);
282 const count = Math.floor(metricNumber(entry.slice(separator + 1), 0, 4096));
283 return name && count > 0 ? { name, count } : null;
284 }).filter(Boolean);
287function parseNetworkInterfaces(lines, defaultInterface) {
288 return lines.filter((line) => line.startsWith("networkInterface=")).slice(0, 64).map((line) => {
289 const fields = line.slice("networkInterface=".length).split("|");
290 const name = metricText(fields[0], "unknown", 64);
291 const state = metricText(fields[1], "unknown", 32).toLowerCase();
292 const addresses = String(fields[5] || "").split(",").slice(0, 16)
293 .map((address) => metricText(address, "", 128)).filter(Boolean);
294 return {
295 name,
296 state,
297 mac: metricText(fields[2], "", 64),
298 rxBytes: metricNumber(fields[3]),
299 txBytes: metricNumber(fields[4]),
300 addresses,
301 isDefault: name === defaultInterface
302 };
303 });
306const LIST_PREFIXES = ["networkInterface=", "meminfo=", "swapArea=", "vmstat=", "zram=", "zswap="];
308function parseLinuxMemoryDetail(lines) {
309 const meminfo = {};
310 for (const line of lines.filter((entry) => entry.startsWith("meminfo="))) {
311 const [key, value] = line.slice(8).split("|");
312 if (/^[A-Za-z_()]+$/.test(key) && Number.isFinite(Number(value))) meminfo[key] = Number(value);
313 }
314 if (!meminfo.MemTotal) return null;
315 const get = (key) => (key in meminfo ? meminfo[key] : null);
316 const total = meminfo.MemTotal;
317 const free = get("MemFree") ?? 0;
318 const buffers = get("Buffers") ?? 0;
319 const cached = get("Cached") ?? 0;
320 const reclaimableSlab = get("SReclaimable") ?? 0;
321 // Non-overlapping slices: Cached already contains Shmem; SwapCached is reported separately (not a slice).
322 const used = Math.max(0, total - free - buffers - cached - reclaimableSlab);
323 const vmstat = Object.fromEntries(lines.filter((entry) => entry.startsWith("vmstat=")).map((line) => line.slice(7).split("|")).map(([key, value]) => [key, Number(value)]));
324 const swapAreas = lines.filter((entry) => entry.startsWith("swapArea=")).slice(0, 32).map((line) => {
325 const [name = "", type = "", size = "", usedBytes = "", priority = ""] = line.slice(9).split("|");
326 const path = name.replace(/\\040/g, " ");
327 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 };
328 });
329 const zram = lines.filter((entry) => entry.startsWith("zram=")).slice(0, 16).map((line) => {
330 const [device = "", disksize = "", mmStat = "", algorithm = ""] = line.slice(5).split("|");
331 const stat = mmStat.split(",").filter(Boolean).map(Number);
332 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 };
333 });
334 const zswapLine = lines.find((entry) => entry.startsWith("zswap="));
335 const [zswapEnabled = "", zswapCompressor = "", zswapPool = ""] = zswapLine ? zswapLine.slice(6).split("|") : [];
336 return {
337 source: "/proc/meminfo",
338 total,
339 free,
340 available: get("MemAvailable"),
341 used,
342 buffers,
343 cached,
344 shmem: get("Shmem"),
345 swapCached: get("SwapCached"),
346 slab: get("Slab"),
347 slabReclaimable: get("SReclaimable"),
348 slabUnreclaimable: get("SUnreclaim"),
349 kernelStack: get("KernelStack"),
350 pageTables: get("PageTables"),
351 vmallocUsed: get("VmallocUsed"),
352 dirty: get("Dirty"),
353 writeback: get("Writeback"),
354 commit: { committed: get("Committed_AS"), limit: get("CommitLimit") },
355 swapTotal: get("SwapTotal"),
356 swapFree: get("SwapFree"),
357 zswapPool: get("Zswap"),
358 zswapStored: get("Zswapped"),
359 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 },
360 swapAreas,
361 zram,
362 zswap: zswapLine ? { enabled: /^[Yy1]/.test(zswapEnabled), compressor: metricText(zswapCompressor, "", 32) || null, maxPoolPercent: Number(zswapPool) || null } : null
363 };
366function parseSystemMetricsOutput(stdout, fallbackHostname = "unknown") {
367 const lines = String(stdout || "").split(/\r?\n/).filter(Boolean);
368 const values = Object.fromEntries(lines.filter((line) => !LIST_PREFIXES.some((prefix) => line.startsWith(prefix))).map((line) => {
369 const index = line.indexOf("=");
370 return index > 0 ? [line.slice(0, index), line.slice(index + 1)] : [line, ""];
371 }));
372 const disk = String(values.disk || "0:0:0").split(":").map((value) => metricNumber(value));
373 const memoryTotal = metricNumber(values.memoryTotal);
374 const memoryAvailable = metricNumber(values.memoryAvailable, 0, memoryTotal);
375 const swapTotal = metricNumber(values.swapTotal);
376 const swapFree = metricNumber(values.swapFree, 0, swapTotal);
377 const defaultInterface = metricText(values.networkDefault, "", 64);
378 const memoryDetail = parseLinuxMemoryDetail(lines);
379 return {
380 os: "linux",
381 loadAverageSupported: true,
382 pageSize: metricNumber(values.pageSize, 0, 1 << 30) || null,
383 memoryDetail,
384 swapAreas: memoryDetail?.swapAreas || [],
385 platform: metricText(values.platform),
386 kernel: metricText(values.kernel),
387 hostname: metricText(values.hostname, fallbackHostname),
388 uptimeSeconds: metricNumber(values.uptimeSeconds),
389 load1: metricNumber(values.load1),
390 memory: { total: memoryTotal, available: memoryAvailable },
391 swap: { total: swapTotal, used: Math.max(0, swapTotal - swapFree), free: swapFree },
392 disk: {
393 mount: "/",
394 total: disk[0] || 0,
395 used: Math.min(disk[0] || 0, disk[1] || 0),
396 available: Math.min(disk[0] || 0, disk[2] || 0)
397 },
398 cpu: {
399 usagePercent: metricNumber(values.cpuUsage, 0, 100),
400 architecture: metricText(values.cpuArchitecture),
401 model: metricText(values.cpuModel),
402 logicalCores: Math.floor(metricNumber(values.cpuLogical, 0, 4096)),
403 physicalCores: Math.floor(metricNumber(values.cpuPhysical, 0, 4096)),
404 sockets: Math.floor(metricNumber(values.cpuSockets, 0, 256)),
405 coreTypes: parseCpuCoreTypes(values.cpuCoreTypes)
406 },
407 network: {
408 defaultInterface,
409 interfaces: parseNetworkInterfaces(lines, defaultInterface)
410 }
411 };
414class SessionManager extends EventEmitter {
415 constructor(options) {
416 super();
417 this.profileService = options.profileService;
418 this.logger = options.logger;
419 this.clientFactory = options.clientFactory || (() => new Client());
420 this.lookupHost = options.lookupHost || dns.lookup;
421 this.resolve4 = options.resolve4 || dns.resolve4;
422 this.resolve6 = options.resolve6 || dns.resolve6;
423 this.latencyMonitor = options.latencyMonitor || new LatencyMonitor();
424 this.sessions = new Map();
425 this.pendingTrust = new Map();
426 }
428 list() {
429 return [...this.sessions.values()].map((record) => this._publicStatus(record));
430 }
432 async connect(options = {}) {
433 const sessionId = String(options.sessionId || "").trim();
434 if (!SESSION_ID_PATTERN.test(sessionId)) throw new SessionError("INVALID_SESSION_ID", "Invalid terminal session ID.");
435 await this.disconnect(sessionId, "reconnect");
436 const profile = this.profileService.get(String(options.profileId || ""));
437 const config = this._buildConnectConfig(profile, options);
438 const resolvedHost = await this._resolveConnectionAddress(profile.host);
439 config.host = resolvedHost.address;
440 const knownFingerprint = this.profileService.getHostKey(profile.host, profile.port);
441 const client = this.clientFactory();
442 const record = {
443 sessionId,
444 profileId: profile.id,
445 label: profile.label,
446 host: profile.host,
447 address: resolvedHost.address,
448 port: profile.port,
449 username: profile.username,
450 authMethod: profile.authMethod,
451 state: "connecting",
452 client,
453 stream: null,
454 decoder: new StringDecoder("utf8"),
455 finalized: false
456 };
457 this.sessions.set(sessionId, record);
458 this.emit("state", this._publicStatus(record));
460 let hostKeyIssue = null;
461 config.hostHash = "sha256";
462 config.hostVerifier = (fingerprint) => {
463 const normalized = String(fingerprint || "").toLowerCase();
464 if (!knownFingerprint) {
465 hostKeyIssue = { code: "HOST_KEY_UNKNOWN", fingerprint: normalized };
466 this.pendingTrust.set(sessionId, {
467 sessionId,
468 profileId: profile.id,
469 host: profile.host,
470 port: profile.port,
471 fingerprint: normalized,
472 knownFingerprint: null,
473 expiresAt: Date.now() + 5 * 60 * 1000
474 });
475 return false;
476 }
477 if (knownFingerprint !== normalized) {
478 hostKeyIssue = { code: "HOST_KEY_MISMATCH", fingerprint: normalized, knownFingerprint };
479 this.pendingTrust.set(sessionId, {
480 sessionId,
481 profileId: profile.id,
482 host: profile.host,
483 port: profile.port,
484 fingerprint: normalized,
485 knownFingerprint,
486 expiresAt: Date.now() + 5 * 60 * 1000
487 });
488 return false;
489 }
490 return true;
491 };
493 return new Promise((resolve, reject) => {
494 let settled = false;
495 const fail = (error) => {
496 if (settled) {
497 const normalized = this._normalizeConnectionError(error, record);
498 this.emit("sessionError", normalized.toJSON());
499 this._finalize(record, normalized.code);
500 return;
501 }
502 settled = true;
503 const normalized = hostKeyIssue
504 ? new SessionError(hostKeyIssue.code, hostKeyIssue.code === "HOST_KEY_UNKNOWN" ? "The SSH host key is not trusted yet." : "The SSH host key changed.", {
505 sessionId,
506 profileId: profile.id,
507 host: profile.host,
508 port: profile.port,
509 ...hostKeyIssue
510 })
511 : this._normalizeConnectionError(error, record);
512 this.logger?.warn("SSH connection failed.", { sessionId, code: normalized.code, message: normalized.message, address: record.address, port: record.port });
513 this._finalize(record, normalized.code);
514 reject(normalized);
515 };
517 client.once("ready", () => {
518 const cols = normalizeTerminalSize(options.cols, 100, 20, 500);
519 const rows = normalizeTerminalSize(options.rows, 30, 5, 300);
520 client.shell({ term: "xterm-256color", cols, rows }, (error, stream) => {
521 if (error) { fail(error); return; }
522 if (settled) { try { stream.close(); } catch {} return; }
523 settled = true;
524 record.stream = stream;
525 record.state = "connected";
526 this.pendingTrust.delete(sessionId);
527 this._bindShell(record);
528 this.profileService.markUsed(profile.id);
529 this.logger?.info("SSH session connected.", {
530 sessionId,
531 profileId: profile.id,
532 host: profile.host,
533 port: profile.port,
534 username: profile.username,
535 authMethod: profile.authMethod
536 });
537 const status = this._publicStatus(record);
538 this.emit("state", status);
539 record.stopLatency = this.latencyMonitor.start(record,
540 () => this._sshRoundTrip(record),
541 (latency) => {
542 if (record.finalized || this.sessions.get(sessionId) !== record) return;
543 record.latency = latency;
544 this.emit("state", this._publicStatus(record));
545 });
546 resolve(status);
547 });
548 });
549 client.on("error", fail);
550 client.once("close", () => {
551 if (!settled) fail(new Error("SSH connection closed before it became ready."));
552 else this._finalize(record, "remote-close");
553 });
554 try { client.connect(config); } catch (error) { fail(error); }
555 });
556 }
558 trustPending(options = {}) {
559 const sessionId = String(options.sessionId || "");
560 const pending = this.pendingTrust.get(sessionId);
561 const fingerprint = String(options.fingerprint || "").toLowerCase();
562 if (!pending || pending.expiresAt < Date.now()) {
563 this.pendingTrust.delete(sessionId);
564 throw new SessionError("HOST_KEY_CONFIRMATION_EXPIRED", "The host-key confirmation expired.");
565 }
566 if (pending.profileId !== options.profileId || pending.fingerprint !== fingerprint) {
567 throw new SessionError("HOST_KEY_CONFIRMATION_INVALID", "The host-key confirmation does not match the pending connection.");
568 }
569 if (pending.knownFingerprint) {
570 const confirmedKnownFingerprint = String(options.knownFingerprint || "").toLowerCase();
571 if (options.replaceKnown !== true || confirmedKnownFingerprint !== pending.knownFingerprint) {
572 throw new SessionError("HOST_KEY_REPLACEMENT_CONFIRMATION_REQUIRED", "Replacing a changed SSH host key requires explicit confirmation of both fingerprints.");
573 }
574 if (this.profileService.getHostKey(pending.host, pending.port) !== pending.knownFingerprint) {
575 this.pendingTrust.delete(sessionId);
576 throw new SessionError("HOST_KEY_REPLACEMENT_STALE", "The stored SSH host key changed while confirmation was pending.");
577 }
578 } else if (options.replaceKnown === true) {
579 throw new SessionError("HOST_KEY_CONFIRMATION_INVALID", "A new host key cannot be confirmed as a replacement.");
580 }
581 const result = this.profileService.trustHostKey(pending.host, pending.port, pending.fingerprint);
582 this.pendingTrust.delete(sessionId);
583 const details = { host: pending.host, port: pending.port, fingerprint: pending.fingerprint, replacedFingerprint: pending.knownFingerprint };
584 if (pending.knownFingerprint) this.logger?.warn("Changed SSH host key replaced by user confirmation.", details);
585 else this.logger?.info("SSH host key trusted by user.", details);
586 return result;
587 }
589 write(sessionId, data) {
590 const record = this._requireConnected(sessionId);
591 const text = String(data ?? "");
592 if (Buffer.byteLength(text, "utf8") > MAX_INPUT_LENGTH) throw new SessionError("TERMINAL_INPUT_TOO_LARGE", "Terminal input is too large.");
593 record.stream.write(text);
594 }
596 resize(sessionId, cols, rows) {
597 const record = this._requireConnected(sessionId);
598 const normalizedCols = normalizeTerminalSize(cols, 100, 20, 500);
599 const normalizedRows = normalizeTerminalSize(rows, 30, 5, 300);
600 record.stream.setWindow(normalizedRows, normalizedCols, 0, 0);
601 return { cols: normalizedCols, rows: normalizedRows };
602 }
604 async readSystemMetrics(sessionId) {
605 const record = this._requireConnected(sessionId);
606 const platform = await this._platform(record);
607 if (platform.os === "windows") return this._readWindowsMetrics(record);
608 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"}.`);
609 const output = await this._execFixed(record, METRICS_COMMAND, 10000);
610 return {
611 sessionId: record.sessionId,
612 ...parseSystemMetricsOutput(output.stdout, record.host),
613 collectedAt: new Date().toISOString()
614 };
615 }
617 // Remote OS of a live session (cached). Required lazily to avoid a require cycle.
618 _platform(record) {
619 if (record.platform) return Promise.resolve(record.platform);
620 return require("./platformDetect").detectPlatform(this, record.sessionId);
621 }
623 async _assertPosix(record, feature) {
624 const platform = await this._platform(record);
625 if (platform.os === "windows") throw new SessionError("FEATURE_PLATFORM_UNSUPPORTED", `${feature} is available only on Linux/POSIX servers; this server runs ${platform.caption || "Windows"}.`);
626 }
628 // Runs a fixed PowerShell script on a Windows session (short encoded bootstrap; script and JSON on stdin).
629 async _runWindowsScript(record, script, args, timeoutMs = 30000) {
630 const { BOOTSTRAP, bootstrapInput, encodedCommand, parseWindowsOutput } = require("./windowsPrivilegedExec");
631 const result = await this._execStream(record, encodedCommand(BOOTSTRAP), {
632 timeoutMs,
633 outputLimit: 4 * 1024 * 1024,
634 onUpdate: (controller, kind) => { if (kind === "open") { controller.write(bootstrapInput(script, args)); controller.end(); } }
635 }).done;
636 return parseWindowsOutput(result.stdout, result.stderr, result.code, "METRICS_");
637 }
639 async _readWindowsMetrics(record) {
640 const { METRICS_SCRIPT, normalizeWindowsMetrics } = require("./windowsMetrics");
641 record.metricsState ||= {};
642 const state = record.metricsState;
643 const raw = await this._runWindowsScript(record, METRICS_SCRIPT, { static: !state.static }, 30000);
644 if (raw.static) state.static = raw.static;
645 const metrics = normalizeWindowsMetrics(raw, state.static, state, record.host);
646 if (raw.cpuRaw) state.cpuRaw = raw.cpuRaw;
647 if (raw.memoryCounters) state.memoryCounters = raw.memoryCounters;
648 return { sessionId: record.sessionId, ...metrics, collectedAt: new Date().toISOString() };
649 }
651 openSftp(sessionId) {
652 const record = this._requireConnected(sessionId);
653 return new Promise((resolve, reject) => record.client.sftp((error, sftp) => error ? reject(new SessionError("SFTP_OPEN_FAILED", error.message)) : resolve(sftp)));
654 }
656 // Internal fixed protocol only. Neither renderer nor plugin can supply code.
657 async remoteEditor(sessionId, request, authorization = {}) {
658 const record = this._requireConnected(sessionId);
659 await this._assertPosix(record, "The remote text editor");
660 let password;
661 try { password = normalizeSudoPassword(authorization.sudoPassword); }
662 catch { throw new SessionError("FILE_SUDO_PASSWORD_INVALID", "Invalid sudo password."); }
663 if (password && authorization.sudo !== true) throw new SessionError("FILE_SUDO_AUTHORIZATION_REQUIRED", "Explicit sudo authorization is required.");
664 const timeoutMs = Math.min(Math.max(Number(authorization.timeoutMs) || 60000, 10000), 24 * 3600 * 1000);
665 const mutation = EDITOR_MUTATIONS.has(request.operation);
666 let output;
667 if (authorization.sudo === true) {
668 try {
669 const result = await this._privilegedExecutor().run(sessionId, {
670 script: EDITOR_PRIVILEGED_SCRIPT, args: [REMOTE_EDITOR], frames: [{ tag: "REQ", data: JSON.stringify(request) }],
671 sudoPassword: password, rememberPassword: authorization.rememberSudo === true, useCachedPassword: authorization.useCachedSudo !== false,
672 purpose: `files.${request.operation}`, errorPrefix: "FILE_", timeoutMs, outputLimit: EDITOR_OUTPUT_LIMIT
673 });
674 output = { stdout: result.stdout, code: result.code };
675 } catch (error) {
676 // sudo refusals happen before the helper starts: definite, original untouched.
677 if (/^SUDO_[A-Z_]+$/.test(error.code) || error.code === "FILE_DEPENDENCY_UNAVAILABLE") throw error;
678 throw new SessionError(mutation ? "FILE_RESULT_UNKNOWN" : "FILE_EXEC_FAILED", "Remote file operation was not confirmed. Inspect the target before retrying.");
679 }
680 } else {
681 const command = `printf '\\036CHJ_EDITOR_START\\n'; exec python3 -I -B -c ${shellQuote(REMOTE_EDITOR)} ${shellQuote(JSON.stringify(request))}`;
682 try {
683 output = await this._execFixed(record, command, timeoutMs, EDITOR_OUTPUT_LIMIT);
684 } catch (error) {
685 // Remote stderr never becomes editor errors or logs.
686 throw new SessionError(error.remoteExitCode === 127 ? "FILE_DEPENDENCY_UNAVAILABLE" : mutation ? "FILE_RESULT_UNKNOWN" : "FILE_EXEC_FAILED",
687 "Remote file operation was not confirmed. Inspect the target before retrying.");
688 }
689 }
690 let response;
691 try {
692 if (!output.stdout.startsWith(EDITOR_STARTED)) throw new Error("Missing editor marker");
693 response = JSON.parse(output.stdout.slice(EDITOR_STARTED.length));
694 }
695 catch { throw new SessionError(output.code === 127 ? "FILE_DEPENDENCY_UNAVAILABLE" : mutation ? "FILE_RESULT_UNKNOWN" : "FILE_EXEC_FAILED", "Remote file operation result could not be verified."); }
696 if (!response || response.ok !== true || !response.value || typeof response.value !== "object") {
697 const code = /^FILE_[A-Z_]+$/.test(response?.code) ? response.code : "FILE_PROTOCOL_ERROR";
698 throw new SessionError(code, `${code}: Remote file operation stopped${mutation ? "; the original was kept and recoverable copies were retained" : ""}.`);
699 }
700 return response.value;
701 }
703 // SHA-256 of a remote file with the standard tools, for servers without python3.
704 async remoteChecksum(sessionId, remotePath) {
705 const record = this._requireConnected(sessionId);
706 const target = String(remotePath || "");
707 if (!target.startsWith("/") || /[\0\r\n]/.test(target)) throw new SessionError("FILE_UNSAFE_PATH", "Remote path must be absolute.");
708 if ((await this._platform(record)).os === "windows") {
709 // Windows OpenSSH SFTP paths look like /C:/Users/x; the path travels as JSON, never in the command line.
710 const result = await this._runWindowsScript(record, WINDOWS_HASH_SCRIPT, { path: target }, 6 * 3600 * 1000);
711 if (!/^[a-f0-9]{64}$/.test(String(result?.hash || ""))) throw new SessionError("FILE_CHECKSUM_UNAVAILABLE", "The remote checksum could not be read.");
712 return result.hash;
713 }
714 const command = `LC_ALL=C; p=${shellQuote(target)}; if command -v sha256sum >/dev/null 2>&1; then sha256sum -- "$p"; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 -- "$p"; else exit 127; fi`;
715 const output = await this._execFixed(record, command, 6 * 3600 * 1000);
716 const match = /^([a-f0-9]{64})\b/.exec(output.stdout.trim());
717 if (!match) throw new SessionError("FILE_CHECKSUM_UNAVAILABLE", "The remote checksum could not be read.");
718 return match[1];
719 }
721 _privilegedExecutor() {
722 if (!this.privilegedExecutor) {
723 // Lazy: privilegedExec.js depends on this module's exports.
724 const { PrivilegedExecutor } = require("./privilegedExec");
725 this.privilegedExecutor = new PrivilegedExecutor({ sessionManager: this, logger: this.logger });
726 }
727 return this.privilegedExecutor;
728 }
730 // Identity facts for lockout protection. Secrets are never returned.
731 sessionAuthInfo(sessionId) {
732 const record = this._requireConnected(sessionId);
733 let publicKeyFingerprint = null;
734 if (record.authMethod === "privateKey") {
735 try {
736 const profile = this.profileService.get(record.profileId);
737 publicKeyFingerprint = publicKeyFingerprintForPrivateKey(profile.privateKeyPath);
738 } catch {}
739 }
740 return { username: record.username, authMethod: record.authMethod, address: record.address, publicKeyFingerprint };
741 }
743 // Reports whether a fresh login can be attempted without asking the user for more secrets.
744 probeLoginRequirements(sessionId, credentials = {}) {
745 const record = this._requireConnected(sessionId);
746 let profile;
747 try { profile = this.profileService.get(record.profileId); }
748 catch { return { ready: false, missing: "profile" }; }
749 if (profile.authMethod === "privateKey") {
750 let data;
751 try { data = fs.readFileSync(profile.privateKeyPath); } catch { return { ready: false, missing: "privateKey" }; }
752 const parsed = sshUtils.parseKey(data, credentials.passphrase ? String(credentials.passphrase) : undefined);
753 return parsed instanceof Error ? { ready: false, missing: "passphrase" } : { ready: true, missing: null };
754 }
755 const password = String(credentials.password || this.profileService.getStoredPassword?.(profile.id) || "");
756 return password ? { ready: true, missing: null } : { ready: false, missing: "password" };
757 }
759 // Profile and address of a live session, kept by callers that must reconnect after sshd restarts.
760 connectionTarget(sessionId) {
761 const record = this._requireConnected(sessionId);
762 return { sessionId: record.sessionId, profileId: record.profileId, address: record.address, host: record.host, port: record.port };
763 }
765 // Opens a separate SSH connection with the session profile to prove that new logins still work.
766 // The host key must match the trusted fingerprint; nothing is stored or trusted implicitly.
767 async probeLogin(sessionId, credentials = {}) {
768 const result = await this.freshExec(this.connectionTarget(sessionId), credentials, { command: "true", acceptAnyExit: true });
769 return { ok: true, address: result.address, checkedAt: result.checkedAt };
770 }
772 // Runs one fixed command on a new, short-lived connection (used to confirm configuration changes
773 // even when the original session did not survive an sshd restart). Output is returned, not logged.
774 async freshExec(target, credentials = {}, { command, input = "", timeoutMs = 60000, acceptAnyExit = false } = {}) {
775 const profile = this.profileService.get(target.profileId);
776 const config = this._buildConnectConfig(profile, credentials);
777 config.host = target.address;
778 config.readyTimeout = 15000;
779 config.keepaliveInterval = 0;
780 const knownFingerprint = this.profileService.getHostKey(profile.host, profile.port);
781 config.hostHash = "sha256";
782 config.hostVerifier = (fingerprint) => Boolean(knownFingerprint) && String(fingerprint || "").toLowerCase() === knownFingerprint;
783 const client = this.clientFactory();
784 const reference = { sessionId: target.sessionId, profileId: target.profileId, host: profile.host, port: profile.port };
785 return new Promise((resolve, reject) => {
786 let settled = false;
787 let stdout = "";
788 let stderr = "";
789 const done = (error, code) => {
790 if (settled) return;
791 settled = true;
792 clearTimeout(timer);
793 try { client.end(); } catch {}
794 if (error) {
795 const normalized = this._normalizeConnectionError(error, reference);
796 reject(new SessionError("SSH_PROBE_FAILED", `A new SSH login was not confirmed: ${normalized.message}`, { causeCode: normalized.code }));
797 } else resolve({ ok: true, code, stdout, stderr, address: target.address, checkedAt: new Date().toISOString() });
798 };
799 const timer = setTimeout(() => done(new Error("Login probe timed out.")), timeoutMs);
800 timer.unref?.();
801 client.once("ready", () => openExec(client, command, (error, stream) => {
802 if (error) { done(error); return; }
803 stream.on("data", (chunk) => { if (stdout.length < MAX_COMMAND_OUTPUT) stdout += chunk.toString("utf8"); });
804 stream.stderr?.on("data", (chunk) => { if (stderr.length < MAX_COMMAND_OUTPUT) stderr += chunk.toString("utf8"); });
805 stream.once("close", (code) => {
806 if (acceptAnyExit || code === 0 || code === undefined || code === null) done(null, code);
807 else done(new Error(`Probe command exited with ${code}.`));
808 });
809 try { stream.end(input); } catch {}
810 }));
811 client.on("error", done);
812 client.once("close", () => done(new Error("SSH connection closed before the login was confirmed.")));
813 try { client.connect(config); } catch (error) { done(error); }
814 });
815 }
817 async inspectNginx(sessionId) {
818 const record = this._requireConnected(sessionId);
819 await this._assertPosix(record, "NGINX management");
820 const output = await this._execFixed(record, NGINX_INSPECT_COMMAND, 10000);
821 return { sessionId: record.sessionId, ...parseNginxInspectionOutput(output.stdout), collectedAt: new Date().toISOString() };
822 }
824 async readNginxConfig(sessionId, payload = {}) {
825 const record = this._requireConnected(sessionId);
826 const path = normalizeNginxConfigPath(payload.path);
827 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
828 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"`;
829 const output = sudoPassword
830 ? await this._execSudo(record, command, sudoPassword, 10000)
831 : await this._execFixed(record, command, 10000);
832 return { path, text: output.stdout, size: Buffer.byteLength(output.stdout, "utf8") };
833 }
835 async dumpNginxConfig(sessionId, payload = {}) {
836 const record = this._requireConnected(sessionId);
837 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
838 const output = await this._execSudo(record, "nginx -T 2>&1", sudoPassword, 20000);
839 return { text: output.stdout, generatedAt: new Date().toISOString() };
840 }
842 async testNginxConfig(sessionId, payload = {}) {
843 const record = this._requireConnected(sessionId);
844 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
845 const command = "set +e; output=$(nginx -t 2>&1); code=$?; printf '%s\\n' \"$output\"; printf '\\036CHJ_EXIT=%s\\n' \"$code\"; exit 0";
846 const output = await this._execSudo(record, command, sudoPassword, 20000);
847 const marker = output.stdout.match(/\x1eCHJ_EXIT=(\d+)\s*$/);
848 const code = marker ? Number(marker[1]) : 1;
849 return { ok: code === 0, output: output.stdout.replace(/\x1eCHJ_EXIT=\d+\s*$/, "").trim(), testedAt: new Date().toISOString() };
850 }
852 async saveNginxConfig(sessionId, payload = {}) {
853 const record = this._requireConnected(sessionId);
854 const path = normalizeNginxConfigPath(payload.path);
855 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
856 const text = String(payload.text ?? "");
857 const bytes = Buffer.byteLength(text, "utf8");
858 if (bytes <= 0 || bytes > MAX_NGINX_CONFIG_BYTES || text.includes("\0")) {
859 throw new SessionError("NGINX_CONFIG_INVALID", "NGINX configuration must be UTF-8 text between 1 byte and 512 KiB.");
860 }
861 const encoded = Buffer.from(text, "utf8").toString("base64");
862 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"`;
863 const output = await this._execSudo(record, command, sudoPassword, 30000);
864 const marker = output.stdout.match(/\x1eCHJ_BACKUP=([^\r\n]+)\s*$/);
865 const backupPath = marker ? marker[1].trim() : "";
866 this.logger?.warn("Remote NGINX configuration saved after successful validation.", { sessionId: record.sessionId, host: record.host, path, backupPath });
867 return { path, backupPath, size: bytes, testOutput: output.stdout.replace(/\x1eCHJ_BACKUP=[^\r\n]+\s*$/, "").trim(), savedAt: new Date().toISOString() };
868 }
870 async reloadNginx(sessionId, payload = {}) {
871 const record = this._requireConnected(sessionId);
872 const sudoPassword = normalizeSudoPassword(payload.sudoPassword);
873 if (payload.confirm !== true) throw new SessionError("NGINX_RELOAD_CONFIRMATION_REQUIRED", "Reloading NGINX requires explicit confirmation.");
874 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\"";
875 const output = await this._execSudo(record, command, sudoPassword, 30000);
876 this.logger?.warn("Remote NGINX graceful reload completed.", { sessionId: record.sessionId, host: record.host });
877 return { ok: true, output: output.stdout.trim(), reloadedAt: new Date().toISOString() };
878 }
880 async disconnect(sessionId, reason = "user") {
881 const record = this.sessions.get(String(sessionId || ""));
882 this.pendingTrust.delete(String(sessionId || ""));
883 if (!record) return { disconnected: false };
884 this._finalize(record, reason);
885 return { disconnected: true };
886 }
888 async disconnectAll(reason = "lock") {
889 const ids = [...this.sessions.keys()];
890 for (const id of ids) await this.disconnect(id, reason);
891 this.pendingTrust.clear();
892 return { disconnected: ids.length };
893 }
895 _buildConnectConfig(profile, options) {
896 const config = {
897 host: profile.host,
898 port: profile.port,
899 username: profile.username,
900 readyTimeout: 15000,
901 keepaliveInterval: 15000,
902 keepaliveCountMax: 3
903 };
904 if (profile.authMethod === "privateKey") {
905 if (profile.privateKeyPath.toLowerCase().endsWith(".pub")) throw new SessionError("PUBLIC_KEY_SELECTED", "Select a private key, not a .pub file.");
906 let stat;
907 try { stat = fs.statSync(profile.privateKeyPath); } catch { throw new SessionError("PRIVATE_KEY_NOT_FOUND", "The private key file cannot be read."); }
908 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.");
909 config.privateKey = fs.readFileSync(profile.privateKeyPath);
910 const passphrase = String(options.passphrase || "");
911 if (passphrase) config.passphrase = passphrase;
912 } else {
913 const password = String(options.password || this.profileService.getStoredPassword?.(profile.id) || "");
914 if (!password) throw new SessionError("PASSWORD_REQUIRED", "Enter the SSH password.");
915 config.password = password;
916 }
917 return config;
918 }
920 _bindShell(record) {
921 record.stream.on("data", (chunk) => this.emit("data", { sessionId: record.sessionId, data: record.decoder.write(chunk) }));
922 record.stream.stderr?.on("data", (chunk) => this.emit("data", { sessionId: record.sessionId, data: record.decoder.write(chunk) }));
923 record.stream.once("close", () => this._finalize(record, "shell-close"));
924 record.stream.once("error", (error) => {
925 this.emit("sessionError", { sessionId: record.sessionId, message: error?.message || String(error) });
926 this._finalize(record, "shell-error");
927 });
928 }
930 _requireConnected(sessionId) {
931 const record = this.sessions.get(String(sessionId || ""));
932 if (!record || record.state !== "connected" || !record.stream) throw new SessionError("SESSION_NOT_CONNECTED", "The terminal session is not connected.");
933 return record;
934 }
936 _normalizeConnectionError(error, record) {
937 const message = error?.message || String(error || "SSH connection failed.");
938 let code = "SSH_CONNECTION_FAILED";
939 if (process.platform === "darwin" && ["EPERM", "EACCES"].includes(error?.code)) code = "SSH_LOCAL_NETWORK_DENIED";
940 else if (process.platform === "darwin" && ["EHOSTUNREACH", "ENETUNREACH"].includes(error?.code)) code = "SSH_MAC_NETWORK_UNREACHABLE";
941 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";
942 else if (/authentication/i.test(message)) code = "SSH_AUTHENTICATION_FAILED";
943 else if (/timed?\s*out/i.test(message)) code = "SSH_TIMEOUT";
944 else if (/refused/i.test(message)) code = "SSH_CONNECTION_REFUSED";
945 return new SessionError(code, message, { sessionId: record.sessionId, profileId: record.profileId, host: record.host, port: record.port });
946 }
948 async _resolveConnectionAddress(host) {
949 const literalFamily = net.isIP(host);
950 if (literalFamily) return { address: host, family: literalFamily, source: "literal" };
951 let addresses = [];
952 let lookupError = null;
953 try {
954 addresses = await this.lookupHost(host, { all: true });
955 } catch (error) {
956 lookupError = error;
957 }
958 let normalized = (Array.isArray(addresses) ? addresses : [addresses]).filter((entry) => entry && net.isIP(entry.address) === Number(entry.family));
959 let source = "system";
960 if (!normalized.length) {
961 const [ipv4, ipv6] = await Promise.allSettled([this.resolve4(host), this.resolve6(host)]);
962 normalized = [
963 ...(ipv4.status === "fulfilled" ? ipv4.value.map((address) => ({ address, family: 4 })) : []),
964 ...(ipv6.status === "fulfilled" ? ipv6.value.map((address) => ({ address, family: 6 })) : [])
965 ].filter((entry) => net.isIP(entry.address) === entry.family);
966 source = "dns";
967 }
968 if (!normalized.length) {
969 throw new SessionError("SSH_DNS_RESOLUTION_FAILED", `DNS name "${host}" could not be resolved.`, { host, causeCode: lookupError?.code });
970 }
971 const selected = normalized.find((entry) => entry.family === 4) || normalized[0];
972 this.logger?.info("SSH hostname resolved.", { host, address: selected.address, addressFamily: selected.family, addressCount: normalized.length, source });
973 return { ...selected, source };
974 }
976 _execFixed(record, command, timeoutMs, outputLimit = MAX_COMMAND_OUTPUT) {
977 return new Promise((resolve, reject) => {
978 let settled = false;
979 let stdout = "";
980 let stderr = "";
981 let channel;
982 const cancel = () => finish(new SessionError("SESSION_NOT_CONNECTED", "The SSH connection was lost."));
983 record.pendingExec ||= new Set();
984 record.pendingExec.add(cancel);
985 const timer = setTimeout(() => {
986 finish(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote command timed out."));
987 try { channel?.close(); } catch {}
988 }, timeoutMs);
989 timer.unref?.();
990 const finish = (error, result) => {
991 if (settled) return;
992 settled = true;
993 clearTimeout(timer);
994 record.pendingExec.delete(cancel);
995 if (error) reject(error); else resolve(result);
996 };
997 openExec(record.client, command, (error, stream) => {
998 if (settled) { try { stream?.close(); } catch {} return; }
999 if (error) { finish(new SessionError("REMOTE_COMMAND_FAILED", error.message)); return; }
1000 channel = stream;
1001 const append = (target, chunk) => {
1002 const next = target + chunk.toString("utf8");
1003 if (Buffer.byteLength(next, "utf8") > outputLimit) {
1004 try { stream.close(); } catch {}
1005 finish(new SessionError("REMOTE_OUTPUT_TOO_LARGE", "The system metrics response is too large."));
1006 return target;
1008 return next;
1009 };
1010 stream.on("data", (chunk) => { stdout = append(stdout, chunk); });
1011 stream.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk); });
1012 stream.once("error", (streamError) => finish(new SessionError("REMOTE_COMMAND_FAILED", streamError.message)));
1013 stream.once("close", (code) => {
1014 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) }));
1015 else finish(null, { stdout, stderr });
1016 });
1017 });
1018 });
1021 // SSH round trip over the existing transport: a keepalive@openssh.com global request with want-reply.
1022 // SUCCESS and FAILURE replies both complete a client->server->client round trip; no channel, no command
1023 // and no new authentication. Falls back to the no-op "exit 0" (valid in sh, cmd.exe and PowerShell),
1024 // which is labelled as a command round trip including remote process start.
1025 _sshRoundTrip(record, timeoutMs = 5000) {
1026 const client = record.client;
1027 if (client && Array.isArray(client._callbacks) && typeof client._protocol?.ping === "function") {
1028 return new Promise((resolve, reject) => {
1029 let done = false;
1030 const started = performance.now();
1031 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);
1032 timer.unref?.();
1033 // Replies are matched to callbacks in order, so the callback stays queued even after a timeout.
1034 client._callbacks.push(() => {
1035 if (done) return;
1036 done = true;
1037 clearTimeout(timer);
1038 resolve({ ms: performance.now() - started, method: "transport" });
1039 });
1040 try { client._protocol.ping(); }
1041 catch (error) { if (!done) { done = true; clearTimeout(timer); reject(new SessionError("SSH_RTT_FAILED", error.message)); } }
1042 });
1044 return this._execFixed(record, "exit 0", timeoutMs).then(() => ({ method: "command" }));
1047 // Streaming exec channel with stdin access. Resolves with the exit code instead of rejecting on
1048 // non-zero exits so callers can classify sudo and protocol failures themselves.
1049 _execStream(record, command, { timeoutMs = 30000, outputLimit = MAX_COMMAND_OUTPUT, onUpdate } = {}) {
1050 let settled = false;
1051 let channel = null;
1052 let received = 0;
1053 let resolveDone; let rejectDone;
1054 const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
1055 const controller = {
1056 stdout: "",
1057 stderr: "",
1058 done: new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; }),
1059 write(text) { if (!settled && channel) { try { channel.write(text); } catch {} } },
1060 end() { if (channel) { try { channel.end(); } catch {} } },
1061 close() { if (channel) { try { channel.close(); } catch {} } }
1062 };
1063 const finish = (error, result) => {
1064 if (settled) return;
1065 settled = true;
1066 clearTimeout(timer);
1067 record.pendingExec?.delete(cancel);
1068 if (error) rejectDone(error); else resolveDone(result);
1069 };
1070 const cancel = () => finish(new SessionError("SESSION_NOT_CONNECTED", "The SSH connection was lost."));
1071 record.pendingExec ||= new Set();
1072 record.pendingExec.add(cancel);
1073 const timer = setTimeout(() => {
1074 finish(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote command timed out."));
1075 controller.close();
1076 }, timeoutMs);
1077 timer.unref?.();
1078 const notify = (kind) => {
1079 try { onUpdate?.(controller, kind); }
1080 catch (error) { finish(error); controller.close(); }
1081 };
1082 openExec(record.client, command, (error, stream) => {
1083 if (settled) { try { stream?.close(); } catch {} return; }
1084 if (error) { finish(new SessionError("REMOTE_COMMAND_FAILED", error.message)); return; }
1085 channel = stream;
1086 const append = (kind, chunk) => {
1087 if (settled) return;
1088 received += chunk.length;
1089 if (received > outputLimit) {
1090 finish(new SessionError("REMOTE_OUTPUT_TOO_LARGE", "The remote response is too large."));
1091 controller.close();
1092 return;
1094 controller[kind] += decoders[kind].write(chunk);
1095 notify(kind);
1096 };
1097 stream.on("data", (chunk) => append("stdout", chunk));
1098 stream.stderr?.on("data", (chunk) => append("stderr", chunk));
1099 stream.once("error", (streamError) => finish(new SessionError("REMOTE_COMMAND_FAILED", streamError.message)));
1100 stream.once("close", (code, signal) => {
1101 controller.stdout += decoders.stdout.end();
1102 controller.stderr += decoders.stderr.end();
1103 finish(null, { code: Number.isInteger(code) ? code : null, signal: signal || null, stdout: controller.stdout, stderr: controller.stderr });
1104 });
1105 notify("open");
1106 });
1107 return controller;
1110 _execSudo(record, command, sudoPassword, timeoutMs, outputLimit = MAX_COMMAND_OUTPUT) {
1111 return new Promise((resolve, reject) => {
1112 let settled = false; let stdout = ""; let stderr = "";
1113 let channel;
1114 const cancel = () => finish(new SessionError("SESSION_NOT_CONNECTED", "The SSH connection was lost."));
1115 record.pendingExec ||= new Set();
1116 record.pendingExec.add(cancel);
1117 const timer = setTimeout(() => {
1118 finish(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote command timed out."));
1119 try { channel?.close(); } catch {}
1120 }, timeoutMs);
1121 timer.unref?.();
1122 const finish = (error, result) => {
1123 if (settled) return;
1124 settled = true; clearTimeout(timer);
1125 record.pendingExec.delete(cancel);
1126 if (error) reject(error); else resolve(result);
1127 };
1128 const isRootSession = record.username === "root";
1129 const prefix = isRootSession ? "sh -c " : (sudoPassword ? "sudo -S -p '' -- sh -c " : "sudo -n -- sh -c ");
1130 const quoted = `'${String(command).replace(/'/g, `'"'"'`)}'`;
1131 openExec(record.client, prefix + quoted, (error, stream) => {
1132 if (settled) { try { stream?.close(); } catch {} return; }
1133 if (error) { finish(new SessionError("USER_ACTION_FAILED", error.message)); return; }
1134 channel = stream;
1135 const append = (target, chunk) => {
1136 const next = target + chunk.toString("utf8");
1137 if (Buffer.byteLength(next, "utf8") > outputLimit) {
1138 try { stream.close(); } catch {}
1139 finish(new SessionError("REMOTE_OUTPUT_TOO_LARGE", "The user-management response is too large."));
1140 return target;
1142 return next;
1143 };
1144 stream.on("data", (chunk) => { stdout = append(stdout, chunk); });
1145 stream.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk); });
1146 stream.once("error", (streamError) => finish(new SessionError("USER_ACTION_FAILED", streamError.message)));
1147 stream.once("close", (code) => {
1148 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) }));
1149 else finish(null, { stdout, stderr });
1150 });
1151 if (!isRootSession && sudoPassword) stream.end(`${sudoPassword}\n`); else stream.end();
1152 });
1153 });
1156 _finalize(record, reason) {
1157 if (record.finalized) return;
1158 record.finalized = true;
1159 record.state = "disconnected";
1160 record.stopLatency?.();
1161 record.latency = null;
1162 for (const cancel of record.pendingExec || []) cancel();
1163 if (this.sessions.get(record.sessionId) === record) this.sessions.delete(record.sessionId);
1164 try { record.stream?.end(); } catch {}
1165 try { record.client?.end(); } catch {}
1166 const trailing = record.decoder.end();
1167 if (trailing) this.emit("data", { sessionId: record.sessionId, data: trailing });
1168 const status = { ...this._publicStatus(record), reason };
1169 this.emit("state", status);
1170 this.logger?.info("SSH session disconnected.", { sessionId: record.sessionId, profileId: record.profileId, reason });
1173 _publicStatus(record) {
1174 return {
1175 sessionId: record.sessionId,
1176 profileId: record.profileId,
1177 label: record.label,
1178 host: record.host,
1179 port: record.port,
1180 username: record.username,
1181 authMethod: record.authMethod,
1182 state: record.state,
1183 latency: record.latency || { pingMs: null, sshRttMs: null, measuredAt: null }
1184 };
1188module.exports = {
1189 MAX_NGINX_CONFIG_BYTES,
1190 METRICS_COMMAND,
1191 NGINX_INSPECT_COMMAND,
1192 SessionError,
1193 SessionManager,
1194 USERNAME_PATTERN,
1195 normalizeNginxConfigPath,
1196 normalizeTerminalSize,
1197 openExec,
1198 parseNginxInspectionOutput,
1199 shellQuote,
1200 parseSystemMetricsOutput
SHA-2560b3c1ddd0beea9778d9e0ca0c14bd35d68fc8999f4108a262dca460891c35a3a

CH-J Proprietary Software License 1.14 · ซอร์สโค้ดนี้ให้ไว้ภายใต้ CH-J Proprietary Software License 1.14 การเปิดให้เข้าถึงไม่ได้เปลี่ยนแปลงเงื่อนไขของสัญญาอนุญาตหรือให้สิทธิ์เพิ่มเติม

SHA-256 ของไฟล์บีบอัดa9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
ไฟล์