"use strict"; const fs = require("node:fs"); const path = require("node:path"); const { SessionError } = require("../sessions/sessionManager"); // Virtual memory configuration for chj.system-monitor: Linux swap (files, partitions, zram, zswap) and Windows // pagefiles. Reads never change anything; changes go only through fixed operations with validated arguments. const SWAP_SCRIPT = fs.readFileSync(path.join(__dirname, "remoteSwap.sh"), "utf8"); const PAGEFILE_SCRIPT = fs.readFileSync(path.join(__dirname, "windowsPagefile.ps1"), "utf8"); const MANAGED_PATH = /^\/(swapfile[A-Za-z0-9._-]{0,32}|swap\/[A-Za-z0-9._-]{1,64}|var\/lib\/swap\/[A-Za-z0-9._-]{1,64})$/; const ANY_SWAP_PATH = /^\/[A-Za-z0-9._/-]{1,199}$/; const LINUX_ACTIONS = new Set(["create", "activate", "deactivate", "priority", "persist", "resize", "delete"]); const WINDOWS_ACTIONS = new Set(["automatic", "set", "remove"]); function fail(code, message) { return new SessionError(code, message); } const text = (value, limit = 300) => String(value ?? "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, limit); const number = (value) => { const result = Number(value); return value === "" || value === null || value === undefined || !Number.isFinite(result) ? null : result; }; function splitSections(stdout) { const sections = {}; const pattern = /\x1eCHJ_SECTION=([a-z]+)\r?\n/g; let current = null; let start = 0; let match; while ((match = pattern.exec(stdout))) { if (current) sections[current] = stdout.slice(start, match.index); current = match[1]; start = pattern.lastIndex; } if (current) sections[current] = stdout.slice(start); return sections; } const lines = (value) => String(value || "").split(/\r?\n/).filter(Boolean); function parseProcSwaps(textValue) { return lines(textValue).slice(1).map((line) => { const [name = "", type = "", size = "", used = "", priority = ""] = line.trim().split(/\s+/); const pathName = name.replace(/\\040/g, " "); return { path: text(pathName, 200), type: /^\/dev\/zram\d+$/.test(pathName) ? "zram" : type === "file" ? "file" : type === "partition" ? "partition" : text(type, 16), size: Number(size) * 1024, used: Number(used) * 1024, priority: Number(priority) }; }); } function parseLinuxInspect(stdout) { const sections = splitSections(stdout); const meta = Object.fromEntries(lines(sections.meta).map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)])); const meminfo = Object.fromEntries(lines(sections.meminfo).map((line) => { const [key, value] = line.split(/:\s+/); return [key, Number.parseInt(value, 10) * 1024]; })); const active = parseProcSwaps(sections.swaps); const fstab = lines(sections.fstab).map((line) => { const index = line.indexOf("|"); const raw = line.slice(index + 1); const fields = raw.trim().split(/\s+/); const options = fields[3] || ""; const priority = options.match(/(?:^|,)pri=(-?\d+)/); return { line: Number(line.slice(0, index)), spec: text(fields[0], 200), type: text(fields[2], 16), options: text(options, 120), priority: priority ? Number(priority[1]) : null, commented: raw.trim().startsWith("#"), managedByChj: /# CH-J chj\.system-monitor/.test(raw) }; }).filter((entry) => entry.type === "swap" && !entry.commented); const units = lines(sections.units).map((line) => { const [unit, active, sub] = line.split("|"); return { unit: text(unit, 120), active: text(active, 20), state: text(sub, 20) }; }); const files = lines(sections.files).map((line) => { const [pathName, kind, fsType, size, owner, mode, managed, free] = line.split("|"); return { path: text(pathName, 200), kind, fsType: fsType === "-" ? null : text(fsType, 24), size: number(size), owner: owner === "0" && kind !== "file" ? null : text(owner, 32), mode: mode === "-" ? null : text(mode, 8), managed: managed === "managed", diskFree: number(free) }; }); const locations = lines(sections.locations).map((line) => { const [dir, fsType, free] = line.split("|"); return { dir, fsType: text(fsType, 24), free: number(free) }; }); const zram = lines(sections.zram).map((line) => { const [device, diskSize, stat] = line.split("|"); const values = String(stat || "").split(",").map(Number); return { device, diskSize: number(diskSize), originalData: values[0] ?? null, compressedData: values[1] ?? null, memoryUsed: values[2] ?? null }; }); const areas = new Map(); for (const entry of active) areas.set(entry.path, { ...entry, active: true }); for (const file of files) if (!areas.has(file.path)) areas.set(file.path, { path: file.path, type: file.kind, size: file.size, used: 0, priority: null, active: false }); for (const entry of fstab) if (entry.spec.startsWith("/") && !areas.has(entry.spec)) areas.set(entry.spec, { path: entry.spec, type: entry.spec.startsWith("/dev/") ? "partition" : "file", size: null, used: 0, priority: entry.priority, active: false }); const swapAreas = [...areas.values()].map((area) => { const file = files.find((candidate) => candidate.path === area.path); const persistent = fstab.find((entry) => entry.spec === area.path) || null; const unit = units.find((candidate) => candidate.unit === `${area.path.replace(/^\//, "").replace(/-/g, "\\x2d").replace(/\//g, "-")}.swap`) || null; const management = area.type === "zram" ? "read-only" : area.type === "partition" ? "partition" : file?.managed || MANAGED_PATH.test(area.path) ? "managed" : "foreign-file"; return { ...area, fsType: file?.fsType || null, mode: file?.mode || null, owner: file?.owner || null, persistent: Boolean(persistent), persistentPriority: persistent?.priority ?? null, persistentByChj: persistent?.managedByChj === true, systemdUnit: unit, management, // Partitions are never deleted or resized; zram is read-only in this phase. allowedActions: management === "read-only" ? [] : management === "partition" ? ["activate", "deactivate", "priority", "persist"] : management === "foreign-file" ? ["activate", "deactivate", "priority", "persist"] : ["activate", "deactivate", "priority", "persist", "resize", "delete"] }; }); return { platform: "linux", root: meta.root === "1", systemd: meta.systemd === "1", btrfsMkswapfile: meta.btrfsMkswapfile === "1", os: text(meta.os, 120), memory: { total: meminfo.MemTotal ?? null, available: meminfo.MemAvailable ?? null, swapTotal: meminfo.SwapTotal ?? null, swapFree: meminfo.SwapFree ?? null }, swapAreas, fstab, units, locations, zram }; } function parseSteps(stdout) { return [...String(stdout).matchAll(/\x1eCHJ_STEP=([a-z-]+)\|([a-z]+)\|([^\n]*)\n/g)].map((match) => ({ step: match[1], status: match[2], message: text(match[3]) })); } function mapWindowsState(raw) { const mb = (value) => (number(value) === null ? null : Number(value) * 1048576); const configured = (raw.configured || []).map((entry) => ({ name: text(entry.name, 260), initial: mb(entry.initialMb), maximum: mb(entry.maximumMb), systemManagedSize: Number(entry.initialMb) === 0 && Number(entry.maximumMb) === 0 })); const active = (raw.active || []).map((entry) => ({ name: text(entry.name, 260), allocated: mb(entry.allocatedMb), used: mb(entry.currentMb), peak: mb(entry.peakMb) })); const normalize = (value) => String(value || "").toLowerCase().replace(/^\\\?\?\\/, "").split(" ")[0]; const registryConfigured = (raw.registryPagingFiles || []).map(normalize).sort(); const existing = (raw.existingPageFiles || []).map(normalize).sort(); const names = new Set([...configured.map((entry) => entry.name.toLowerCase()), ...active.map((entry) => entry.name.toLowerCase())]); const pagefiles = [...names].map((name) => { const setting = configured.find((entry) => entry.name.toLowerCase() === name) || null; const usage = active.find((entry) => entry.name.toLowerCase() === name) || null; const volume = (raw.volumes || []).find((entry) => name.startsWith(String(entry.drive).toLowerCase())); return { name: setting?.name || usage?.name, drive: (setting?.name || usage?.name || "").slice(0, 2).toUpperCase(), configured: Boolean(setting), active: Boolean(usage), initial: setting?.initial ?? null, maximum: setting?.maximum ?? null, systemManagedSize: setting?.systemManagedSize ?? null, allocated: usage?.allocated ?? null, used: usage?.used ?? null, peak: usage?.peak ?? null, volumeFree: volume ? volume.free : null }; }); return { platform: "windows", elevated: raw.elevated === true, automatic: typeof raw.automatic === "boolean" ? raw.automatic : null, pagefiles, // Commit limit = physical memory + pagefiles; it is not the pagefile size. commit: { limit: number(raw.commitLimit), committed: number(raw.committed) }, volumes: (raw.volumes || []).map((volume) => ({ drive: text(volume.drive, 3), format: text(volume.format, 12), free: number(volume.free), total: number(volume.total) })), systemDrive: text(raw.systemDrive, 3), crashDumpEnabled: Number(raw.crashDumpEnabled) || 0, restartPending: Boolean(raw.pending) || (raw.automatic === false && registryConfigured.length > 0 && JSON.stringify(registryConfigured) !== JSON.stringify(existing)), pendingChanges: (raw.pending?.changes || []).map((change) => ({ at: change.at, description: text(change.description, 300) })), bootTime: raw.bootTime || null, unavailable: (raw.errors || []).map((entry) => text(entry, 200)) }; } class VirtualMemoryService { constructor(options) { this.sessionManager = options.sessionManager; this.executor = options.privilegedExecutor; this.logger = options.logger; this.running = new Set(); } async _platform(sessionId) { const record = this.sessionManager._requireConnected(sessionId); const platform = await this.sessionManager._platform(record); if (platform.os === "unknown") throw fail("VM_PLATFORM_UNSUPPORTED", `The remote operating system could not be identified: ${platform.reason || "unknown"}.`); return { record, platform }; } async read(sessionId, options = {}) { const { record, platform } = await this._platform(sessionId); if (platform.os === "windows") { const raw = await this.sessionManager._runWindowsScript(record, PAGEFILE_SCRIPT, { op: "inspect" }, 90000).catch((error) => { throw this._windowsError(error); }); return { ...mapWindowsState(raw), canManage: raw.elevated === true, readOnlyReason: raw.elevated ? null : "Changing the pagefile requires an SSH session with an elevated administrator token.", collectedAt: new Date().toISOString() }; } const privilege = await this.executor.probe(sessionId); const privileged = Boolean(options.sudoPassword) || options.privileged === true || ["root", "sudo-nopasswd"].includes(privilege.mode); const run = await this.executor.run(sessionId, { script: SWAP_SCRIPT, args: ["inspect"], privileged, sudoPassword: options.sudoPassword, purpose: "system.swap.inspect", timeoutMs: 60000, errorPrefix: "VM_" }); const state = parseLinuxInspect(run.stdout); return { ...state, privilege, canManage: privilege.mode !== "none", readOnlyReason: privilege.mode === "none" ? "The SSH account has no sudo rights; swap is shown read-only." : null, collectedAt: new Date().toISOString() }; } async manage(sessionId, payload = {}) { const { platform } = await this._platform(sessionId); if (this.running.has(sessionId)) throw fail("VM_BUSY", "Another virtual memory change is still running for this session."); this.running.add(sessionId); try { return platform.os === "windows" ? await this._manageWindows(sessionId, payload) : await this._manageLinux(sessionId, payload); } finally { this.running.delete(sessionId); } } async _manageLinux(sessionId, payload) { const action = String(payload.action || ""); if (!LINUX_ACTIONS.has(action)) throw fail("VM_ACTION_INVALID", "Unsupported swap action."); const target = String(payload.path || ""); if (!ANY_SWAP_PATH.test(target) || target.includes("..")) throw fail("VM_SWAP_PATH_INVALID", "Invalid swap path."); if (["create", "resize", "delete"].includes(action) && !MANAGED_PATH.test(target)) throw fail("VM_SWAP_PATH_INVALID", "CH-J creates, resizes and deletes swap files only as /swapfile*, /swap/ or /var/lib/swap/."); if (target.startsWith("/dev/zram")) throw fail("VM_SWAP_ZRAM_READ_ONLY", "zram is shown read-only; CH-J does not configure it."); const size = ["create", "resize"].includes(action) ? Number(payload.sizeMiB) : 0; if (["create", "resize"].includes(action) && (!Number.isInteger(size) || size < 64 || size > 65536)) throw fail("VM_SWAP_SIZE_INVALID", "Size must be a whole number of MiB between 64 MiB and 64 GiB."); const priority = payload.priority === undefined || payload.priority === null || payload.priority === "" ? -1 : Number(payload.priority); if (!Number.isInteger(priority) || priority < -1 || priority > 32767) throw fail("VM_SWAP_PRIORITY_INVALID", "Priority must be -1 (default) or 0-32767."); const persist = payload.persist === true ? "1" : payload.persist === false ? "0" : "keep"; if (["create", "persist"].includes(action) && persist === "keep") throw fail("VM_ARGUMENTS_INVALID", "Choose whether the swap space is activated after restart."); const run = await this.executor.run(sessionId, { script: SWAP_SCRIPT, args: ["manage", action, target, String(size), String(priority), persist], privileged: true, sudoPassword: payload.sudoPassword, purpose: `system.swap.${action}`, timeoutMs: 30 * 60 * 1000, errorPrefix: "VM_" }); this.logger?.warn("Swap configuration changed.", { sessionId, action, path: target }); const state = await this.read(sessionId, { sudoPassword: payload.sudoPassword, privileged: true }).catch(() => null); return { action, path: target, result: "ok", steps: parseSteps(run.stdout), active: parseProcSwaps(splitSections(run.stdout).swaps), state }; } async _manageWindows(sessionId, payload) { const action = String(payload.action || ""); if (!WINDOWS_ACTIONS.has(action)) throw fail("VM_ACTION_INVALID", "Unsupported pagefile action."); const args = { op: "apply", action }; if (action !== "automatic") { args.drive = String(payload.drive || "").toUpperCase(); if (!/^[A-Z]:$/.test(args.drive)) throw fail("VM_PAGEFILE_DRIVE_INVALID", "Invalid drive letter."); } if (action === "set") { const initial = Number(payload.initialMb); const maximum = Number(payload.maximumMb); const systemManaged = initial === 0 && maximum === 0; if (!Number.isInteger(initial) || !Number.isInteger(maximum) || (!systemManaged && (initial < 16 || maximum < initial || maximum > 4194304))) { throw fail("VM_PAGEFILE_SIZE_INVALID", "Initial and maximum size must be whole MB values of at least 16 with initial <= maximum, or both 0 for a system-managed size."); } Object.assign(args, { initialMb: initial, maximumMb: maximum }); } if (action === "remove") Object.assign(args, { confirmLast: payload.confirmLast === true, confirmCrashDump: payload.confirmCrashDump === true }); const record = this.sessionManager._requireConnected(sessionId); const raw = await this.sessionManager._runWindowsScript(record, PAGEFILE_SCRIPT, args, 120000).catch((error) => { throw this._windowsError(error); }); this.logger?.warn("Windows pagefile configuration changed (restart pending).", { sessionId, action }); return { action, result: raw.result, backup: text(raw.backup, 300), restartRequired: true, state: { ...mapWindowsState(raw.state), canManage: true, collectedAt: new Date().toISOString() } }; } _windowsError(error) { const code = String(error?.code || "").replace(/^METRICS_/, "VM_"); return code && code !== error.code ? new SessionError(code, error.message) : error; } } module.exports = { MANAGED_PATH, VirtualMemoryService, mapWindowsState, parseLinuxInspect, parseProcSwaps };