import 'express-async-errors'; import express from 'express'; import https from 'https'; import http from 'http'; import fs from 'fs'; import { env } from './config/env'; import { logger } from './utils/logger'; import { mtlsAuth } from './middleware/mtls-auth'; import { errorHandler } from './middleware/error-handler'; import healthRoutes from './routes/health.routes'; import composeRoutes from './routes/compose.routes'; import filesRoutes from './routes/files.routes'; import registryRoutes from './routes/registry.routes'; import backupRoutes from './routes/backup.routes'; import upgradeRoutes from './routes/upgrade.routes'; const app = express(); // Parse JSON bodies (up to 50MB for template file uploads) app.use(express.json({ limit: '50mb' })); // Health endpoint is always accessible (no mTLS required) app.use(healthRoutes); // All other routes require mTLS authentication function hasCerts(): boolean { try { fs.accessSync(env.AGENT_CERT_PATH); fs.accessSync(env.AGENT_KEY_PATH); fs.accessSync(env.AGENT_CA_CERT_PATH); return true; } catch { return false; } } if (hasCerts()) { // mTLS mode — certificates are installed const tlsOptions: https.ServerOptions = { key: fs.readFileSync(env.AGENT_KEY_PATH), cert: fs.readFileSync(env.AGENT_CERT_PATH), ca: fs.readFileSync(env.AGENT_CA_CERT_PATH), requestCert: true, rejectUnauthorized: true, }; app.use(mtlsAuth); app.use(composeRoutes); app.use(filesRoutes); app.use(registryRoutes); app.use(backupRoutes); app.use(upgradeRoutes); app.use(errorHandler); const server = https.createServer(tlsOptions, app); server.listen(env.AGENT_PORT, async () => { logger.info(`CCP Agent (mTLS) listening on port ${env.AGENT_PORT}`); // Auto-register this instance's slug if configured if (env.INSTANCE_SLUG && env.INSTANCE_BASE_PATH) { const { registerSlug, getSlugEntry } = await import('./services/registry.service'); try { await getSlugEntry(env.INSTANCE_SLUG); logger.debug(`[registry] Slug ${env.INSTANCE_SLUG} already registered`); } catch { // Detect compose project name: use env override, or derive from basePath directory name // (Docker Compose default: directory name with special chars stripped) const pathMod = await import('path'); const composeProject = env.COMPOSE_PROJECT || pathMod.basename(env.INSTANCE_BASE_PATH).replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); await registerSlug(env.INSTANCE_SLUG, env.INSTANCE_BASE_PATH, composeProject); } } }); } else { // Pre-approval mode — start HTTP, only health + phone-home polling logger.info('No certificates found — starting in phone-home registration mode'); app.use(errorHandler); const server = http.createServer(app); server.listen(env.AGENT_PORT, () => { logger.info(`CCP Agent (registration mode) listening on port ${env.AGENT_PORT}`); }); // Start phone-home polling if CCP_URL and CCP_INVITE_CODE are set if (env.CCP_URL && env.CCP_INVITE_CODE) { startPhoneHome(); } } /** * Phone-home registration flow: * 1. POST to CCP with invite code + instance metadata * 2. Poll CCP every 30s until approved * 3. On approval, save certs and restart with mTLS */ async function startPhoneHome() { logger.info(`[phone-home] Registering with CCP at ${env.CCP_URL}...`); // Step 1: Send registration request try { const response = await fetch(`${env.CCP_URL}/api/agents/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteCode: env.CCP_INVITE_CODE, slug: env.INSTANCE_SLUG, name: env.INSTANCE_SLUG, domain: env.INSTANCE_DOMAIN, agentUrl: env.CCP_AGENT_URL, basePath: env.INSTANCE_BASE_PATH, }), }); if (!response.ok) { const err = await response.text(); logger.error(`[phone-home] Registration failed: ${response.status} ${err}`); return; } const result = await response.json() as { registrationId: string }; logger.info(`[phone-home] Registration submitted (id: ${result.registrationId}). Waiting for approval...`); // Step 2: Poll for approval. Every path inside the callback is wrapped in // try/catch so an unexpected throw never kills the interval silently. // On every poll we log either the status transition or a heartbeat every // 10th attempt, so admins can see the loop is alive. // Self-scheduling poll with exponential backoff on HTTP 429. Fixed-interval // setInterval blew up at the 15-min mark when CCP's rate limiter rejected // the 11th poll and subsequent polls — the only recovery was an agent // restart. This loop doubles the delay on 429 (cap 5 min) and resets on // any 2xx response. let pollCount = 0; let lastLoggedStatus: string | null = null; const BASE_DELAY_MS = 30_000; const MAX_DELAY_MS = 300_000; let currentDelayMs = BASE_DELAY_MS; let pollTimer: NodeJS.Timeout | null = null; let stopped = false; const scheduleNext = (delayMs: number) => { if (stopped) return; pollTimer = setTimeout(runPoll, delayMs); }; const runPoll = async () => { if (stopped) return; pollCount += 1; try { const pollResp = await fetch( `${env.CCP_URL}/api/agents/poll?registrationId=${result.registrationId}&slug=${env.INSTANCE_SLUG}` ); if (pollResp.status === 429) { // Rate limited — back off, don't try to parse body. const newDelay = Math.min(currentDelayMs * 2, MAX_DELAY_MS); logger.warn(`[phone-home] Poll #${pollCount} HTTP 429 — backing off ${currentDelayMs}ms → ${newDelay}ms`); currentDelayMs = newDelay; scheduleNext(currentDelayMs); return; } if (!pollResp.ok) { logger.warn(`[phone-home] Poll #${pollCount} HTTP ${pollResp.status} ${pollResp.statusText}`); scheduleNext(currentDelayMs); return; } // Success — reset backoff if previously inflated. if (currentDelayMs !== BASE_DELAY_MS) { logger.info(`[phone-home] Poll succeeded — resetting delay ${currentDelayMs}ms → ${BASE_DELAY_MS}ms`); currentDelayMs = BASE_DELAY_MS; } const pollData = await pollResp.json() as { status: string; certBundle?: { caCertPem: string; agentCertPem: string; agentKeyPem: string; ccpFingerprint: string }; message?: string; }; if (pollData.status !== lastLoggedStatus) { logger.info(`[phone-home] Poll #${pollCount}: status=${pollData.status}${pollData.message ? ` — ${pollData.message}` : ''}`); lastLoggedStatus = pollData.status; } else if (pollCount % 10 === 0) { logger.debug(`[phone-home] Poll #${pollCount}: still ${pollData.status}`); } if (pollData.status === 'APPROVED' && pollData.certBundle) { stopped = true; if (pollTimer) clearTimeout(pollTimer); logger.info('[phone-home] Approved! Saving certificates...'); // Save certs const fsp = await import('fs/promises'); const pathMod = await import('path'); await fsp.mkdir(pathMod.dirname(env.AGENT_CERT_PATH), { recursive: true }); await fsp.writeFile(env.AGENT_CERT_PATH, pollData.certBundle.agentCertPem); await fsp.writeFile(env.AGENT_KEY_PATH, pollData.certBundle.agentKeyPem); await fsp.writeFile(env.AGENT_CA_CERT_PATH, pollData.certBundle.caCertPem); // SECURITY: Write the CCP fingerprint to a config file so the agent // can verify the CCP's identity on subsequent connections. if (pollData.certBundle.ccpFingerprint) { const configPath = pathMod.join(env.AGENT_DATA_DIR, 'ccp-fingerprint'); await fsp.mkdir(env.AGENT_DATA_DIR, { recursive: true }); await fsp.writeFile(configPath, pollData.certBundle.ccpFingerprint); logger.info(`[phone-home] CCP fingerprint saved: ${pollData.certBundle.ccpFingerprint.substring(0, 16)}...`); } logger.info('[phone-home] Certificates saved. Restarting with mTLS...'); process.exit(0); } else if (pollData.status === 'APPROVED' && !pollData.certBundle) { // Admin approved but cert bundle was consumed (e.g. by debug curl). // Keep polling — admin can re-issue certs via the new endpoint. scheduleNext(currentDelayMs); } else if (pollData.status === 'REJECTED') { stopped = true; if (pollTimer) clearTimeout(pollTimer); logger.error('[phone-home] Registration was rejected by CCP admin'); } else { scheduleNext(currentDelayMs); } } catch (err) { // CRITICAL: this catch MUST swallow every error — previously an unhandled // rejection could silently kill the poll loop depending on Node config. logger.warn(`[phone-home] Poll #${pollCount} failed: ${(err as Error).message}`); scheduleNext(currentDelayMs); } }; scheduleNext(currentDelayMs); // Defensive: if the Node process receives an unhandled rejection that // somehow originates from the poll path, log it instead of dying quietly. process.on('unhandledRejection', (reason) => { logger.error(`[phone-home] Unhandled rejection in poll loop: ${reason}`); }); } catch (err) { logger.error(`[phone-home] Registration request failed: ${(err as Error).message}`); } }