From c520ca5a17fe0677a65075d4adb3df3bf1efeb86 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Thu, 4 Jun 2026 21:06:37 +0200 Subject: [PATCH] feat: couche SSH (password, sudo -S, exec streaming) Co-Authored-By: Claude Opus 4.8 --- server/ssh/client.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 server/ssh/client.ts diff --git a/server/ssh/client.ts b/server/ssh/client.ts new file mode 100644 index 0000000..3a989b0 --- /dev/null +++ b/server/ssh/client.ts @@ -0,0 +1,91 @@ +// 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. + */ +export async function runScriptSudo( + creds: SshCreds, + script: string, + onData: (chunk: string) => void, +): 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); + } finally { + conn.end(); + } +} + +function execStream( + conn: Client, + command: string, + stdinData: string | null, + onData: (chunk: string) => void, +): Promise { + return new Promise((resolve, reject) => { + conn.exec(command, { pty: false }, (err, stream) => { + if (err) return reject(err); + let stdout = ""; + let code = 0; + if (stdinData) { + stream.write(stdinData); + } + stream + .on("close", (c: number) => resolve({ stdout, code: c ?? code })) + .on("data", (d: Buffer) => { + const s = d.toString("utf8"); + stdout += s; + onData(s); + }); + stream.stderr.on("data", (d: Buffer) => { + const s = d.toString("utf8"); + stdout += s; + onData(s); + }); + }); + }); +}