"use strict"; const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); const { SessionError } = require("../sessions/sessionManager"); const { buildInventory, splitSections } = require("./inventory"); const { planOperations, suggestions } = require("./planner"); // Core service behind chj.disks (Remote Disk Manager). Linux only; other platforms get an explicit "unsupported" // result and no Linux tool is ever started on them. Reads can run without root; every change goes through // PrivilegedExecutor with the fixed remoteDisks.sh, as a detached remote job with persistent state and log. const SCRIPT = fs.readFileSync(path.join(__dirname, "remoteDisks.sh"), "utf8"); const PLAN_TTL_MS = 30 * 60 * 1000; const MAX_PLANS = 20; const JOB_ID = /^[a-z0-9-]{8,64}$/; const DEVICE = /^\/dev\/[A-Za-z0-9][A-Za-z0-9._/:+-]{0,90}$/; function fail(code, message, details) { return new SessionError(`DISKS_${code}`, message, details); } const text = (value, limit = 300) => String(value ?? "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, " ").slice(0, limit); function parseKeyValues(section) { const values = {}; for (const line of String(section || "").split(/\r?\n/)) { const index = line.indexOf("="); if (index > 0) values[line.slice(0, index)] = line.slice(index + 1); } return values; } function publicJob(values) { const [errorCode, ...errorMessage] = String(values.error || "").split("|"); return { id: text(values.id, 64), state: text(values.state, 20) || "unknown", step: Number(values.step) || 0, total: Number(values.total) || 0, created: text(values.created, 30) || null, started: text(values.started, 30) || null, finished: text(values.finished, 30) || null, runner: text(values.runner, 16) || null, title: text(values.title, 300), alive: values.alive === "1", error: errorCode ? { code: `DISKS_${text(errorCode, 64)}`, message: text(errorMessage.join("|"), 600) } : null, // "running" without a live runner means the outcome is unknown (crash/reboot): never restart it automatically. outcome: ["succeeded", "failed", "pending-reboot"].includes(values.state) ? values.state : (values.state === "running" || values.state === "queued") && values.alive !== "1" ? "unknown" : values.state === "running" || values.state === "queued" ? "running" : "unknown" }; } function parseStepMarkers(log) { return [...String(log).matchAll(/\x1eSTEP (\d+)\|(\d+)\|([a-z]+)\|([^\n]*)/g)].map((match) => ({ index: Number(match[1]), total: Number(match[2]), status: match[3], step: text(match[4], 400) })); } function smartSummary(sections) { const smart = (() => { try { return JSON.parse(sections.smartctl || "null"); } catch { return null; } })(); let nvmeLog = null; let nvmeId = null; if (sections.nvme) { const parts = sections.nvme.split(/^@@/m); for (const part of parts) { const body = part.slice(part.indexOf("\n") + 1); try { if (part.startsWith("smart")) nvmeLog = JSON.parse(body); else if (part.startsWith("id")) nvmeId = JSON.parse(body); } catch {} } } const messages = (smart?.smartctl?.messages || []).map((entry) => text(entry.string, 200)); const nvmeHealth = smart?.nvme_smart_health_information_log || nvmeLog || null; const attributes = smart?.ata_smart_attributes?.table || []; const attribute = (id) => attributes.find((entry) => entry.id === id)?.raw?.value ?? null; const available = Boolean(smart?.smart_status || nvmeHealth || smart?.smart_support?.enabled === true); return { available, tool: smart && !smart.chj_unavailable ? "smartctl" : nvmeLog ? "nvme-cli" : null, reason: available ? null : smart?.chj_unavailable ? "SMARTCTL_MISSING" : messages[0] || "SMART_UNAVAILABLE", messages, model: text(smart?.model_name || nvmeId?.mn || "", 80).trim() || null, serial: text(smart?.serial_number || nvmeId?.sn || "", 80).trim() || null, firmware: text(smart?.firmware_version || nvmeId?.fr || "", 40).trim() || null, capacity: smart?.user_capacity?.bytes ?? null, passed: typeof smart?.smart_status?.passed === "boolean" ? smart.smart_status.passed : nvmeHealth ? Number(nvmeHealth.critical_warning) === 0 : null, temperatureC: smart?.temperature?.current ?? (nvmeHealth?.temperature !== undefined ? (nvmeHealth.temperature > 200 ? nvmeHealth.temperature - 273 : nvmeHealth.temperature) : null), powerOnHours: smart?.power_on_time?.hours ?? nvmeHealth?.power_on_hours ?? null, powerCycles: smart?.power_cycle_count ?? nvmeHealth?.power_cycles ?? null, reallocatedSectors: attribute(5), pendingSectors: attribute(197), offlineUncorrectable: attribute(198), crcErrors: attribute(199), percentageUsed: nvmeHealth?.percentage_used ?? nvmeHealth?.percent_used ?? null, availableSpare: nvmeHealth?.available_spare ?? null, mediaErrors: nvmeHealth?.media_errors ?? null, errorLogEntries: nvmeHealth?.num_err_log_entries ?? smart?.ata_smart_error_log?.summary?.count ?? null, dataUnitsWritten: nvmeHealth?.data_units_written ?? null, selfTestPassed: smart?.ata_smart_self_test_log?.standard?.table?.[0]?.status?.passed ?? null }; } class DiskService { constructor(options) { this.sessionManager = options.sessionManager; this.executor = options.privilegedExecutor; this.logger = options.logger; this.plans = new Map(); this.busy = new Set(); this.script = options.script || SCRIPT; } _identity(sessionId) { const record = this.sessionManager._requireConnected(sessionId); return { record, identity: `${record.profileId}|${record.host}|${record.port}|${record.username}` }; } async _platform(sessionId) { const { record, identity } = this._identity(sessionId); const platform = await this.sessionManager._platform(record); return { record, identity, platform }; } async _run(sessionId, args, options = {}) { return this.executor.run(sessionId, { script: this.script, args, privileged: options.privileged === true, sudoPassword: options.sudoPassword, rememberPassword: options.rememberSudo === true, useCachedPassword: options.privileged === true, frames: options.frames, purpose: `disks.${args[0]}`, timeoutMs: options.timeoutMs || 120000, outputLimit: 8 * 1024 * 1024, errorPrefix: "DISKS_" }); } async inspect(sessionId, options = {}) { const { platform, identity } = await this._platform(sessionId); if (platform.os !== "linux") { return { platform: platform.os, supported: false, identity, os: text(platform.caption || platform.kernel || "", 120), reason: platform.os === "windows" ? "WINDOWS" : platform.os === "posix" ? "POSIX_NOT_LINUX" : "UNKNOWN", collectedAt: new Date().toISOString() }; } const privilege = await this.executor.probe(sessionId); const cached = this.executor.hasCachedPassword?.(sessionId) === true; const privileged = options.privileged === true || Boolean(options.sudoPassword) || ["root", "sudo-nopasswd"].includes(privilege.mode) || cached; const run = await this._run(sessionId, ["inspect"], { privileged, sudoPassword: options.sudoPassword, rememberSudo: options.rememberSudo }); const inventory = buildInventory(run.stdout); return { ...inventory, identity, privilege: await this.executor.probe(sessionId), canManage: privilege.mode !== "none", sudoCached: this.executor.hasCachedPassword?.(sessionId) === true, suggestions: suggestions(inventory), collectedAt: new Date().toISOString() }; } async smart(sessionId, payload = {}) { const { platform } = await this._platform(sessionId); if (platform.os !== "linux") throw fail("PLATFORM_UNSUPPORTED", "SMART is read only on Linux servers."); const device = String(payload.device || ""); if (!DEVICE.test(device) || device.includes("..")) throw fail("ARGUMENTS_INVALID", "Invalid device."); const run = await this._run(sessionId, ["smart", device], { privileged: true, sudoPassword: payload.sudoPassword, rememberSudo: payload.rememberSudo, timeoutMs: 60000 }); return { device, ...smartSummary(splitSections(run.stdout)), collectedAt: new Date().toISOString() }; } // Validates and simulates a queue of operations against a fresh privileged inventory. Nothing changes on disk. async plan(sessionId, payload = {}) { const { platform, identity } = await this._platform(sessionId); if (platform.os !== "linux") throw fail("PLATFORM_UNSUPPORTED", "Disk changes are supported only on Linux servers."); const operations = Array.isArray(payload.operations) ? payload.operations : []; const inventory = await this.inspect(sessionId, { privileged: true, sudoPassword: payload.sudoPassword, rememberSudo: payload.rememberSudo }); const plan = planOperations(inventory, operations, { title: payload.title }); this._prunePlans(); const planId = crypto.randomUUID(); this.plans.set(planId, { sessionId, identity, operations: plan.operations, planText: plan.planText, title: plan.title, createdAt: Date.now() }); return { planId, ...plan, expiresAt: new Date(Date.now() + PLAN_TTL_MS).toISOString() }; } async autoExpand(sessionId, payload = {}) { const inventory = await this.inspect(sessionId, { privileged: true, sudoPassword: payload.sudoPassword, rememberSudo: payload.rememberSudo }); if (!inventory.supported) throw fail("PLATFORM_UNSUPPORTED", "Disk changes are supported only on Linux servers."); const suggestion = inventory.suggestions.find((entry) => entry.id === String(payload.suggestionId || "")); if (!suggestion) throw fail("SUGGESTION_GONE", "The expansion is no longer possible with the current layout; the view was refreshed."); if (!suggestion.available) throw fail(String(suggestion.blocked?.code || "DISKS_FAILED").replace(/^DISKS_/, ""), suggestion.blocked?.message || "The expansion is not possible."); return this.plan(sessionId, { operations: suggestion.operations, title: `Auto-expand ${suggestion.target}`, sudoPassword: payload.sudoPassword }); } cancelPlan(planId) { return { cancelled: this.plans.delete(String(planId || "")) }; } // Starts a confirmed plan. The plan is rebuilt from a fresh inventory and must produce exactly the reviewed // steps; otherwise the user has to review again. A plan is single-use, so a retry after a lost connection can // never start the same destructive job twice (the remote side also refuses an existing job ID). async apply(sessionId, payload = {}) { const planId = String(payload.planId || ""); const stored = this.plans.get(planId); if (!stored || Date.now() - stored.createdAt > PLAN_TTL_MS) { this.plans.delete(planId); throw fail("PLAN_EXPIRED", "The plan expired or was already used; plan the operations again."); } if (payload.confirm !== true) throw fail("CONFIRMATION_REQUIRED", "The plan must be confirmed explicitly."); const { identity } = this._identity(sessionId); if (stored.sessionId !== sessionId || stored.identity !== identity) throw fail("PLAN_SERVER_CHANGED", "The plan belongs to a different server or connection."); if (this.busy.has(sessionId)) throw fail("BUSY", "Another disk job is being started for this server."); this.busy.add(sessionId); try { this.plans.delete(planId); const inventory = await this.inspect(sessionId, { privileged: true, sudoPassword: payload.sudoPassword, rememberSudo: payload.rememberSudo !== false }); let fresh; try { fresh = planOperations(inventory, stored.operations, { title: stored.title }); } catch (error) { throw fail("PLAN_STALE", `The layout changed since the plan was reviewed: ${error.message}`); } if (fresh.planText !== stored.planText) throw fail("PLAN_STALE", "The layout changed since the plan was reviewed; review the new plan."); const jobId = crypto.randomUUID(); this.logger?.warn("Disk job submitted.", { sessionId, jobId, title: stored.title, steps: fresh.steps.length }); const run = await this._run(sessionId, ["job-start", jobId], { privileged: true, sudoPassword: payload.sudoPassword, rememberSudo: payload.rememberSudo !== false, timeoutMs: 90000, frames: [{ tag: "PLAN", data: `${fresh.planText}\n` }, { tag: "HELPER", data: this.script }, { tag: "TITLE", data: stored.title }] }); const values = parseKeyValues(splitSections(run.stdout).job); return { jobId, state: text(values.state, 20), runner: text(values.runner, 16), steps: fresh.steps }; } finally { this.busy.delete(sessionId); } } async jobs(sessionId, payload = {}) { const { platform } = await this._platform(sessionId); if (platform.os !== "linux") return { jobs: [] }; const run = await this._run(sessionId, ["job-list"], { privileged: true, sudoPassword: payload.sudoPassword, timeoutMs: 30000 }); const jobs = String(splitSections(run.stdout).jobs || "").split(/\r?\n/).filter(Boolean).map((line) => { const [id, state, step, total, created, finished, alive, title] = line.split("|"); return publicJob({ id, state, step, total, created, finished, alive, title }); }).filter((job) => JOB_ID.test(job.id)); return { jobs }; } async jobStatus(sessionId, payload = {}) { const jobId = String(payload.jobId || ""); if (!JOB_ID.test(jobId)) throw fail("ARGUMENTS_INVALID", "Invalid job ID."); const offset = Math.max(0, Math.min(Number(payload.offset) || 0, 1e12)); const run = await this._run(sessionId, ["job-status", jobId, String(Math.floor(offset))], { privileged: true, sudoPassword: payload.sudoPassword, timeoutMs: 30000 }); const sections = splitSections(run.stdout); const values = parseKeyValues(sections.job); const log = String(sections.log || ""); return { ...publicJob(values), plan: String(sections.plan || "").split(/\r?\n/).filter(Boolean).map((line) => text(line, 400)), log: log.replace(/\x1eSTEP [^\n]*\n?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ""), steps: parseStepMarkers(log), logOffset: offset + Buffer.byteLength(log, "utf8"), logSize: Number(values.logSize) || 0 }; } _prunePlans() { const now = Date.now(); for (const [id, plan] of this.plans) if (now - plan.createdAt > PLAN_TTL_MS) this.plans.delete(id); while (this.plans.size >= MAX_PLANS) this.plans.delete(this.plans.keys().next().value); } } module.exports = { DiskService, publicJob, smartSummary };