CH-J Server Managerspráva serverů přes SSH
Menu
Vydané zdroje

CH-J Server Manager

Procházejte adresáře a soubory konkrétního vydání aplikace.

Stáhnout zdroje ZIP
CH-J / Server Manager 368 souborů
core-20261017T201717Z-057348c4a70f Přidáno: Datum není evidováno · Sestaveno:
privilegedExec.js Přidáno: Datum není evidováno 11.9 KB · 240 řádků
Kód Stáhnout soubor
1"use strict";
3const { SessionError, shellQuote } = require("./sessionManager");
5// Shared Core service for bounded privileged remote operations over an existing SSH session.
6// Callers pass a fixed script plus validated arguments; there is no API for arbitrary commands.
7// The sudo password is written to stdin only after sudo prints our prompt marker and is never
8// stored, logged or placed in argv. Payloads ("frames") are sent only after the script's READY marker.
9const SUDO_PROMPT = "[chj-sudo-auth]";
10const READY_MARKER = "\x1eCHJ_READY\n";
11const ERROR_MARKER = /\x1eCHJ_ERR=([A-Z0-9_]{2,64})\r?\n/;
12const MAX_PASSWORD_LENGTH = 1024;
13const PROBE_SCRIPT = String.raw`LC_ALL=C; export LC_ALL
14PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH
15printf 'user=%s\nuid=%s\ngroups=%s\nclient=%s\n' "$(id -un)" "$(id -u)" "$(id -Gn 2>/dev/null | tr ' ' ',')" "${"$"}{SSH_CLIENT%% *}"
16if command -v sudo >/dev/null 2>&1; then
17 out=$(sudo -n -p '' true 2>&1 </dev/null); code=$?
18 printf 'sudoInstalled=1\nsudoNonInteractive=%s\nsudoMessage=%s\n' "$code" "$(printf '%s' "$out" | head -n 1 | tr -d '\r\n' | cut -c1-300)"
19else
20 printf 'sudoInstalled=0\n'
21fi`;
23function normalizePassword(value) {
24 if (value === undefined || value === null || value === "") return "";
25 const password = String(value);
26 if (password.length > MAX_PASSWORD_LENGTH || /[\0\r\n]/.test(password)) throw new SessionError("SUDO_PASSWORD_INVALID", "Invalid sudo password.");
27 return password;
30function countOccurrences(text, token) {
31 let count = 0;
32 for (let index = text.indexOf(token); index >= 0; index = text.indexOf(token, index + token.length)) count += 1;
33 return count;
36function cleanRemoteMessage(text, limit = 600) {
37 return String(text || "")
38 .split(SUDO_PROMPT).join("")
39 .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, " ")
40 .split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
41 .slice(-6).join(" | ")
42 .slice(0, limit);
45function classifySudoFailure(stderr, code, state) {
46 const text = String(stderr || "");
47 if (state.authFailure) return state.authFailure;
48 if (/incorrect password attempt|Sorry, try again/i.test(text)) return "SUDO_PASSWORD_INCORRECT";
49 if (/is not in the sudoers file|is not allowed to run sudo|may not run sudo|not allowed to execute|is not allowed to run/i.test(text)) return "SUDO_NOT_PERMITTED";
50 if (/must have a tty|a terminal is required|no tty present/i.test(text)) return "SUDO_TTY_REQUIRED";
51 if (/a password is required|no password was provided/i.test(text)) return "SUDO_PASSWORD_REQUIRED";
52 if (/account.*(locked|expired)|authentication (failure|token)|maximum number of tries/i.test(text)) return "SUDO_AUTH_FAILED";
53 if (code === 127 || /sudo: (command )?not found/i.test(text)) return "SUDO_UNAVAILABLE";
54 return "SUDO_FAILED";
57const SUDO_MESSAGES = Object.freeze({
58 SUDO_PASSWORD_REQUIRED: "This operation requires the sudo password of the SSH account.",
59 SUDO_PASSWORD_INCORRECT: "The sudo password was rejected.",
60 SUDO_NOT_PERMITTED: "The SSH account is not permitted to run this operation with sudo.",
61 SUDO_TTY_REQUIRED: "The sudo policy requires a terminal (requiretty); CH-J does not bypass it.",
62 SUDO_AUTH_FAILED: "sudo authentication failed or the account is locked.",
63 SUDO_UNAVAILABLE: "sudo is not installed and the SSH account is not root.",
64 SUDO_FAILED: "sudo did not start the operation."
65});
67function parseKeyValues(text) {
68 const values = {};
69 for (const line of String(text || "").split(/\r?\n/)) {
70 const index = line.indexOf("=");
71 if (index > 0) values[line.slice(0, index)] = line.slice(index + 1);
72 }
73 return values;
76function publicPrivilege(privilege) {
77 return {
78 mode: privilege.mode,
79 verified: privilege.verified === true,
80 username: privilege.username,
81 sudoInstalled: privilege.sudoInstalled === true,
82 groups: [...(privilege.groups || [])],
83 clientAddress: privilege.clientAddress || "",
84 message: privilege.message || "",
85 checkedAt: privilege.checkedAt
86 };
89class PrivilegedExecutor {
90 constructor(options) {
91 this.sessionManager = options.sessionManager;
92 this.logger = options.logger;
93 }
95 // Detects what the SSH account can do without prompting: root, sudo without password,
96 // sudo with password (not yet verified), or no sudo at all. Cached per live session.
97 async probe(sessionId, { refresh = false } = {}) {
98 const record = this.sessionManager._requireConnected(sessionId);
99 if (record.privilege && !refresh) return publicPrivilege(record.privilege);
100 const result = await this.sessionManager._execStream(record, `sh -c ${shellQuote(PROBE_SCRIPT)}`, { timeoutMs: 15000 }).done;
101 const values = parseKeyValues(result.stdout);
102 const privilege = {
103 username: values.user || record.username,
104 sudoInstalled: values.sudoInstalled === "1",
105 groups: String(values.groups || "").split(",").filter(Boolean).slice(0, 256),
106 clientAddress: String(values.client || "").trim().slice(0, 64),
107 message: cleanRemoteMessage(values.sudoMessage || "", 300),
108 checkedAt: new Date().toISOString(),
109 mode: "unknown",
110 verified: false
111 };
112 if (values.uid === "0") Object.assign(privilege, { mode: "root", verified: true });
113 else if (!privilege.sudoInstalled) Object.assign(privilege, { mode: "none", verified: true });
114 else if (values.sudoNonInteractive === "0") Object.assign(privilege, { mode: "sudo-nopasswd", verified: true });
115 else if (/password is required/i.test(values.sudoMessage || "")) privilege.mode = "sudo-password";
116 else if (/not allowed|may not run sudo|not in the sudoers/i.test(values.sudoMessage || "")) Object.assign(privilege, { mode: "none", verified: true });
117 record.privilege = privilege;
118 return publicPrivilege(privilege);
119 }
121 // Runs a fixed script. With privileged=true it runs as root (root session) or via sudo -S.
122 // Frames are { tag, data } pairs written as base64 lines after READY. interact(channel) may drive
123 // a confirmation protocol; otherwise stdin is closed after the frames.
124 async run(sessionId, options) {
125 const record = this.sessionManager._requireConnected(sessionId);
126 const privileged = options.privileged !== false;
127 const password = normalizePassword(options.sudoPassword);
128 const privilege = privileged ? await this.probe(sessionId) : null;
129 if (privileged && privilege.mode === "none" && privilege.verified) {
130 throw new SessionError(privilege.sudoInstalled ? "SUDO_NOT_PERMITTED" : "SUDO_UNAVAILABLE",
131 privilege.sudoInstalled ? SUDO_MESSAGES.SUDO_NOT_PERMITTED : SUDO_MESSAGES.SUDO_UNAVAILABLE);
132 }
133 const useSudo = privileged && privilege.mode !== "root";
134 const body = `sh -c ${shellQuote(options.script)} chj ${(options.args || []).map((value) => shellQuote(value)).join(" ")}`;
135 const command = useSudo ? `LC_ALL=C sudo -S -p ${shellQuote(SUDO_PROMPT)} -- ${body}` : `LC_ALL=C ${body}`;
136 const frames = (options.frames || []).map(({ tag, data }) => {
137 if (!/^[A-Z]{2,16}$/.test(tag)) throw new SessionError("PROTOCOL_ERROR", "Invalid frame tag.");
138 return `CHJ${tag}:${Buffer.from(data ?? "", typeof data === "string" ? "utf8" : undefined).toString("base64")}\n`;
139 });
140 const state = { answered: 0, started: false, authFailure: null, interaction: null };
141 const listeners = new Set();
142 const channel = this.sessionManager._execStream(record, command, {
143 timeoutMs: options.timeoutMs || 60000,
144 outputLimit: options.outputLimit || 4 * 1024 * 1024,
145 onUpdate: (controller) => {
146 if (useSudo && !state.started) {
147 const prompts = countOccurrences(controller.stderr, SUDO_PROMPT);
148 if (prompts > state.answered) {
149 if (state.answered === 0 && password) {
150 state.answered = 1;
151 controller.write(`${password}\n`);
152 } else {
153 state.answered = prompts;
154 state.authFailure = password ? "SUDO_PASSWORD_INCORRECT" : "SUDO_PASSWORD_REQUIRED";
155 controller.end();
156 controller.close();
157 }
158 }
159 }
160 if (!state.started && controller.stdout.includes(READY_MARKER)) {
161 state.started = true;
162 for (const frame of frames) controller.write(frame);
163 if (options.interact) {
164 // Any failure closes stdin; the remote script treats EOF as "roll back".
165 state.interaction = Promise.resolve()
166 .then(() => options.interact(createInteraction(controller, listeners)))
167 .catch((error) => { controller.end(); throw error; });
168 state.interaction.catch(() => {});
169 } else controller.end();
170 }
171 for (const listener of listeners) listener();
172 }
173 });
174 const result = await channel.done;
175 if (!state.started) {
176 const code = classifySudoFailure(result.stderr, result.code, state);
177 this._rememberFailure(record, code);
178 this.logger?.warn("Privileged remote operation was not started.", { sessionId: record.sessionId, host: record.host, purpose: options.purpose, code });
179 throw new SessionError(code, SUDO_MESSAGES[code] || SUDO_MESSAGES.SUDO_FAILED, { remoteMessage: cleanRemoteMessage(result.stderr) });
180 }
181 if (useSudo) {
182 const mode = password || record.privilege.mode === "sudo-password" ? "sudo-password" : "sudo-nopasswd";
183 record.privilege = { ...record.privilege, verified: true, mode };
184 }
185 const stdout = result.stdout.slice(result.stdout.indexOf(READY_MARKER) + READY_MARKER.length);
186 const marker = stdout.match(ERROR_MARKER);
187 if (marker) {
188 throw new SessionError(`${options.errorPrefix || "REMOTE_"}${marker[1]}`, cleanRemoteMessage(result.stderr) || marker[1], { remoteExitCode: result.code });
189 }
190 const interaction = state.interaction ? await state.interaction : undefined;
191 return { code: result.code, stdout, stderr: cleanRemoteMessage(result.stderr), interaction, privileged };
192 }
194 _rememberFailure(record, code) {
195 if (!record.privilege) return;
196 if (code === "SUDO_NOT_PERMITTED") record.privilege = { ...record.privilege, mode: "none", verified: true };
197 else if (code === "SUDO_UNAVAILABLE") record.privilege = { ...record.privilege, mode: "none", verified: true, sudoInstalled: false };
198 else if ((code === "SUDO_PASSWORD_REQUIRED" || code === "SUDO_PASSWORD_INCORRECT") && record.privilege.mode !== "sudo-password") {
199 record.privilege = { ...record.privilege, mode: "sudo-password" };
200 }
201 }
204// Minimal line protocol over a running exec channel: wait for a marker, then answer on stdin.
205function createInteraction(controller, listeners) {
206 let consumed = controller.stdout.indexOf(READY_MARKER) + READY_MARKER.length;
207 const waiters = new Set();
208 const settleAll = (error) => {
209 for (const waiter of waiters) { clearTimeout(waiter.timer); waiter.reject(error); }
210 waiters.clear();
211 };
212 const check = () => {
213 for (const waiter of [...waiters]) {
214 const text = controller.stdout.slice(consumed);
215 const match = text.match(waiter.pattern);
216 if (!match) continue;
217 waiters.delete(waiter);
218 clearTimeout(waiter.timer);
219 consumed += match.index + match[0].length;
220 waiter.resolve({ match, before: text.slice(0, match.index) });
221 }
222 };
223 listeners.add(check);
224 controller.done.then(() => { check(); settleAll(new SessionError("PROTOCOL_ERROR", "The remote operation ended unexpectedly.")); }, settleAll);
225 return {
226 waitFor(pattern, timeoutMs = 60000) {
227 return new Promise((resolve, reject) => {
228 const waiter = { pattern, resolve, reject };
229 waiter.timer = setTimeout(() => { waiters.delete(waiter); reject(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote operation did not respond in time.")); }, timeoutMs);
230 waiter.timer.unref?.();
231 waiters.add(waiter);
232 check();
233 });
234 },
235 send(line) { controller.write(`${line}\n`); },
236 end() { controller.end(); }
237 };
240module.exports = { PROBE_SCRIPT, PrivilegedExecutor, READY_MARKER, SUDO_MESSAGES, SUDO_PROMPT, classifySudoFailure, cleanRemoteMessage };
SHA-25634a78f3232e496e9d16481c206658d0090a15651b32d83248f68231e80b00eab

CH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.

SHA-256 archivucf4455b698f0f28e456a37b1834976a0194d8486acb7101052d59a838745cca3
Soubory