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