/** * One-shot SSH-exec helper into the Coolify host. * * Used to pull docker runtime data that the Coolify REST API doesn't * surface cleanly (per-service logs for compose apps, container state, * etc.). Strictly command-in / string-out — we never hold a persistent * channel and we never expose an interactive shell to callers. * * Required env (injected into vibn-frontend at deploy time): * COOLIFY_SSH_HOST e.g. 34.19.250.135 or coolify.vibnai.com * COOLIFY_SSH_PORT default 22 * COOLIFY_SSH_USER default "vibn-logs" * COOLIFY_SSH_PRIVATE_KEY_B64 base64-encoded PEM private key * * The authorised key lives in ~vibn-logs/.ssh/authorized_keys on the * Coolify host. That user must be a member of the `docker` group so * `docker logs`/`docker ps` work without sudo; it should NOT have * sudo rights. */ import { Client, type ConnectConfig } from 'ssh2'; const DEFAULT_EXEC_TIMEOUT_MS = 10_000; const DEFAULT_MAX_BYTES = 1_000_000; // 1 MB – more than enough for `docker logs --tail 2000` export interface CoolifySshResult { code: number | null; stdout: string; stderr: string; truncated: boolean; } export interface CoolifySshOptions { /** Millis to wait for the command to finish before killing the channel. */ timeoutMs?: number; /** Cap combined stdout+stderr. Extra bytes are dropped and `truncated=true`. */ maxBytes?: number; } function loadConnectConfig(): ConnectConfig { const host = process.env.COOLIFY_SSH_HOST; const keyB64 = process.env.COOLIFY_SSH_PRIVATE_KEY_B64; if (!host) throw new Error('COOLIFY_SSH_HOST is not set'); if (!keyB64) throw new Error('COOLIFY_SSH_PRIVATE_KEY_B64 is not set'); const privateKey = Buffer.from(keyB64, 'base64').toString('utf8'); const port = Number(process.env.COOLIFY_SSH_PORT ?? 22); const username = process.env.COOLIFY_SSH_USER ?? 'vibn-logs'; return { host, port, username, privateKey, readyTimeout: 8_000, // We accept the host key on first connect; we're always connecting // to infra we own, but a future hardening pass should pin the host // key fingerprint via `hostVerifier`. }; } /** * Run a single command on the Coolify host and collect its output. * * The helper enforces: * - a wall-clock timeout (default 10s) * - a combined-output byte cap (default 1 MB) * - always-close semantics (connection is destroyed on return / throw) */ export function runOnCoolifyHost( command: string, opts: CoolifySshOptions = {}, ): Promise { const cfg = loadConnectConfig(); const timeoutMs = opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS; const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES; return new Promise((resolve, reject) => { const conn = new Client(); let settled = false; let stdout = ''; let stderr = ''; let truncated = false; const settle = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); try { conn.end(); } catch { /* noop */ } fn(); }; const timer = setTimeout(() => { settle(() => reject(new Error(`[coolify-ssh] timeout after ${timeoutMs}ms running: ${command}`))); }, timeoutMs); conn .on('ready', () => { conn.exec(command, (err, stream) => { if (err) return settle(() => reject(err)); stream .on('close', (code: number | null) => { settle(() => resolve({ code, stdout, stderr, truncated })); }) .on('data', (chunk: Buffer) => { if (stdout.length + stderr.length + chunk.length > maxBytes) { truncated = true; const room = Math.max(0, maxBytes - stdout.length - stderr.length); stdout += chunk.slice(0, room).toString('utf8'); } else { stdout += chunk.toString('utf8'); } }) .stderr.on('data', (chunk: Buffer) => { if (stdout.length + stderr.length + chunk.length > maxBytes) { truncated = true; const room = Math.max(0, maxBytes - stdout.length - stderr.length); stderr += chunk.slice(0, room).toString('utf8'); } else { stderr += chunk.toString('utf8'); } }); }); }) .on('error', (err) => settle(() => reject(err))) .connect(cfg); }); } /** * True if the env is configured to talk to the Coolify host. * Callers use this to gracefully degrade when SSH isn't available * (e.g. local dev without the key pair mounted). */ export function isCoolifySshConfigured(): boolean { return !!(process.env.COOLIFY_SSH_HOST && process.env.COOLIFY_SSH_PRIVATE_KEY_B64); }