Files
gilles 08919752e3 feat: socle BDD (tâche 1.9 Phase 1-2) + moteur APT (tâche 2 SJ-0→3) + WIP capabilities/auth/Rust
Checkpoint multi-chantiers (arbre vert : tsc 0 erreur, 70 tests, build OK).
- tâche 1.9 Phase 1 : schéma socle (machine_state/events/reports/raw_artifacts/
  hardware/metrics + colonnes étendues) + wiring refresh/execute. Migration 0002.
- tâche 1.9 Phase 2 : machine_credentials + machine_host_keys (non destructif,
  dual-read + backfill). Migration 0003. Fix séquence journal de migration.
- tâche 2 : SJ-0 (types étendus rétro-compatibles, réducteur Docker, resolveTemplate),
  SJ-1 (update-analyze enrichi), SJ-2 (apply + diff dpkg + timeout inactivité SSH),
  SJ-3 (reboot vérifié boot_id).
- WIP parallèle inclus : /api/capabilities, auth/apiTokens/apiClients, system metrics,
  scaffold app_rust, ajustements frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:50:25 +02:00

114 lines
3.1 KiB
TypeScript

// 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.
* `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<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, inactivityTimeoutMs);
} finally {
conn.end();
}
}
function execStream(
conn: Client,
command: string,
stdinData: string | null,
onData: (chunk: string) => void,
inactivityTimeoutMs = 0,
): Promise<RunResult> {
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<typeof setTimeout> | 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();
});
});
});
}