diff --git a/readme.hints.md b/readme.hints.md index 546c637..1c0c434 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -43,3 +43,23 @@ ts/database/ ## Current Migration Version: 8 Migration 8 converted certificate storage from file paths to PEM content. + +## Reverse Proxy SNI Support (November 2025) + +The HTTPS reverse proxy now uses Node.js `https.createServer()` with SNI support: +- Uses Deno's Node.js compatibility layer for `node:https` module +- Implements `server.addContext(hostname, {cert, key})` for per-domain certificates +- Dynamic certificate addition via `addCertificate()` without server restart +- HTTP-to-HTTPS redirect when certificate exists for domain +- Wildcard pattern support (e.g., `*.bleu.de` covers `sub.bleu.de`) + +**Key files:** +- `ts/classes/reverseproxy.ts` - SNI-enabled HTTPS server +- `ts/classes/services.ts` - Dynamic route updates on service start/stop + +**Certificate workflow:** +1. `CertRequirementManager` creates requirements for domains +2. Daemon processes requirements via `certmanager.ts` +3. Certificates stored in database (PEM content) +4. `reverseProxy.addCertificate()` dynamically adds SNI context +5. HTTP requests redirect to HTTPS when cert exists diff --git a/ts/classes/reverseproxy.ts b/ts/classes/reverseproxy.ts index 335ed0a..c62df23 100644 --- a/ts/classes/reverseproxy.ts +++ b/ts/classes/reverseproxy.ts @@ -1,12 +1,17 @@ /** * Reverse Proxy for Onebox * - * Native Deno HTTP/HTTPS reverse proxy with WebSocket support + * 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; @@ -27,7 +32,7 @@ export class OneboxReverseProxy { private database: OneboxDatabase; private routes: Map = new Map(); private httpServer: Deno.HttpServer | null = null; - private httpsServer: 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(); @@ -84,7 +89,8 @@ export class OneboxReverseProxy { } /** - * Start the HTTPS reverse proxy server + * 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) { @@ -103,25 +109,44 @@ export class OneboxReverseProxy { return; } - logger.info(`Starting HTTPS reverse proxy on port ${this.httpsPort}...`); + logger.info(`Starting HTTPS reverse proxy on port ${this.httpsPort} with SNI support...`); - // Get the first certificate as default + // Get the first certificate as default (required for server creation) const defaultConfig = Array.from(this.tlsConfigs.values())[0]; - this.httpsServer = Deno.serve( + // Create HTTPS server with Node.js for SNI support + this.httpsServer = nodeHttps.createServer( { - 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) + (req, res) => this.handleNodeRequest(req, res, true) ); - logger.success(`HTTPS reverse proxy started on port ${this.httpsPort}`); + // 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 @@ -129,6 +154,91 @@ export class OneboxReverseProxy { } } + /** + * 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 */ @@ -142,7 +252,13 @@ export class OneboxReverseProxy { } if (this.httpsServer) { - promises.push(this.httpsServer.shutdown()); + // 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'); } @@ -150,6 +266,20 @@ export class OneboxReverseProxy { 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 */ @@ -159,6 +289,13 @@ export class OneboxReverseProxy { 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); @@ -340,15 +477,29 @@ export class OneboxReverseProxy { 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 + // 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); @@ -403,6 +554,7 @@ export class OneboxReverseProxy { /** * 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) { @@ -418,10 +570,17 @@ export class OneboxReverseProxy { logger.success(`Added TLS certificate for ${domain}`); - // If HTTPS server is already running, we need to restart it - // TODO: Implement hot reload for certificates + // Dynamically add SNI context if HTTPS server is already running if (this.httpsServer) { - logger.warn('HTTPS server restart required for new certificate to take effect'); + 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}`); } } @@ -458,10 +617,15 @@ export class OneboxReverseProxy { logger.success(`Loaded ${this.tlsConfigs.size} TLS certificates`); - // Restart HTTPS server if it was running + // Restart HTTPS server if it was running (needed for full reload) if (this.httpsServer) { logger.info('Restarting HTTPS server with new certificates...'); - await this.httpsServer.shutdown(); + await new Promise((resolve, reject) => { + this.httpsServer!.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); this.httpsServer = null; await this.startHttps(); } diff --git a/ts/classes/services.ts b/ts/classes/services.ts index 35db95f..378e109 100644 --- a/ts/classes/services.ts +++ b/ts/classes/services.ts @@ -184,17 +184,8 @@ export class OneboxServicesManager { logger.warn(`Failed to configure reverse proxy for ${options.domain}: ${getErrorMessage(error)}`); } - // Configure SSL (if autoSSL is enabled) - // Note: With CertRequirement system, certificates are managed automatically - // but we still support the old direct obtainCertificate for backward compatibility - if (options.autoSSL !== false) { - try { - await this.oneboxRef.ssl.obtainCertificate(options.domain); - await this.oneboxRef.reverseProxy.reloadCertificates(); - } catch (error) { - logger.warn(`Failed to obtain SSL certificate for ${options.domain}: ${getErrorMessage(error)}`); - } - } + // Note: SSL certificates are now handled automatically by CertRequirementManager + // which processes pending requirements created above. No direct obtainCertificate call needed. } logger.success(`Service deployed successfully: ${options.name}`); @@ -228,6 +219,15 @@ export class OneboxServicesManager { this.database.updateService(service.id!, { status: 'running' }); + // Add reverse proxy route if service has a domain + if (service.domain) { + try { + await this.oneboxRef.reverseProxy.addRoute(service.id!, service.domain, service.port); + } catch (routeError) { + logger.warn(`Failed to add proxy route for ${service.domain}: ${getErrorMessage(routeError)}`); + } + } + logger.success(`Service started: ${name}`); } catch (error) { logger.error(`Failed to start service ${name}: ${getErrorMessage(error)}`); @@ -261,6 +261,11 @@ export class OneboxServicesManager { this.database.updateService(service.id!, { status: 'stopped' }); + // Remove reverse proxy route if service has a domain + if (service.domain) { + this.oneboxRef.reverseProxy.removeRoute(service.domain); + } + logger.success(`Service stopped: ${name}`); } catch (error) { logger.error(`Failed to stop service ${name}: ${getErrorMessage(error)}`); diff --git a/ts/plugins.ts b/ts/plugins.ts index 36a4273..f2e5eeb 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -52,3 +52,8 @@ export { jwt}; // Crypto key management import { crypto } from 'https://deno.land/std@0.208.0/crypto/mod.ts'; export { crypto }; + +// Node.js compatibility layer for HTTPS with SNI support +import * as nodeHttps from 'node:https'; +import * as nodeHttp from 'node:http'; +export { nodeHttps, nodeHttp };