/** * Reverse Proxy for Onebox * * HTTP/HTTPS reverse proxy with SNI support for multi-domain TLS * Uses Node.js https module for SNI via Deno's Node compatibility layer */ import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; import { OneboxDatabase } from './database.ts'; import * as nodeHttps from 'node:https'; import * as nodeHttp from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { Buffer } from 'node:buffer'; 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: nodeHttps.Server | null = null; // Node.js HTTPS server for SNI support 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 with SNI support * Uses Node.js https.createServer() + addContext() for per-domain certificates */ 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} with SNI support...`); // Get the first certificate as default (required for server creation) const defaultConfig = Array.from(this.tlsConfigs.values())[0]; // Create HTTPS server with Node.js for SNI support this.httpsServer = nodeHttps.createServer( { cert: defaultConfig.certPem, key: defaultConfig.keyPem, }, (req, res) => this.handleNodeRequest(req, res, true) ); // Add SNI contexts for each domain for (const [domain, config] of this.tlsConfigs) { this.httpsServer.addContext(domain, { cert: config.certPem, key: config.keyPem, }); // Also add wildcard pattern for subdomains this.httpsServer.addContext(`*.${domain}`, { cert: config.certPem, key: config.keyPem, }); logger.info(`Added SNI context for ${domain} and *.${domain}`); } // Start listening await new Promise((resolve, reject) => { this.httpsServer!.listen(this.httpsPort, '0.0.0.0', () => { logger.success(`HTTPS reverse proxy listening on https://0.0.0.0:${this.httpsPort}`); resolve(); }); this.httpsServer!.on('error', reject); }); logger.success(`HTTPS reverse proxy started on port ${this.httpsPort} with ${this.tlsConfigs.size} certificates`); } catch (error) { logger.error(`Failed to start HTTPS reverse proxy: ${getErrorMessage(error)}`); // Don't throw - HTTPS is optional logger.warn('Continuing without HTTPS support'); } } /** * Handle Node.js HTTP request and convert to fetch-style handling */ private handleNodeRequest( req: IncomingMessage, res: ServerResponse, isHttps: boolean ): void { // Collect request body const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => { chunks.push(chunk); }); req.on('end', async () => { try { const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined; // Build URL from Node.js request const protocol = isHttps ? 'https' : 'http'; const host = req.headers.host || 'localhost'; const url = new URL(req.url || '/', `${protocol}://${host}`); // Convert Node.js headers to Headers const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (value) { if (Array.isArray(value)) { value.forEach(v => headers.append(key, v)); } else { headers.set(key, value); } } } // Create fetch-style Request const fetchRequest = new Request(url.toString(), { method: req.method || 'GET', headers, body: body && req.method !== 'GET' && req.method !== 'HEAD' ? body : undefined, }); // Use existing handleRequest logic const response = await this.handleRequest(fetchRequest, isHttps); // Send response back via Node.js ServerResponse res.statusCode = response.status; res.statusMessage = response.statusText; // Copy response headers response.headers.forEach((value, key) => { res.setHeader(key, value); }); // Send response body if (response.body) { const reader = response.body.getReader(); const pump = async () => { const { done, value } = await reader.read(); if (done) { res.end(); return; } res.write(value); await pump(); }; await pump(); } else { res.end(); } } catch (error) { logger.error(`Node request handler error: ${getErrorMessage(error)}`); res.statusCode = 502; res.end('Bad Gateway'); } }); req.on('error', (error) => { logger.error(`Node request error: ${getErrorMessage(error)}`); res.statusCode = 502; res.end('Bad Gateway'); }); } /** * 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) { // Node.js server uses close() which accepts a callback promises.push(new Promise((resolve, reject) => { this.httpsServer!.close((err) => { if (err) reject(err); else resolve(); }); })); this.httpsServer = null; logger.info('HTTPS reverse proxy stopped'); } await Promise.all(promises); } /** * Check if we have a certificate for a domain (exact or wildcard match) */ private hasCertificateForDomain(host: string): boolean { if (this.tlsConfigs.has(host)) return true; // Check wildcard: *.example.com covers sub.example.com const parts = host.split('.'); if (parts.length >= 2) { const rootDomain = parts.slice(-2).join('.'); if (this.tlsConfigs.has(rootDomain)) return true; } return false; } /** * 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}`); // HTTP to HTTPS redirect when certificate exists if (!isHttps && this.httpsServer !== null && this.hasCertificateForDomain(host)) { const httpsUrl = `https://${host}${url.pathname}${url.search}`; logger.debug(`Redirecting HTTP to HTTPS: ${httpsUrl}`); return Response.redirect(httpsUrl, 301); } // 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}`); } // Get container IP from Docker network, fallback to Docker DNS name let targetHost = 'localhost'; try { const containerIP = await this.oneboxRef.docker.getContainerIP(service.containerID); if (containerIP) { targetHost = containerIP; } else { // Use Docker internal DNS name as fallback targetHost = `onebox-${service.name}`; logger.info(`Using Docker DNS name for ${service.name}: ${targetHost}`); } } catch (error) { logger.warn(`Could not resolve container IP for ${service.name}: ${getErrorMessage(error)}`); // Fall back to Docker internal DNS name targetHost = `onebox-${service.name}`; } const route: IProxyRoute = { domain, targetHost, targetPort, serviceId, containerID: service.containerID, }; 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) * Dynamically adds SNI context if HTTPS server is already running */ 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}`); // Dynamically add SNI context if HTTPS server is already running if (this.httpsServer) { this.httpsServer.addContext(domain, { cert: certPem, key: keyPem, }); this.httpsServer.addContext(`*.${domain}`, { cert: certPem, key: keyPem, }); logger.success(`Added SNI context for ${domain} and *.${domain}`); } } /** * 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 (needed for full reload) if (this.httpsServer) { logger.info('Restarting HTTPS server with new certificates...'); await new Promise((resolve, reject) => { this.httpsServer!.close((err) => { if (err) reject(err); else resolve(); }); }); 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, }; } }