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

@@ -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<string, IProxyRoute> = 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<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> {
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<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) {
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<void>((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<void>((resolve, reject) => {
this.httpsServer!.close((err) => {
if (err) reject(err);
else resolve();
});
});
this.httpsServer = null;
await this.startHttps();
}

View File

@@ -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)}`);