CH-J Toolsمدیریت سرور از طریق SSH
منو
منبع منتشرشده

CH-J Tools

پوشه‌ها و فایل‌های یک نسخهٔ مشخص از برنامه را مرور کنید.

دانلود ZIP منبع
CH-J / Server Manager 406 فایل
core-20261024T135205Z-7c487d719de2 افزوده‌شده: · ساخته‌شده:
diskService.js افزوده‌شده: تاریخ ثبت نشده است ۱۴٫۱ KB · 238 خط
کد دانلود فایل
1"use strict";
3const crypto = require("node:crypto");
4const fs = require("node:fs");
5const path = require("node:path");
6const { SessionError } = require("../sessions/sessionManager");
7const { buildInventory, splitSections } = require("./inventory");
8const { 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 through
12// PrivilegedExecutor with the fixed remoteDisks.sh, as a detached remote job with persistent state and log.
13const SCRIPT = fs.readFileSync(path.join(__dirname, "remoteDisks.sh"), "utf8");
14const PLAN_TTL_MS = 30 * 60 * 1000;
15const MAX_PLANS = 20;
16const JOB_ID = /^[a-z0-9-]{8,64}$/;
17const DEVICE = /^\/dev\/[A-Za-z0-9][A-Za-z0-9._/:+-]{0,90}$/;
19function fail(code, message, details) { return new SessionError(`DISKS_${code}`, message, details); }
20const text = (value, limit = 300) => String(value ?? "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, " ").slice(0, limit);
22function 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;
31function 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.state
42 : (values.state === "running" || values.state === "queued") && values.alive !== "1" ? "unknown" : values.state === "running" || values.state === "queued" ? "running" : "unknown"
43 };
46function 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) }));
50function 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 ?? null
85 };
88class 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 reviewed
176 // steps; otherwise the user has to review again. A plan is single-use, so a retry after a lost connection can
177 // 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) || 0
228 };
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 }
238module.exports = { DiskService, publicJob, smartSummary };
SHA-25638083c6a6c7ab772eb3219d2964ea02b573f0fed7e478e6ae5ba985d59b72335

CH-J Proprietary Software License 1.14 · کد منبع تحت CH-J Proprietary Software License 1.14 ارائه می‌شود. در دسترس بودن آن شرایط مجوز را تغییر نمی‌دهد و حق اضافه‌ای اعطا نمی‌کند.

SHA-256 بایگانی476ed8aef0f1e96a5acdf223127b6692acdf4492eeaf5d0816b6e2a634fbbda6
فایل‌ها