update
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user