CH-J Toolsserverio valdymas per SSH
Meniu
Paskelbtas šaltinis

CH-J Tools

Naršyti aplankus ir failus konkrečiai programai išleisti.

Atsisiųsti šaltinį ZIP
CH-J / Server Manager 406 failai
core-20261024T135205Z-7c487d719de2 Pridėta: · Pastatytas:
e2eFunctionalVm.js Pridėta: Data neregistruota 20,5 KB · 278 eilutės
Kodas Atsiųsti failą
1"use strict";
2// End-to-end functional check of the complete application (real bootstrap, main window, preloads, IPC, bundled
3// plugins) with an isolated userData and a NEW vault, against the machine it runs on over real SSH (private key, or
4// a password stored encrypted in the vault).
5// Non-destructive: only a temporary directory in the SSH user's home is created and removed; no sudo, no system
6// changes. Never point it at a production server. Environment (values are never printed):
7// CHJ_E2E_HOST, CHJ_E2E_USER, CHJ_E2E_FINGERPRINT (SHA256:base64), CHJ_E2E_REMOTE_HOME (absolute SFTP path, e.g.
8// /home/ai or /C:/Users/ai), CHJ_E2E_VAULT_PASSWORD_FILE and either CHJ_E2E_KEY (private key path) or
9// CHJ_E2E_SSH_PASSWORD_FILE (files read at runtime). Optional CHJ_E2E_SKIP (see step()).
10// Run: electron scripts/e2eFunctionalVm.js (Linux without a desktop: xvfb-run -a electron --no-sandbox ...)
11const { app, BrowserWindow, dialog } = require("electron");
12const assert = require("node:assert/strict");
13const crypto = require("node:crypto");
14const fs = require("node:fs");
15const os = require("node:os");
16const path = require("node:path");
18const env = process.env;
19const required = ["CHJ_E2E_HOST", "CHJ_E2E_USER", "CHJ_E2E_FINGERPRINT", "CHJ_E2E_REMOTE_HOME", "CHJ_E2E_VAULT_PASSWORD_FILE"];
20const missing = required.filter((name) => !env[name]);
21if (!env.CHJ_E2E_KEY && !env.CHJ_E2E_SSH_PASSWORD_FILE) missing.push("CHJ_E2E_KEY or CHJ_E2E_SSH_PASSWORD_FILE");
22if (missing.length) { console.error(`Missing environment: ${missing.join(", ")}`); process.exit(2); }
23// Only a trailing line break is removed: the vault password may consist of spaces.
24const vaultPassword = fs.readFileSync(env.CHJ_E2E_VAULT_PASSWORD_FILE, "utf8").replace(/\r?\n$/, "");
25const sshPassword = env.CHJ_E2E_KEY ? "" : fs.readFileSync(env.CHJ_E2E_SSH_PASSWORD_FILE, "utf8").replace(/\r?\n$/, "");
26// Secret material that must never appear in userData, exports or logs.
27const secret = env.CHJ_E2E_KEY ? fs.readFileSync(env.CHJ_E2E_KEY, "utf8").split(/\r?\n/).filter((line) => line && !line.startsWith("-----")).sort((a, b) => b.length - a.length)[0] : sshPassword;
28const expectedHex = Buffer.from(env.CHJ_E2E_FINGERPRINT.replace(/^SHA256:/, ""), "base64").toString("hex");
29const windows = process.platform === "win32";
30const appRoot = path.resolve(__dirname, "..");
31const root = fs.mkdtempSync(path.join(os.tmpdir(), "chj-e2e-"));
32const sessionId = "terminal-main";
33const remoteDir = path.posix.join(env.CHJ_E2E_REMOTE_HOME, `chj-e2e-${crypto.randomBytes(4).toString("hex")}`);
34const results = [];
35const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
37// Isolated userData without CHJ_BUILD_SMOKE_USER_DATA_DIR (that build-smoke mode quits right after startup). Core
38// resolves LICENSE and docs from the app root; the license is pre-accepted like in the installed-app smoke.
39app.setPath("userData", root);
40app.getAppPath = () => appRoot;
41const { LicenseAcceptance } = require("../src/main/legal/licenseAcceptance");
42const buildInfo = require("../src/shared/buildInfo.json");
43// Registered before Core so it runs first on ready: on Linux app.getVersion() only settles once the app is ready.
44app.whenReady().then(() => {
45 const acceptance = new LicenseAcceptance({ storageRoot: root, licensePath: path.join(appRoot, "LICENSE"), docsRoot: path.join(appRoot, "docs"), appVersion: app.getVersion(), buildId: buildInfo.buildId });
46 acceptance.accept({ accepted: true, sha256: acceptance.identity.sha256, guidanceShown: true, guidanceSha256: acceptance.identity.guidanceSha256 });
47});
49// Native file dialogs answer from a queue (upload source, download target, hash file, profile export target).
50const dialogQueue = [];
51dialog.showOpenDialog = async () => dialogQueue.shift() || { canceled: true, filePaths: [] };
52dialog.showSaveDialog = async () => dialogQueue.shift() || { canceled: true, filePath: "" };
54require("../src/main/index.js");
55app.setPath("userData", root); // again after Core's app.setName()
57async function waitFor(check, label, timeout = 30000) {
58 const started = Date.now();
59 while (Date.now() - started < timeout) { const value = await check().catch(() => null); if (value) return value; await delay(100); }
60 throw new Error(`Timed out: ${label}`);
62function run(window, body) { return window.webContents.executeJavaScript(`(async () => { ${body} })()`); }
63// CHJ_E2E_SKIP: comma-separated step-name prefixes to skip (for isolating a failure); skipped steps never count as passed.
64const skipPrefixes = String(env.CHJ_E2E_SKIP || "").split(",").map((item) => item.trim()).filter(Boolean);
65async function step(name, action) {
66 if (skipPrefixes.some((prefix) => name.startsWith(prefix))) { console.log(`SKIP ${name}`); return; }
67 try { const detail = await action(); results.push({ name, ok: true, detail: detail || "" }); console.log(`PASS ${name}${detail ? ` — ${detail}` : ""}`); }
68 catch (error) { results.push({ name, ok: false, detail: error.message }); console.log(`FAIL ${name} — ${String(error.message).split("\n")[0].slice(0, 300)}`); }
70async function pluginWindow(main, id) {
71 await run(main, `return window.chjCore.openPlugin(${JSON.stringify(id)});`);
72 const window = await waitFor(async () => BrowserWindow.getAllWindows().find((item) => !item.isDestroyed() && item.webContents.getURL().startsWith(`chj-plugin://${id}/`)), `${id} window`);
73 await waitFor(() => run(window, "return typeof window.chjPlugin === 'object';"), `${id} preload`);
74 return window;
77async function main() {
78 await app.whenReady();
79 const mainWindow = await waitFor(async () => BrowserWindow.getAllWindows().find((window) => !window.isDestroyed() && window.webContents.getURL().endsWith("renderer/index.html")), "main window");
80 await waitFor(() => run(mainWindow, "return typeof window.chjCore === 'object';"), "core preload");
81 const core = (body) => run(mainWindow, `const api = window.chjCore; ${body}`);
82 const password = JSON.stringify(vaultPassword);
84 await step("Core info (name, Plugin API)", async () => {
85 const info = await core("return api.getInfo();");
86 assert.equal(info.name, "CH-J Server Manager");
87 assert.equal(info.pluginApi, require("../src/main/plugins/pluginRegistry").PLUGIN_API_VERSION);
88 return `${info.name}, Plugin API ${info.pluginApi}, build ${buildInfo.buildId}`;
89 });
90 await step("Vault: create, lock, wrong password refused, unlock", async () => {
91 assert.equal((await core("return api.getVaultStatus();")).needsSetup, true);
92 assert.equal((await core(`return api.createVault(${password});`)).unlocked, true);
93 assert.equal((await core("return api.lockVault();")).unlocked, false);
94 const wrong = await core("return api.unlockVault('wrong-password-e2e').then(() => 'accepted', (error) => 'refused');");
95 assert.equal(wrong, "refused");
96 assert.equal((await core(`return api.unlockVault(${password});`)).unlocked, true);
97 });
98 let profileId;
99 await step(env.CHJ_E2E_KEY ? "Server profile with private key" : "Server profile with password stored encrypted in the vault", async () => {
100 const input = env.CHJ_E2E_KEY
101 ? { label: "e2e-self", host: env.CHJ_E2E_HOST, port: 22, username: env.CHJ_E2E_USER, authMethod: "privateKey", privateKeyPath: env.CHJ_E2E_KEY, tags: ["e2e"] }
102 : { label: "e2e-self", host: env.CHJ_E2E_HOST, port: 22, username: env.CHJ_E2E_USER, authMethod: "password", storePassword: true, password: sshPassword, tags: ["e2e"] };
103 const saved = await core(`return api.saveProfile(${JSON.stringify(input)});`);
104 profileId = saved.id || saved.profile?.id;
105 const list = await core("return api.listProfiles();");
106 assert.ok((Array.isArray(list) ? list : list.profiles).some((profile) => profile.id === profileId && profile.label === "e2e-self" && (env.CHJ_E2E_KEY ? true : profile.hasStoredPassword === true)));
107 const regular = (file) => { try { const stat = fs.lstatSync(file); return stat.isFile() && stat.size < 4 * 1024 * 1024; } catch { return false; } };
108 const stored = fs.readdirSync(root, { recursive: true }).map((name) => path.join(root, String(name))).filter(regular);
109 let locked = 0;
110 for (const file of stored) {
111 let data;
112 try { data = fs.readFileSync(file); } catch (error) { if (error.code === "EBUSY" || error.code === "EPERM") { locked += 1; continue; } throw error; }
113 assert.equal(data.includes(Buffer.from(secret)), false, `SSH secret readable in ${path.basename(file)}`);
114 }
115 return `${stored.length - locked} userData files contain no plaintext SSH secret${locked ? ` (${locked} files locked by Chromium skipped)` : ""}`;
116 });
117 await step("Profile export JSON without secrets", async () => {
118 const target = path.join(root, "profiles-export.json");
119 dialogQueue.push({ canceled: false, filePath: target });
120 await core("return api.exportProfiles('json');");
121 const text = fs.readFileSync(target, "utf8");
122 assert.match(text, /e2e-self/);
123 assert.doesNotMatch(text, /PRIVATE KEY|hostKeys/);
124 assert.equal(text.includes(secret), false, "no SSH secret in the export");
125 return `${text.length} B`;
126 });
127 await step("SSH: unknown host key shown, fingerprint matches, trust, connect", async () => {
128 await core("window.__e2e = ''; api.onSshData((payload) => { window.__e2e += payload?.data || ''; }); return true;");
129 const first = await core(`return api.connectSsh({ sessionId: ${JSON.stringify(sessionId)}, profileId: ${JSON.stringify(profileId)}, cols: 120, rows: 30 });`);
130 assert.equal(first.ok, false); assert.equal(first.error.code, "HOST_KEY_UNKNOWN");
131 const fingerprint = first.error.fingerprint || first.error.details?.fingerprint;
132 assert.equal(fingerprint, expectedHex, "presented host key equals the recorded VM fingerprint");
133 const trusted = await core(`return api.trustSshHostKey({ sessionId: ${JSON.stringify(sessionId)}, profileId: ${JSON.stringify(profileId)}, fingerprint: ${JSON.stringify(fingerprint)} });`);
134 assert.equal(trusted.ok, true, JSON.stringify(trusted.error));
135 const second = await core(`return api.connectSsh({ sessionId: ${JSON.stringify(sessionId)}, profileId: ${JSON.stringify(profileId)}, cols: 120, rows: 30 });`);
136 assert.equal(second.ok, true, JSON.stringify(second.error));
137 });
138 await step("Terminal: command output and resize", async () => {
139 const command = windows ? 'cmd /c "set /a 6000+77"\r\n' : "echo CHJ_E2E_$((6000+77))\n";
140 await core(`api.sendTerminalInput(${JSON.stringify(sessionId)}, ${JSON.stringify(command)}); return true;`);
141 await waitFor(() => core(`return window.__e2e.includes(${JSON.stringify(windows ? "6077" : "CHJ_E2E_6077")});`), "terminal output", 20000);
142 assert.equal((await core(`return api.resizeTerminal(${JSON.stringify(sessionId)}, 100, 28);`)).ok, true);
143 const sessions = await core("return api.listSshSessions();");
144 assert.ok(JSON.stringify(sessions).includes(sessionId));
145 });
146 await step("Bundled plugins listed", async () => {
147 const plugins = await core("return api.listPlugins();");
148 const ids = (Array.isArray(plugins) ? plugins : plugins.plugins || []).map((plugin) => plugin.id || plugin.manifest?.id);
149 for (const id of ["chj.disks", "chj.file-manager", "chj.hash-checksum", "chj.key-generator", "chj.log-viewer", "chj.nginx-manager", "chj.system-monitor", "chj.users"]) assert.ok(ids.includes(id), id);
150 return `${ids.length} plugins`;
151 });
153 const sid = JSON.stringify(sessionId);
154 await step("System Monitor: live metrics", async () => {
155 const window = await pluginWindow(mainWindow, "chj.system-monitor");
156 const sessions = await run(window, "return window.chjPlugin.sessions.list();");
157 assert.ok(JSON.stringify(sessions).includes(sessionId));
158 const metrics = await run(window, `return window.chjPlugin.system.readMetrics(${sid});`);
159 assert.ok(metrics && typeof metrics === "object");
160 return Object.keys(metrics).slice(0, 8).join(", ");
161 });
162 await step("File Manager: list, mkdir, write/read text, upload, download, rename, delete", async () => {
163 const window = await pluginWindow(mainWindow, "chj.file-manager");
164 const files = (body) => run(window, `const api = window.chjPlugin.files; ${body}`);
165 try { return await fileChecks(window, files); }
166 catch (error) { await files(`return api.removeMany(${sid}, [${JSON.stringify(remoteDir)}], true);`).catch(() => {}); throw error; }
167 });
168 async function fileChecks(window, files) {
169 const dir = JSON.stringify(remoteDir);
170 const names = async (target) => { const listing = await files(`return api.list(${sid}, ${JSON.stringify(target)});`); return (Array.isArray(listing) ? listing : listing.entries).map((entry) => entry.name); };
171 assert.ok(Array.isArray(await names(env.CHJ_E2E_REMOTE_HOME)));
172 await files(`return api.mkdir(${sid}, ${dir});`);
173 assert.ok((await names(env.CHJ_E2E_REMOTE_HOME)).includes(path.posix.basename(remoteDir)));
174 const note = `${remoteDir}/note.txt`;
175 let editor = "OK";
176 try {
177 // Same path as the editor UI: create, open (baseline), save, reopen.
178 await files(`return api.createFile(${sid}, ${JSON.stringify(note)});`);
179 const opened = await files(`return api.readText(${sid}, ${JSON.stringify(note)});`);
180 await files(`return api.writeText(${sid}, ${JSON.stringify(note)}, "Příliš žluťoučký kůň e2e\\n");`);
181 const read = await files(`return api.readText(${sid}, ${JSON.stringify(note)});`);
182 assert.equal(read.text ?? read.content, "Příliš žluťoučký kůň e2e\n");
183 for (const id of [opened.editId, read.editId].filter(Boolean)) await files(`return api.closeText(${JSON.stringify(id)});`);
184 } catch (error) { if (!windows) throw error; editor = `Windows: ${error.message.split("\n")[0].slice(0, 120)}`; }
185 const local = path.join(root, "upload-e2e.bin");
186 const payload = crypto.randomBytes(256 * 1024 + 123);
187 fs.writeFileSync(local, payload);
188 dialogQueue.push({ canceled: false, filePaths: [local] });
189 await files(`return api.upload(${sid}, ${dir});`);
190 await waitFor(async () => (await names(remoteDir)).includes("upload-e2e.bin"), "uploaded file", 30000);
191 const downloaded = path.join(root, "download-e2e.bin");
192 dialogQueue.push({ canceled: false, filePath: downloaded });
193 await files(`return api.download(${sid}, ${JSON.stringify(`${remoteDir}/upload-e2e.bin`)});`);
194 await waitFor(async () => fs.existsSync(downloaded) && fs.statSync(downloaded).size === payload.length, "downloaded file", 30000);
195 assert.ok(fs.readFileSync(downloaded).equals(payload), "download equals upload");
196 await files(`return api.rename(${sid}, ${JSON.stringify(`${remoteDir}/upload-e2e.bin`)}, ${JSON.stringify(`${remoteDir}/renamed.bin`)});`);
197 assert.ok((await names(remoteDir)).includes("renamed.bin"));
198 await files(`return api.removeMany(${sid}, [${dir}], true);`);
199 assert.ok(!(await names(env.CHJ_E2E_REMOTE_HOME)).includes(path.posix.basename(remoteDir)));
200 return `256 KiB round trip identical; editor ${editor}`;
201 }
202 await step("Users: account list", async () => {
203 const window = await pluginWindow(mainWindow, "chj.users");
204 const list = await run(window, `return window.chjPlugin.users.list(${sid});`);
205 assert.ok(JSON.stringify(list).toLowerCase().includes(`"${env.CHJ_E2E_USER.toLowerCase()}"`), "SSH user is listed");
206 return `${(list.users || list).length ?? "?"} accounts`;
207 });
208 await step("NGINX Manager: inspection", async () => {
209 const window = await pluginWindow(mainWindow, "chj.nginx-manager");
210 const result = await run(window, `return window.chjPlugin.nginx.inspect(${sid}).then((value) => ({ ok: true, value }), (error) => ({ ok: false, message: error.message }));`);
211 assert.ok(result.ok || /not installed|nicht installiert|není nainstal|not found|unsupported|Windows/i.test(result.message), result.message);
212 return result.ok ? `installed=${result.value?.installed ?? "?"}` : result.message.slice(0, 100);
213 });
214 await step("Disk Manager: inventory without sudo / platform guard", async () => {
215 const window = await pluginWindow(mainWindow, "chj.disks");
216 const result = await run(window, `return window.chjPlugin.disks.inspect(${sid}).then((value) => ({ ok: true, value }), (error) => ({ ok: false, message: error.message }));`);
217 if (windows) { assert.ok(!result.ok || result.value?.supported === false, "Windows is refused"); return "Windows refused as unsupported"; }
218 assert.ok(result.ok || /sudo|privilege|oprávn|password/i.test(result.message), result.message);
219 return result.ok ? `${(result.value.disks || []).length} disks` : "needs sudo (not tested: no sudo in this run)";
220 });
221 await step("Key Generator: Ed25519 key and grouped password", async () => {
222 const window = await pluginWindow(mainWindow, "chj.key-generator");
223 const key = await run(window, "return window.chjPlugin.keys.generate({ type: 'ed25519', privateKeyFormat: 'openssh', comment: 'e2e' });");
224 assert.match(key.publicKey, /^ssh-ed25519 AAAA/);
225 const pw = await run(window, "return window.chjPlugin.keys.password({ length: 20, upper: true, lower: true, digits: true, special: true, grouped: true });");
226 assert.match(pw.password, /^[^-]{4}-[^-]{4}-[^-]{4}-[^-]{4}-$/u);
227 });
228 await step("Hash & Checksum: SHA-256 of a local file", async () => {
229 const window = await pluginWindow(mainWindow, "chj.hash-checksum");
230 const file = path.join(root, "hash-e2e.txt");
231 fs.writeFileSync(file, "abc");
232 dialogQueue.push({ canceled: false, filePaths: [file] });
233 const selection = await run(window, "return window.chjPlugin.hashing.selectFiles(false);");
234 const job = await run(window, `return window.chjPlugin.hashing.start({ selectionId: ${JSON.stringify(selection.selectionId)}, algorithms: ['sha256'] });`);
235 const jobId = JSON.stringify(job.jobId || job.id);
236 const done = await waitFor(async () => { const status = await run(window, `return window.chjPlugin.hashing.status(${jobId});`); return ["completed", "failed", "cancelled"].includes(status.state) ? status : null; }, "hash job");
237 assert.equal(done.state, "completed");
238 assert.ok(JSON.stringify(done).includes("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"));
239 });
240 await step("Log Viewer: application log, no secrets", async () => {
241 const window = await pluginWindow(mainWindow, "chj.log-viewer");
242 const log = await run(window, "return window.chjPlugin.logs.read({ maxBytes: 262144 });");
243 const text = log.text ?? log.content ?? String(log);
244 assert.match(text, /host key trusted/i);
245 assert.doesNotMatch(text, /PRIVATE KEY/);
246 assert.equal(text.includes(secret), false, "no SSH secret in the log");
247 return `${text.length} B`;
248 });
249 await step("Updates: catalog check", async () => {
250 const state = await core("return api.checkForUpdates().then((value) => ({ ok: true, value }), (error) => ({ ok: false, message: error.message }));");
251 assert.ok(state.ok, state.message);
252 assert.equal(state.value.status, "idle", "check() reports its result after finishing");
253 const after = await core("return api.getUpdateState();");
254 assert.equal(after.status, "idle");
255 if (after.release?.build_id === after.currentBuildId) assert.ok(after.currentVersion !== after.release.version || after.updateAvailable === false, "same build offered as an update");
256 return `version ${after.currentVersion}, current ${after.currentBuildId}, offered ${after.release?.build_id || after.release?.buildId || "none"} (update available: ${after.updateAvailable})`;
257 });
258 await step("Disconnect and lock: sessions closed, vault locked", async () => {
259 assert.equal((await core(`return api.disconnectSsh(${sid});`)).ok, true);
260 assert.equal((await core("return api.lockVault();")).unlocked, false);
261 assert.equal(JSON.stringify(await core("return api.listSshSessions();")).includes(`"state":"connected"`), false);
262 });
265function finish(code) {
266 for (const window of BrowserWindow.getAllWindows()) window.destroy();
267 const failed = results.filter((result) => !result.ok);
268 if (failed.length || code) {
269 // The application log holds no secrets (checked by the Log Viewer step); its tail explains failures.
270 for (const file of fs.readdirSync(root, { recursive: true }).map(String).filter((name) => name.endsWith(".log"))) {
271 try { console.log(`--- ${file} (tail)\n${fs.readFileSync(path.join(root, file), "utf8").split("\n").slice(-60).join("\n")}`); } catch {}
272 }
273 }
274 console.log(`${failed.length || code ? "CHJ_E2E_FAILED" : "CHJ_E2E_OK"}: ${results.length - failed.length}/${results.length} steps passed on ${process.platform}`);
275 setTimeout(() => { try { fs.rmSync(root, { recursive: true, force: true }); } catch {} app.exit(failed.length || code ? 1 : 0); }, 300);
277main().then(() => finish(0), (error) => { console.error(error); finish(1); });
278setTimeout(() => { console.error("E2E exceeded 240s"); finish(1); }, 240000).unref();
SHA-256b634e88a665822c6fe2d766c322066e936f681072762cdac1bb4e77344b9c664

CH-J Proprietary Software License 1.14 · Šaltinis pateiktas pagal CH-J Proprietary Software License 1.14. Jo prieinamumas nekeičia licencijos sąlygų ir nesuteikia papildomų teisių.

Archyvas SHA-256476ed8aef0f1e96a5acdf223127b6692acdf4492eeaf5d0816b6e2a634fbbda6
Failai