feat: replace onebox ingress with SmartProxy
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user