CH-J Server Managerserverihaldus üle SSH
Menüü
Avaldatud allikas

CH-J Server Manager

Vaata katalooge ja faile konkreetse rakenduse vabastamiseks.

Laadi alla allikas ZIP
CH-J / Server Manager 377 failid
core-20261019T111616Z-160a00bfcddc Lisatud: · Ehitatud:
linuxVmIntegration.test.js Lisatud: Kuupäeva ei registreerita 20,1 KB · 266 read
Kood Faili allalaadimine
1"use strict";
3// System Monitor + chj.users on ONE isolated Linux test VM over real SSH (key login, sudo with password).
4// Never point this at a production server: it creates/deletes a swap file, a test account and changes sshd policy
5// (always reverted). Environment: CHJ_VM_HOST, CHJ_VM_FINGERPRINT (SHA256:...), CHJ_VM_KEY (private key path),
6// CHJ_VM_USER (default ai) and CHJ_VM_SUDO_PASSWORD or CHJ_VM_SUDO_PASSWORD_FILE + CHJ_VM_SUDO_PASSWORD_LINE
7// ("name = value" line read at runtime, never printed).
8const test = require("node:test");
9const assert = require("node:assert/strict");
10const fs = require("node:fs");
11const os = require("node:os");
12const path = require("node:path");
13const { execFileSync, spawnSync } = require("node:child_process");
14const { SessionManager } = require("../src/main/sessions/sessionManager");
15const { PrivilegedExecutor } = require("../src/main/sessions/privilegedExec");
16const { VirtualMemoryService } = require("../src/main/system/virtualMemoryService");
17const { UserAdminService, createUserAdminApi } = require("../src/main/users/userAdminService");
18const { detectPlatform } = require("../src/main/sessions/platformDetect");
20const env = process.env;
21const enabled = Boolean(env.CHJ_VM_HOST && env.CHJ_VM_FINGERPRINT && env.CHJ_VM_KEY && (env.CHJ_VM_SUDO_PASSWORD || env.CHJ_VM_SUDO_PASSWORD_FILE));
22const skip = enabled ? false : "Set CHJ_VM_* to run against an isolated Linux test VM.";
23const sudoPassword = !enabled ? "" : env.CHJ_VM_SUDO_PASSWORD
24 || fs.readFileSync(env.CHJ_VM_SUDO_PASSWORD_FILE, "utf8").split("\n")[Number(env.CHJ_VM_SUDO_PASSWORD_LINE || 1) - 1].split("=").slice(1).join("=").trim();
25const vmUser = env.CHJ_VM_USER || "ai";
26const host = env.CHJ_VM_HOST;
27const SWAP = "/swapfile-chjvmtest";
28const USER = "chjvmtest";
29let keyDir; let knownHosts;
31function keygen(name) {
32 const file = path.join(keyDir, name);
33 execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", `chj-vm-${name}`, "-f", file]);
34 return { privatePath: file, publicKey: fs.readFileSync(`${file}.pub`, "utf8").trim() };
36function remote(command) {
37 return execFileSync("ssh", ["-i", env.CHJ_VM_KEY, "-o", "BatchMode=yes", "-o", `UserKnownHostsFile=${knownHosts}`, "-o", "StrictHostKeyChecking=yes", `${vmUser}@${host}`, command], { encoding: "utf8" }).trim();
39function canLogin(user, key, address = host) {
40 return spawnSync("ssh", ["-i", key, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "PreferredAuthentications=publickey", "-o", `UserKnownHostsFile=${knownHosts}`, "-o", "StrictHostKeyChecking=yes", "-o", "HostKeyAlias=" + host, "-o", "ConnectTimeout=8", `${user}@${address}`, "exit 0"], { encoding: "utf8" }).status === 0;
43test("System Monitor and chj.users on an isolated Linux test VM", { skip, timeout: 1200000 }, async (t) => {
44 keyDir = fs.mkdtempSync(path.join(os.tmpdir(), "chj-vm-"));
45 knownHosts = path.join(keyDir, "known_hosts");
46 execFileSync("sh", ["-c", `ssh-keyscan -t ed25519 ${host} > '${knownHosts}' 2>/dev/null`]);
47 assert.equal(execFileSync("ssh-keygen", ["-lf", knownHosts], { encoding: "utf8" }).split(" ")[1], env.CHJ_VM_FINGERPRINT, "documented host key");
48 const hostKeys = new Map();
49 const profileService = {
50 get: () => ({ id: "vm", label: "vm", host, port: 22, username: vmUser, authMethod: "privateKey", privateKeyPath: env.CHJ_VM_KEY }),
51 getHostKey: (h, p) => hostKeys.get(`${h}:${p}`) || null, trustHostKey: (h, p, fp) => hostKeys.set(`${h}:${p}`, fp), getStoredPassword: () => "", markUsed() {}
52 };
53 const manager = new SessionManager({ profileService });
54 const latency = new Promise((resolve) => manager.on("state", (state) => { if (state.latency?.measuredAt) resolve(state.latency); }));
55 try { await manager.connect({ sessionId: "vm", profileId: "vm" }); }
56 catch (error) {
57 if (error.code !== "HOST_KEY_UNKNOWN") throw error;
58 assert.equal(`SHA256:${Buffer.from(error.fingerprint, "hex").toString("base64").replace(/=+$/, "")}`, env.CHJ_VM_FINGERPRINT, "app sees the documented host key");
59 manager.trustPending({ sessionId: "vm", profileId: "vm", fingerprint: error.fingerprint });
60 await manager.connect({ sessionId: "vm", profileId: "vm" });
61 }
62 const executor = new PrivilegedExecutor({ sessionManager: manager });
63 const vm = new VirtualMemoryService({ sessionManager: manager, privilegedExecutor: executor });
64 const api = createUserAdminApi(new UserAdminService({ sessionManager: manager, privilegedExecutor: executor, detectPlatform }));
65 const auth = { sudoPassword };
66 // systemd keeps the user manager alive for a few seconds after logout; CH-J correctly refuses USERS_USER_HAS_PROCESSES.
67 async function deleteUser() {
68 for (let attempt = 0; ; attempt += 1) {
69 try { return await api.manage("vm", { action: "delete", username: USER, removeHome: true, ...auth }); }
70 catch (error) { if (error.code !== "USERS_USER_HAS_PROCESSES" || attempt >= 30) throw error; await new Promise((resolve) => setTimeout(resolve, 1000)); }
71 }
72 }
73 const aiKey = env.CHJ_VM_KEY;
74 let fstabBefore = null;
75 t.after(async () => {
76 // Best-effort cleanup of everything this test may have created.
77 for (const step of [
78 () => vm.manage("vm", { action: "delete", path: SWAP, ...auth }),
79 () => vm.manage("vm", { action: "delete", path: `${SWAP}2`, ...auth }),
80 () => api.sshPolicy("vm", { username: "root", policy: {}, ...auth }),
81 () => api.sshPolicy("vm", { username: USER, policy: {}, ...auth }),
82 () => deleteUser()
83 ]) { try { await step(); } catch {} }
84 try {
85 const detail = await api.detail("vm", { username: "root", ...auth });
86 for (const file of detail.keys?.files || []) for (const entry of [...file.entries].reverse()) {
87 if (entry.comment === "chj-vm-root") await api.keys("vm", { action: "remove", username: "root", path: file.path, line: entry.line, fingerprint: entry.fingerprint, ...auth });
88 }
89 } catch {}
90 await manager.disconnectAll("test");
91 fs.rmSync(keyDir, { recursive: true, force: true });
92 assert.ok(canLogin(vmUser, aiKey) || true);
93 });
95 await t.test("platform detection and metrics", async () => {
96 const metrics = await manager.readSystemMetrics("vm");
97 t.diagnostic(`${metrics.platform} | kernel ${metrics.kernel} | RAM ${Math.round(metrics.memory.total / 1048576)} MiB`);
98 assert.equal(metrics.os, "linux");
99 assert.equal(metrics.loadAverageSupported, true);
100 assert.ok(metrics.cpu.logicalCores >= 1 && metrics.uptimeSeconds > 0);
101 const d = metrics.memoryDetail;
102 assert.ok(Math.abs(d.used + d.free + d.buffers + d.cached + d.slabReclaimable - d.total) < 1048576, "RAM slices add up without double counting");
103 assert.ok(metrics.disk.total > 0 && metrics.disk.mount === "/");
104 assert.ok(metrics.network.interfaces.some((entry) => entry.state === "up"));
105 });
107 await t.test("ICMP ping and SSH RTT", async () => {
108 const value = await Promise.race([latency, new Promise((resolve) => setTimeout(() => resolve(null), 40000))]);
109 t.diagnostic(`ping ${value?.pingMs} ms (${value?.pingStatus}), ssh ${value?.sshRttMs} ms (${value?.sshRttMethod})`);
110 assert.ok(value?.sshRttMs > 0);
111 assert.ok(value.pingMs > 0 || ["timeout", "unreachable", "denied"].includes(value.pingStatus));
112 });
114 await t.test("swap file lifecycle on the real root file system", async () => {
115 const before = await vm.read("vm", { privileged: true, ...auth });
116 if (before.swapAreas.some((entry) => entry.path === SWAP)) { await vm.manage("vm", { action: "delete", path: SWAP, ...auth }); t.diagnostic("removed leftover test swap file from an earlier run"); }
117 fstabBefore = remote("cksum /etc/fstab");
118 t.diagnostic(`root fs: ${JSON.stringify(before.locations || before.fileSystems || null)}`);
119 assert.equal(before.platform, "linux"); assert.equal(before.canManage, true);
120 await assert.rejects(() => vm.manage("vm", { action: "create", path: "/etc/passwd", sizeMiB: 64, persist: false, ...auth }), { code: "VM_SWAP_PATH_INVALID" });
121 await assert.rejects(() => vm.manage("vm", { action: "create", path: SWAP, sizeMiB: 128, persist: false, sudoPassword: "wrong-pass" }), { code: /SUDO_PASSWORD_INCORRECT/ });
122 let result = await vm.manage("vm", { action: "create", path: SWAP, sizeMiB: 128, priority: 5, persist: true, ...auth });
123 assert.ok(result.active.some((area) => area.path === SWAP && area.priority === 5), "created + active");
124 let state = await vm.read("vm", { privileged: true, ...auth });
125 const area = state.swapAreas.find((entry) => entry.path === SWAP);
126 assert.equal(area.persistent, true); assert.equal(area.management, "managed");
127 t.diagnostic(`fs ${area.fsType}`);
128 result = await vm.manage("vm", { action: "resize", path: SWAP, sizeMiB: 192, priority: 5, ...auth });
129 assert.ok(result.active.some((entry) => entry.path === SWAP && Math.abs(entry.size - 192 * 1048576) <= 65536));
130 result = await vm.manage("vm", { action: "priority", path: SWAP, priority: 7, ...auth });
131 assert.ok(result.active.some((entry) => entry.path === SWAP && entry.priority === 7));
132 result = await vm.manage("vm", { action: "deactivate", path: SWAP, ...auth });
133 assert.equal(result.active.some((entry) => entry.path === SWAP), false);
134 result = await vm.manage("vm", { action: "activate", path: SWAP, priority: 3, ...auth });
135 assert.ok(result.active.some((entry) => entry.path === SWAP && entry.priority === 3));
136 const concurrent = vm.manage("vm", { action: "persist", path: SWAP, persist: false, ...auth });
137 await assert.rejects(() => vm.manage("vm", { action: "delete", path: SWAP, ...auth }), { code: "VM_BUSY" });
138 await concurrent;
139 await vm.manage("vm", { action: "delete", path: SWAP, ...auth });
140 state = await vm.read("vm", { privileged: true, ...auth });
141 assert.equal(state.swapAreas.some((entry) => entry.path === SWAP), false, "swap file removed");
142 assert.equal(remote("cksum /etc/fstab"), fstabBefore, "fstab back to original content");
143 });
145 await t.test("swap priorities: two swap files as main and backup, persistent in fstab", async () => {
146 const SWAP2 = `${SWAP}2`, swaps = [SWAP, SWAP2];
147 const pick = (state, name) => state.swapAreas.find((entry) => entry.path === name);
148 const fstabLine = (name) => remote(`grep -E '^${name}[[:space:]]' /etc/fstab || true`);
149 const before = await vm.read("vm", { privileged: true, ...auth });
150 for (const name of swaps) if (before.swapAreas.some((entry) => entry.path === name)) await vm.manage("vm", { action: "delete", path: name, ...auth });
151 const fstabOriginal = remote("cksum /etc/fstab");
152 try {
153 // 1-2: two swaps with automatic priorities (kernel assigns negative values).
154 for (const name of swaps) await vm.manage("vm", { action: "create", path: name, sizeMiB: 128, priority: "auto", persist: true, ...auth });
155 let state = await vm.read("vm", { privileged: true, ...auth });
156 for (const name of swaps) { const area = pick(state, name); assert.ok(area.effectivePriority < 0, `${name} automatic negative`); assert.equal(area.priorityMode, "auto"); assert.equal(area.persistentPriority, null); }
157 assert.equal(fstabLine(SWAP), `${SWAP} none swap sw 0 0 # CH-J chj.system-monitor`);
158 // 3-5: explicit 100 (main) and 10 (backup), applied now and saved.
159 let result = await vm.manage("vm", { action: "priority", path: SWAP, priority: 100, apply: "now", ...auth });
160 assert.deepEqual(result.steps.filter((step) => step.step.startsWith("priority")).map((step) => [step.step, step.status]), [["priority-saved", "ok"], ["priority-runtime", "ok"]]);
161 await vm.manage("vm", { action: "priority", path: SWAP2, priority: 10, ...auth });
162 state = await vm.read("vm", { privileged: true, ...auth });
163 assert.equal(pick(state, SWAP).effectivePriority, 100); assert.equal(pick(state, SWAP2).effectivePriority, 10);
164 assert.equal(pick(state, SWAP).precedence, 1); assert.equal(pick(state, SWAP2).precedence, 2);
165 assert.equal(fstabLine(SWAP), `${SWAP} none swap sw,pri=100 0 0 # CH-J chj.system-monitor`);
166 assert.equal(fstabLine(SWAP2), `${SWAP2} none swap sw,pri=10 0 0 # CH-J chj.system-monitor`);
167 // 4: the kernel fills the higher priority first. Under memory pressure the 128 MiB main area takes pages before the backup.
168 t.diagnostic(`proc/swaps:\n${remote("cat /proc/swaps")}`);
169 // 7 + 18: changing an existing pri= replaces it; repeating it creates no duplicates.
170 await vm.manage("vm", { action: "priority", path: SWAP, priority: 200, ...auth });
171 result = await vm.manage("vm", { action: "priority", path: SWAP, priority: 200, ...auth });
172 assert.ok(result.steps.some((step) => step.step === "priority-runtime" && /already/.test(step.message)));
173 assert.equal(remote(`grep -c -E '^${SWAP}[[:space:]]' /etc/fstab`), "1");
174 // 6: back to automatic, saved only (applies at next activation): fstab loses pri=, runtime keeps 200 and is flagged pending.
175 result = await vm.manage("vm", { action: "priority", path: SWAP, priority: "auto", apply: "later", ...auth });
176 assert.ok(result.steps.some((step) => step.step === "priority-runtime" && step.status === "pending"));
177 state = await vm.read("vm", { privileged: true, ...auth });
178 assert.equal(pick(state, SWAP).effectivePriority, 200); assert.equal(pick(state, SWAP).priorityPending, true);
179 assert.equal(fstabLine(SWAP), `${SWAP} none swap sw 0 0 # CH-J chj.system-monitor`);
180 // 10: new activation (as after a restart) takes the saved setting; other operations keep the user's priority.
181 await vm.manage("vm", { action: "priority", path: SWAP, priority: 100, apply: "later", ...auth });
182 await vm.manage("vm", { action: "deactivate", path: SWAP, ...auth });
183 await vm.manage("vm", { action: "activate", path: SWAP, priority: "keep", ...auth });
184 state = await vm.read("vm", { privileged: true, ...auth });
185 assert.equal(pick(state, SWAP).effectivePriority, 100); assert.equal(pick(state, SWAP).priorityPending, false);
186 await vm.manage("vm", { action: "resize", path: SWAP2, sizeMiB: 96, priority: "keep", ...auth });
187 state = await vm.read("vm", { privileged: true, ...auth });
188 assert.equal(pick(state, SWAP2).effectivePriority, 10, "resize keeps the configured priority"); assert.match(fstabLine(SWAP2), /pri=10/);
189 // 15: equal explicit priorities are reported as shared (round-robin).
190 await vm.manage("vm", { action: "priority", path: SWAP2, priority: 100, ...auth });
191 state = await vm.read("vm", { privileged: true, ...auth });
192 assert.equal(pick(state, SWAP).sharedPriority, true); assert.equal(pick(state, SWAP2).precedence, 1);
193 // 12: invalid input is refused before the server changes anything.
194 for (const priority of [-2, 40000, "x"]) await assert.rejects(() => vm.manage("vm", { action: "priority", path: SWAP, priority, ...auth }), { code: "VM_SWAP_PRIORITY_INVALID" });
195 // 17: runtime-only swap (not in fstab) can be reprioritized without being written to fstab.
196 await vm.manage("vm", { action: "persist", path: SWAP2, persist: false, ...auth });
197 result = await vm.manage("vm", { action: "priority", path: SWAP2, priority: 5, ...auth });
198 assert.ok(result.steps.some((step) => step.step === "priority-saved" && step.status === "none"));
199 state = await vm.read("vm", { privileged: true, ...auth });
200 assert.equal(pick(state, SWAP2).effectivePriority, 5); assert.equal(pick(state, SWAP2).persistent, false); assert.equal(fstabLine(SWAP2), "");
201 await vm.manage("vm", { action: "persist", path: SWAP2, persist: true, priority: "keep", ...auth });
202 assert.equal(fstabLine(SWAP2), `${SWAP2} none swap sw,pri=5 0 0 # CH-J chj.system-monitor`, "persist keeps the explicit runtime priority");
203 } finally {
204 for (const name of swaps) { try { await vm.manage("vm", { action: "delete", path: name, ...auth }); } catch {} }
205 }
206 assert.equal(remote("cksum /etc/fstab"), fstabOriginal, "fstab back to original content");
207 assert.equal(remote(`ls ${SWAP} ${SWAP2} 2>/dev/null || true`), "");
208 });
210 await t.test("chj.users: account, Ed25519 key and per-user SSH policy", async () => {
211 const privileges = await api.privileges("vm");
212 t.diagnostic(`privileges ${privileges.mode}`);
213 assert.equal(privileges.platform, "linux");
214 const inventory = await api.inventory("vm", auth);
215 assert.ok(inventory.users.some((account) => account.username === "root"));
216 if ((await api.inventory("vm", auth)).users.some((account) => account.username === USER)) await deleteUser();
217 await api.manage("vm", { action: "create", username: USER, password: "ChjVm-Test-Pass-9", comment: "CH-J VM test", ...auth });
218 const key = keygen("user");
219 await api.keys("vm", { action: "add", username: USER, publicKey: key.publicKey, ...auth });
220 assert.ok(canLogin(USER, key.privatePath), "new user logs in with the added Ed25519 key");
221 let result = await api.sshPolicy("vm", { username: USER, policy: { access: "deny" }, ...auth });
222 assert.equal(canLogin(USER, key.privatePath), false, "access denied by CH-J policy");
223 assert.ok(canLogin(vmUser, aiKey), "session account unaffected");
224 result = await api.sshPolicy("vm", { username: USER, policy: {}, ...auth });
225 assert.ok(canLogin(USER, key.privatePath), "inherit restores access");
226 await deleteUser();
227 assert.equal((await api.inventory("vm", auth)).users.some((account) => account.username === USER), false);
228 });
230 await t.test("chj.users: root keys and PermitRootLogin via sudo; root stays protected", async () => {
231 await assert.rejects(() => api.manage("vm", { action: "delete", username: "root", ...auth }), { code: "USERS_PROTECTED_USER" });
232 await assert.rejects(() => api.manage("vm", { action: "setShell", username: "root", shell: "/bin/sh", ...auth }), { code: "USERS_PROTECTED_USER" });
233 const rootKey = keygen("root");
234 try { await api.keys("vm", { action: "add", username: "root", publicKey: rootKey.publicKey, ...auth }); }
235 catch (error) {
236 // Proxmox: /root/.ssh/authorized_keys is a symlink into /etc/pve; CH-J must refuse rather than follow it.
237 if (error.code !== "USERS_KEYS_FILE_UNMANAGEABLE") throw error;
238 t.diagnostic(`root keys refused: ${error.message}`);
239 await api.sshPolicy("vm", { username: "root", policy: { permitRootLogin: "no" }, ...auth });
240 const effective = (await api.detail("vm", { username: "root", ...auth }));
241 t.diagnostic(`policy after no: ${JSON.stringify(effective.sshPolicy?.managed || effective.ssh?.policy || null)}`);
242 assert.ok(canLogin(vmUser, aiKey), "session account unaffected");
243 await api.sshPolicy("vm", { username: "root", policy: {}, ...auth });
244 return;
245 }
246 await assert.rejects(() => api.sshPolicy("vm", { username: "root", policy: { password: "yes" }, ...auth }), { code: "USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED" });
247 await api.sshPolicy("vm", { username: "root", policy: { permitRootLogin: "prohibit-password" }, ...auth });
248 const allowed = canLogin("root", rootKey.privatePath);
249 t.diagnostic(`root key login with prohibit-password: ${allowed}`);
250 if (!allowed) {
251 // OpenSSH without PAM (Alpine) refuses every login, keys included, for an account whose password field starts with "!".
252 const rootAccount = (await api.inventory("vm", auth)).users.find((account) => account.username === "root");
253 t.diagnostic(`root passwordState=${rootAccount.passwordState} locked=${rootAccount.locked}`);
254 assert.ok(rootAccount.locked || rootAccount.passwordState === "none", "root key login allowed by PermitRootLogin prohibit-password (unless the root account itself is locked)");
255 }
256 await api.sshPolicy("vm", { username: "root", policy: { permitRootLogin: "no" }, ...auth });
257 assert.equal(canLogin("root", rootKey.privatePath), false, "PermitRootLogin no blocks root");
258 assert.ok(canLogin(vmUser, aiKey), "session account unaffected");
259 await api.sshPolicy("vm", { username: "root", policy: {}, ...auth });
260 const detail = await api.detail("vm", { username: "root", ...auth });
261 for (const file of detail.keys.files) for (const entry of [...file.entries].reverse()) {
262 if (entry.comment === "chj-vm-root") await api.keys("vm", { action: "remove", username: "root", path: file.path, line: entry.line, fingerprint: entry.fingerprint, ...auth });
263 }
264 assert.equal(canLogin("root", rootKey.privatePath), false, "removed root key no longer works");
265 });
266});
SHA-256b1f210addb5c04ddb3c27ce5e200d62fced046363b345a61061041d2e9919ae3

CH-J Proprietary Software License 1.14 · Allikas on esitatud CH-J Proprietary Software License 1.14 all. Selle kättesaadavus ei muuda litsentsitingimusi ega anna täiendavaid õigusi.

Arhiiv SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
Failid