/** * Reverse Proxy for Onebox * * Delegates to Caddy for production-grade reverse proxy with native SNI support, * HTTP/2, WebSocket proxying, and zero-downtime configuration updates. */ 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; containerID?: 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 - ensures Caddy binary is available */ async init(): Promise { try { await this.caddy.ensureBinary(); logger.info('Reverse proxy initialized (Caddy)'); } catch (error) { logger.error(`Failed to initialize reverse proxy: ${getErrorMessage(error)}`); throw error; } } /** * 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)`); } 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 */ async addRoute(serviceId: number, domain: string, targetPort: number): Promise { try { // Get container IP from Docker const service = this.database.getServiceByID(serviceId); if (!service || !service.containerID) { throw new Error(`Service not found or has no container: ${serviceId}`); } // Get container IP from Docker network let targetHost = 'localhost'; try { const containerIP = await this.oneboxRef.docker.getContainerIP(service.containerID); if (containerIP) { targetHost = containerIP; } else { // Caddy runs on host, so we need the actual IP // Try getting task IP from Swarm const taskIP = await this.getSwarmTaskIP(service.containerID); if (taskIP) { targetHost = taskIP; } else { logger.warn(`Could not resolve IP for ${service.name}, using localhost`); } } } catch (error) { logger.warn(`Could not resolve container IP for ${service.name}: ${getErrorMessage(error)}`); } const route: IProxyRoute = { domain, targetHost, targetPort, serviceId, containerID: service.containerID, }; this.routes.set(domain, route); // Add route to Caddy 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; } } /** * Get IP address for a Swarm task */ private async getSwarmTaskIP(containerIdOrTaskId: string): Promise { try { // Try to get task details from Swarm const docker = this.oneboxRef.docker; // First, try to find the task by inspecting the container const containerInfo = await docker.inspectContainer(containerIdOrTaskId); if (containerInfo?.NetworkSettings?.Networks) { // Get IP from the overlay network for (const [networkName, networkInfo] of Object.entries(containerInfo.NetworkSettings.Networks)) { if (networkName.includes('onebox') && (networkInfo as any).IPAddress) { return (networkInfo as any).IPAddress; } } // Fall back to any network for (const networkInfo of Object.values(containerInfo.NetworkSettings.Networks)) { if ((networkInfo as any).IPAddress) { return (networkInfo as any).IPAddress; } } } return null; } catch { return null; } } /** * 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) { 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 * Writes PEM files to disk for Caddy to load */ 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', }; } }