"use strict"; const { SessionError, shellQuote } = require("./sessionManager"); // Shared Core service for bounded privileged remote operations over an existing SSH session. // Callers pass a fixed script plus validated arguments; there is no API for arbitrary commands. // The sudo password is written to stdin only after sudo prints our prompt marker and is never // stored, logged or placed in argv. Payloads ("frames") are sent only after the script's READY marker. const SUDO_PROMPT = "[chj-sudo-auth]"; const READY_MARKER = "\x1eCHJ_READY\n"; const ERROR_MARKER = /\x1eCHJ_ERR=([A-Z0-9_]{2,64})\r?\n/; const MAX_PASSWORD_LENGTH = 1024; const PROBE_SCRIPT = String.raw`LC_ALL=C; export LC_ALL PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH printf 'user=%s\nuid=%s\ngroups=%s\nclient=%s\n' "$(id -un)" "$(id -u)" "$(id -Gn 2>/dev/null | tr ' ' ',')" "${"$"}{SSH_CLIENT%% *}" if command -v sudo >/dev/null 2>&1; then out=$(sudo -n -p '' true 2>&1 MAX_PASSWORD_LENGTH || /[\0\r\n]/.test(password)) throw new SessionError("SUDO_PASSWORD_INVALID", "Invalid sudo password."); return password; } function countOccurrences(text, token) { let count = 0; for (let index = text.indexOf(token); index >= 0; index = text.indexOf(token, index + token.length)) count += 1; return count; } function cleanRemoteMessage(text, limit = 600) { return String(text || "") .split(SUDO_PROMPT).join("") .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, " ") .split(/\r?\n/).map((line) => line.trim()).filter(Boolean) .slice(-6).join(" | ") .slice(0, limit); } function classifySudoFailure(stderr, code, state) { const text = String(stderr || ""); if (state.authFailure) return state.authFailure; if (/incorrect password attempt|Sorry, try again/i.test(text)) return "SUDO_PASSWORD_INCORRECT"; 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"; if (/must have a tty|a terminal is required|no tty present/i.test(text)) return "SUDO_TTY_REQUIRED"; if (/a password is required|no password was provided/i.test(text)) return "SUDO_PASSWORD_REQUIRED"; if (/account.*(locked|expired)|authentication (failure|token)|maximum number of tries/i.test(text)) return "SUDO_AUTH_FAILED"; if (code === 127 || /sudo: (command )?not found/i.test(text)) return "SUDO_UNAVAILABLE"; return "SUDO_FAILED"; } const SUDO_MESSAGES = Object.freeze({ SUDO_PASSWORD_REQUIRED: "This operation requires the sudo password of the SSH account.", SUDO_PASSWORD_INCORRECT: "The sudo password was rejected.", SUDO_NOT_PERMITTED: "The SSH account is not permitted to run this operation with sudo.", SUDO_TTY_REQUIRED: "The sudo policy requires a terminal (requiretty); CH-J does not bypass it.", SUDO_AUTH_FAILED: "sudo authentication failed or the account is locked.", SUDO_UNAVAILABLE: "sudo is not installed and the SSH account is not root.", SUDO_FAILED: "sudo did not start the operation." }); function parseKeyValues(text) { const values = {}; for (const line of String(text || "").split(/\r?\n/)) { const index = line.indexOf("="); if (index > 0) values[line.slice(0, index)] = line.slice(index + 1); } return values; } function publicPrivilege(privilege) { return { mode: privilege.mode, verified: privilege.verified === true, username: privilege.username, sudoInstalled: privilege.sudoInstalled === true, groups: [...(privilege.groups || [])], clientAddress: privilege.clientAddress || "", message: privilege.message || "", checkedAt: privilege.checkedAt }; } class PrivilegedExecutor { constructor(options) { this.sessionManager = options.sessionManager; this.logger = options.logger; } // Detects what the SSH account can do without prompting: root, sudo without password, // sudo with password (not yet verified), or no sudo at all. Cached per live session. async probe(sessionId, { refresh = false } = {}) { const record = this.sessionManager._requireConnected(sessionId); if (record.privilege && !refresh) return publicPrivilege(record.privilege); const result = await this.sessionManager._execStream(record, `sh -c ${shellQuote(PROBE_SCRIPT)}`, { timeoutMs: 15000 }).done; const values = parseKeyValues(result.stdout); const privilege = { username: values.user || record.username, sudoInstalled: values.sudoInstalled === "1", groups: String(values.groups || "").split(",").filter(Boolean).slice(0, 256), clientAddress: String(values.client || "").trim().slice(0, 64), message: cleanRemoteMessage(values.sudoMessage || "", 300), checkedAt: new Date().toISOString(), mode: "unknown", verified: false }; if (values.uid === "0") Object.assign(privilege, { mode: "root", verified: true }); else if (!privilege.sudoInstalled) Object.assign(privilege, { mode: "none", verified: true }); else if (values.sudoNonInteractive === "0") Object.assign(privilege, { mode: "sudo-nopasswd", verified: true }); else if (/password is required/i.test(values.sudoMessage || "")) privilege.mode = "sudo-password"; else if (/not allowed|may not run sudo|not in the sudoers/i.test(values.sudoMessage || "")) Object.assign(privilege, { mode: "none", verified: true }); record.privilege = privilege; return publicPrivilege(privilege); } // Runs a fixed script. With privileged=true it runs as root (root session) or via sudo -S. // Frames are { tag, data } pairs written as base64 lines after READY. interact(channel) may drive // a confirmation protocol; otherwise stdin is closed after the frames. async run(sessionId, options) { const record = this.sessionManager._requireConnected(sessionId); const privileged = options.privileged !== false; const password = normalizePassword(options.sudoPassword); const privilege = privileged ? await this.probe(sessionId) : null; if (privileged && privilege.mode === "none" && privilege.verified) { throw new SessionError(privilege.sudoInstalled ? "SUDO_NOT_PERMITTED" : "SUDO_UNAVAILABLE", privilege.sudoInstalled ? SUDO_MESSAGES.SUDO_NOT_PERMITTED : SUDO_MESSAGES.SUDO_UNAVAILABLE); } const useSudo = privileged && privilege.mode !== "root"; const body = `sh -c ${shellQuote(options.script)} chj ${(options.args || []).map((value) => shellQuote(value)).join(" ")}`; const command = useSudo ? `LC_ALL=C sudo -S -p ${shellQuote(SUDO_PROMPT)} -- ${body}` : `LC_ALL=C ${body}`; const frames = (options.frames || []).map(({ tag, data }) => { if (!/^[A-Z]{2,16}$/.test(tag)) throw new SessionError("PROTOCOL_ERROR", "Invalid frame tag."); return `CHJ${tag}:${Buffer.from(data ?? "", typeof data === "string" ? "utf8" : undefined).toString("base64")}\n`; }); const state = { answered: 0, started: false, authFailure: null, interaction: null }; const listeners = new Set(); const channel = this.sessionManager._execStream(record, command, { timeoutMs: options.timeoutMs || 60000, outputLimit: options.outputLimit || 4 * 1024 * 1024, onUpdate: (controller) => { if (useSudo && !state.started) { const prompts = countOccurrences(controller.stderr, SUDO_PROMPT); if (prompts > state.answered) { if (state.answered === 0 && password) { state.answered = 1; controller.write(`${password}\n`); } else { state.answered = prompts; state.authFailure = password ? "SUDO_PASSWORD_INCORRECT" : "SUDO_PASSWORD_REQUIRED"; controller.end(); controller.close(); } } } if (!state.started && controller.stdout.includes(READY_MARKER)) { state.started = true; for (const frame of frames) controller.write(frame); if (options.interact) { // Any failure closes stdin; the remote script treats EOF as "roll back". state.interaction = Promise.resolve() .then(() => options.interact(createInteraction(controller, listeners))) .catch((error) => { controller.end(); throw error; }); state.interaction.catch(() => {}); } else controller.end(); } for (const listener of listeners) listener(); } }); const result = await channel.done; if (!state.started) { const code = classifySudoFailure(result.stderr, result.code, state); this._rememberFailure(record, code); this.logger?.warn("Privileged remote operation was not started.", { sessionId: record.sessionId, host: record.host, purpose: options.purpose, code }); throw new SessionError(code, SUDO_MESSAGES[code] || SUDO_MESSAGES.SUDO_FAILED, { remoteMessage: cleanRemoteMessage(result.stderr) }); } if (useSudo) { const mode = password || record.privilege.mode === "sudo-password" ? "sudo-password" : "sudo-nopasswd"; record.privilege = { ...record.privilege, verified: true, mode }; } const stdout = result.stdout.slice(result.stdout.indexOf(READY_MARKER) + READY_MARKER.length); const marker = stdout.match(ERROR_MARKER); if (marker) { throw new SessionError(`${options.errorPrefix || "REMOTE_"}${marker[1]}`, cleanRemoteMessage(result.stderr) || marker[1], { remoteExitCode: result.code }); } const interaction = state.interaction ? await state.interaction : undefined; return { code: result.code, stdout, stderr: cleanRemoteMessage(result.stderr), interaction, privileged }; } _rememberFailure(record, code) { if (!record.privilege) return; if (code === "SUDO_NOT_PERMITTED") record.privilege = { ...record.privilege, mode: "none", verified: true }; else if (code === "SUDO_UNAVAILABLE") record.privilege = { ...record.privilege, mode: "none", verified: true, sudoInstalled: false }; else if ((code === "SUDO_PASSWORD_REQUIRED" || code === "SUDO_PASSWORD_INCORRECT") && record.privilege.mode !== "sudo-password") { record.privilege = { ...record.privilege, mode: "sudo-password" }; } } } // Minimal line protocol over a running exec channel: wait for a marker, then answer on stdin. function createInteraction(controller, listeners) { let consumed = controller.stdout.indexOf(READY_MARKER) + READY_MARKER.length; const waiters = new Set(); const settleAll = (error) => { for (const waiter of waiters) { clearTimeout(waiter.timer); waiter.reject(error); } waiters.clear(); }; const check = () => { for (const waiter of [...waiters]) { const text = controller.stdout.slice(consumed); const match = text.match(waiter.pattern); if (!match) continue; waiters.delete(waiter); clearTimeout(waiter.timer); consumed += match.index + match[0].length; waiter.resolve({ match, before: text.slice(0, match.index) }); } }; listeners.add(check); controller.done.then(() => { check(); settleAll(new SessionError("PROTOCOL_ERROR", "The remote operation ended unexpectedly.")); }, settleAll); return { waitFor(pattern, timeoutMs = 60000) { return new Promise((resolve, reject) => { const waiter = { pattern, resolve, reject }; waiter.timer = setTimeout(() => { waiters.delete(waiter); reject(new SessionError("REMOTE_COMMAND_TIMEOUT", "The remote operation did not respond in time.")); }, timeoutMs); waiter.timer.unref?.(); waiters.add(waiter); check(); }); }, send(line) { controller.write(`${line}\n`); }, end() { controller.end(); } }; } module.exports = { PROBE_SCRIPT, PrivilegedExecutor, READY_MARKER, SUDO_MESSAGES, SUDO_PROMPT, classifySudoFailure, cleanRemoteMessage };