/** * Caddy Manager for Onebox * * Manages Caddy as a Docker Swarm service instead of a host binary. * This allows Caddy to access services on the Docker overlay network. */ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; const CADDY_SERVICE_NAME = 'onebox-caddy'; const CADDY_IMAGE = 'caddy:2-alpine'; const DOCKER_GATEWAY_IP = '172.17.0.1'; // Docker bridge gateway for container-to-host communication export interface ICaddyRoute { domain: string; upstream: string; // e.g., "onebox-hello-world:80" } export interface ICaddyCertificate { domain: string; certPem: string; keyPem: 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_pem?: 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 dockerClient: InstanceType | null = null; 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(); private networkName = 'onebox-network'; private serviceRunning = false; constructor(options?: { certsDir?: string; adminPort?: number; httpPort?: number; httpsPort?: number; logReceiverPort?: number; loggingEnabled?: boolean; }) { 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; } /** * Initialize Docker client for Caddy service management */ private async ensureDockerClient(): Promise { if (!this.dockerClient) { this.dockerClient = new plugins.docker.Docker({ socketPath: 'unix:///var/run/docker.sock', }); await this.dockerClient.start(); } } /** * Update listening ports (must call reloadConfig after if running) */ setPorts(httpPort: number, httpsPort: number): void { this.httpPort = httpPort; this.httpsPort = httpsPort; } /** * Start Caddy as a Docker Swarm service */ async start(): Promise { if (this.serviceRunning) { logger.warn('Caddy service is already running'); return; } try { await this.ensureDockerClient(); // Create certs directory for backup/persistence await Deno.mkdir(this.certsDir, { recursive: true }); logger.info('Starting Caddy Docker service...'); // Check if service already exists const existingService = await this.getExistingService(); if (existingService) { logger.info('Caddy service exists, removing old service...'); await this.removeService(); // Wait for service to be removed await new Promise((resolve) => setTimeout(resolve, 2000)); } // Get network ID const networkId = await this.getNetworkId(); // Create Caddy Docker service const response = await this.dockerClient!.request('POST', '/services/create', { Name: CADDY_SERVICE_NAME, Labels: { 'managed-by': 'onebox', 'onebox-type': 'caddy', }, TaskTemplate: { ContainerSpec: { Image: CADDY_IMAGE, // Start Caddy with admin listening on all interfaces so we can reach it from host // Write minimal config to /tmp and start Caddy with that config Command: ['sh', '-c', 'printf \'{"admin":{"listen":"0.0.0.0:2019"}}\' > /tmp/caddy.json && caddy run --config /tmp/caddy.json'], }, Networks: [ { Target: networkId, }, ], RestartPolicy: { Condition: 'any', MaxAttempts: 0, }, }, Mode: { Replicated: { Replicas: 1, }, }, EndpointSpec: { Ports: [ { Protocol: 'tcp', TargetPort: 80, PublishedPort: this.httpPort, PublishMode: 'host', }, { Protocol: 'tcp', TargetPort: 443, PublishedPort: this.httpsPort, PublishMode: 'host', }, { Protocol: 'tcp', TargetPort: 2019, PublishedPort: 2019, PublishMode: 'host', }, ], }, }); if (response.statusCode >= 300) { throw new Error(`Failed to create Caddy service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`); } logger.info(`Caddy service created: ${response.body.ID}`); // Wait for Admin API to be ready await this.waitForReady(); this.serviceRunning = true; // 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; } } /** * Get existing Caddy service if any */ private async getExistingService(): Promise { try { const response = await this.dockerClient!.request('GET', `/services/${CADDY_SERVICE_NAME}`, {}); if (response.statusCode === 200) { return response.body; } return null; } catch { return null; } } /** * Remove the Caddy service */ private async removeService(): Promise { try { await this.dockerClient!.request('DELETE', `/services/${CADDY_SERVICE_NAME}`, {}); } catch { // Service may not exist } } /** * Get network ID by name */ private async getNetworkId(): Promise { const networks = await this.dockerClient!.listNetworks(); const network = networks.find((n: any) => n.Name === this.networkName); if (!network) { throw new Error(`Network not found: ${this.networkName}`); } return network.Id; } /** * Wait for Caddy Admin API to be ready */ private async waitForReady(maxAttempts = 60, intervalMs = 500): 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 service failed to start within timeout'); } /** * Stop Caddy Docker service */ async stop(): Promise { if (!this.serviceRunning && !(await this.getExistingService())) { return; } try { await this.ensureDockerClient(); logger.info('Stopping Caddy service...'); await this.removeService(); this.serviceRunning = false; logger.info('Caddy service stopped'); } catch (error) { logger.error(`Failed to stop Caddy: ${getErrorMessage(error)}`); } } /** * Check if Caddy Admin API is healthy */ async isHealthy(): Promise { try { const response = await fetch(`${this.adminUrl}/config/`); return response.ok; } catch { return false; } } /** * Check if Caddy service is running */ async isRunning(): Promise { try { await this.ensureDockerClient(); const service = await this.getExistingService(); if (!service) return false; // Check if service has running tasks const tasksResponse = await this.dockerClient!.request( 'GET', `/tasks?filters=${encodeURIComponent(JSON.stringify({ service: [CADDY_SERVICE_NAME] }))}`, {} ); if (tasksResponse.statusCode !== 200) return false; const tasks = tasksResponse.body; return tasks.some((task: any) => task.Status?.State === 'running'); } 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_pem entries (inline PEM content) const loadPem: Array<{ certificate: string; key: string; tags?: string[] }> = []; for (const [domain, cert] of this.certificates) { loadPem.push({ certificate: cert.certPem, key: cert.keyPem, tags: [domain], }); } const config: ICaddyConfig = { admin: { listen: '0.0.0.0:2019', // Listen on all interfaces inside container }, apps: { http: { servers: { main: { listen: [':80', ':443'], routes, // Disable automatic HTTPS to prevent Caddy from trying to obtain certs automatic_https: { disable: true, }, }, }, }, }, }; // Add access logging configuration if enabled if (this.loggingEnabled) { config.logging = { logs: { access: { writer: { output: 'net', // Use Docker bridge gateway IP to reach log receiver on host address: `tcp/${DOCKER_GATEWAY_IP}:${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 (loadPem.length > 0) { config.apps.tls = { automation: { // Disable automatic HTTPS - we manage certs ourselves policies: [{ issuers: [] }], }, certificates: { load_pem: loadPem, }, }; } return config; } /** * Reload Caddy configuration via Admin API */ async reloadConfig(): Promise { const isRunning = await this.isRunning(); if (!isRunning) { 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 (await this.isRunning()) { await this.reloadConfig(); } logger.success(`Added Caddy route: ${domain} -> ${upstream}`); } /** * Remove a route */ async removeRoute(domain: string): Promise { if (this.routes.delete(domain)) { if (await this.isRunning()) { await this.reloadConfig(); } logger.success(`Removed Caddy route: ${domain}`); } } /** * Add or update a TLS certificate * Stores PEM content in memory for Admin API, also writes to disk for backup */ async addCertificate(domain: string, certPem: string, keyPem: string): Promise { // Store PEM content in memory for buildConfig() this.certificates.set(domain, { domain, certPem, keyPem, }); // Also write to disk for backup/persistence try { await Deno.mkdir(this.certsDir, { recursive: true }); await Deno.writeTextFile(`${this.certsDir}/${domain}.crt`, certPem); await Deno.writeTextFile(`${this.certsDir}/${domain}.key`, keyPem); } catch (error) { logger.warn(`Failed to write certificate backup for ${domain}: ${getErrorMessage(error)}`); } if (await this.isRunning()) { await this.reloadConfig(); } logger.success(`Added TLS certificate for ${domain}`); } /** * Remove a TLS certificate */ async removeCertificate(domain: string): Promise { if (this.certificates.delete(domain)) { // Remove backup files try { await Deno.remove(`${this.certsDir}/${domain}.crt`); await Deno.remove(`${this.certsDir}/${domain}.key`); } catch { // Files may not exist } if (await this.isRunning()) { 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.serviceRunning, httpPort: this.httpPort, httpsPort: this.httpsPort, routes: this.routes.size, certificates: this.certificates.size, }; } }