/** * Caddy Manager for Onebox * * Manages Caddy binary download, process lifecycle, and Admin API configuration. * Caddy is used as the reverse proxy with native SNI support. */ import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; const CADDY_VERSION = '2.10.2'; const CADDY_DOWNLOAD_URL = `https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz`; export interface ICaddyRoute { domain: string; upstream: string; // e.g., "10.0.1.40:80" } export interface ICaddyCertificate { domain: string; certPath: string; keyPath: string; } interface ICaddyLoggingConfig { logs: { [name: string]: { writer: { output: string; address?: string; dial_timeout?: string; soft_start?: boolean; }; encoder?: { format: string }; level?: string; include?: string[]; }; }; } interface ICaddyConfig { admin: { listen: string; }; logging?: ICaddyLoggingConfig; apps: { http: { servers: { [key: string]: { listen: string[]; routes: ICaddyRouteConfig[]; automatic_https?: { disable?: boolean; disable_redirects?: boolean; }; logs?: { default_logger_name: string; }; }; }; }; tls?: { automation?: { policies: Array<{ issuers: never[] }>; }; certificates?: { load_files?: Array<{ certificate: string; key: string; tags?: string[]; }>; }; }; }; } interface ICaddyRouteConfig { match: Array<{ host: string[] }>; handle: Array<{ handler: string; upstreams?: Array<{ dial: string }>; routes?: ICaddyRouteConfig[]; }>; terminal?: boolean; } export class CaddyManager { private process: Deno.ChildProcess | null = null; private binaryPath: string; private certsDir: string; private adminUrl: string; private httpPort: number; private httpsPort: number; private logReceiverPort: number; private loggingEnabled: boolean; private routes: Map = new Map(); private certificates: Map = new Map(); constructor(options?: { binaryPath?: string; certsDir?: string; adminPort?: number; httpPort?: number; httpsPort?: number; logReceiverPort?: number; loggingEnabled?: boolean; }) { this.binaryPath = options?.binaryPath || './.nogit/caddy'; this.certsDir = options?.certsDir || './.nogit/certs'; this.adminUrl = `http://localhost:${options?.adminPort || 2019}`; this.httpPort = options?.httpPort || 8080; this.httpsPort = options?.httpsPort || 8443; this.logReceiverPort = options?.logReceiverPort || 9999; this.loggingEnabled = options?.loggingEnabled ?? true; } /** * Update listening ports (must call reloadConfig after if running) */ setPorts(httpPort: number, httpsPort: number): void { this.httpPort = httpPort; this.httpsPort = httpsPort; } /** * Ensure Caddy binary is downloaded and executable */ async ensureBinary(): Promise { try { // Check if binary exists try { const stat = await Deno.stat(this.binaryPath); if (stat.isFile) { // Verify it's executable by checking version const cmd = new Deno.Command(this.binaryPath, { args: ['version'], stdout: 'piped', stderr: 'piped', }); const result = await cmd.output(); if (result.success) { const version = new TextDecoder().decode(result.stdout).trim(); logger.info(`Caddy binary found: ${version}`); return; } } } catch { // Binary doesn't exist, need to download } logger.info(`Downloading Caddy v${CADDY_VERSION}...`); // Create directory if needed const dir = this.binaryPath.substring(0, this.binaryPath.lastIndexOf('/')); await Deno.mkdir(dir, { recursive: true }); // Download tar.gz const response = await fetch(CADDY_DOWNLOAD_URL); if (!response.ok) { throw new Error(`Failed to download Caddy: ${response.status} ${response.statusText}`); } const tarGzPath = `${this.binaryPath}.tar.gz`; const data = new Uint8Array(await response.arrayBuffer()); await Deno.writeFile(tarGzPath, data); // Extract using tar command const extractCmd = new Deno.Command('tar', { args: ['-xzf', tarGzPath, '-C', dir, 'caddy'], stdout: 'piped', stderr: 'piped', }); const extractResult = await extractCmd.output(); if (!extractResult.success) { throw new Error(`Failed to extract Caddy: ${new TextDecoder().decode(extractResult.stderr)}`); } // Clean up tar.gz await Deno.remove(tarGzPath); // Make executable await Deno.chmod(this.binaryPath, 0o755); logger.success(`Caddy v${CADDY_VERSION} downloaded to ${this.binaryPath}`); } catch (error) { logger.error(`Failed to ensure Caddy binary: ${getErrorMessage(error)}`); throw error; } } /** * Start Caddy process with minimal config, then configure via Admin API */ async start(): Promise { if (this.process) { logger.warn('Caddy is already running'); return; } try { // Create certs directory await Deno.mkdir(this.certsDir, { recursive: true }); logger.info('Starting Caddy server...'); // Start Caddy with blank config - Admin API is available immediately // We'll push the full configuration via Admin API after it's ready const cmd = new Deno.Command(this.binaryPath, { args: ['run'], stdin: 'null', stdout: 'piped', stderr: 'piped', }); this.process = cmd.spawn(); // Start log readers (non-blocking) this.readProcessOutput(); // Wait for Admin API to be ready await this.waitForReady(); // Now configure via Admin API with current routes and certificates await this.reloadConfig(); logger.success(`Caddy started (HTTP: ${this.httpPort}, HTTPS: ${this.httpsPort}, Admin: ${this.adminUrl})`); } catch (error) { logger.error(`Failed to start Caddy: ${getErrorMessage(error)}`); throw error; } } /** * Read process stdout/stderr and log */ private async readProcessOutput(): Promise { if (!this.process) return; // Read stderr (Caddy logs to stderr by default) const stderrReader = this.process.stderr.getReader(); (async () => { try { while (true) { const { done, value } = await stderrReader.read(); if (done) break; const text = new TextDecoder().decode(value).trim(); if (text) { // Parse Caddy's JSON log format or just log as-is for (const line of text.split('\n')) { if (line.includes('"level":"error"')) { logger.error(`[Caddy] ${line}`); } else if (line.includes('"level":"warn"')) { logger.warn(`[Caddy] ${line}`); } else { logger.debug(`[Caddy] ${line}`); } } } } } catch { // Process ended } })(); } /** * Wait for Caddy to be ready by polling admin API */ private async waitForReady(maxAttempts = 50, intervalMs = 100): Promise { for (let i = 0; i < maxAttempts; i++) { try { const response = await fetch(`${this.adminUrl}/config/`); if (response.ok) { return; } } catch { // Not ready yet } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error('Caddy failed to start within timeout'); } /** * Stop Caddy process */ async stop(): Promise { if (!this.process) { return; } try { logger.info('Stopping Caddy...'); // Try graceful shutdown via API first try { await fetch(`${this.adminUrl}/stop`, { method: 'POST' }); // Wait for process to exit await Promise.race([ this.process.status, new Promise((resolve) => setTimeout(resolve, 5000)), ]); } catch { // API not available, kill directly } // Force kill if still running try { this.process.kill('SIGTERM'); } catch { // Already dead } this.process = null; logger.info('Caddy stopped'); } catch (error) { logger.error(`Failed to stop Caddy: ${getErrorMessage(error)}`); } } /** * Check if Caddy is healthy */ async isHealthy(): Promise { try { const response = await fetch(`${this.adminUrl}/config/`); return response.ok; } catch { return false; } } /** * Build Caddy JSON configuration from current routes and certificates */ private buildConfig(): ICaddyConfig { const routes: ICaddyRouteConfig[] = []; // Add routes for (const [domain, route] of this.routes) { routes.push({ match: [{ host: [domain] }], handle: [ { handler: 'reverse_proxy', upstreams: [{ dial: route.upstream }], }, ], terminal: true, }); } // Build certificate load_files const loadFiles: Array<{ certificate: string; key: string; tags?: string[] }> = []; for (const [domain, cert] of this.certificates) { loadFiles.push({ certificate: cert.certPath, key: cert.keyPath, tags: [domain], }); } const config: ICaddyConfig = { admin: { listen: this.adminUrl.replace('http://', ''), }, apps: { http: { servers: { main: { listen: [`:${this.httpPort}`, `:${this.httpsPort}`], routes, // Disable automatic HTTPS to prevent Caddy from trying to bind to port 80/443 automatic_https: { disable: true, }, }, }, }, }, }; // Add access logging configuration if enabled if (this.loggingEnabled) { config.logging = { logs: { access: { writer: { output: 'net', address: `tcp/localhost:${this.logReceiverPort}`, dial_timeout: '5s', soft_start: true, // Continue even if log receiver is down }, encoder: { format: 'json' }, level: 'INFO', include: ['http.log.access'], }, }, }; // Associate server with access logger config.apps.http.servers.main.logs = { default_logger_name: 'access', }; } // Add TLS config if we have certificates if (loadFiles.length > 0) { config.apps.tls = { automation: { // Disable automatic HTTPS - we manage certs ourselves policies: [{ issuers: [] }], }, certificates: { load_files: loadFiles, }, }; } return config; } /** * Reload Caddy configuration via Admin API */ async reloadConfig(): Promise { if (!this.process) { logger.warn('Caddy not running, cannot reload config'); return; } const config = this.buildConfig(); try { const response = await fetch(`${this.adminUrl}/load`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }); if (!response.ok) { const text = await response.text(); throw new Error(`Failed to reload Caddy config: ${response.status} ${text}`); } logger.debug('Caddy configuration reloaded'); } catch (error) { logger.error(`Failed to reload Caddy config: ${getErrorMessage(error)}`); throw error; } } /** * Add or update a route */ async addRoute(domain: string, upstream: string): Promise { this.routes.set(domain, { domain, upstream }); if (this.process) { await this.reloadConfig(); } logger.success(`Added Caddy route: ${domain} -> ${upstream}`); } /** * Remove a route */ async removeRoute(domain: string): Promise { if (this.routes.delete(domain)) { if (this.process) { await this.reloadConfig(); } logger.success(`Removed Caddy route: ${domain}`); } } /** * Add or update a TLS certificate * Writes PEM files to disk and updates config */ async addCertificate(domain: string, certPem: string, keyPem: string): Promise { // Write PEM files const certPath = `${this.certsDir}/${domain}.crt`; const keyPath = `${this.certsDir}/${domain}.key`; await Deno.mkdir(this.certsDir, { recursive: true }); await Deno.writeTextFile(certPath, certPem); await Deno.writeTextFile(keyPath, keyPem); // Use absolute paths for Caddy const absoluteCertPath = await Deno.realPath(certPath); const absoluteKeyPath = await Deno.realPath(keyPath); this.certificates.set(domain, { domain, certPath: absoluteCertPath, keyPath: absoluteKeyPath, }); if (this.process) { await this.reloadConfig(); } logger.success(`Added TLS certificate for ${domain}`); } /** * Remove a TLS certificate */ async removeCertificate(domain: string): Promise { const cert = this.certificates.get(domain); if (cert) { this.certificates.delete(domain); // Remove files try { await Deno.remove(cert.certPath); await Deno.remove(cert.keyPath); } catch { // Files may not exist } if (this.process) { await this.reloadConfig(); } logger.success(`Removed TLS certificate for ${domain}`); } } /** * Get all current routes */ getRoutes(): ICaddyRoute[] { return Array.from(this.routes.values()); } /** * Get all current certificates */ getCertificates(): ICaddyCertificate[] { return Array.from(this.certificates.values()); } /** * Clear all routes and certificates (useful for reload from database) */ clear(): void { this.routes.clear(); this.certificates.clear(); } /** * Get status */ getStatus(): { running: boolean; httpPort: number; httpsPort: number; routes: number; certificates: number; } { return { running: this.process !== null, httpPort: this.httpPort, httpsPort: this.httpsPort, routes: this.routes.size, certificates: this.certificates.size, }; } }