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