feat: Refactor CaddyManager and OneboxReverseProxy to use Docker service for Caddy management

This commit is contained in:
2025-11-26 13:23:56 +00:00
parent c639453e01
commit c03e0e055c
2 changed files with 228 additions and 254 deletions

View File

@@ -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<typeof plugins.docker.Docker> | null = null;
private certsDir: string;
private adminUrl: string;
private httpPort: number;
@@ -95,9 +96,10 @@ export class CaddyManager {
private loggingEnabled: boolean;
private routes: Map<string, ICaddyRoute> = new Map();
private certificates: Map<string, ICaddyCertificate> = 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<void> {
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<void> {
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<void> {
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<void> {
if (!this.process) return;
// Read stderr (Caddy logs to stderr by default)
const stderrReader = this.process.stderr.getReader();
(async () => {
private async getExistingService(): Promise<any | null> {
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}`);
}
}
}
const response = await this.dockerClient!.request('GET', `/services/${CADDY_SERVICE_NAME}`, {});
if (response.statusCode === 200) {
return response.body;
}
return null;
} catch {
// Process ended
return null;
}
})();
}
/**
* Wait for Caddy to be ready by polling admin API
* Remove the Caddy service
*/
private async waitForReady(maxAttempts = 50, intervalMs = 100): Promise<void> {
private async removeService(): Promise<void> {
try {
await this.dockerClient!.request('DELETE', `/services/${CADDY_SERVICE_NAME}`, {});
} catch {
// Service may not exist
}
}
/**
* Get network ID by name
*/
private async getNetworkId(): Promise<string> {
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<void> {
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<void> {
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<boolean> {
try {
@@ -330,6 +328,31 @@ export class CaddyManager {
}
}
/**
* Check if Caddy service is running
*/
async isRunning(): Promise<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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,

View File

@@ -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<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;
}
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<void> {
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<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
*/
@@ -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<void> {
if (!certPem || !keyPem) {
@@ -288,7 +242,7 @@ export class OneboxReverseProxy {
certificates: caddyStatus.certificates,
},
routes: caddyStatus.routes,
backend: 'caddy',
backend: 'caddy-docker',
};
}
}