2025-11-26 12:16:50 +00:00
|
|
|
/**
|
|
|
|
|
* Caddy Manager for Onebox
|
|
|
|
|
*
|
2025-11-26 13:23:56 +00:00
|
|
|
* 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
|
|
|
*/
|
|
|
|
|
|
2025-11-26 13:23:56 +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';
|
|
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
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;
|
2025-11-26 13:23:56 +00:00
|
|
|
upstream: string; // e.g., "onebox-hello-world:80"
|
2025-11-26 12:16:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ICaddyCertificate {
|
|
|
|
|
domain: string;
|
2025-11-26 13:23:56 +00:00
|
|
|
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?: {
|
2025-11-26 13:23:56 +00:00
|
|
|
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 {
|
2025-11-26 13:23:56 +00:00
|
|
|
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();
|
2025-11-26 13:23:56 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 13:23:56 +00:00
|
|
|
* Initialize Docker client for Caddy service management
|
2025-11-26 12:16:50 +00:00
|
|
|
*/
|
2025-11-26 13:23:56 +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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 13:23:56 +00:00
|
|
|
* Update listening ports (must call reloadConfig after if running)
|
2025-11-26 12:16:50 +00:00
|
|
|
*/
|
2025-11-26 13:23:56 +00:00
|
|
|
setPorts(httpPort: number, httpsPort: number): void {
|
|
|
|
|
this.httpPort = httpPort;
|
|
|
|
|
this.httpsPort = httpsPort;
|
2025-11-26 12:16:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 13:23:56 +00:00
|
|
|
* Start Caddy as a Docker Swarm service
|
2025-11-26 12:16:50 +00:00
|
|
|
*/
|
|
|
|
|
async start(): Promise<void> {
|
2025-11-26 13:23:56 +00:00
|
|
|
if (this.serviceRunning) {
|
|
|
|
|
logger.warn('Caddy service is already running');
|
2025-11-26 12:16:50 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-11-26 13:23:56 +00:00
|
|
|
await this.ensureDockerClient();
|
|
|
|
|
|
|
|
|
|
// Create certs directory for backup/persistence
|
2025-11-26 12:16:50 +00:00
|
|
|
await Deno.mkdir(this.certsDir, { recursive: true });
|
|
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
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
|
|
|
|
2025-11-26 13:23:56 +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
|
|
|
});
|
|
|
|
|
|
2025-11-26 13:23:56 +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
|
|
|
|
2025-11-26 13:23:56 +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();
|
|
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 13:23:56 +00:00
|
|
|
* Get existing Caddy service if any
|
2025-11-26 12:16:50 +00:00
|
|
|
*/
|
2025-11-26 13:23:56 +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
|
|
|
}
|
2025-11-26 13:23:56 +00:00
|
|
|
return null;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2025-11-26 12:16:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 13:23:56 +00:00
|
|
|
* Remove the Caddy service
|
2025-11-26 12:16:50 +00:00
|
|
|
*/
|
2025-11-26 13:23:56 +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));
|
|
|
|
|
}
|
2025-11-26 13:23:56 +00:00
|
|
|
throw new Error('Caddy service failed to start within timeout');
|
2025-11-26 12:16:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 13:23:56 +00:00
|
|
|
* Stop Caddy Docker service
|
2025-11-26 12:16:50 +00:00
|
|
|
*/
|
|
|
|
|
async stop(): Promise<void> {
|
2025-11-26 13:23:56 +00:00
|
|
|
if (!this.serviceRunning && !(await this.getExistingService())) {
|
2025-11-26 12:16:50 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-11-26 13:23:56 +00:00
|
|
|
await this.ensureDockerClient();
|
2025-11-26 12:16:50 +00:00
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
logger.info('Stopping Caddy service...');
|
2025-11-26 12:16:50 +00:00
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
await this.removeService();
|
2025-11-26 12:16:50 +00:00
|
|
|
|
2025-11-26 13:23:56 +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)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-26 13:23:56 +00:00
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
// 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) {
|
2025-11-26 13:23:56 +00:00
|
|
|
loadPem.push({
|
|
|
|
|
certificate: cert.certPem,
|
|
|
|
|
key: cert.keyPem,
|
2025-11-26 12:16:50 +00:00
|
|
|
tags: [domain],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const config: ICaddyConfig = {
|
|
|
|
|
admin: {
|
2025-11-26 13:23:56 +00:00
|
|
|
listen: '0.0.0.0:2019', // Listen on all interfaces inside container
|
2025-11-26 12:16:50 +00:00
|
|
|
},
|
|
|
|
|
apps: {
|
|
|
|
|
http: {
|
|
|
|
|
servers: {
|
|
|
|
|
main: {
|
2025-11-26 13:23:56 +00:00
|
|
|
listen: [':80', ':443'],
|
2025-11-26 12:16:50 +00:00
|
|
|
routes,
|
2025-11-26 13:23:56 +00:00
|
|
|
// 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',
|
2025-11-26 13:23:56 +00:00
|
|
|
// 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
|
2025-11-26 13:23:56 +00:00
|
|
|
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: {
|
2025-11-26 13:23:56 +00:00
|
|
|
load_pem: loadPem,
|
2025-11-26 12:16:50 +00:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return config;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reload Caddy configuration via Admin API
|
|
|
|
|
*/
|
|
|
|
|
async reloadConfig(): Promise<void> {
|
2025-11-26 13:23:56 +00:00
|
|
|
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 });
|
|
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
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)) {
|
2025-11-26 13:23:56 +00:00
|
|
|
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
|
2025-11-26 13:23:56 +00:00
|
|
|
* 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> {
|
2025-11-26 13:23:56 +00:00
|
|
|
// Store PEM content in memory for buildConfig()
|
2025-11-26 12:16:50 +00:00
|
|
|
this.certificates.set(domain, {
|
|
|
|
|
domain,
|
2025-11-26 13:23:56 +00:00
|
|
|
certPem,
|
|
|
|
|
keyPem,
|
2025-11-26 12:16:50 +00:00
|
|
|
});
|
|
|
|
|
|
2025-11-26 13:23:56 +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> {
|
2025-11-26 13:23:56 +00:00
|
|
|
if (this.certificates.delete(domain)) {
|
|
|
|
|
// Remove backup files
|
2025-11-26 12:16:50 +00:00
|
|
|
try {
|
2025-11-26 13:23:56 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 13:23:56 +00:00
|
|
|
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 {
|
2025-11-26 13:23:56 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|