Files
vibn-frontend/lib/coolify-ssh.ts
Mark Henderson d86f2bea03 feat(mcp): apps.logs — compose-aware runtime logs
Adds apps.logs MCP tool + session REST endpoint for tailing runtime
container logs. Unblocks cold-start debugging for agent-deployed
compose apps (Twenty, Cal.com, Plane, etc.) where Coolify's own
/applications/{uuid}/logs endpoint returns empty.

Architecture:
  - dockerfile / nixpacks / static apps → Coolify's REST logs API
  - dockercompose apps                  → SSH into Coolify host,
                                          `docker logs` per service

New SSH path uses a dedicated `vibn-logs` user (docker group, no
sudo, no pty, no port-forwarding, single ed25519 key). Private key
lives in COOLIFY_SSH_PRIVATE_KEY_B64 on the vibn-frontend Coolify
app; authorized_key is installed by scripts/setup-vibn-logs-user.sh
on the Coolify host.

Tool shape:
  params:   { uuid, service?, lines? (default 200, max 5000) }
  returns:  { uuid, buildPack, source: 'coolify_api'|'ssh_docker'|'empty',
              services: { [name]: { container, lines, bytes, logs, status? } },
              warnings: string[], truncated: boolean }

Made-with: Cursor
2026-04-23 13:21:52 -07:00

134 lines
4.7 KiB
TypeScript
Raw 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.
*/
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);
}