295 lines
8.5 KiB
TypeScript
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',
|
|
};
|
|
}
|
|
}
|