/** * Reverse Proxy for Onebox * * Delegates to Caddy (running as Docker service) for production-grade reverse proxy * with native SNI support, HTTP/2, WebSocket proxying, and zero-downtime configuration updates. * * Routes use Docker service names (e.g., onebox-hello-world:80) for container-to-container * communication within the Docker overlay network. */ import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; import { OneboxDatabase } from './database.ts'; import { CaddyManager } from './caddy.ts'; interface IProxyRoute { domain: string; targetHost: string; targetPort: number; serviceId: number; serviceName?: string; } export class OneboxReverseProxy { private oneboxRef: any; private database: OneboxDatabase; private caddy: CaddyManager; private routes: Map = new Map(); private httpPort = 8080; // Default to dev ports (will be overridden if production) private httpsPort = 8443; constructor(oneboxRef: any) { this.oneboxRef = oneboxRef; this.database = oneboxRef.database; this.caddy = new CaddyManager({ httpPort: this.httpPort, httpsPort: this.httpsPort, }); } /** * Initialize reverse proxy - Caddy runs as Docker service, no setup needed */ async init(): Promise { logger.info('Reverse proxy initialized (Caddy Docker service)'); } /** * Start the HTTP/HTTPS reverse proxy server * Caddy handles both HTTP and HTTPS on the configured ports */ async startHttp(port?: number): Promise { if (port) { this.httpPort = port; this.caddy.setPorts(this.httpPort, this.httpsPort); } try { // Start Caddy (handles both HTTP and HTTPS) await this.caddy.start(); logger.success(`Reverse proxy started on port ${this.httpPort} (Caddy Docker service)`); } catch (error) { logger.error(`Failed to start reverse proxy: ${getErrorMessage(error)}`); throw error; } } /** * Start HTTPS - Caddy already handles HTTPS when started * This method exists for interface compatibility */ async startHttps(port?: number): Promise { if (port) { this.httpsPort = port; this.caddy.setPorts(this.httpPort, this.httpsPort); } // Caddy handles both HTTP and HTTPS together // If already running, just log and optionally reload with new port const status = this.caddy.getStatus(); if (status.running) { logger.info(`HTTPS already running on port ${this.httpsPort} via Caddy`); } else { await this.caddy.start(); } } /** * Stop the reverse proxy */ async stop(): Promise { await this.caddy.stop(); logger.info('Reverse proxy stopped'); } /** * Add a route for a service * Uses Docker service name for upstream (Caddy runs in same Docker network) */ async addRoute(serviceId: number, domain: string, targetPort: number): Promise { try { // Get service info from database const service = this.database.getServiceByID(serviceId); if (!service) { throw new Error(`Service not found: ${serviceId}`); } // Use Docker service name as upstream target // Caddy runs on the same Docker network, so it can resolve service names directly const serviceName = `onebox-${service.name}`; const targetHost = serviceName; const route: IProxyRoute = { domain, targetHost, targetPort, serviceId, serviceName, }; this.routes.set(domain, route); // Add route to Caddy using Docker service name const upstream = `${targetHost}:${targetPort}`; await this.caddy.addRoute(domain, upstream); logger.success(`Added proxy route: ${domain} -> ${upstream}`); } catch (error) { logger.error(`Failed to add route for ${domain}: ${getErrorMessage(error)}`); throw error; } } /** * Remove a route */ removeRoute(domain: string): void { if (this.routes.delete(domain)) { // Remove from Caddy (async but we don't wait) this.caddy.removeRoute(domain).catch((error) => { logger.error(`Failed to remove Caddy route for ${domain}: ${getErrorMessage(error)}`); }); logger.success(`Removed proxy route: ${domain}`); } else { logger.warn(`Route not found: ${domain}`); } } /** * Get all routes */ getRoutes(): IProxyRoute[] { return Array.from(this.routes.values()); } /** * Reload routes from database */ async reloadRoutes(): Promise { try { logger.info('Reloading proxy routes...'); // Clear local and Caddy routes this.routes.clear(); this.caddy.clear(); const services = this.database.getAllServices(); for (const service of services) { // Route by domain if running (containerID is the service ID for Swarm services) if (service.domain && service.status === 'running' && service.containerID) { await this.addRoute(service.id!, service.domain, service.port); } } logger.success(`Loaded ${this.routes.size} proxy routes`); } catch (error) { logger.error(`Failed to reload routes: ${getErrorMessage(error)}`); throw error; } } /** * Add TLS certificate for a domain * Sends PEM content to Caddy via Admin API */ async addCertificate(domain: string, certPem: string, keyPem: string): Promise { if (!certPem || !keyPem) { logger.warn(`Cannot add certificate for ${domain}: missing PEM content`); return; } await this.caddy.addCertificate(domain, certPem, keyPem); } /** * Remove TLS certificate for a domain */ removeCertificate(domain: string): void { this.caddy.removeCertificate(domain).catch((error) => { logger.error(`Failed to remove certificate for ${domain}: ${getErrorMessage(error)}`); }); } /** * Reload TLS certificates from database */ async reloadCertificates(): Promise { try { logger.info('Reloading TLS certificates from database...'); const certificates = this.database.getAllSSLCertificates(); for (const cert of certificates) { // Use fullchainPem for the cert (includes intermediates) and keyPem for the key if (cert.domain && cert.fullchainPem && cert.keyPem) { await this.caddy.addCertificate(cert.domain, cert.fullchainPem, cert.keyPem); } else { logger.warn(`Skipping certificate for ${cert.domain}: missing PEM content`); } } logger.success(`Loaded ${this.caddy.getCertificates().length} TLS certificates`); } catch (error) { logger.error(`Failed to reload certificates: ${getErrorMessage(error)}`); throw error; } } /** * Get status of reverse proxy */ getStatus() { const caddyStatus = this.caddy.getStatus(); return { http: { running: caddyStatus.running, port: caddyStatus.httpPort, }, https: { running: caddyStatus.running, port: caddyStatus.httpsPort, certificates: caddyStatus.certificates, }, routes: caddyStatus.routes, backend: 'caddy-docker', }; } }