This commit is contained in:
2025-11-26 09:36:40 +00:00
parent ad89f2cc1f
commit e6f7d70d51
4 changed files with 227 additions and 33 deletions

View File

@@ -43,3 +43,23 @@ ts/database/
## Current Migration Version: 8 ## Current Migration Version: 8
Migration 8 converted certificate storage from file paths to PEM content. 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

View File

@@ -1,12 +1,17 @@
/** /**
* Reverse Proxy for Onebox * 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 { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts'; import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.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 { interface IProxyRoute {
domain: string; domain: string;
@@ -27,7 +32,7 @@ export class OneboxReverseProxy {
private database: OneboxDatabase; private database: OneboxDatabase;
private routes: Map<string, IProxyRoute> = new Map(); private routes: Map<string, IProxyRoute> = new Map();
private httpServer: Deno.HttpServer | null = null; 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 httpPort = 80;
private httpsPort = 443; private httpsPort = 443;
private tlsConfigs: Map<string, ITlsConfig> = new Map(); private tlsConfigs: Map<string, ITlsConfig> = 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<void> { async startHttps(port?: number): Promise<void> {
if (this.httpsServer) { if (this.httpsServer) {
@@ -103,25 +109,44 @@ export class OneboxReverseProxy {
return; 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]; 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, cert: defaultConfig.certPem,
key: defaultConfig.keyPem, 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<void>((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) { } catch (error) {
logger.error(`Failed to start HTTPS reverse proxy: ${getErrorMessage(error)}`); logger.error(`Failed to start HTTPS reverse proxy: ${getErrorMessage(error)}`);
// Don't throw - HTTPS is optional // 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 * Stop all reverse proxy servers
*/ */
@@ -142,7 +252,13 @@ export class OneboxReverseProxy {
} }
if (this.httpsServer) { if (this.httpsServer) {
promises.push(this.httpsServer.shutdown()); // Node.js server uses close() which accepts a callback
promises.push(new Promise<void>((resolve, reject) => {
this.httpsServer!.close((err) => {
if (err) reject(err);
else resolve();
});
}));
this.httpsServer = null; this.httpsServer = null;
logger.info('HTTPS reverse proxy stopped'); logger.info('HTTPS reverse proxy stopped');
} }
@@ -150,6 +266,20 @@ export class OneboxReverseProxy {
await Promise.all(promises); 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 * Handle incoming HTTP/HTTPS request
*/ */
@@ -159,6 +289,13 @@ export class OneboxReverseProxy {
logger.debug(`Proxy request: ${req.method} ${host}${url.pathname}`); 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 // Find matching route
const route = this.routes.get(host); const route = this.routes.get(host);
@@ -340,15 +477,29 @@ export class OneboxReverseProxy {
throw new Error(`Service not found or has no container: ${serviceId}`); throw new Error(`Service not found or has no container: ${serviceId}`);
} }
// For Docker, we can use the container name or get its IP // Get container IP from Docker network, fallback to Docker DNS name
// For now, use localhost since containers expose ports let targetHost = 'localhost';
const targetHost = 'localhost'; // TODO: Get actual container IP from Docker network 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 = { const route: IProxyRoute = {
domain, domain,
targetHost, targetHost,
targetPort, targetPort,
serviceId, serviceId,
containerID: service.containerID,
}; };
this.routes.set(domain, route); this.routes.set(domain, route);
@@ -403,6 +554,7 @@ export class OneboxReverseProxy {
/** /**
* Add TLS certificate for a domain (using PEM content) * 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 { addCertificate(domain: string, certPem: string, keyPem: string): void {
if (!certPem || !keyPem) { if (!certPem || !keyPem) {
@@ -418,10 +570,17 @@ export class OneboxReverseProxy {
logger.success(`Added TLS certificate for ${domain}`); logger.success(`Added TLS certificate for ${domain}`);
// If HTTPS server is already running, we need to restart it // Dynamically add SNI context if HTTPS server is already running
// TODO: Implement hot reload for certificates
if (this.httpsServer) { 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`); 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) { if (this.httpsServer) {
logger.info('Restarting HTTPS server with new certificates...'); logger.info('Restarting HTTPS server with new certificates...');
await this.httpsServer.shutdown(); await new Promise<void>((resolve, reject) => {
this.httpsServer!.close((err) => {
if (err) reject(err);
else resolve();
});
});
this.httpsServer = null; this.httpsServer = null;
await this.startHttps(); await this.startHttps();
} }

View File

@@ -184,17 +184,8 @@ export class OneboxServicesManager {
logger.warn(`Failed to configure reverse proxy for ${options.domain}: ${getErrorMessage(error)}`); logger.warn(`Failed to configure reverse proxy for ${options.domain}: ${getErrorMessage(error)}`);
} }
// Configure SSL (if autoSSL is enabled) // Note: SSL certificates are now handled automatically by CertRequirementManager
// Note: With CertRequirement system, certificates are managed automatically // which processes pending requirements created above. No direct obtainCertificate call needed.
// 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)}`);
}
}
} }
logger.success(`Service deployed successfully: ${options.name}`); logger.success(`Service deployed successfully: ${options.name}`);
@@ -228,6 +219,15 @@ export class OneboxServicesManager {
this.database.updateService(service.id!, { status: 'running' }); 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}`); logger.success(`Service started: ${name}`);
} catch (error) { } catch (error) {
logger.error(`Failed to start service ${name}: ${getErrorMessage(error)}`); logger.error(`Failed to start service ${name}: ${getErrorMessage(error)}`);
@@ -261,6 +261,11 @@ export class OneboxServicesManager {
this.database.updateService(service.id!, { status: 'stopped' }); 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}`); logger.success(`Service stopped: ${name}`);
} catch (error) { } catch (error) {
logger.error(`Failed to stop service ${name}: ${getErrorMessage(error)}`); logger.error(`Failed to stop service ${name}: ${getErrorMessage(error)}`);

View File

@@ -52,3 +52,8 @@ export { jwt};
// Crypto key management // Crypto key management
import { crypto } from 'https://deno.land/std@0.208.0/crypto/mod.ts'; import { crypto } from 'https://deno.land/std@0.208.0/crypto/mod.ts';
export { crypto }; 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 };