feat: replace onebox ingress with SmartProxy

This commit is contained in:
2026-04-28 21:30:48 +00:00
parent 0f5ce708d9
commit c5d9158078
20 changed files with 697 additions and 824 deletions
-592
View File
@@ -1,592 +0,0 @@
/**
* 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.
*/
import * as plugins from '../plugins.ts';
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
export interface ICaddyRoute {
domain: string;
upstream: string; // e.g., "onebox-hello-world:80"
}
export interface ICaddyCertificate {
domain: string;
certPem: string;
keyPem: string;
}
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<{
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;
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;
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
*/
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)
*/
setPorts(httpPort: number, httpsPort: number): void {
this.httpPort = httpPort;
this.httpsPort = httpsPort;
}
/**
* Start Caddy as a Docker Swarm service
*/
async start(): Promise<void> {
if (this.serviceRunning) {
logger.warn('Caddy service is already running');
return;
}
try {
await this.ensureDockerClient();
// Create certs directory for backup/persistence
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();
// 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',
},
],
},
});
if (response.statusCode >= 300) {
throw new Error(`Failed to create Caddy service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`);
}
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();
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
*/
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;
}
}
/**
* Remove the Caddy service
*/
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/`);
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');
}
/**
* Stop Caddy Docker service
*/
async stop(): Promise<void> {
if (!this.serviceRunning && !(await this.getExistingService())) {
return;
}
try {
await this.ensureDockerClient();
logger.info('Stopping Caddy service...');
await this.removeService();
this.serviceRunning = false;
logger.info('Caddy service stopped');
} catch (error) {
logger.error(`Failed to stop Caddy: ${getErrorMessage(error)}`);
}
}
/**
* Check if Caddy Admin API is healthy
*/
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;
}
}
/**
* 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[] }> = [];
for (const [domain, cert] of this.certificates) {
loadPem.push({
certificate: cert.certPem,
key: cert.keyPem,
tags: [domain],
});
}
const config: ICaddyConfig = {
admin: {
listen: '0.0.0.0:2019', // Listen on all interfaces inside container
},
apps: {
http: {
servers: {
main: {
listen: [':80', ':443'],
routes,
// Disable automatic HTTPS to prevent Caddy from trying to obtain certs
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}`,
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) {
config.apps.tls = {
automation: {
// Disable automatic HTTPS - we manage certs ourselves
policies: [{ issuers: [] }],
},
certificates: {
load_pem: loadPem,
},
};
}
return config;
}
/**
* Reload Caddy configuration via Admin API
*/
async reloadConfig(): Promise<void> {
const isRunning = await this.isRunning();
if (!isRunning) {
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()) {
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()) {
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
*/
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
// Store PEM content in memory for buildConfig()
this.certificates.set(domain, {
domain,
certPem,
keyPem,
});
// 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();
}
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
try {
await Deno.remove(`${this.certsDir}/${domain}.crt`);
await Deno.remove(`${this.certsDir}/${domain}.key`);
} catch {
// Files may not exist
}
if (await this.isRunning()) {
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,
httpPort: this.httpPort,
httpsPort: this.httpsPort,
routes: this.routes.size,
certificates: this.certificates.size,
};
}
}
+11 -11
View File
@@ -21,7 +21,7 @@ import { CertRequirementManager } from './cert-requirement-manager.ts';
import { RegistryManager } from './registry.ts';
import { PlatformServicesManager } from './platform-services/index.ts';
import { AppStoreManager } from './appstore.ts';
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
import { ProxyLogReceiver } from './proxy-log-receiver.ts';
import { BackupManager } from './backup-manager.ts';
import { BackupScheduler } from './backup-scheduler.ts';
import { OpsServer } from '../opsserver/index.ts';
@@ -41,7 +41,7 @@ export class Onebox {
public registry: RegistryManager;
public platformServices: PlatformServicesManager;
public appStore: AppStoreManager;
public caddyLogReceiver: CaddyLogReceiver;
public proxyLogReceiver: ProxyLogReceiver;
public backupManager: BackupManager;
public backupScheduler: BackupScheduler;
public opsServer: OpsServer;
@@ -77,8 +77,8 @@ export class Onebox {
// Initialize App Store manager
this.appStore = new AppStoreManager(this);
// Initialize Caddy log receiver
this.caddyLogReceiver = new CaddyLogReceiver(9999);
// Initialize reverse proxy log receiver
this.proxyLogReceiver = new ProxyLogReceiver(9999);
// Initialize Backup manager
this.backupManager = new BackupManager(this);
@@ -106,11 +106,11 @@ export class Onebox {
// Initialize Docker
await this.docker.init();
// Start Caddy log receiver BEFORE reverse proxy (so Caddy can connect to it)
// Start proxy log receiver before reverse proxy startup.
try {
await this.caddyLogReceiver.start();
await this.proxyLogReceiver.start();
} catch (error) {
logger.warn(`Failed to start Caddy log receiver: ${getErrorMessage(error)}`);
logger.warn(`Failed to start proxy log receiver: ${getErrorMessage(error)}`);
}
// Initialize Reverse Proxy
@@ -268,9 +268,9 @@ export class Onebox {
const providers = this.platformServices.getAllProviders();
const platformServicesStatus = providers.map((provider) => {
const service = platformServices.find((s) => s.type === provider.type);
// For Caddy, check actual runtime status since it starts without a DB record
// For SmartProxy, check actual runtime status since it starts without a DB record
let status = service?.status || 'not-deployed';
if (provider.type === 'caddy') {
if (provider.type === 'smartproxy') {
status = proxyStatus.http.running ? 'running' : 'stopped';
}
// Count resources for this platform service
@@ -432,8 +432,8 @@ export class Onebox {
// Stop reverse proxy if running
await this.reverseProxy.stop();
// Stop Caddy log receiver
await this.caddyLogReceiver.stop();
// Stop proxy log receiver
await this.proxyLogReceiver.stop();
// Close backup archive
await this.backupManager.close();
+2 -2
View File
@@ -14,7 +14,7 @@ import type {
import type { IPlatformServiceProvider } from './providers/base.ts';
import { MongoDBProvider } from './providers/mongodb.ts';
import { MinioProvider } from './providers/minio.ts';
import { CaddyProvider } from './providers/caddy.ts';
import { SmartProxyProvider } from './providers/smartproxy.ts';
import { ClickHouseProvider } from './providers/clickhouse.ts';
import { MariaDBProvider } from './providers/mariadb.ts';
import { RedisProvider } from './providers/redis.ts';
@@ -41,7 +41,7 @@ export class PlatformServicesManager {
// Register providers
this.registerProvider(new MongoDBProvider(this.oneboxRef));
this.registerProvider(new MinioProvider(this.oneboxRef));
this.registerProvider(new CaddyProvider(this.oneboxRef));
this.registerProvider(new SmartProxyProvider(this.oneboxRef));
this.registerProvider(new ClickHouseProvider(this.oneboxRef));
this.registerProvider(new MariaDBProvider(this.oneboxRef));
this.registerProvider(new RedisProvider(this.oneboxRef));
@@ -1,110 +0,0 @@
/**
* Caddy Platform Service Provider
*
* Caddy is a core infrastructure service that provides reverse proxy functionality.
* Unlike other platform services:
* - It doesn't provision resources for user services
* - It's started automatically by Onebox and cannot be stopped by users
* - It delegates to the existing CaddyManager for actual operations
*/
import { BasePlatformServiceProvider } from './base.ts';
import type {
IService,
IPlatformResource,
IPlatformServiceConfig,
IProvisionedResource,
IEnvVarMapping,
TPlatformServiceType,
TPlatformResourceType,
} from '../../../types.ts';
import { logger } from '../../../logging.ts';
import type { Onebox } from '../../onebox.ts';
export class CaddyProvider extends BasePlatformServiceProvider {
readonly type: TPlatformServiceType = 'caddy';
readonly displayName = 'Caddy Reverse Proxy';
readonly resourceTypes: TPlatformResourceType[] = []; // Caddy doesn't provision resources
readonly isCore = true; // Core infrastructure - cannot be stopped by users
constructor(oneboxRef: Onebox) {
super(oneboxRef);
}
getDefaultConfig(): IPlatformServiceConfig {
return {
image: 'caddy:2-alpine',
port: 80,
volumes: [],
environment: {},
};
}
getEnvVarMappings(): IEnvVarMapping[] {
// Caddy doesn't inject any env vars into user services
return [];
}
/**
* Deploy Caddy container - delegates to CaddyManager via reverseProxy
*/
async deployContainer(): Promise<string> {
logger.info('Starting Caddy via reverse proxy manager...');
// Get the reverse proxy which manages Caddy
const reverseProxy = this.oneboxRef.reverseProxy;
// Start reverse proxy (which starts Caddy)
await reverseProxy.startHttp();
// Get Caddy status to find container ID
const status = reverseProxy.getStatus();
// Update platform service record
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, {
status: 'running',
containerId: 'onebox-caddy', // Service name for Swarm services
});
}
logger.success('Caddy platform service started');
return 'onebox-caddy';
}
/**
* Stop Caddy container - NOT ALLOWED for core infrastructure
*/
async stopContainer(_containerId: string): Promise<void> {
throw new Error('Caddy is a core infrastructure service and cannot be stopped');
}
/**
* Check if Caddy is healthy via the reverse proxy
*/
async healthCheck(): Promise<boolean> {
try {
const reverseProxy = this.oneboxRef.reverseProxy;
const status = reverseProxy.getStatus();
return status.http.running;
} catch (error) {
logger.debug(`Caddy health check failed: ${error}`);
return false;
}
}
/**
* Caddy doesn't provision resources for user services
*/
async provisionResource(_userService: IService): Promise<IProvisionedResource> {
throw new Error('Caddy does not provision resources for user services');
}
/**
* Caddy doesn't deprovision resources
*/
async deprovisionResource(_resource: IPlatformResource, _credentials: Record<string, string>): Promise<void> {
throw new Error('Caddy does not manage resources for user services');
}
}
@@ -0,0 +1,87 @@
/**
* SmartProxy Platform Service Provider
*
* SmartProxy is a core infrastructure service that provides reverse proxy functionality.
* Unlike other platform services:
* - It doesn't provision resources for user services
* - It's started automatically by Onebox and cannot be stopped by users
* - It delegates to the existing reverse proxy manager for actual operations
*/
import { BasePlatformServiceProvider } from './base.ts';
import type {
IService,
IPlatformResource,
IPlatformServiceConfig,
IProvisionedResource,
IEnvVarMapping,
TPlatformServiceType,
TPlatformResourceType,
} from '../../../types.ts';
import { logger } from '../../../logging.ts';
import type { Onebox } from '../../onebox.ts';
export class SmartProxyProvider extends BasePlatformServiceProvider {
readonly type: TPlatformServiceType = 'smartproxy';
readonly displayName = 'SmartProxy Reverse Proxy';
readonly resourceTypes: TPlatformResourceType[] = [];
readonly isCore = true;
constructor(oneboxRef: Onebox) {
super(oneboxRef);
}
getDefaultConfig(): IPlatformServiceConfig {
return {
image: 'code.foss.global/host.today/ht-docker-smartproxy:latest',
port: 80,
volumes: [],
environment: {},
};
}
getEnvVarMappings(): IEnvVarMapping[] {
return [];
}
async deployContainer(): Promise<string> {
logger.info('Starting SmartProxy via reverse proxy manager...');
const reverseProxy = this.oneboxRef.reverseProxy;
await reverseProxy.startHttp();
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, {
status: 'running',
containerId: 'onebox-smartproxy',
});
}
logger.success('SmartProxy platform service started');
return 'onebox-smartproxy';
}
async stopContainer(_containerId: string): Promise<void> {
throw new Error('SmartProxy is a core infrastructure service and cannot be stopped');
}
async healthCheck(): Promise<boolean> {
try {
const reverseProxy = this.oneboxRef.reverseProxy;
const status = reverseProxy.getStatus();
return status.http.running;
} catch (error) {
logger.debug(`SmartProxy health check failed: ${error}`);
return false;
}
}
async provisionResource(_userService: IService): Promise<IProvisionedResource> {
throw new Error('SmartProxy does not provision resources for user services');
}
async deprovisionResource(_resource: IPlatformResource, _credentials: Record<string, string>): Promise<void> {
throw new Error('SmartProxy does not manage resources for user services');
}
}
@@ -1,7 +1,7 @@
/**
* Caddy Log Receiver for Onebox
* Proxy Log Receiver for Onebox
*
* TCP server that receives access logs from Caddy and broadcasts them to WebSocket clients.
* TCP server that receives reverse proxy access logs and broadcasts them to WebSocket clients.
* Supports per-client filtering by domain and adaptive sampling at high volume.
*/
@@ -18,9 +18,9 @@ export interface ILogFilter {
}
/**
* Caddy access log entry structure (from Caddy JSON format)
* Reverse proxy access log entry structure.
*/
export interface ICaddyAccessLog {
export interface IProxyAccessLog {
ts: number;
level?: string;
logger?: string;
@@ -60,9 +60,9 @@ interface ILogClient {
}
/**
* CaddyLogReceiver - TCP server for Caddy access logs
* ProxyLogReceiver - TCP server for reverse proxy access logs
*/
export class CaddyLogReceiver {
export class ProxyLogReceiver {
private server: Deno.TcpListener | null = null;
private clients: Map<string, ILogClient> = new Map();
private port: number;
@@ -76,7 +76,7 @@ export class CaddyLogReceiver {
private logCounter = 0;
// Ring buffer for recent logs (for late-joining clients)
private recentLogs: ICaddyAccessLog[] = [];
private recentLogs: IProxyAccessLog[] = [];
private maxRecentLogs = 100;
// Traffic stats aggregation (hourly rolling window)
@@ -137,7 +137,7 @@ export class CaddyLogReceiver {
/**
* Record a request in traffic stats
*/
private recordTrafficStats(log: ICaddyAccessLog): void {
private recordTrafficStats(log: IProxyAccessLog): void {
const bucket = this.getCurrentStatsBucket();
bucket.requestCount++;
@@ -164,25 +164,25 @@ export class CaddyLogReceiver {
*/
async start(): Promise<void> {
if (this.running) {
logger.warn('CaddyLogReceiver is already running');
logger.warn('ProxyLogReceiver is already running');
return;
}
try {
this.server = Deno.listen({ port: this.port, transport: 'tcp' });
this.running = true;
logger.success(`CaddyLogReceiver started on TCP port ${this.port}`);
logger.success(`ProxyLogReceiver started on TCP port ${this.port}`);
// Start accepting connections in background
this.acceptConnections();
} catch (error) {
logger.error(`Failed to start CaddyLogReceiver: ${getErrorMessage(error)}`);
logger.error(`Failed to start ProxyLogReceiver: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Accept incoming TCP connections from Caddy
* Accept incoming TCP connections from the reverse proxy
*/
private async acceptConnections(): Promise<void> {
if (!this.server) return;
@@ -194,17 +194,17 @@ export class CaddyLogReceiver {
}
} catch (error) {
if (this.running) {
logger.error(`CaddyLogReceiver accept error: ${getErrorMessage(error)}`);
logger.error(`ProxyLogReceiver accept error: ${getErrorMessage(error)}`);
}
}
}
/**
* Handle a single TCP connection from Caddy
* Handle a single TCP connection from the reverse proxy
*/
private async handleConnection(conn: Deno.TcpConn): Promise<void> {
const remoteAddr = conn.remoteAddr as Deno.NetAddr;
logger.debug(`CaddyLogReceiver: Connection from ${remoteAddr.hostname}:${remoteAddr.port}`);
logger.debug(`ProxyLogReceiver: Connection from ${remoteAddr.hostname}:${remoteAddr.port}`);
const reader = conn.readable.getReader();
const decoder = new TextDecoder();
@@ -217,7 +217,7 @@ export class CaddyLogReceiver {
buffer += decoder.decode(value, { stream: true });
// Process complete lines (Caddy sends newline-delimited JSON)
// Process complete newline-delimited JSON log lines.
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
@@ -229,7 +229,7 @@ export class CaddyLogReceiver {
}
} catch (error) {
if (this.running) {
logger.debug(`CaddyLogReceiver connection closed: ${getErrorMessage(error)}`);
logger.debug(`ProxyLogReceiver connection closed: ${getErrorMessage(error)}`);
}
} finally {
this.connections.delete(conn);
@@ -242,18 +242,18 @@ export class CaddyLogReceiver {
}
/**
* Process a single log line from Caddy
* Process a single access log line
*/
private processLogLine(line: string): void {
try {
const log = JSON.parse(line) as ICaddyAccessLog;
const log = JSON.parse(line) as IProxyAccessLog;
// Only process access logs (check for http.log.access or just access, or any log with request/status)
const isAccessLog = log.logger === 'http.log.access' ||
log.logger === 'access' ||
(log.request && typeof log.status === 'number');
if (!isAccessLog) {
logger.debug(`CaddyLogReceiver: Skipping non-access log: ${log.logger || 'unknown'}`);
logger.debug(`ProxyLogReceiver: Skipping non-access log: ${log.logger || 'unknown'}`);
return;
}
@@ -268,7 +268,7 @@ export class CaddyLogReceiver {
return;
}
logger.debug(`CaddyLogReceiver: Access log received - ${log.request?.method} ${log.request?.host}${log.request?.uri} (status: ${log.status})`);
logger.debug(`ProxyLogReceiver: Access log received - ${log.request?.method} ${log.request?.host}${log.request?.uri} (status: ${log.status})`);
// Add to recent logs buffer
this.recentLogs.push(log);
@@ -277,10 +277,10 @@ export class CaddyLogReceiver {
}
// Broadcast to WebSocket clients (log how many clients)
logger.debug(`CaddyLogReceiver: Broadcasting to ${this.clients.size} clients`);
logger.debug(`ProxyLogReceiver: Broadcasting to ${this.clients.size} clients`);
this.broadcast(log);
} catch (error) {
logger.debug(`Failed to parse Caddy log line: ${getErrorMessage(error)}`);
logger.debug(`Failed to parse proxy log line: ${getErrorMessage(error)}`);
}
}
@@ -317,7 +317,7 @@ export class CaddyLogReceiver {
/**
* Broadcast a log entry to all connected WebSocket clients
*/
private broadcast(log: ICaddyAccessLog): void {
private broadcast(log: IProxyAccessLog): void {
const message = JSON.stringify({
type: 'access_log',
data: {
@@ -365,7 +365,7 @@ export class CaddyLogReceiver {
/**
* Check if a log entry matches a client's filter
*/
private matchesFilter(log: ICaddyAccessLog, filter: ILogFilter): boolean {
private matchesFilter(log: IProxyAccessLog, filter: ILogFilter): boolean {
// Domain filter
if (filter.domain) {
const logHost = log.request.host.toLowerCase();
@@ -385,7 +385,7 @@ export class CaddyLogReceiver {
*/
addClient(clientId: string, ws: WebSocket, filter: ILogFilter = {}): void {
this.clients.set(clientId, { id: clientId, ws, filter });
logger.debug(`CaddyLogReceiver: Added client ${clientId} (${this.clients.size} total)`);
logger.debug(`ProxyLogReceiver: Added client ${clientId} (${this.clients.size} total)`);
// Send recent logs to new client
for (const log of this.recentLogs) {
@@ -422,7 +422,7 @@ export class CaddyLogReceiver {
*/
removeClient(clientId: string): void {
if (this.clients.delete(clientId)) {
logger.debug(`CaddyLogReceiver: Removed client ${clientId} (${this.clients.size} remaining)`);
logger.debug(`ProxyLogReceiver: Removed client ${clientId} (${this.clients.size} remaining)`);
}
}
@@ -433,7 +433,7 @@ export class CaddyLogReceiver {
const client = this.clients.get(clientId);
if (client) {
client.filter = filter;
logger.debug(`CaddyLogReceiver: Updated filter for client ${clientId}`);
logger.debug(`ProxyLogReceiver: Updated filter for client ${clientId}`);
}
}
@@ -470,7 +470,7 @@ export class CaddyLogReceiver {
// Clear clients
this.clients.clear();
logger.info('CaddyLogReceiver stopped');
logger.info('ProxyLogReceiver stopped');
}
/**
+39 -42
View File
@@ -1,8 +1,8 @@
/**
* Reverse Proxy for Onebox
*
* 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.
* Delegates to SmartProxy (running as Docker service) for production-grade reverse proxy
* with TLS termination, 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.
@@ -11,7 +11,7 @@
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.ts';
import { CaddyManager } from './caddy.ts';
import { SmartProxyManager } from './smartproxy.ts';
interface IProxyRoute {
domain: string;
@@ -24,7 +24,7 @@ interface IProxyRoute {
export class OneboxReverseProxy {
private oneboxRef: any;
private database: OneboxDatabase;
private caddy: CaddyManager;
private smartProxy: SmartProxyManager;
private routes: Map<string, IProxyRoute> = new Map();
private httpPort = 8080; // Default to dev ports (will be overridden if production)
private httpsPort = 8443;
@@ -32,33 +32,32 @@ export class OneboxReverseProxy {
constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef;
this.database = oneboxRef.database;
this.caddy = new CaddyManager({
this.smartProxy = new SmartProxyManager({
httpPort: this.httpPort,
httpsPort: this.httpsPort,
});
}
/**
* Initialize reverse proxy - Caddy runs as Docker service, no setup needed
* Initialize reverse proxy - SmartProxy runs as Docker service, no setup needed
*/
async init(): Promise<void> {
logger.info('Reverse proxy initialized (Caddy Docker service)');
logger.info('Reverse proxy initialized (SmartProxy Docker service)');
}
/**
* Start the HTTP/HTTPS reverse proxy server
* Caddy handles both HTTP and HTTPS on the configured ports
* SmartProxy handles both HTTP and HTTPS on the configured ports
*/
async startHttp(port?: number): Promise<void> {
if (port) {
this.httpPort = port;
this.caddy.setPorts(this.httpPort, this.httpsPort);
this.smartProxy.setPorts(this.httpPort, this.httpsPort);
}
try {
// Start Caddy (handles both HTTP and HTTPS)
await this.caddy.start();
logger.success(`Reverse proxy started on port ${this.httpPort} (Caddy Docker service)`);
await this.smartProxy.start();
logger.success(`Reverse proxy started on port ${this.httpPort} (SmartProxy Docker service)`);
} catch (error) {
logger.error(`Failed to start reverse proxy: ${getErrorMessage(error)}`);
throw error;
@@ -66,21 +65,19 @@ export class OneboxReverseProxy {
}
/**
* Start HTTPS - Caddy already handles HTTPS when started
* Start HTTPS - SmartProxy already handles HTTPS when started
* This method exists for interface compatibility
*/
async startHttps(port?: number): Promise<void> {
if (port) {
this.httpsPort = port;
this.caddy.setPorts(this.httpPort, this.httpsPort);
this.smartProxy.setPorts(this.httpPort, this.httpsPort);
}
// Caddy handles both HTTP and HTTPS together
// If already running, just log and optionally reload with new port
const status = this.caddy.getStatus();
const status = this.smartProxy.getStatus();
if (status.running) {
logger.info(`HTTPS already running on port ${this.httpsPort} via Caddy`);
logger.info(`HTTPS already running on port ${this.httpsPort} via SmartProxy`);
} else {
await this.caddy.start();
await this.smartProxy.start();
}
}
@@ -88,13 +85,13 @@ export class OneboxReverseProxy {
* Stop the reverse proxy
*/
async stop(): Promise<void> {
await this.caddy.stop();
await this.smartProxy.stop();
logger.info('Reverse proxy stopped');
}
/**
* Add a route for a service
* Uses Docker service name for upstream (Caddy runs in same Docker network)
* Uses Docker service name for upstream (SmartProxy runs in same Docker network)
*/
async addRoute(serviceId: number, domain: string, targetPort: number): Promise<void> {
try {
@@ -105,7 +102,7 @@ export class OneboxReverseProxy {
}
// Use Docker service name as upstream target
// Caddy runs on the same Docker network, so it can resolve service names directly
// SmartProxy runs on the same Docker network, so it can resolve service names directly
const serviceName = `onebox-${service.name}`;
const targetHost = serviceName;
@@ -119,9 +116,9 @@ export class OneboxReverseProxy {
this.routes.set(domain, route);
// Add route to Caddy using Docker service name
// Add route to SmartProxy using Docker service name
const upstream = `${targetHost}:${targetPort}`;
await this.caddy.addRoute(domain, upstream);
await this.smartProxy.addRoute(domain, upstream);
logger.success(`Added proxy route: ${domain} -> ${upstream}`);
} catch (error) {
@@ -135,9 +132,9 @@ export class OneboxReverseProxy {
*/
removeRoute(domain: string): void {
if (this.routes.delete(domain)) {
// Remove from Caddy (async but we don't wait)
this.caddy.removeRoute(domain).catch((error) => {
logger.error(`Failed to remove Caddy route for ${domain}: ${getErrorMessage(error)}`);
// Remove from SmartProxy (async but we don't wait)
this.smartProxy.removeRoute(domain).catch((error) => {
logger.error(`Failed to remove SmartProxy route for ${domain}: ${getErrorMessage(error)}`);
});
logger.success(`Removed proxy route: ${domain}`);
} else {
@@ -159,9 +156,9 @@ export class OneboxReverseProxy {
try {
logger.info('Reloading proxy routes...');
// Clear local and Caddy routes
// Clear local and SmartProxy routes
this.routes.clear();
this.caddy.clear();
this.smartProxy.clear();
const services = this.database.getAllServices();
@@ -181,7 +178,7 @@ export class OneboxReverseProxy {
/**
* Add TLS certificate for a domain
* Sends PEM content to Caddy via Admin API
* Sends PEM content to SmartProxy via Admin API
*/
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
if (!certPem || !keyPem) {
@@ -189,14 +186,14 @@ export class OneboxReverseProxy {
return;
}
await this.caddy.addCertificate(domain, certPem, keyPem);
await this.smartProxy.addCertificate(domain, certPem, keyPem);
}
/**
* Remove TLS certificate for a domain
*/
removeCertificate(domain: string): void {
this.caddy.removeCertificate(domain).catch((error) => {
this.smartProxy.removeCertificate(domain).catch((error) => {
logger.error(`Failed to remove certificate for ${domain}: ${getErrorMessage(error)}`);
});
}
@@ -213,13 +210,13 @@ export class OneboxReverseProxy {
for (const cert of certificates) {
// Use fullchainPem for the cert (includes intermediates) and keyPem for the key
if (cert.domain && cert.fullchainPem && cert.keyPem) {
await this.caddy.addCertificate(cert.domain, cert.fullchainPem, cert.keyPem);
await this.smartProxy.addCertificate(cert.domain, cert.fullchainPem, cert.keyPem);
} else {
logger.warn(`Skipping certificate for ${cert.domain}: missing PEM content`);
}
}
logger.success(`Loaded ${this.caddy.getCertificates().length} TLS certificates`);
logger.success(`Loaded ${this.smartProxy.getCertificates().length} TLS certificates`);
} catch (error) {
logger.error(`Failed to reload certificates: ${getErrorMessage(error)}`);
throw error;
@@ -230,19 +227,19 @@ export class OneboxReverseProxy {
* Get status of reverse proxy
*/
getStatus() {
const caddyStatus = this.caddy.getStatus();
const smartProxyStatus = this.smartProxy.getStatus();
return {
http: {
running: caddyStatus.running,
port: caddyStatus.httpPort,
running: smartProxyStatus.running,
port: smartProxyStatus.httpPort,
},
https: {
running: caddyStatus.running,
port: caddyStatus.httpsPort,
certificates: caddyStatus.certificates,
running: smartProxyStatus.running,
port: smartProxyStatus.httpsPort,
certificates: smartProxyStatus.certificates,
},
routes: caddyStatus.routes,
backend: 'caddy-docker',
routes: smartProxyStatus.routes,
backend: 'smartproxy-docker',
};
}
}
+459
View File
@@ -0,0 +1,459 @@
/**
* SmartProxy Manager for Onebox
*
* Manages SmartProxy as a Docker Swarm service so it can route to services on
* the Onebox overlay network.
*/
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
const SMARTPROXY_SERVICE_NAME = 'onebox-smartproxy';
const SMARTPROXY_IMAGE = 'code.foss.global/host.today/ht-docker-smartproxy:latest';
const SMARTPROXY_ADMIN_CONTAINER_PORT = 3000;
const SMARTPROXY_HTTP_CONTAINER_PORT = 80;
const SMARTPROXY_HTTPS_CONTAINER_PORT = 443;
export interface ISmartProxyRoute {
domain: string;
upstream: string;
}
export interface ISmartProxyCertificate {
domain: string;
certPem: string;
keyPem: string;
}
interface ISmartProxyRouteConfig {
name: string;
match: {
ports: number;
domains: string;
protocol?: 'http' | 'tcp' | 'udp' | 'quic' | 'http3';
};
action: {
type: 'forward';
targets: Array<{ host: string; port: number }>;
tls?: {
mode: 'terminate';
certificate: {
key: string;
cert: string;
};
};
websocket?: {
enabled: boolean;
};
};
priority?: number;
}
export class SmartProxyManager {
private dockerClient: InstanceType<typeof plugins.docker.Docker> | null = null;
private certsDir: string;
private adminUrl: string;
private adminPort: number;
private httpPort: number;
private httpsPort: number;
private routes: Map<string, ISmartProxyRoute> = new Map();
private certificates: Map<string, ISmartProxyCertificate> = new Map();
private networkName = 'onebox-network';
private serviceRunning = false;
constructor(options?: {
certsDir?: string;
adminPort?: number;
httpPort?: number;
httpsPort?: number;
}) {
this.certsDir = options?.certsDir || './.nogit/certs';
this.adminPort = options?.adminPort || 2019;
this.adminUrl = `http://localhost:${this.adminPort}`;
this.httpPort = options?.httpPort || 8080;
this.httpsPort = options?.httpsPort || 8443;
}
private async ensureDockerClient(): Promise<void> {
if (!this.dockerClient) {
this.dockerClient = new plugins.docker.Docker({
socketPath: 'unix:///var/run/docker.sock',
});
await this.dockerClient.start();
}
}
setPorts(httpPort: number, httpsPort: number): void {
this.httpPort = httpPort;
this.httpsPort = httpsPort;
}
async start(): Promise<void> {
if (this.serviceRunning) {
logger.warn('SmartProxy service is already running');
return;
}
try {
await this.ensureDockerClient();
await Deno.mkdir(this.certsDir, { recursive: true });
logger.info('Starting SmartProxy Docker service...');
const existingService = await this.getExistingService();
if (existingService) {
logger.info('SmartProxy service exists, removing old service...');
await this.removeService();
await new Promise((resolve) => setTimeout(resolve, 2000));
}
const networkId = await this.getNetworkId();
const response = await this.dockerClient!.request('POST', '/services/create', {
Name: SMARTPROXY_SERVICE_NAME,
Labels: {
'managed-by': 'onebox',
'onebox-type': 'smartproxy',
},
TaskTemplate: {
ContainerSpec: {
Image: SMARTPROXY_IMAGE,
Env: [
'SMARTPROXY_ADMIN_HOST=0.0.0.0',
`SMARTPROXY_ADMIN_PORT=${SMARTPROXY_ADMIN_CONTAINER_PORT}`,
],
},
Networks: [
{
Target: networkId,
},
],
RestartPolicy: {
Condition: 'any',
MaxAttempts: 0,
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
EndpointSpec: {
Ports: [
{
Protocol: 'tcp',
TargetPort: SMARTPROXY_HTTP_CONTAINER_PORT,
PublishedPort: this.httpPort,
PublishMode: 'host',
},
{
Protocol: 'tcp',
TargetPort: SMARTPROXY_HTTPS_CONTAINER_PORT,
PublishedPort: this.httpsPort,
PublishMode: 'host',
},
{
Protocol: 'tcp',
TargetPort: SMARTPROXY_ADMIN_CONTAINER_PORT,
PublishedPort: this.adminPort,
PublishMode: 'host',
},
],
},
});
if (response.statusCode >= 300) {
throw new Error(`Failed to create SmartProxy service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`);
}
logger.info(`SmartProxy service created: ${response.body.ID}`);
await this.waitForReady();
this.serviceRunning = true;
await this.reloadConfig();
logger.success(`SmartProxy started (HTTP: ${this.httpPort}, HTTPS: ${this.httpsPort}, Admin: ${this.adminUrl})`);
} catch (error) {
logger.error(`Failed to start SmartProxy: ${getErrorMessage(error)}`);
throw error;
}
}
private async getExistingService(): Promise<any | null> {
try {
const response = await this.dockerClient!.request('GET', `/services/${SMARTPROXY_SERVICE_NAME}`, {});
if (response.statusCode === 200) {
return response.body;
}
return null;
} catch {
return null;
}
}
private async removeService(): Promise<void> {
try {
await this.dockerClient!.request('DELETE', `/services/${SMARTPROXY_SERVICE_NAME}`, {});
} catch {
// Service may not exist.
}
}
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;
}
private async waitForReady(maxAttempts = 120, intervalMs = 1000): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(`${this.adminUrl}/ready`);
if (response.ok) {
return;
}
} catch {
// Not ready yet.
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('SmartProxy service failed to start within timeout');
}
async stop(): Promise<void> {
if (!this.serviceRunning && !(await this.getExistingService())) {
return;
}
try {
await this.ensureDockerClient();
logger.info('Stopping SmartProxy service...');
await this.removeService();
this.serviceRunning = false;
logger.info('SmartProxy service stopped');
} catch (error) {
logger.error(`Failed to stop SmartProxy: ${getErrorMessage(error)}`);
}
}
async isHealthy(): Promise<boolean> {
try {
const response = await fetch(`${this.adminUrl}/health`);
return response.ok;
} catch {
return false;
}
}
async isRunning(): Promise<boolean> {
try {
await this.ensureDockerClient();
const service = await this.getExistingService();
if (!service) return false;
const tasksResponse = await this.dockerClient!.request(
'GET',
`/tasks?filters=${encodeURIComponent(JSON.stringify({ service: [SMARTPROXY_SERVICE_NAME] }))}`,
{},
);
if (tasksResponse.statusCode !== 200) return false;
const tasks = tasksResponse.body;
return tasks.some((task: any) => task.Status?.State === 'running');
} catch {
return false;
}
}
private routeName(prefixArg: string, domainArg: string): string {
return `${prefixArg}-${domainArg.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
}
private parseUpstream(upstreamArg: string): { host: string; port: number } {
const separatorIndex = upstreamArg.lastIndexOf(':');
if (separatorIndex <= 0 || separatorIndex === upstreamArg.length - 1) {
throw new Error(`Invalid upstream target: ${upstreamArg}`);
}
const host = upstreamArg.slice(0, separatorIndex);
const port = Number(upstreamArg.slice(separatorIndex + 1));
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid upstream port in target: ${upstreamArg}`);
}
return { host, port };
}
private buildRoutes(): ISmartProxyRouteConfig[] {
const routeConfigs: ISmartProxyRouteConfig[] = [];
for (const [domain, route] of this.routes) {
const target = this.parseUpstream(route.upstream);
const baseAction = {
type: 'forward' as const,
targets: [target],
websocket: {
enabled: true,
},
};
routeConfigs.push({
name: this.routeName('http', domain),
match: {
ports: SMARTPROXY_HTTP_CONTAINER_PORT,
domains: domain,
protocol: 'http',
},
action: baseAction,
priority: 10,
});
const certificate = this.certificates.get(domain);
if (certificate) {
routeConfigs.push({
name: this.routeName('https', domain),
match: {
ports: SMARTPROXY_HTTPS_CONTAINER_PORT,
domains: domain,
protocol: 'http',
},
action: {
...baseAction,
tls: {
mode: 'terminate',
certificate: {
key: certificate.keyPem,
cert: certificate.certPem,
},
},
},
priority: 20,
});
}
}
return routeConfigs;
}
async reloadConfig(): Promise<void> {
const isRunning = await this.isRunning();
if (!isRunning) {
logger.warn('SmartProxy not running, cannot reload config');
return;
}
const routes = this.buildRoutes();
try {
const response = await fetch(`${this.adminUrl}/routes`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routes }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to reload SmartProxy routes: ${response.status} ${text}`);
}
logger.debug('SmartProxy routes reloaded');
} catch (error) {
logger.error(`Failed to reload SmartProxy routes: ${getErrorMessage(error)}`);
throw error;
}
}
async addRoute(domain: string, upstream: string): Promise<void> {
this.routes.set(domain, { domain, upstream });
if (await this.isRunning()) {
await this.reloadConfig();
}
logger.success(`Added SmartProxy route: ${domain} -> ${upstream}`);
}
async removeRoute(domain: string): Promise<void> {
if (this.routes.delete(domain)) {
if (await this.isRunning()) {
await this.reloadConfig();
}
logger.success(`Removed SmartProxy route: ${domain}`);
}
}
async addCertificate(domain: string, certPem: string, keyPem: string): Promise<void> {
this.certificates.set(domain, {
domain,
certPem,
keyPem,
});
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();
}
logger.success(`Added TLS certificate for ${domain}`);
}
async removeCertificate(domain: string): Promise<void> {
if (this.certificates.delete(domain)) {
try {
await Deno.remove(`${this.certsDir}/${domain}.crt`);
await Deno.remove(`${this.certsDir}/${domain}.key`);
} catch {
// Files may not exist.
}
if (await this.isRunning()) {
await this.reloadConfig();
}
logger.success(`Removed TLS certificate for ${domain}`);
}
}
getRoutes(): ISmartProxyRoute[] {
return Array.from(this.routes.values());
}
getCertificates(): ISmartProxyCertificate[] {
return Array.from(this.certificates.values());
}
clear(): void {
this.routes.clear();
this.certificates.clear();
}
getStatus(): {
running: boolean;
httpPort: number;
httpsPort: number;
routes: number;
certificates: number;
} {
return {
running: this.serviceRunning,
httpPort: this.httpPort,
httpsPort: this.httpsPort,
routes: this.routes.size,
certificates: this.certificates.size,
};
}
}