Files
onebox/ts/classes/reverseproxy.ts
2025-11-26 12:16:50 +00:00

295 lines
8.5 KiB
TypeScript

/**
* Reverse Proxy for Onebox
*
* Delegates to Caddy for production-grade reverse proxy with native SNI support,
* HTTP/2, WebSocket proxying, and zero-downtime configuration updates.
*/
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.ts';
import { CaddyManager } from './caddy.ts';
interface IProxyRoute {
domain: string;
targetHost: string;
targetPort: number;
serviceId: number;
containerID?: string;
}
export class OneboxReverseProxy {
private oneboxRef: any;
private database: OneboxDatabase;
private caddy: CaddyManager;
private routes: Map<string, IProxyRoute> = new Map();
private httpPort = 8080; // Default to dev ports (will be overridden if production)
private httpsPort = 8443;
constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef;
this.database = oneboxRef.database;
this.caddy = new CaddyManager({
httpPort: this.httpPort,
httpsPort: this.httpsPort,
});
}
/**
* Initialize reverse proxy - ensures Caddy binary is available
*/
async init(): Promise<void> {
try {
await this.caddy.ensureBinary();
logger.info('Reverse proxy initialized (Caddy)');
} catch (error) {
logger.error(`Failed to initialize reverse proxy: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Start the HTTP/HTTPS reverse proxy server
* Caddy handles both HTTP and HTTPS on the configured ports
*/
async startHttp(port?: number): Promise<void> {
if (port) {
this.httpPort = port;
this.caddy.setPorts(this.httpPort, this.httpsPort);
}
try {
// Start Caddy (handles both HTTP and HTTPS)
await this.caddy.start();
logger.success(`Reverse proxy started on port ${this.httpPort} (Caddy)`);
} catch (error) {
logger.error(`Failed to start reverse proxy: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Start HTTPS - Caddy already handles HTTPS when started
* This method exists for interface compatibility
*/
async startHttps(port?: number): Promise<void> {
if (port) {
this.httpsPort = port;
this.caddy.setPorts(this.httpPort, this.httpsPort);
}
// 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();
}
}
/**
* Stop the reverse proxy
*/
async stop(): Promise<void> {
await this.caddy.stop();
logger.info('Reverse proxy stopped');
}
/**
* Add a route for a service
*/
async addRoute(serviceId: number, domain: string, targetPort: number): Promise<void> {
try {
// Get container IP from Docker
const service = this.database.getServiceByID(serviceId);
if (!service || !service.containerID) {
throw new Error(`Service not found or has no container: ${serviceId}`);
}
// Get container IP from Docker network
let targetHost = 'localhost';
try {
const containerIP = await this.oneboxRef.docker.getContainerIP(service.containerID);
if (containerIP) {
targetHost = containerIP;
} else {
// Caddy runs on host, so we need the actual IP
// Try getting task IP from Swarm
const taskIP = await this.getSwarmTaskIP(service.containerID);
if (taskIP) {
targetHost = taskIP;
} else {
logger.warn(`Could not resolve IP for ${service.name}, using localhost`);
}
}
} catch (error) {
logger.warn(`Could not resolve container IP for ${service.name}: ${getErrorMessage(error)}`);
}
const route: IProxyRoute = {
domain,
targetHost,
targetPort,
serviceId,
containerID: service.containerID,
};
this.routes.set(domain, route);
// Add route to Caddy
const upstream = `${targetHost}:${targetPort}`;
await this.caddy.addRoute(domain, upstream);
logger.success(`Added proxy route: ${domain} -> ${upstream}`);
} catch (error) {
logger.error(`Failed to add route for ${domain}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Get IP address for a Swarm task
*/
private async getSwarmTaskIP(containerIdOrTaskId: string): Promise<string | null> {
try {
// Try to get task details from Swarm
const docker = this.oneboxRef.docker;
// First, try to find the task by inspecting the container
const containerInfo = await docker.inspectContainer(containerIdOrTaskId);
if (containerInfo?.NetworkSettings?.Networks) {
// Get IP from the overlay network
for (const [networkName, networkInfo] of Object.entries(containerInfo.NetworkSettings.Networks)) {
if (networkName.includes('onebox') && (networkInfo as any).IPAddress) {
return (networkInfo as any).IPAddress;
}
}
// Fall back to any network
for (const networkInfo of Object.values(containerInfo.NetworkSettings.Networks)) {
if ((networkInfo as any).IPAddress) {
return (networkInfo as any).IPAddress;
}
}
}
return null;
} catch {
return null;
}
}
/**
* Remove a route
*/
removeRoute(domain: string): void {
if (this.routes.delete(domain)) {
// 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)}`);
});
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...');
// Clear local and Caddy routes
this.routes.clear();
this.caddy.clear();
const services = this.database.getAllServices();
for (const service of services) {
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) {
logger.error(`Failed to reload routes: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Add TLS certificate for a domain
* Writes PEM files to disk for Caddy to load
*/
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
if (!certPem || !keyPem) {
logger.warn(`Cannot add certificate for ${domain}: missing PEM content`);
return;
}
await this.caddy.addCertificate(domain, certPem, keyPem);
}
/**
* Remove TLS certificate for a domain
*/
removeCertificate(domain: string): void {
this.caddy.removeCertificate(domain).catch((error) => {
logger.error(`Failed to remove certificate for ${domain}: ${getErrorMessage(error)}`);
});
}
/**
* Reload TLS certificates from database
*/
async reloadCertificates(): Promise<void> {
try {
logger.info('Reloading TLS certificates from database...');
const certificates = this.database.getAllSSLCertificates();
for (const cert of certificates) {
// Use fullchainPem for the cert (includes intermediates) and keyPem for the key
if (cert.domain && cert.fullchainPem && cert.keyPem) {
await this.caddy.addCertificate(cert.domain, cert.fullchainPem, cert.keyPem);
} else {
logger.warn(`Skipping certificate for ${cert.domain}: missing PEM content`);
}
}
logger.success(`Loaded ${this.caddy.getCertificates().length} TLS certificates`);
} catch (error) {
logger.error(`Failed to reload certificates: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Get status of reverse proxy
*/
getStatus() {
const caddyStatus = this.caddy.getStatus();
return {
http: {
running: caddyStatus.running,
port: caddyStatus.httpPort,
},
https: {
running: caddyStatus.running,
port: caddyStatus.httpsPort,
certificates: caddyStatus.certificates,
},
routes: caddyStatus.routes,
backend: 'caddy',
};
}
}