2025-11-18 00:03:24 +00:00
|
|
|
/**
|
|
|
|
|
* Reverse Proxy for Onebox
|
|
|
|
|
*
|
2025-11-26 13:23:56 +00:00
|
|
|
* Delegates to Caddy (running as Docker service) for production-grade reverse proxy
|
|
|
|
|
* with native SNI support, HTTP/2, WebSocket proxying, and zero-downtime configuration updates.
|
|
|
|
|
*
|
|
|
|
|
* Routes use Docker service names (e.g., onebox-hello-world:80) for container-to-container
|
|
|
|
|
* communication within the Docker overlay network.
|
2025-11-18 00:03:24 +00:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { logger } from '../logging.ts';
|
2025-11-25 08:25:54 +00:00
|
|
|
import { getErrorMessage } from '../utils/error.ts';
|
2025-11-18 00:03:24 +00:00
|
|
|
import { OneboxDatabase } from './database.ts';
|
2025-11-26 12:16:50 +00:00
|
|
|
import { CaddyManager } from './caddy.ts';
|
2025-11-18 00:03:24 +00:00
|
|
|
|
|
|
|
|
interface IProxyRoute {
|
|
|
|
|
domain: string;
|
|
|
|
|
targetHost: string;
|
|
|
|
|
targetPort: number;
|
|
|
|
|
serviceId: number;
|
2025-11-26 13:23:56 +00:00
|
|
|
serviceName?: string;
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class OneboxReverseProxy {
|
|
|
|
|
private oneboxRef: any;
|
|
|
|
|
private database: OneboxDatabase;
|
2025-11-26 12:16:50 +00:00
|
|
|
private caddy: CaddyManager;
|
2025-11-18 00:03:24 +00:00
|
|
|
private routes: Map<string, IProxyRoute> = new Map();
|
2025-11-26 12:16:50 +00:00
|
|
|
private httpPort = 8080; // Default to dev ports (will be overridden if production)
|
|
|
|
|
private httpsPort = 8443;
|
2025-11-18 00:03:24 +00:00
|
|
|
|
|
|
|
|
constructor(oneboxRef: any) {
|
|
|
|
|
this.oneboxRef = oneboxRef;
|
|
|
|
|
this.database = oneboxRef.database;
|
2025-11-26 12:16:50 +00:00
|
|
|
this.caddy = new CaddyManager({
|
|
|
|
|
httpPort: this.httpPort,
|
|
|
|
|
httpsPort: this.httpsPort,
|
|
|
|
|
});
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 13:23:56 +00:00
|
|
|
* Initialize reverse proxy - Caddy runs as Docker service, no setup needed
|
2025-11-18 00:03:24 +00:00
|
|
|
*/
|
|
|
|
|
async init(): Promise<void> {
|
2025-11-26 13:23:56 +00:00
|
|
|
logger.info('Reverse proxy initialized (Caddy Docker service)');
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 12:16:50 +00:00
|
|
|
* Start the HTTP/HTTPS reverse proxy server
|
|
|
|
|
* Caddy handles both HTTP and HTTPS on the configured ports
|
2025-11-18 00:03:24 +00:00
|
|
|
*/
|
|
|
|
|
async startHttp(port?: number): Promise<void> {
|
|
|
|
|
if (port) {
|
|
|
|
|
this.httpPort = port;
|
2025-11-26 12:16:50 +00:00
|
|
|
this.caddy.setPorts(this.httpPort, this.httpsPort);
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-11-26 12:16:50 +00:00
|
|
|
// Start Caddy (handles both HTTP and HTTPS)
|
|
|
|
|
await this.caddy.start();
|
2025-11-26 13:23:56 +00:00
|
|
|
logger.success(`Reverse proxy started on port ${this.httpPort} (Caddy Docker service)`);
|
2025-11-18 00:03:24 +00:00
|
|
|
} catch (error) {
|
2025-11-26 12:16:50 +00:00
|
|
|
logger.error(`Failed to start reverse proxy: ${getErrorMessage(error)}`);
|
2025-11-18 00:03:24 +00:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 12:16:50 +00:00
|
|
|
* Start HTTPS - Caddy already handles HTTPS when started
|
|
|
|
|
* This method exists for interface compatibility
|
2025-11-18 00:03:24 +00:00
|
|
|
*/
|
|
|
|
|
async startHttps(port?: number): Promise<void> {
|
|
|
|
|
if (port) {
|
|
|
|
|
this.httpsPort = port;
|
2025-11-26 12:16:50 +00:00
|
|
|
this.caddy.setPorts(this.httpPort, this.httpsPort);
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
2025-11-26 12:16:50 +00:00
|
|
|
// Caddy handles both HTTP and HTTPS together
|
|
|
|
|
// If already running, just log and optionally reload with new port
|
|
|
|
|
const status = this.caddy.getStatus();
|
|
|
|
|
if (status.running) {
|
|
|
|
|
logger.info(`HTTPS already running on port ${this.httpsPort} via Caddy`);
|
|
|
|
|
} else {
|
|
|
|
|
await this.caddy.start();
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 09:36:40 +00:00
|
|
|
/**
|
2025-11-26 12:16:50 +00:00
|
|
|
* Stop the reverse proxy
|
2025-11-18 00:03:24 +00:00
|
|
|
*/
|
|
|
|
|
async stop(): Promise<void> {
|
2025-11-26 12:16:50 +00:00
|
|
|
await this.caddy.stop();
|
|
|
|
|
logger.info('Reverse proxy stopped');
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Add a route for a service
|
2025-11-26 13:23:56 +00:00
|
|
|
* Uses Docker service name for upstream (Caddy runs in same Docker network)
|
2025-11-18 00:03:24 +00:00
|
|
|
*/
|
|
|
|
|
async addRoute(serviceId: number, domain: string, targetPort: number): Promise<void> {
|
|
|
|
|
try {
|
2025-11-26 13:23:56 +00:00
|
|
|
// Get service info from database
|
2025-11-18 00:03:24 +00:00
|
|
|
const service = this.database.getServiceByID(serviceId);
|
2025-11-26 13:23:56 +00:00
|
|
|
if (!service) {
|
|
|
|
|
throw new Error(`Service not found: ${serviceId}`);
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
// Use Docker service name as upstream target
|
|
|
|
|
// Caddy runs on the same Docker network, so it can resolve service names directly
|
|
|
|
|
const serviceName = `onebox-${service.name}`;
|
|
|
|
|
const targetHost = serviceName;
|
2025-11-18 00:03:24 +00:00
|
|
|
|
|
|
|
|
const route: IProxyRoute = {
|
|
|
|
|
domain,
|
|
|
|
|
targetHost,
|
|
|
|
|
targetPort,
|
|
|
|
|
serviceId,
|
2025-11-26 13:23:56 +00:00
|
|
|
serviceName,
|
2025-11-18 00:03:24 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.routes.set(domain, route);
|
2025-11-26 12:16:50 +00:00
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
// Add route to Caddy using Docker service name
|
2025-11-26 12:16:50 +00:00
|
|
|
const upstream = `${targetHost}:${targetPort}`;
|
|
|
|
|
await this.caddy.addRoute(domain, upstream);
|
|
|
|
|
|
|
|
|
|
logger.success(`Added proxy route: ${domain} -> ${upstream}`);
|
2025-11-18 00:03:24 +00:00
|
|
|
} catch (error) {
|
2025-11-25 08:25:54 +00:00
|
|
|
logger.error(`Failed to add route for ${domain}: ${getErrorMessage(error)}`);
|
2025-11-18 00:03:24 +00:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove a route
|
|
|
|
|
*/
|
|
|
|
|
removeRoute(domain: string): void {
|
|
|
|
|
if (this.routes.delete(domain)) {
|
2025-11-26 12:16:50 +00:00
|
|
|
// Remove from Caddy (async but we don't wait)
|
|
|
|
|
this.caddy.removeRoute(domain).catch((error) => {
|
|
|
|
|
logger.error(`Failed to remove Caddy route for ${domain}: ${getErrorMessage(error)}`);
|
|
|
|
|
});
|
2025-11-18 00:03:24 +00:00
|
|
|
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<void> {
|
|
|
|
|
try {
|
|
|
|
|
logger.info('Reloading proxy routes...');
|
|
|
|
|
|
2025-11-26 12:16:50 +00:00
|
|
|
// Clear local and Caddy routes
|
2025-11-18 00:03:24 +00:00
|
|
|
this.routes.clear();
|
2025-11-26 12:16:50 +00:00
|
|
|
this.caddy.clear();
|
2025-11-18 00:03:24 +00:00
|
|
|
|
|
|
|
|
const services = this.database.getAllServices();
|
|
|
|
|
|
|
|
|
|
for (const service of services) {
|
2025-11-26 13:23:56 +00:00
|
|
|
// Route by domain if running (containerID is the service ID for Swarm services)
|
2025-11-18 00:03:24 +00:00
|
|
|
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) {
|
2025-11-25 08:25:54 +00:00
|
|
|
logger.error(`Failed to reload routes: ${getErrorMessage(error)}`);
|
2025-11-18 00:03:24 +00:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 12:16:50 +00:00
|
|
|
* Add TLS certificate for a domain
|
2025-11-26 13:23:56 +00:00
|
|
|
* Sends PEM content to Caddy via Admin API
|
2025-11-18 00:03:24 +00:00
|
|
|
*/
|
2025-11-26 12:16:50 +00:00
|
|
|
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
|
2025-11-25 23:27:27 +00:00
|
|
|
if (!certPem || !keyPem) {
|
|
|
|
|
logger.warn(`Cannot add certificate for ${domain}: missing PEM content`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-18 00:03:24 +00:00
|
|
|
|
2025-11-26 12:16:50 +00:00
|
|
|
await this.caddy.addCertificate(domain, certPem, keyPem);
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove TLS certificate for a domain
|
|
|
|
|
*/
|
|
|
|
|
removeCertificate(domain: string): void {
|
2025-11-26 12:16:50 +00:00
|
|
|
this.caddy.removeCertificate(domain).catch((error) => {
|
|
|
|
|
logger.error(`Failed to remove certificate for ${domain}: ${getErrorMessage(error)}`);
|
|
|
|
|
});
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-25 23:27:27 +00:00
|
|
|
* Reload TLS certificates from database
|
2025-11-18 00:03:24 +00:00
|
|
|
*/
|
|
|
|
|
async reloadCertificates(): Promise<void> {
|
|
|
|
|
try {
|
2025-11-25 23:27:27 +00:00
|
|
|
logger.info('Reloading TLS certificates from database...');
|
2025-11-18 00:03:24 +00:00
|
|
|
|
|
|
|
|
const certificates = this.database.getAllSSLCertificates();
|
|
|
|
|
|
|
|
|
|
for (const cert of certificates) {
|
2025-11-25 23:27:27 +00:00
|
|
|
// Use fullchainPem for the cert (includes intermediates) and keyPem for the key
|
|
|
|
|
if (cert.domain && cert.fullchainPem && cert.keyPem) {
|
2025-11-26 12:16:50 +00:00
|
|
|
await this.caddy.addCertificate(cert.domain, cert.fullchainPem, cert.keyPem);
|
2025-11-25 23:27:27 +00:00
|
|
|
} else {
|
|
|
|
|
logger.warn(`Skipping certificate for ${cert.domain}: missing PEM content`);
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 12:16:50 +00:00
|
|
|
logger.success(`Loaded ${this.caddy.getCertificates().length} TLS certificates`);
|
2025-11-18 00:03:24 +00:00
|
|
|
} catch (error) {
|
2025-11-25 08:25:54 +00:00
|
|
|
logger.error(`Failed to reload certificates: ${getErrorMessage(error)}`);
|
2025-11-18 00:03:24 +00:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get status of reverse proxy
|
|
|
|
|
*/
|
|
|
|
|
getStatus() {
|
2025-11-26 12:16:50 +00:00
|
|
|
const caddyStatus = this.caddy.getStatus();
|
2025-11-18 00:03:24 +00:00
|
|
|
return {
|
|
|
|
|
http: {
|
2025-11-26 12:16:50 +00:00
|
|
|
running: caddyStatus.running,
|
|
|
|
|
port: caddyStatus.httpPort,
|
2025-11-18 00:03:24 +00:00
|
|
|
},
|
|
|
|
|
https: {
|
2025-11-26 12:16:50 +00:00
|
|
|
running: caddyStatus.running,
|
|
|
|
|
port: caddyStatus.httpsPort,
|
|
|
|
|
certificates: caddyStatus.certificates,
|
2025-11-18 00:03:24 +00:00
|
|
|
},
|
2025-11-26 12:16:50 +00:00
|
|
|
routes: caddyStatus.routes,
|
2025-11-26 13:23:56 +00:00
|
|
|
backend: 'caddy-docker',
|
2025-11-18 00:03:24 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|