From c03e0e055cfc1a31d22f4ba1bad6997f6a98a8c1 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 26 Nov 2025 13:23:56 +0000 Subject: [PATCH] feat: Refactor CaddyManager and OneboxReverseProxy to use Docker service for Caddy management --- ts/classes/caddy.ts | 392 +++++++++++++++++++------------------ ts/classes/reverseproxy.ts | 90 +++------ 2 files changed, 228 insertions(+), 254 deletions(-) diff --git a/ts/classes/caddy.ts b/ts/classes/caddy.ts index 122aa7b..fd2c88f 100644 --- a/ts/classes/caddy.ts +++ b/ts/classes/caddy.ts @@ -1,25 +1,27 @@ /** * Caddy Manager for Onebox * - * Manages Caddy binary download, process lifecycle, and Admin API configuration. - * Caddy is used as the reverse proxy with native SNI support. + * Manages Caddy as a Docker Swarm service instead of a host binary. + * This allows Caddy to access services on the Docker overlay network. */ +import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; -const CADDY_VERSION = '2.10.2'; -const CADDY_DOWNLOAD_URL = `https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz`; +const CADDY_SERVICE_NAME = 'onebox-caddy'; +const CADDY_IMAGE = 'caddy:2-alpine'; +const DOCKER_GATEWAY_IP = '172.17.0.1'; // Docker bridge gateway for container-to-host communication export interface ICaddyRoute { domain: string; - upstream: string; // e.g., "10.0.1.40:80" + upstream: string; // e.g., "onebox-hello-world:80" } export interface ICaddyCertificate { domain: string; - certPath: string; - keyPath: string; + certPem: string; + keyPem: string; } interface ICaddyLoggingConfig { @@ -64,7 +66,7 @@ interface ICaddyConfig { policies: Array<{ issuers: never[] }>; }; certificates?: { - load_files?: Array<{ + load_pem?: Array<{ certificate: string; key: string; tags?: string[]; @@ -85,8 +87,7 @@ interface ICaddyRouteConfig { } export class CaddyManager { - private process: Deno.ChildProcess | null = null; - private binaryPath: string; + private dockerClient: InstanceType | null = null; private certsDir: string; private adminUrl: string; private httpPort: number; @@ -95,9 +96,10 @@ export class CaddyManager { private loggingEnabled: boolean; private routes: Map = new Map(); private certificates: Map = new Map(); + private networkName = 'onebox-network'; + private serviceRunning = false; constructor(options?: { - binaryPath?: string; certsDir?: string; adminPort?: number; httpPort?: number; @@ -105,7 +107,6 @@ export class CaddyManager { logReceiverPort?: number; loggingEnabled?: boolean; }) { - this.binaryPath = options?.binaryPath || './.nogit/caddy'; this.certsDir = options?.certsDir || './.nogit/certs'; this.adminUrl = `http://localhost:${options?.adminPort || 2019}`; this.httpPort = options?.httpPort || 8080; @@ -114,6 +115,18 @@ export class CaddyManager { this.loggingEnabled = options?.loggingEnabled ?? true; } + /** + * Initialize Docker client for Caddy service management + */ + private async ensureDockerClient(): Promise { + if (!this.dockerClient) { + this.dockerClient = new plugins.docker.Docker({ + socketPath: 'unix:///var/run/docker.sock', + }); + await this.dockerClient.start(); + } + } + /** * Update listening ports (must call reloadConfig after if running) */ @@ -123,103 +136,98 @@ export class CaddyManager { } /** - * Ensure Caddy binary is downloaded and executable - */ - async ensureBinary(): Promise { - try { - // Check if binary exists - try { - const stat = await Deno.stat(this.binaryPath); - if (stat.isFile) { - // Verify it's executable by checking version - const cmd = new Deno.Command(this.binaryPath, { - args: ['version'], - stdout: 'piped', - stderr: 'piped', - }); - const result = await cmd.output(); - if (result.success) { - const version = new TextDecoder().decode(result.stdout).trim(); - logger.info(`Caddy binary found: ${version}`); - return; - } - } - } catch { - // Binary doesn't exist, need to download - } - - logger.info(`Downloading Caddy v${CADDY_VERSION}...`); - - // Create directory if needed - const dir = this.binaryPath.substring(0, this.binaryPath.lastIndexOf('/')); - await Deno.mkdir(dir, { recursive: true }); - - // Download tar.gz - const response = await fetch(CADDY_DOWNLOAD_URL); - if (!response.ok) { - throw new Error(`Failed to download Caddy: ${response.status} ${response.statusText}`); - } - - const tarGzPath = `${this.binaryPath}.tar.gz`; - const data = new Uint8Array(await response.arrayBuffer()); - await Deno.writeFile(tarGzPath, data); - - // Extract using tar command - const extractCmd = new Deno.Command('tar', { - args: ['-xzf', tarGzPath, '-C', dir, 'caddy'], - stdout: 'piped', - stderr: 'piped', - }); - const extractResult = await extractCmd.output(); - if (!extractResult.success) { - throw new Error(`Failed to extract Caddy: ${new TextDecoder().decode(extractResult.stderr)}`); - } - - // Clean up tar.gz - await Deno.remove(tarGzPath); - - // Make executable - await Deno.chmod(this.binaryPath, 0o755); - - logger.success(`Caddy v${CADDY_VERSION} downloaded to ${this.binaryPath}`); - } catch (error) { - logger.error(`Failed to ensure Caddy binary: ${getErrorMessage(error)}`); - throw error; - } - } - - /** - * Start Caddy process with minimal config, then configure via Admin API + * Start Caddy as a Docker Swarm service */ async start(): Promise { - if (this.process) { - logger.warn('Caddy is already running'); + if (this.serviceRunning) { + logger.warn('Caddy service is already running'); return; } try { - // Create certs directory + await this.ensureDockerClient(); + + // Create certs directory for backup/persistence await Deno.mkdir(this.certsDir, { recursive: true }); - logger.info('Starting Caddy server...'); + logger.info('Starting Caddy Docker service...'); - // Start Caddy with blank config - Admin API is available immediately - // We'll push the full configuration via Admin API after it's ready - const cmd = new Deno.Command(this.binaryPath, { - args: ['run'], - stdin: 'null', - stdout: 'piped', - stderr: 'piped', + // Check if service already exists + const existingService = await this.getExistingService(); + if (existingService) { + logger.info('Caddy service exists, removing old service...'); + await this.removeService(); + // Wait for service to be removed + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + // Get network ID + const networkId = await this.getNetworkId(); + + // Create Caddy Docker service + const response = await this.dockerClient!.request('POST', '/services/create', { + Name: CADDY_SERVICE_NAME, + Labels: { + 'managed-by': 'onebox', + 'onebox-type': 'caddy', + }, + TaskTemplate: { + ContainerSpec: { + Image: CADDY_IMAGE, + // Start Caddy with admin listening on all interfaces so we can reach it from host + // Write minimal config to /tmp and start Caddy with that config + Command: ['sh', '-c', 'printf \'{"admin":{"listen":"0.0.0.0:2019"}}\' > /tmp/caddy.json && caddy run --config /tmp/caddy.json'], + }, + Networks: [ + { + Target: networkId, + }, + ], + RestartPolicy: { + Condition: 'any', + MaxAttempts: 0, + }, + }, + Mode: { + Replicated: { + Replicas: 1, + }, + }, + EndpointSpec: { + Ports: [ + { + Protocol: 'tcp', + TargetPort: 80, + PublishedPort: this.httpPort, + PublishMode: 'host', + }, + { + Protocol: 'tcp', + TargetPort: 443, + PublishedPort: this.httpsPort, + PublishMode: 'host', + }, + { + Protocol: 'tcp', + TargetPort: 2019, + PublishedPort: 2019, + PublishMode: 'host', + }, + ], + }, }); - this.process = cmd.spawn(); + if (response.statusCode >= 300) { + throw new Error(`Failed to create Caddy service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`); + } - // Start log readers (non-blocking) - this.readProcessOutput(); + logger.info(`Caddy service created: ${response.body.ID}`); // Wait for Admin API to be ready await this.waitForReady(); + this.serviceRunning = true; + // Now configure via Admin API with current routes and certificates await this.reloadConfig(); @@ -231,42 +239,47 @@ export class CaddyManager { } /** - * Read process stdout/stderr and log + * Get existing Caddy service if any */ - private async readProcessOutput(): Promise { - if (!this.process) return; - - // Read stderr (Caddy logs to stderr by default) - const stderrReader = this.process.stderr.getReader(); - (async () => { - try { - while (true) { - const { done, value } = await stderrReader.read(); - if (done) break; - const text = new TextDecoder().decode(value).trim(); - if (text) { - // Parse Caddy's JSON log format or just log as-is - for (const line of text.split('\n')) { - if (line.includes('"level":"error"')) { - logger.error(`[Caddy] ${line}`); - } else if (line.includes('"level":"warn"')) { - logger.warn(`[Caddy] ${line}`); - } else { - logger.debug(`[Caddy] ${line}`); - } - } - } - } - } catch { - // Process ended + private async getExistingService(): Promise { + try { + const response = await this.dockerClient!.request('GET', `/services/${CADDY_SERVICE_NAME}`, {}); + if (response.statusCode === 200) { + return response.body; } - })(); + return null; + } catch { + return null; + } } /** - * Wait for Caddy to be ready by polling admin API + * Remove the Caddy service */ - private async waitForReady(maxAttempts = 50, intervalMs = 100): Promise { + private async removeService(): Promise { + try { + await this.dockerClient!.request('DELETE', `/services/${CADDY_SERVICE_NAME}`, {}); + } catch { + // Service may not exist + } + } + + /** + * Get network ID by name + */ + private async getNetworkId(): Promise { + const networks = await this.dockerClient!.listNetworks(); + const network = networks.find((n: any) => n.Name === this.networkName); + if (!network) { + throw new Error(`Network not found: ${this.networkName}`); + } + return network.Id; + } + + /** + * Wait for Caddy Admin API to be ready + */ + private async waitForReady(maxAttempts = 60, intervalMs = 500): Promise { for (let i = 0; i < maxAttempts; i++) { try { const response = await fetch(`${this.adminUrl}/config/`); @@ -278,48 +291,33 @@ export class CaddyManager { } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } - throw new Error('Caddy failed to start within timeout'); + throw new Error('Caddy service failed to start within timeout'); } /** - * Stop Caddy process + * Stop Caddy Docker service */ async stop(): Promise { - if (!this.process) { + if (!this.serviceRunning && !(await this.getExistingService())) { return; } try { - logger.info('Stopping Caddy...'); + await this.ensureDockerClient(); - // Try graceful shutdown via API first - try { - await fetch(`${this.adminUrl}/stop`, { method: 'POST' }); - // Wait for process to exit - await Promise.race([ - this.process.status, - new Promise((resolve) => setTimeout(resolve, 5000)), - ]); - } catch { - // API not available, kill directly - } + logger.info('Stopping Caddy service...'); - // Force kill if still running - try { - this.process.kill('SIGTERM'); - } catch { - // Already dead - } + await this.removeService(); - this.process = null; - logger.info('Caddy stopped'); + this.serviceRunning = false; + logger.info('Caddy service stopped'); } catch (error) { logger.error(`Failed to stop Caddy: ${getErrorMessage(error)}`); } } /** - * Check if Caddy is healthy + * Check if Caddy Admin API is healthy */ async isHealthy(): Promise { try { @@ -330,6 +328,31 @@ export class CaddyManager { } } + /** + * Check if Caddy service is running + */ + async isRunning(): Promise { + try { + await this.ensureDockerClient(); + const service = await this.getExistingService(); + if (!service) return false; + + // Check if service has running tasks + const tasksResponse = await this.dockerClient!.request( + 'GET', + `/tasks?filters=${encodeURIComponent(JSON.stringify({ service: [CADDY_SERVICE_NAME] }))}`, + {} + ); + + if (tasksResponse.statusCode !== 200) return false; + + const tasks = tasksResponse.body; + return tasks.some((task: any) => task.Status?.State === 'running'); + } catch { + return false; + } + } + /** * Build Caddy JSON configuration from current routes and certificates */ @@ -350,27 +373,27 @@ export class CaddyManager { }); } - // Build certificate load_files - const loadFiles: Array<{ certificate: string; key: string; tags?: string[] }> = []; + // Build certificate load_pem entries (inline PEM content) + const loadPem: Array<{ certificate: string; key: string; tags?: string[] }> = []; for (const [domain, cert] of this.certificates) { - loadFiles.push({ - certificate: cert.certPath, - key: cert.keyPath, + loadPem.push({ + certificate: cert.certPem, + key: cert.keyPem, tags: [domain], }); } const config: ICaddyConfig = { admin: { - listen: this.adminUrl.replace('http://', ''), + listen: '0.0.0.0:2019', // Listen on all interfaces inside container }, apps: { http: { servers: { main: { - listen: [`:${this.httpPort}`, `:${this.httpsPort}`], + listen: [':80', ':443'], routes, - // Disable automatic HTTPS to prevent Caddy from trying to bind to port 80/443 + // Disable automatic HTTPS to prevent Caddy from trying to obtain certs automatic_https: { disable: true, }, @@ -387,7 +410,8 @@ export class CaddyManager { access: { writer: { output: 'net', - address: `tcp/localhost:${this.logReceiverPort}`, + // Use Docker bridge gateway IP to reach log receiver on host + address: `tcp/${DOCKER_GATEWAY_IP}:${this.logReceiverPort}`, dial_timeout: '5s', soft_start: true, // Continue even if log receiver is down }, @@ -405,14 +429,14 @@ export class CaddyManager { } // Add TLS config if we have certificates - if (loadFiles.length > 0) { + if (loadPem.length > 0) { config.apps.tls = { automation: { // Disable automatic HTTPS - we manage certs ourselves policies: [{ issuers: [] }], }, certificates: { - load_files: loadFiles, + load_pem: loadPem, }, }; } @@ -424,7 +448,8 @@ export class CaddyManager { * Reload Caddy configuration via Admin API */ async reloadConfig(): Promise { - if (!this.process) { + const isRunning = await this.isRunning(); + if (!isRunning) { logger.warn('Caddy not running, cannot reload config'); return; } @@ -456,7 +481,7 @@ export class CaddyManager { async addRoute(domain: string, upstream: string): Promise { this.routes.set(domain, { domain, upstream }); - if (this.process) { + if (await this.isRunning()) { await this.reloadConfig(); } @@ -468,7 +493,7 @@ export class CaddyManager { */ async removeRoute(domain: string): Promise { if (this.routes.delete(domain)) { - if (this.process) { + if (await this.isRunning()) { await this.reloadConfig(); } logger.success(`Removed Caddy route: ${domain}`); @@ -477,28 +502,26 @@ export class CaddyManager { /** * Add or update a TLS certificate - * Writes PEM files to disk and updates config + * Stores PEM content in memory for Admin API, also writes to disk for backup */ async addCertificate(domain: string, certPem: string, keyPem: string): Promise { - // Write PEM files - const certPath = `${this.certsDir}/${domain}.crt`; - const keyPath = `${this.certsDir}/${domain}.key`; - - await Deno.mkdir(this.certsDir, { recursive: true }); - await Deno.writeTextFile(certPath, certPem); - await Deno.writeTextFile(keyPath, keyPem); - - // Use absolute paths for Caddy - const absoluteCertPath = await Deno.realPath(certPath); - const absoluteKeyPath = await Deno.realPath(keyPath); - + // Store PEM content in memory for buildConfig() this.certificates.set(domain, { domain, - certPath: absoluteCertPath, - keyPath: absoluteKeyPath, + certPem, + keyPem, }); - if (this.process) { + // Also write to disk for backup/persistence + try { + await Deno.mkdir(this.certsDir, { recursive: true }); + await Deno.writeTextFile(`${this.certsDir}/${domain}.crt`, certPem); + await Deno.writeTextFile(`${this.certsDir}/${domain}.key`, keyPem); + } catch (error) { + logger.warn(`Failed to write certificate backup for ${domain}: ${getErrorMessage(error)}`); + } + + if (await this.isRunning()) { await this.reloadConfig(); } @@ -509,19 +532,16 @@ export class CaddyManager { * Remove a TLS certificate */ async removeCertificate(domain: string): Promise { - const cert = this.certificates.get(domain); - if (cert) { - this.certificates.delete(domain); - - // Remove files + if (this.certificates.delete(domain)) { + // Remove backup files try { - await Deno.remove(cert.certPath); - await Deno.remove(cert.keyPath); + await Deno.remove(`${this.certsDir}/${domain}.crt`); + await Deno.remove(`${this.certsDir}/${domain}.key`); } catch { // Files may not exist } - if (this.process) { + if (await this.isRunning()) { await this.reloadConfig(); } @@ -562,7 +582,7 @@ export class CaddyManager { certificates: number; } { return { - running: this.process !== null, + running: this.serviceRunning, httpPort: this.httpPort, httpsPort: this.httpsPort, routes: this.routes.size, diff --git a/ts/classes/reverseproxy.ts b/ts/classes/reverseproxy.ts index 54169b9..04eaa4c 100644 --- a/ts/classes/reverseproxy.ts +++ b/ts/classes/reverseproxy.ts @@ -1,8 +1,11 @@ /** * 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. + * 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. */ import { logger } from '../logging.ts'; @@ -15,7 +18,7 @@ interface IProxyRoute { targetHost: string; targetPort: number; serviceId: number; - containerID?: string; + serviceName?: string; } export class OneboxReverseProxy { @@ -36,16 +39,10 @@ export class OneboxReverseProxy { } /** - * Initialize reverse proxy - ensures Caddy binary is available + * Initialize reverse proxy - Caddy runs as Docker service, no setup needed */ async init(): Promise { - try { - await this.caddy.ensureBinary(); - logger.info('Reverse proxy initialized (Caddy)'); - } catch (error) { - logger.error(`Failed to initialize reverse proxy: ${getErrorMessage(error)}`); - throw error; - } + logger.info('Reverse proxy initialized (Caddy Docker service)'); } /** @@ -61,7 +58,7 @@ export class OneboxReverseProxy { try { // Start Caddy (handles both HTTP and HTTPS) await this.caddy.start(); - logger.success(`Reverse proxy started on port ${this.httpPort} (Caddy)`); + logger.success(`Reverse proxy started on port ${this.httpPort} (Caddy Docker service)`); } catch (error) { logger.error(`Failed to start reverse proxy: ${getErrorMessage(error)}`); throw error; @@ -97,46 +94,32 @@ export class OneboxReverseProxy { /** * Add a route for a service + * Uses Docker service name for upstream (Caddy runs in same Docker network) */ async addRoute(serviceId: number, domain: string, targetPort: number): Promise { try { - // Get container IP from Docker + // Get service info from database const service = this.database.getServiceByID(serviceId); - if (!service || !service.containerID) { - throw new Error(`Service not found or has no container: ${serviceId}`); + if (!service) { + throw new Error(`Service not found: ${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)}`); - } + // 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; const route: IProxyRoute = { domain, targetHost, targetPort, serviceId, - containerID: service.containerID, + serviceName, }; this.routes.set(domain, route); - // Add route to Caddy + // Add route to Caddy using Docker service name const upstream = `${targetHost}:${targetPort}`; await this.caddy.addRoute(domain, upstream); @@ -147,36 +130,6 @@ export class OneboxReverseProxy { } } - /** - * Get IP address for a Swarm task - */ - private async getSwarmTaskIP(containerIdOrTaskId: string): Promise { - 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 */ @@ -213,6 +166,7 @@ export class OneboxReverseProxy { const services = this.database.getAllServices(); for (const service of services) { + // Route by domain if running (containerID is the service ID for Swarm services) if (service.domain && service.status === 'running' && service.containerID) { await this.addRoute(service.id!, service.domain, service.port); } @@ -227,7 +181,7 @@ export class OneboxReverseProxy { /** * Add TLS certificate for a domain - * Writes PEM files to disk for Caddy to load + * Sends PEM content to Caddy via Admin API */ async addCertificate(domain: string, certPem: string, keyPem: string): Promise { if (!certPem || !keyPem) { @@ -288,7 +242,7 @@ export class OneboxReverseProxy { certificates: caddyStatus.certificates, }, routes: caddyStatus.routes, - backend: 'caddy', + backend: 'caddy-docker', }; } }