CH-J Server Managergerenciamento de servidor sobre SSH
E-mail
Fonte publicada

CH-J Server Manager

Navegue por diretórios e arquivos para uma versão específica do aplicativo.

Transferir fonte ZIP
CH-J / Server Manager 377 arquivos
core-20261019T111616Z-160a00bfcddc Adicionado: · Construído:
remoteEditorProtocol.test.js Adicionado: Data não registada 11,1 KB · 169 linhas
Código Transferir ficheiro
1"use strict";
3const test = require("node:test");
4const assert = require("node:assert/strict");
5const { EventEmitter } = require("node:events");
6const { SessionManager } = require("../src/main/sessions/sessionManager");
7const marker = "\x1eCHJ_EDITOR_START\n";
9function fixture({ username = "user", stdout = marker + JSON.stringify({ ok: true, value: { status: "saved" } }), code = 0, stderr = "" } = {}) {
10 const commands = [], input = [];
11 const manager = new SessionManager({});
12 manager.sessions.set("s", { sessionId: "s", username, state: "connected", stream: {}, platform: { os: "linux" }, client: {
13 exec(command, callback) {
14 commands.push(command);
15 const stream = new EventEmitter(); stream.stderr = new EventEmitter(); stream.end = (value) => input.push(value);
16 callback(null, stream);
17 queueMicrotask(() => { stream.emit("data", Buffer.from(stdout)); stream.stderr.emit("data", Buffer.from(stderr)); stream.emit("close", code); });
18 }
19 } });
20 return { manager, commands, input };
23const READY = "\x1eCHJ_READY\n";
24const PROMPT = "[chj-sudo-auth]";
25// Simulated server for the shared PrivilegedExecutor protocol: probe, sudo prompt, READY, stdin frame.
26function privilegedFixture({ username = "user", sudo = "password", correct = "sudo-secret-'$value", python = true, reply = { ok: true, value: { status: "saved" } }, dropAfterRequest = false } = {}) {
27 const commands = [], writes = [], requests = [];
28 const manager = new SessionManager({});
29 const record = { sessionId: "s", username, state: "connected", stream: {}, platform: { os: "linux" }, client: {
30 exec(command, callback) {
31 commands.push(command);
32 const stream = new EventEmitter(); stream.stderr = new EventEmitter();
33 let ready = false; let closed = false;
34 const out = (text) => queueMicrotask(() => stream.emit("data", Buffer.from(text)));
35 const err = (text) => queueMicrotask(() => stream.stderr.emit("data", Buffer.from(text)));
36 const close = (code) => { if (closed) return; closed = true; queueMicrotask(() => stream.emit("close", code)); };
37 const becomeReady = () => { ready = true; out(READY); };
38 stream.write = (data) => {
39 const text = String(data); writes.push(text);
40 if (!ready) {
41 if (text === correct + "\n") becomeReady(); else { err("Sorry, try again.\n" + PROMPT); }
42 return;
43 }
44 const frame = /^CHJREQ:(.*)\n$/.exec(text);
45 if (!frame) return;
46 requests.push(JSON.parse(Buffer.from(frame[1], "base64").toString("utf8")));
47 if (dropAfterRequest) { manager.disconnect("s"); return; }
48 if (!python) { out("\x1eCHJ_ERR=DEPENDENCY_UNAVAILABLE\n"); close(127); return; }
49 out("\x1eCHJ_EDITOR_START\n" + JSON.stringify(reply)); close(0);
50 };
51 stream.end = () => { if (!ready) close(1); };
52 stream.close = () => close(1);
53 callback(null, stream);
54 if (command.includes("sudoNonInteractive")) {
55 const nopass = sudo === "nopasswd";
56 out(`user=${username}\nuid=${username === "root" ? 0 : 1000}\ngroups=${username}\nclient=10.0.0.1 1 22\nsudoInstalled=1\nsudoNonInteractive=${nopass ? 0 : 1}\nsudoMessage=${nopass ? "" : sudo === "none" ? "Sorry, user user may not run sudo on host." : "sudo: a password is required"}\n`);
57 close(0);
58 } else if (command.startsWith("printf ")) { out("\x1eCHJ_EDITOR_START\n" + JSON.stringify(reply)); close(0); }
59 else if (command.startsWith("LC_ALL=C sudo -S -p ")) { if (sudo === "nopasswd") becomeReady(); else err(PROMPT); }
60 else if (command.startsWith("LC_ALL=C sh -c ")) becomeReady();
61 }
62 } };
63 record.decoder = { end: () => "" }; record.client.end = () => {};
64 manager.sessions.set("s", record);
65 return { manager, record, commands, writes, requests };
68test("editor sudo uses the shared PrivilegedExecutor: password only after the prompt, request as stdin frame", async () => {
69 const request = { operation: "finalize", path: "/etc/test 'quoted' ; $(echo unsafe)" };
70 const password = "sudo-secret-'$value";
71 const f = privilegedFixture();
72 assert.deepEqual(await f.manager.remoteEditor("s", request, { sudo: true, sudoPassword: password }), { status: "saved" });
73 const editorCommand = f.commands.find((command) => command.startsWith("LC_ALL=C sudo"));
74 assert.match(editorCommand, /^LC_ALL=C sudo -S -p '\[chj-sudo-auth\]' -- sh -c /);
75 assert.equal(f.commands.some((command) => command.includes(password)), false, "password never in argv");
76 assert.equal(f.commands.some((command) => command.includes("echo unsafe")), false, "request travels on stdin, not argv");
77 assert.equal(f.writes[0], password + "\n");
78 assert.deepEqual(f.requests, [request]);
80 const passwordless = privilegedFixture({ sudo: "nopasswd" });
81 await passwordless.manager.remoteEditor("s", request, { sudo: true, sudoPassword: password });
82 assert.equal(passwordless.writes.some((text) => text.includes(password)), false, "NOPASSWD sudo never receives the password");
84 const root = privilegedFixture({ username: "root" });
85 await root.manager.remoteEditor("s", request, { sudo: true });
86 assert.equal(root.commands.some((command) => /\bsudo -S\b/.test(command)), false, "root sessions never call sudo");
87 assert.deepEqual(root.requests, [request]);
89 const plain = privilegedFixture();
90 await plain.manager.remoteEditor("s", request);
91 assert.match(plain.commands[0], /^printf /);
92 await assert.rejects(() => f.manager.remoteEditor("s", request, { sudoPassword: password }), { code: "FILE_SUDO_AUTHORIZATION_REQUIRED" });
93});
95test("sudo refusals are definite and classified; credentials never reach error messages", async () => {
96 const wrong = privilegedFixture();
97 await assert.rejects(() => wrong.manager.remoteEditor("s", { operation: "finalize" }, { sudo: true, sudoPassword: "wrong-secret" }), (error) => {
98 assert.equal(error.code, "SUDO_PASSWORD_INCORRECT"); assert.doesNotMatch(error.message + JSON.stringify(error.details || {}), /wrong-secret/); return true;
99 });
100 assert.equal(wrong.requests.length, 0, "helper never started");
101 await assert.rejects(() => privilegedFixture().manager.remoteEditor("s", { operation: "read" }, { sudo: true }), { code: "SUDO_PASSWORD_REQUIRED" });
102 await assert.rejects(() => privilegedFixture({ sudo: "none" }).manager.remoteEditor("s", { operation: "read" }, { sudo: true, sudoPassword: "x" }), { code: "SUDO_NOT_PERMITTED" });
103 await assert.rejects(() => privilegedFixture({ python: false }).manager.remoteEditor("s", { operation: "read" }, { sudo: true, sudoPassword: "sudo-secret-'$value" }), { code: "FILE_DEPENDENCY_UNAVAILABLE" });
104 const started = fixture({ stdout: marker, code: 1 });
105 await assert.rejects(() => started.manager.remoteEditor("s", { operation: "finalize" }), { code: "FILE_RESULT_UNKNOWN" });
106 const noPython = fixture({ stdout: marker, code: 127 });
107 await assert.rejects(() => noPython.manager.remoteEditor("s", { operation: "read" }), { code: "FILE_DEPENDENCY_UNAVAILABLE" });
108});
110test("a remembered sudo password is reused only in memory for the same live session", async () => {
111 const f = privilegedFixture();
112 await f.manager.remoteEditor("s", { operation: "read" }, { sudo: true, sudoPassword: "sudo-secret-'$value", rememberSudo: true });
113 assert.equal(f.manager.privilegedExecutor.hasCachedPassword("s"), true);
114 await f.manager.remoteEditor("s", { operation: "read" }, { sudo: true });
115 assert.equal(f.writes.filter((text) => text === "sudo-secret-'$value\n").length, 2, "cached password answered the second prompt");
116 await assert.rejects(() => f.manager.remoteEditor("s", { operation: "read" }, { sudo: true, useCachedSudo: false }), { code: "SUDO_PASSWORD_REQUIRED" });
117 const notRemembered = privilegedFixture();
118 await notRemembered.manager.remoteEditor("s", { operation: "read" }, { sudo: true, sudoPassword: "sudo-secret-'$value" });
119 assert.equal(notRemembered.manager.privilegedExecutor.hasCachedPassword("s"), false);
120 await f.manager.disconnect("s");
121 assert.equal(f.manager.privilegedExecutor.hasCachedPassword("s"), false, "disconnect drops the cache");
122});
124test("SSH loss after a privileged finalize was delivered is reported as unknown", async () => {
125 const f = privilegedFixture({ dropAfterRequest: true });
126 await assert.rejects(() => f.manager.remoteEditor("s", { operation: "finalize" }, { sudo: true, sudoPassword: "sudo-secret-'$value" }), { code: "FILE_RESULT_UNKNOWN" });
127 assert.equal(f.record.pendingExec.size, 0);
128});
130test("invalid replies and missing remote exit status fail closed", async () => {
131 for (const stdout of ["", "{}", marker + "null", marker + "unexpected"]) {
132 const f = fixture({ stdout });
133 await assert.rejects(() => f.manager.remoteEditor("s", { operation: "finalize" }));
134 }
135 const f = fixture({ code: null });
136 await assert.rejects(() => f.manager.remoteEditor("s", { operation: "finalize" }), { code: "FILE_RESULT_UNKNOWN" });
137});
139test("synchronous exec failure releases timers and pending requests", async () => {
140 const manager = new SessionManager({}); const record = { client: { exec() { throw new Error("Disconnected"); } } };
141 await assert.rejects(() => manager._execFixed(record, "true", 5000));
142 assert.equal(record.pendingExec.size, 0);
143 await assert.rejects(() => manager._execSudo(record, "true", "", 5000));
144 assert.equal(record.pendingExec.size, 0);
145});
147test("SSH loss settles a pending editor save as unknown immediately", async () => {
148 const f = fixture(); const record = f.manager.sessions.get("s");
149 record.decoder = { end: () => "" }; record.client.end = () => {}; record.client.exec = () => {};
150 const pending = assert.rejects(() => f.manager.remoteEditor("s", { operation: "finalize" }), { code: "FILE_RESULT_UNKNOWN" });
151 await f.manager.disconnect("s"); await pending;
152 assert.equal(record.pendingExec.size, 0);
153});
155test("host shell receives hostile paths as an exact JSON argument with no expansion", async (t) => {
156 const fs = require("node:fs"), os = require("node:os"), path = require("node:path"), { execFile } = require("node:child_process");
157 if (process.platform === "win32") { t.skip("POSIX quoting belongs to the Linux server shell"); return; }
158 const root = fs.mkdtempSync(path.join(os.tmpdir(), "chj-editor-shell-")); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
159 fs.writeFileSync(path.join(root, "python3"), '#!' + process.execPath + '\nconst value=JSON.parse(process.argv.at(-1)); process.stdout.write(JSON.stringify({ok:true,value}));\n', { mode: 0o700 });
160 const request = { operation: "read", path: "/etc/trailing ' ; $(touch " + root + "/EXPANDED) `echo bad` \\" };
161 const manager = new SessionManager({});
162 const record = { state: "connected", stream: {}, client: {}, platform: { os: "linux" } }; manager.sessions.set("s", record);
163 // The real shell parses exactly the fixed command handed to ssh2.exec.
164 manager._execFixed = (_record, command) => new Promise((resolve, reject) => {
165 execFile("/bin/sh", ["-c", command], { shell: false, env: { ...process.env, PATH: root + ":" + process.env.PATH }, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => error ? reject(error) : resolve({ stdout, stderr }));
166 });
167 assert.deepEqual(await manager.remoteEditor("s", request), request);
168 assert.equal(fs.existsSync(path.join(root, "EXPANDED")), false);
169});
SHA-256099499d0fdc2587f95c2e50707444e43ad2ca3fad46903d680f37e3109921954

CH-J Proprietary Software License 1.14 · A fonte é fornecida sob o CH-J Proprietary Software License 1.14. A sua disponibilidade não altera os termos licença nem concede direitos adicionais.

Ficheiro SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
Ficheiros