// server/ssh/client.ts import { Client } from "ssh2"; export interface SshCreds { hostname: string; port: number; username: string; password: string; sudoPassword?: string | null; } export interface RunResult { stdout: string; code: number; } function connect(creds: SshCreds): Promise { return new Promise((resolve, reject) => { const conn = new Client(); conn .on("ready", () => resolve(conn)) .on("error", reject) .connect({ host: creds.hostname, port: creds.port, username: creds.username, password: creds.password, readyTimeout: 15000, }); }); } /** Exécute une commande simple (sans sudo), renvoie stdout agrégé. */ export async function runPlain(creds: SshCreds, command: string): Promise { const conn = await connect(creds); try { return await execStream(conn, command, null, () => {}); } finally { conn.end(); } } /** * Exécute un script shell sous sudo. Le script est encodé en base64 pour éviter * tout problème de quoting; le mot de passe sudo est poussé sur stdin (sudo -S -p ''). * `onData` reçoit chaque chunk de sortie pour le streaming live. * `inactivityTimeoutMs` (défaut 0 = désactivé) : si aucune sortie n'est reçue pendant * cette durée, la connexion est fermée et une erreur `human_interaction_required` est levée. */ export async function runScriptSudo( creds: SshCreds, script: string, onData: (chunk: string) => void, inactivityTimeoutMs = 0, ): Promise { const conn = await connect(creds); try { const b64 = Buffer.from(script, "utf8").toString("base64"); const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`; return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData, inactivityTimeoutMs); } finally { conn.end(); } } function execStream( conn: Client, command: string, stdinData: string | null, onData: (chunk: string) => void, inactivityTimeoutMs = 0, ): Promise { return new Promise((resolve, reject) => { conn.exec(command, { pty: false }, (err, stream) => { if (err) return reject(err); let stdout = ""; let code = 0; let timer: ReturnType | undefined; const arm = () => { if (!inactivityTimeoutMs) return; clearTimeout(timer); timer = setTimeout(() => { stream.close(); conn.end(); reject(new Error(`human_interaction_required: aucune sortie depuis ${Math.round(inactivityTimeoutMs / 1000)}s`)); }, inactivityTimeoutMs); }; arm(); if (stdinData) { stream.write(stdinData); } stream .on("close", (c: number) => { clearTimeout(timer); resolve({ stdout, code: c ?? code }); }) .on("data", (d: Buffer) => { const s = d.toString("utf8"); stdout += s; onData(s); arm(); }); stream.stderr.on("data", (d: Buffer) => { const s = d.toString("utf8"); stdout += s; onData(s); arm(); }); }); }); }