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 () => {
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<any | null> {
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<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,