/** * 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 | null = null; private certsDir: string; private adminUrl: string; private adminPort: number; private httpPort: number; private httpsPort: number; private routes: Map = new Map(); private certificates: Map = 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 { 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 { 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 { 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 { try { await this.dockerClient!.request('DELETE', `/services/${SMARTPROXY_SERVICE_NAME}`, {}); } catch { // Service may not exist. } } private async getNetworkId(): Promise { 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 { 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 { 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 { try { const response = await fetch(`${this.adminUrl}/health`); return response.ok; } catch { return false; } } async isRunning(): Promise { 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 { 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 { 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 { 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 { 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 { 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, }; } }