feat: couche SSH (password, sudo -S, exec streaming)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Client> {
|
||||
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<RunResult> {
|
||||
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<RunResult> {
|
||||
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<RunResult> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user