/** * Reverse Proxy for Onebox * * Native Deno HTTP/HTTPS reverse proxy with WebSocket support */ import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; import { OneboxDatabase } from './database.ts'; interface IProxyRoute { domain: string; targetHost: string; targetPort: number; serviceId: number; containerID?: string; } interface ITlsConfig { domain: string; certPem: string; // Certificate PEM content keyPem: string; // Private key PEM content } export class OneboxReverseProxy { private oneboxRef: any; private database: OneboxDatabase; private routes: Map = new Map(); private httpServer: Deno.HttpServer | null = null; private httpsServer: Deno.HttpServer | null = null; private httpPort = 80; private httpsPort = 443; private tlsConfigs: Map = new Map(); constructor(oneboxRef: any) { this.oneboxRef = oneboxRef; this.database = oneboxRef.database; } /** * Initialize reverse proxy */ async init(): Promise { try { logger.info('Reverse proxy initialized'); } catch (error) { logger.error(`Failed to initialize reverse proxy: ${getErrorMessage(error)}`); throw error; } } /** * Start the HTTP reverse proxy server */ async startHttp(port?: number): Promise { if (this.httpServer) { logger.warn('HTTP reverse proxy already running'); return; } if (port) { this.httpPort = port; } try { logger.info(`Starting HTTP reverse proxy on port ${this.httpPort}...`); this.httpServer = Deno.serve( { port: this.httpPort, hostname: '0.0.0.0', onListen: ({ hostname, port }) => { logger.success(`HTTP reverse proxy listening on http://${hostname}:${port}`); }, }, (req) => this.handleRequest(req, false) ); logger.success(`HTTP reverse proxy started on port ${this.httpPort}`); } catch (error) { logger.error(`Failed to start HTTP reverse proxy: ${getErrorMessage(error)}`); throw error; } } /** * Start the HTTPS reverse proxy server */ async startHttps(port?: number): Promise { if (this.httpsServer) { logger.warn('HTTPS reverse proxy already running'); return; } if (port) { this.httpsPort = port; } try { // Check if we have any TLS configs if (this.tlsConfigs.size === 0) { logger.info('No TLS certificates configured, skipping HTTPS server'); return; } logger.info(`Starting HTTPS reverse proxy on port ${this.httpsPort}...`); // Get the first certificate as default const defaultConfig = Array.from(this.tlsConfigs.values())[0]; this.httpsServer = Deno.serve( { port: this.httpsPort, hostname: '0.0.0.0', cert: defaultConfig.certPem, key: defaultConfig.keyPem, onListen: ({ hostname, port }) => { logger.success(`HTTPS reverse proxy listening on https://${hostname}:${port}`); }, }, (req) => this.handleRequest(req, true) ); logger.success(`HTTPS reverse proxy started on port ${this.httpsPort}`); } catch (error) { logger.error(`Failed to start HTTPS reverse proxy: ${getErrorMessage(error)}`); // Don't throw - HTTPS is optional logger.warn('Continuing without HTTPS support'); } } /** * Stop all reverse proxy servers */ async stop(): Promise { const promises: Promise[] = []; if (this.httpServer) { promises.push(this.httpServer.shutdown()); this.httpServer = null; logger.info('HTTP reverse proxy stopped'); } if (this.httpsServer) { promises.push(this.httpsServer.shutdown()); this.httpsServer = null; logger.info('HTTPS reverse proxy stopped'); } await Promise.all(promises); } /** * Handle incoming HTTP/HTTPS request */ private async handleRequest(req: Request, isHttps: boolean): Promise { const url = new URL(req.url); const host = req.headers.get('host')?.split(':')[0] || ''; logger.debug(`Proxy request: ${req.method} ${host}${url.pathname}`); // Find matching route const route = this.routes.get(host); if (!route) { logger.debug(`No route found for host: ${host}`); return new Response('Service not found', { status: 404, headers: { 'Content-Type': 'text/plain' }, }); } // Check if this is a WebSocket upgrade request const upgrade = req.headers.get('upgrade')?.toLowerCase(); if (upgrade === 'websocket') { return await this.handleWebSocketUpgrade(req, route, isHttps); } try { // Build target URL const targetUrl = `http://${route.targetHost}:${route.targetPort}${url.pathname}${url.search}`; logger.debug(`Proxying to: ${targetUrl}`); // Forward request to target const targetReq = new Request(targetUrl, { method: req.method, headers: this.forwardHeaders(req.headers, host, isHttps), body: req.body, }); const response = await fetch(targetReq); // Forward response back to client return new Response(response.body, { status: response.status, statusText: response.statusText, headers: this.filterResponseHeaders(response.headers), }); } catch (error) { logger.error(`Proxy error for ${host}: ${getErrorMessage(error)}`); return new Response('Bad Gateway', { status: 502, headers: { 'Content-Type': 'text/plain' }, }); } } /** * Handle WebSocket upgrade and proxy connection */ private async handleWebSocketUpgrade( req: Request, route: IProxyRoute, isHttps: boolean ): Promise { try { const url = new URL(req.url); const targetUrl = `ws://${route.targetHost}:${route.targetPort}${url.pathname}${url.search}`; logger.info(`WebSocket upgrade: ${url.host} -> ${targetUrl}`); // Upgrade the client connection const { socket: clientSocket, response } = Deno.upgradeWebSocket(req); // Connect to backend WebSocket const backendSocket = new WebSocket(targetUrl); // Proxy messages from client to backend clientSocket.onmessage = (e) => { if (backendSocket.readyState === WebSocket.OPEN) { backendSocket.send(e.data); } }; // Proxy messages from backend to client backendSocket.onmessage = (e) => { if (clientSocket.readyState === WebSocket.OPEN) { clientSocket.send(e.data); } }; // Handle client close clientSocket.onclose = () => { logger.debug(`Client WebSocket closed for ${url.host}`); backendSocket.close(); }; // Handle backend close backendSocket.onclose = () => { logger.debug(`Backend WebSocket closed for ${targetUrl}`); clientSocket.close(); }; // Handle errors clientSocket.onerror = (e) => { logger.error(`Client WebSocket error: ${e}`); backendSocket.close(); }; backendSocket.onerror = (e) => { logger.error(`Backend WebSocket error: ${e}`); clientSocket.close(); }; return response; } catch (error) { logger.error(`WebSocket upgrade error: ${getErrorMessage(error)}`); return new Response('WebSocket Upgrade Failed', { status: 500, headers: { 'Content-Type': 'text/plain' }, }); } } /** * Forward request headers to target, filtering out problematic ones */ private forwardHeaders(headers: Headers, originalHost: string, isHttps: boolean): Headers { const forwarded = new Headers(); // Copy most headers for (const [key, value] of headers.entries()) { // Skip headers that should not be forwarded if ( key.toLowerCase() === 'host' || key.toLowerCase() === 'connection' || key.toLowerCase() === 'keep-alive' || key.toLowerCase() === 'proxy-authenticate' || key.toLowerCase() === 'proxy-authorization' || key.toLowerCase() === 'te' || key.toLowerCase() === 'trailers' || key.toLowerCase() === 'transfer-encoding' || key.toLowerCase() === 'upgrade' ) { continue; } forwarded.set(key, value); } // Add X-Forwarded headers forwarded.set('X-Forwarded-For', headers.get('x-forwarded-for') || 'unknown'); forwarded.set('X-Forwarded-Host', originalHost); forwarded.set('X-Forwarded-Proto', isHttps ? 'https' : 'http'); return forwarded; } /** * Filter response headers */ private filterResponseHeaders(headers: Headers): Headers { const filtered = new Headers(); for (const [key, value] of headers.entries()) { // Skip problematic headers if ( key.toLowerCase() === 'connection' || key.toLowerCase() === 'keep-alive' || key.toLowerCase() === 'transfer-encoding' ) { continue; } filtered.set(key, value); } return filtered; } /** * 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}`); } // For Docker, we can use the container name or get its IP // For now, use localhost since containers expose ports const targetHost = 'localhost'; // TODO: Get actual container IP from Docker network const route: IProxyRoute = { domain, targetHost, targetPort, serviceId, }; this.routes.set(domain, route); logger.success(`Added proxy route: ${domain} -> ${targetHost}:${targetPort}`); } 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)) { 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...'); this.routes.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 (using PEM content) */ addCertificate(domain: string, certPem: string, keyPem: string): void { if (!certPem || !keyPem) { logger.warn(`Cannot add certificate for ${domain}: missing PEM content`); return; } this.tlsConfigs.set(domain, { domain, certPem, keyPem, }); logger.success(`Added TLS certificate for ${domain}`); // If HTTPS server is already running, we need to restart it // TODO: Implement hot reload for certificates if (this.httpsServer) { logger.warn('HTTPS server restart required for new certificate to take effect'); } } /** * Remove TLS certificate for a domain */ removeCertificate(domain: string): void { if (this.tlsConfigs.delete(domain)) { logger.success(`Removed TLS certificate for ${domain}`); } else { logger.warn(`Certificate not found for domain: ${domain}`); } } /** * Reload TLS certificates from database */ async reloadCertificates(): Promise { try { logger.info('Reloading TLS certificates from database...'); this.tlsConfigs.clear(); 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) { this.addCertificate(cert.domain, cert.fullchainPem, cert.keyPem); } else { logger.warn(`Skipping certificate for ${cert.domain}: missing PEM content`); } } logger.success(`Loaded ${this.tlsConfigs.size} TLS certificates`); // Restart HTTPS server if it was running if (this.httpsServer) { logger.info('Restarting HTTPS server with new certificates...'); await this.httpsServer.shutdown(); this.httpsServer = null; await this.startHttps(); } } catch (error) { logger.error(`Failed to reload certificates: ${getErrorMessage(error)}`); throw error; } } /** * Get status of reverse proxy */ getStatus() { return { http: { running: this.httpServer !== null, port: this.httpPort, }, https: { running: this.httpsServer !== null, port: this.httpsPort, certificates: this.tlsConfigs.size, }, routes: this.routes.size, }; } }