This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/lib/coolify-ssh.ts

136 lines
4.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.
*
* Aggregate probe for monitors: GET /api/internal/infra-health with INFRA_HEALTH_SECRET.
*/
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<CoolifySshResult> {
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);
}