Files

593 lines
15 KiB
TypeScript
Raw Permalink Normal View History

2025-11-26 12:16:50 +00:00
/**
* Caddy Manager for Onebox
*
* Manages Caddy as a Docker Swarm service instead of a host binary.
* This allows Caddy to access services on the Docker overlay network.
2025-11-26 12:16:50 +00:00
*/
import * as plugins from '../plugins.ts';
2025-11-26 12:16:50 +00:00
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
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
2025-11-26 12:16:50 +00:00
export interface ICaddyRoute {
domain: string;
upstream: string; // e.g., "onebox-hello-world:80"
2025-11-26 12:16:50 +00:00
}
export interface ICaddyCertificate {
domain: string;
certPem: string;
keyPem: string;
2025-11-26 12:16:50 +00:00
}
interface ICaddyLoggingConfig {
logs: {
[name: string]: {
writer: {
output: string;
address?: string;
dial_timeout?: string;
soft_start?: boolean;
};
encoder?: { format: string };
level?: string;
include?: string[];
};
};
}
interface ICaddyConfig {
admin: {
listen: string;
};
logging?: ICaddyLoggingConfig;
apps: {
http: {
servers: {
[key: string]: {
listen: string[];
routes: ICaddyRouteConfig[];
automatic_https?: {
disable?: boolean;
disable_redirects?: boolean;
};
logs?: {
default_logger_name: string;
};
};
};
};
tls?: {
automation?: {
policies: Array<{ issuers: never[] }>;
};
certificates?: {
load_pem?: Array<{
2025-11-26 12:16:50 +00:00
certificate: string;
key: string;
tags?: string[];
}>;
};
};
};
}
interface ICaddyRouteConfig {
match: Array<{ host: string[] }>;
handle: Array<{
handler: string;
upstreams?: Array<{ dial: string }>;
routes?: ICaddyRouteConfig[];
}>;
terminal?: boolean;
}
export class CaddyManager {
private dockerClient: InstanceType<typeof plugins.docker.Docker> | null = null;
2025-11-26 12:16:50 +00:00
private certsDir: string;
private adminUrl: string;
private httpPort: number;
private httpsPort: number;
private logReceiverPort: number;
private loggingEnabled: boolean;
private routes: Map<string, ICaddyRoute> = new Map();
private certificates: Map<string, ICaddyCertificate> = new Map();
private networkName = 'onebox-network';
private serviceRunning = false;
2025-11-26 12:16:50 +00:00
constructor(options?: {
certsDir?: string;
adminPort?: number;
httpPort?: number;
httpsPort?: number;
logReceiverPort?: number;
loggingEnabled?: boolean;
}) {
this.certsDir = options?.certsDir || './.nogit/certs';
this.adminUrl = `http://localhost:${options?.adminPort || 2019}`;
this.httpPort = options?.httpPort || 8080;
this.httpsPort = options?.httpsPort || 8443;
this.logReceiverPort = options?.logReceiverPort || 9999;
this.loggingEnabled = options?.loggingEnabled ?? true;
}
/**
* Initialize Docker client for Caddy service management
2025-11-26 12:16:50 +00:00
*/
private async ensureDockerClient(): Promise<void> {
if (!this.dockerClient) {
this.dockerClient = new plugins.docker.Docker({
socketPath: 'unix:///var/run/docker.sock',
});
await this.dockerClient.start();
}
2025-11-26 12:16:50 +00:00
}
/**
* Update listening ports (must call reloadConfig after if running)
2025-11-26 12:16:50 +00:00
*/
setPorts(httpPort: number, httpsPort: number): void {
this.httpPort = httpPort;
this.httpsPort = httpsPort;
2025-11-26 12:16:50 +00:00
}
/**
* Start Caddy as a Docker Swarm service
2025-11-26 12:16:50 +00:00
*/
async start(): Promise<void> {
if (this.serviceRunning) {
logger.warn('Caddy service is already running');
2025-11-26 12:16:50 +00:00
return;
}
try {
await this.ensureDockerClient();
// Create certs directory for backup/persistence
2025-11-26 12:16:50 +00:00
await Deno.mkdir(this.certsDir, { recursive: true });
logger.info('Starting Caddy Docker service...');
// 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();
2025-11-26 12:16:50 +00:00
// 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',
},
],
},
2025-11-26 12:16:50 +00:00
});
if (response.statusCode >= 300) {
throw new Error(`Failed to create Caddy service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`);
}
2025-11-26 12:16:50 +00:00
logger.info(`Caddy service created: ${response.body.ID}`);
2025-11-26 12:16:50 +00:00
// Wait for Admin API to be ready
await this.waitForReady();
this.serviceRunning = true;
2025-11-26 12:16:50 +00:00
// Now configure via Admin API with current routes and certificates
await this.reloadConfig();
logger.success(`Caddy started (HTTP: ${this.httpPort}, HTTPS: ${this.httpsPort}, Admin: ${this.adminUrl})`);
} catch (error) {
logger.error(`Failed to start Caddy: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Get existing Caddy service if any
2025-11-26 12:16:50 +00:00
*/
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;
2025-11-26 12:16:50 +00:00
}
return null;
} catch {
return null;
}
2025-11-26 12:16:50 +00:00
}
/**
* Remove the Caddy service
2025-11-26 12:16:50 +00:00
*/
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> {
2025-11-26 12:16:50 +00:00
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(`${this.adminUrl}/config/`);
if (response.ok) {
return;
}
} catch {
// Not ready yet
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('Caddy service failed to start within timeout');
2025-11-26 12:16:50 +00:00
}
/**
* Stop Caddy Docker service
2025-11-26 12:16:50 +00:00
*/
async stop(): Promise<void> {
if (!this.serviceRunning && !(await this.getExistingService())) {
2025-11-26 12:16:50 +00:00
return;
}
try {
await this.ensureDockerClient();
2025-11-26 12:16:50 +00:00
logger.info('Stopping Caddy service...');
2025-11-26 12:16:50 +00:00
await this.removeService();
2025-11-26 12:16:50 +00:00
this.serviceRunning = false;
logger.info('Caddy service stopped');
2025-11-26 12:16:50 +00:00
} catch (error) {
logger.error(`Failed to stop Caddy: ${getErrorMessage(error)}`);
}
}
/**
* Check if Caddy Admin API is healthy
2025-11-26 12:16:50 +00:00
*/
async isHealthy(): Promise<boolean> {
try {
const response = await fetch(`${this.adminUrl}/config/`);
return response.ok;
} catch {
return false;
}
}
/**
* 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;
}
}
2025-11-26 12:16:50 +00:00
/**
* Build Caddy JSON configuration from current routes and certificates
*/
private buildConfig(): ICaddyConfig {
const routes: ICaddyRouteConfig[] = [];
// Add routes
for (const [domain, route] of this.routes) {
routes.push({
match: [{ host: [domain] }],
handle: [
{
handler: 'reverse_proxy',
upstreams: [{ dial: route.upstream }],
},
],
terminal: true,
});
}
// Build certificate load_pem entries (inline PEM content)
const loadPem: Array<{ certificate: string; key: string; tags?: string[] }> = [];
2025-11-26 12:16:50 +00:00
for (const [domain, cert] of this.certificates) {
loadPem.push({
certificate: cert.certPem,
key: cert.keyPem,
2025-11-26 12:16:50 +00:00
tags: [domain],
});
}
const config: ICaddyConfig = {
admin: {
listen: '0.0.0.0:2019', // Listen on all interfaces inside container
2025-11-26 12:16:50 +00:00
},
apps: {
http: {
servers: {
main: {
listen: [':80', ':443'],
2025-11-26 12:16:50 +00:00
routes,
// Disable automatic HTTPS to prevent Caddy from trying to obtain certs
2025-11-26 12:16:50 +00:00
automatic_https: {
disable: true,
},
},
},
},
},
};
// Add access logging configuration if enabled
if (this.loggingEnabled) {
config.logging = {
logs: {
access: {
writer: {
output: 'net',
// Use Docker bridge gateway IP to reach log receiver on host
address: `tcp/${DOCKER_GATEWAY_IP}:${this.logReceiverPort}`,
2025-11-26 12:16:50 +00:00
dial_timeout: '5s',
soft_start: true, // Continue even if log receiver is down
},
encoder: { format: 'json' },
level: 'INFO',
include: ['http.log.access'],
},
},
};
// Associate server with access logger
config.apps.http.servers.main.logs = {
default_logger_name: 'access',
};
}
// Add TLS config if we have certificates
if (loadPem.length > 0) {
2025-11-26 12:16:50 +00:00
config.apps.tls = {
automation: {
// Disable automatic HTTPS - we manage certs ourselves
policies: [{ issuers: [] }],
},
certificates: {
load_pem: loadPem,
2025-11-26 12:16:50 +00:00
},
};
}
return config;
}
/**
* Reload Caddy configuration via Admin API
*/
async reloadConfig(): Promise<void> {
const isRunning = await this.isRunning();
if (!isRunning) {
2025-11-26 12:16:50 +00:00
logger.warn('Caddy not running, cannot reload config');
return;
}
const config = this.buildConfig();
try {
const response = await fetch(`${this.adminUrl}/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to reload Caddy config: ${response.status} ${text}`);
}
logger.debug('Caddy configuration reloaded');
} catch (error) {
logger.error(`Failed to reload Caddy config: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Add or update a route
*/
async addRoute(domain: string, upstream: string): Promise<void> {
this.routes.set(domain, { domain, upstream });
if (await this.isRunning()) {
2025-11-26 12:16:50 +00:00
await this.reloadConfig();
}
logger.success(`Added Caddy route: ${domain} -> ${upstream}`);
}
/**
* Remove a route
*/
async removeRoute(domain: string): Promise<void> {
if (this.routes.delete(domain)) {
if (await this.isRunning()) {
2025-11-26 12:16:50 +00:00
await this.reloadConfig();
}
logger.success(`Removed Caddy route: ${domain}`);
}
}
/**
* Add or update a TLS certificate
* Stores PEM content in memory for Admin API, also writes to disk for backup
2025-11-26 12:16:50 +00:00
*/
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
// Store PEM content in memory for buildConfig()
2025-11-26 12:16:50 +00:00
this.certificates.set(domain, {
domain,
certPem,
keyPem,
2025-11-26 12:16:50 +00:00
});
// 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()) {
2025-11-26 12:16:50 +00:00
await this.reloadConfig();
}
logger.success(`Added TLS certificate for ${domain}`);
}
/**
* Remove a TLS certificate
*/
async removeCertificate(domain: string): Promise<void> {
if (this.certificates.delete(domain)) {
// Remove backup files
2025-11-26 12:16:50 +00:00
try {
await Deno.remove(`${this.certsDir}/${domain}.crt`);
await Deno.remove(`${this.certsDir}/${domain}.key`);
2025-11-26 12:16:50 +00:00
} catch {
// Files may not exist
}
if (await this.isRunning()) {
2025-11-26 12:16:50 +00:00
await this.reloadConfig();
}
logger.success(`Removed TLS certificate for ${domain}`);
}
}
/**
* Get all current routes
*/
getRoutes(): ICaddyRoute[] {
return Array.from(this.routes.values());
}
/**
* Get all current certificates
*/
getCertificates(): ICaddyCertificate[] {
return Array.from(this.certificates.values());
}
/**
* Clear all routes and certificates (useful for reload from database)
*/
clear(): void {
this.routes.clear();
this.certificates.clear();
}
/**
* Get status
*/
getStatus(): {
running: boolean;
httpPort: number;
httpsPort: number;
routes: number;
certificates: number;
} {
return {
running: this.serviceRunning,
2025-11-26 12:16:50 +00:00
httpPort: this.httpPort,
httpsPort: this.httpsPort,
routes: this.routes.size,
certificates: this.certificates.size,
};
}
}