Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J Tools
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / Server Manager
406 Dateien
core-20261024T135205Z-7c487d719de2
Hinzugefügt: · Erstellt:
1
"use strict";3
const crypto = require("node:crypto");4
const fs = require("node:fs");5
const path = require("node:path");6
const { SessionError } = require("../sessions/sessionManager");7
const { buildInventory, splitSections } = require("./inventory");8
const { planOperations, suggestions } = require("./planner");10
// Core service behind chj.disks (Remote Disk Manager). Linux only; other platforms get an explicit "unsupported"11
// result and no Linux tool is ever started on them. Reads can run without root; every change goes through12
// PrivilegedExecutor with the fixed remoteDisks.sh, as a detached remote job with persistent state and log.13
const SCRIPT = fs.readFileSync(path.join(__dirname, "remoteDisks.sh"), "utf8");14
const PLAN_TTL_MS = 30 * 60 * 1000;15
const MAX_PLANS = 20;16
const JOB_ID = /^[a-z0-9-]{8,64}$/;17
const DEVICE = /^\/dev\/[A-Za-z0-9][A-Za-z0-9._/:+-]{0,90}$/;19
function fail(code, message, details) { return new SessionError(`DISKS_${code}`, message, details); }20
const text = (value, limit = 300) => String(value ?? "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, " ").slice(0, limit);22
function parseKeyValues(section) {23
const values = {};24
for (const line of String(section || "").split(/\r?\n/)) {25
const index = line.indexOf("=");26
if (index > 0) values[line.slice(0, index)] = line.slice(index + 1);27
}28
return values;29
}31
function publicJob(values) {32
const [errorCode, ...errorMessage] = String(values.error || "").split("|");33
return {34
id: text(values.id, 64), state: text(values.state, 20) || "unknown",35
step: Number(values.step) || 0, total: Number(values.total) || 0,36
created: text(values.created, 30) || null, started: text(values.started, 30) || null, finished: text(values.finished, 30) || null,37
runner: text(values.runner, 16) || null, title: text(values.title, 300),38
alive: values.alive === "1",39
error: errorCode ? { code: `DISKS_${text(errorCode, 64)}`, message: text(errorMessage.join("|"), 600) } : null,40
// "running" without a live runner means the outcome is unknown (crash/reboot): never restart it automatically.41
outcome: ["succeeded", "failed", "pending-reboot"].includes(values.state) ? values.state42
: (values.state === "running" || values.state === "queued") && values.alive !== "1" ? "unknown" : values.state === "running" || values.state === "queued" ? "running" : "unknown"43
};44
}46
function parseStepMarkers(log) {47
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) }));48
}50
function smartSummary(sections) {51
const smart = (() => { try { return JSON.parse(sections.smartctl || "null"); } catch { return null; } })();52
let nvmeLog = null; let nvmeId = null;53
if (sections.nvme) {54
const parts = sections.nvme.split(/^@@/m);55
for (const part of parts) {56
const body = part.slice(part.indexOf("\n") + 1);57
try { if (part.startsWith("smart")) nvmeLog = JSON.parse(body); else if (part.startsWith("id")) nvmeId = JSON.parse(body); } catch {}58
}59
}60
const messages = (smart?.smartctl?.messages || []).map((entry) => text(entry.string, 200));61
const nvmeHealth = smart?.nvme_smart_health_information_log || nvmeLog || null;62
const attributes = smart?.ata_smart_attributes?.table || [];63
const attribute = (id) => attributes.find((entry) => entry.id === id)?.raw?.value ?? null;64
const available = Boolean(smart?.smart_status || nvmeHealth || smart?.smart_support?.enabled === true);65
return {66
available,67
tool: smart && !smart.chj_unavailable ? "smartctl" : nvmeLog ? "nvme-cli" : null,68
reason: available ? null : smart?.chj_unavailable ? "SMARTCTL_MISSING" : messages[0] || "SMART_UNAVAILABLE",69
messages,70
model: text(smart?.model_name || nvmeId?.mn || "", 80).trim() || null,71
serial: text(smart?.serial_number || nvmeId?.sn || "", 80).trim() || null,72
firmware: text(smart?.firmware_version || nvmeId?.fr || "", 40).trim() || null,73
capacity: smart?.user_capacity?.bytes ?? null,74
passed: typeof smart?.smart_status?.passed === "boolean" ? smart.smart_status.passed : nvmeHealth ? Number(nvmeHealth.critical_warning) === 0 : null,75
temperatureC: smart?.temperature?.current ?? (nvmeHealth?.temperature !== undefined ? (nvmeHealth.temperature > 200 ? nvmeHealth.temperature - 273 : nvmeHealth.temperature) : null),76
powerOnHours: smart?.power_on_time?.hours ?? nvmeHealth?.power_on_hours ?? null,77
powerCycles: smart?.power_cycle_count ?? nvmeHealth?.power_cycles ?? null,78
reallocatedSectors: attribute(5), pendingSectors: attribute(197), offlineUncorrectable: attribute(198), crcErrors: attribute(199),79
percentageUsed: nvmeHealth?.percentage_used ?? nvmeHealth?.percent_used ?? null,80
availableSpare: nvmeHealth?.available_spare ?? null,81
mediaErrors: nvmeHealth?.media_errors ?? null,82
errorLogEntries: nvmeHealth?.num_err_log_entries ?? smart?.ata_smart_error_log?.summary?.count ?? null,83
dataUnitsWritten: nvmeHealth?.data_units_written ?? null,84
selfTestPassed: smart?.ata_smart_self_test_log?.standard?.table?.[0]?.status?.passed ?? null85
};86
}88
class DiskService {89
constructor(options) {90
this.sessionManager = options.sessionManager;91
this.executor = options.privilegedExecutor;92
this.logger = options.logger;93
this.plans = new Map();94
this.busy = new Set();95
this.script = options.script || SCRIPT;96
}98
_identity(sessionId) {99
const record = this.sessionManager._requireConnected(sessionId);100
return { record, identity: `${record.profileId}|${record.host}|${record.port}|${record.username}` };101
}103
async _platform(sessionId) {104
const { record, identity } = this._identity(sessionId);105
const platform = await this.sessionManager._platform(record);106
return { record, identity, platform };107
}109
async _run(sessionId, args, options = {}) {110
return this.executor.run(sessionId, {111
script: this.script, args, privileged: options.privileged === true, sudoPassword: options.sudoPassword,112
rememberPassword: options.rememberSudo === true, useCachedPassword: options.privileged === true,113
frames: options.frames, purpose: `disks.${args[0]}`, timeoutMs: options.timeoutMs || 120000, outputLimit: 8 * 1024 * 1024, errorPrefix: "DISKS_"114
});115
}117
async inspect(sessionId, options = {}) {118
const { platform, identity } = await this._platform(sessionId);119
if (platform.os !== "linux") {120
return {121
platform: platform.os, supported: false, identity,122
os: text(platform.caption || platform.kernel || "", 120),123
reason: platform.os === "windows" ? "WINDOWS" : platform.os === "posix" ? "POSIX_NOT_LINUX" : "UNKNOWN",124
collectedAt: new Date().toISOString()125
};126
}127
const privilege = await this.executor.probe(sessionId);128
const cached = this.executor.hasCachedPassword?.(sessionId) === true;129
const privileged = options.privileged === true || Boolean(options.sudoPassword) || ["root", "sudo-nopasswd"].includes(privilege.mode) || cached;130
const run = await this._run(sessionId, ["inspect"], { privileged, sudoPassword: options.sudoPassword, rememberSudo: options.rememberSudo });131
const inventory = buildInventory(run.stdout);132
return {133
...inventory, identity, privilege: await this.executor.probe(sessionId),134
canManage: privilege.mode !== "none", sudoCached: this.executor.hasCachedPassword?.(sessionId) === true,135
suggestions: suggestions(inventory),136
collectedAt: new Date().toISOString()137
};138
}140
async smart(sessionId, payload = {}) {141
const { platform } = await this._platform(sessionId);142
if (platform.os !== "linux") throw fail("PLATFORM_UNSUPPORTED", "SMART is read only on Linux servers.");143
const device = String(payload.device || "");144
if (!DEVICE.test(device) || device.includes("..")) throw fail("ARGUMENTS_INVALID", "Invalid device.");145
const run = await this._run(sessionId, ["smart", device], { privileged: true, sudoPassword: payload.sudoPassword, rememberSudo: payload.rememberSudo, timeoutMs: 60000 });146
return { device, ...smartSummary(splitSections(run.stdout)), collectedAt: new Date().toISOString() };147
}149
// Validates and simulates a queue of operations against a fresh privileged inventory. Nothing changes on disk.150
async plan(sessionId, payload = {}) {151
const { platform, identity } = await this._platform(sessionId);152
if (platform.os !== "linux") throw fail("PLATFORM_UNSUPPORTED", "Disk changes are supported only on Linux servers.");153
const operations = Array.isArray(payload.operations) ? payload.operations : [];154
const inventory = await this.inspect(sessionId, { privileged: true, sudoPassword: payload.sudoPassword, rememberSudo: payload.rememberSudo });155
const plan = planOperations(inventory, operations, { title: payload.title });156
this._prunePlans();157
const planId = crypto.randomUUID();158
this.plans.set(planId, { sessionId, identity, operations: plan.operations, planText: plan.planText, title: plan.title, createdAt: Date.now() });159
return { planId, ...plan, expiresAt: new Date(Date.now() + PLAN_TTL_MS).toISOString() };160
}162
async autoExpand(sessionId, payload = {}) {163
const inventory = await this.inspect(sessionId, { privileged: true, sudoPassword: payload.sudoPassword, rememberSudo: payload.rememberSudo });164
if (!inventory.supported) throw fail("PLATFORM_UNSUPPORTED", "Disk changes are supported only on Linux servers.");165
const suggestion = inventory.suggestions.find((entry) => entry.id === String(payload.suggestionId || ""));166
if (!suggestion) throw fail("SUGGESTION_GONE", "The expansion is no longer possible with the current layout; the view was refreshed.");167
if (!suggestion.available) throw fail(String(suggestion.blocked?.code || "DISKS_FAILED").replace(/^DISKS_/, ""), suggestion.blocked?.message || "The expansion is not possible.");168
return this.plan(sessionId, { operations: suggestion.operations, title: `Auto-expand ${suggestion.target}`, sudoPassword: payload.sudoPassword });169
}171
cancelPlan(planId) {172
return { cancelled: this.plans.delete(String(planId || "")) };173
}175
// Starts a confirmed plan. The plan is rebuilt from a fresh inventory and must produce exactly the reviewed176
// steps; otherwise the user has to review again. A plan is single-use, so a retry after a lost connection can177
// never start the same destructive job twice (the remote side also refuses an existing job ID).178
async apply(sessionId, payload = {}) {179
const planId = String(payload.planId || "");180
const stored = this.plans.get(planId);181
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."); }182
if (payload.confirm !== true) throw fail("CONFIRMATION_REQUIRED", "The plan must be confirmed explicitly.");183
const { identity } = this._identity(sessionId);184
if (stored.sessionId !== sessionId || stored.identity !== identity) throw fail("PLAN_SERVER_CHANGED", "The plan belongs to a different server or connection.");185
if (this.busy.has(sessionId)) throw fail("BUSY", "Another disk job is being started for this server.");186
this.busy.add(sessionId);187
try {188
this.plans.delete(planId);189
const inventory = await this.inspect(sessionId, { privileged: true, sudoPassword: payload.sudoPassword, rememberSudo: payload.rememberSudo !== false });190
let fresh;191
try { fresh = planOperations(inventory, stored.operations, { title: stored.title }); }192
catch (error) { throw fail("PLAN_STALE", `The layout changed since the plan was reviewed: ${error.message}`); }193
if (fresh.planText !== stored.planText) throw fail("PLAN_STALE", "The layout changed since the plan was reviewed; review the new plan.");194
const jobId = crypto.randomUUID();195
this.logger?.warn("Disk job submitted.", { sessionId, jobId, title: stored.title, steps: fresh.steps.length });196
const run = await this._run(sessionId, ["job-start", jobId], {197
privileged: true, sudoPassword: payload.sudoPassword, rememberSudo: payload.rememberSudo !== false, timeoutMs: 90000,198
frames: [{ tag: "PLAN", data: `${fresh.planText}\n` }, { tag: "HELPER", data: this.script }, { tag: "TITLE", data: stored.title }]199
});200
const values = parseKeyValues(splitSections(run.stdout).job);201
return { jobId, state: text(values.state, 20), runner: text(values.runner, 16), steps: fresh.steps };202
} finally { this.busy.delete(sessionId); }203
}205
async jobs(sessionId, payload = {}) {206
const { platform } = await this._platform(sessionId);207
if (platform.os !== "linux") return { jobs: [] };208
const run = await this._run(sessionId, ["job-list"], { privileged: true, sudoPassword: payload.sudoPassword, timeoutMs: 30000 });209
const jobs = String(splitSections(run.stdout).jobs || "").split(/\r?\n/).filter(Boolean).map((line) => {210
const [id, state, step, total, created, finished, alive, title] = line.split("|");211
return publicJob({ id, state, step, total, created, finished, alive, title });212
}).filter((job) => JOB_ID.test(job.id));213
return { jobs };214
}216
async jobStatus(sessionId, payload = {}) {217
const jobId = String(payload.jobId || "");218
if (!JOB_ID.test(jobId)) throw fail("ARGUMENTS_INVALID", "Invalid job ID.");219
const offset = Math.max(0, Math.min(Number(payload.offset) || 0, 1e12));220
const run = await this._run(sessionId, ["job-status", jobId, String(Math.floor(offset))], { privileged: true, sudoPassword: payload.sudoPassword, timeoutMs: 30000 });221
const sections = splitSections(run.stdout);222
const values = parseKeyValues(sections.job);223
const log = String(sections.log || "");224
return {225
...publicJob(values), plan: String(sections.plan || "").split(/\r?\n/).filter(Boolean).map((line) => text(line, 400)),226
log: log.replace(/\x1eSTEP [^\n]*\n?/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ""),227
steps: parseStepMarkers(log), logOffset: offset + Buffer.byteLength(log, "utf8"), logSize: Number(values.logSize) || 0228
};229
}231
_prunePlans() {232
const now = Date.now();233
for (const [id, plan] of this.plans) if (now - plan.createdAt > PLAN_TTL_MS) this.plans.delete(id);234
while (this.plans.size >= MAX_PLANS) this.plans.delete(this.plans.keys().next().value);235
}236
}238
module.exports = { DiskService, publicJob, smartSummary };SHA-256
38083c6a6c7ab772eb3219d2964ea02b573f0fed7e478e6ae5ba985d59b72335CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.
SHA-256 des Archivs
476ed8aef0f1e96a5acdf223127b6692acdf4492eeaf5d0816b6e2a634fbbda6