import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; import { OneboxDatabase } from './database.ts'; export type TDcRouterMode = 'managed' | 'external' | 'disabled'; export interface IManagedDcRouterStatus { mode: TDcRouterMode; configured: boolean; running: boolean; healthy: boolean; containerId?: string; image: string; gatewayUrl: string; opsPort: number; httpPort: number; httpsPort: number; message?: string; } const containerName = 'onebox-dcrouter'; const defaultImage = 'code.foss.global/serve.zone/dcrouter:latest'; const defaultDataDir = './.nogit/dcrouter-data'; const defaultOpsPort = 3300; const defaultHttpPort = 80; const defaultHttpsPort = 443; const internalBaseDir = '/data'; export class ManagedDcRouterManager { private database: OneboxDatabase; private dockerClient: InstanceType | null = null; constructor(private oneboxRef: any) { this.database = oneboxRef.database; } public getMode(): TDcRouterMode { const storedMode = this.database.getSetting('dcrouterMode'); if (storedMode === 'managed' || storedMode === 'external' || storedMode === 'disabled') { return storedMode; } const hasExternalGateway = Boolean(this.database.getSetting('dcrouterGatewayUrl')); return hasExternalGateway ? 'external' : 'managed'; } public getImage(): string { return this.database.getSetting('dcrouterManagedImage') || defaultImage; } public getOpsPort(): number { return this.parsePort(this.database.getSetting('dcrouterManagedOpsPort'), defaultOpsPort); } public getHttpPort(): number { return this.parsePort(this.database.getSetting('dcrouterManagedHttpPort'), defaultHttpPort); } public getHttpsPort(): number { return this.parsePort(this.database.getSetting('dcrouterManagedHttpsPort'), defaultHttpsPort); } public getDataDir(): string { return this.database.getSetting('dcrouterManagedDataDir') || defaultDataDir; } public getGatewayUrl(): string { return `http://127.0.0.1:${this.getOpsPort()}`; } public getRouteTarget(): { host: string; port: number } { return { host: 'onebox-smartproxy', port: 80, }; } public ensureGatewayClientId(): string { let gatewayClientId = this.database.getSetting('dcrouterGatewayClientId') || this.database.getSetting('dcrouterWorkHosterId'); if (!gatewayClientId) { gatewayClientId = `onebox-${crypto.randomUUID()}`; this.database.setSetting('dcrouterGatewayClientId', gatewayClientId); } return gatewayClientId; } public async getAdminToken(): Promise { const existingToken = await this.database.getSecretSetting('dcrouterManagedAdminApiToken'); if (existingToken) { return existingToken; } const token = `dcr_${crypto.randomUUID().replaceAll('-', '')}${crypto.randomUUID().replaceAll('-', '')}`; await this.database.setSecretSetting('dcrouterManagedAdminApiToken', token); return token; } public async prepareGatewaySettings(): Promise { if (this.getMode() !== 'managed') { return; } const target = this.getRouteTarget(); this.database.setSetting('dcrouterMode', 'managed'); this.database.setSetting('dcrouterGatewayUrl', this.getGatewayUrl()); this.database.setSetting('dcrouterTargetHost', target.host); this.database.setSetting('dcrouterTargetPort', String(target.port)); this.ensureGatewayClientId(); await this.getAdminToken(); } public async init(): Promise { if (this.getMode() === 'managed') { await this.start(); return; } await this.stop(); } public async start(options: { recreate?: boolean } = {}): Promise { if (this.getMode() !== 'managed') { throw new Error('Managed dcrouter mode is not enabled'); } await this.prepareGatewaySettings(); await this.ensureDockerClient(); if (options.recreate) { await this.removeExistingContainer(); } const existingContainer = await this.getExistingContainer(); if (existingContainer) { if (this.isContainerRunning(existingContainer)) { await this.waitForReady().catch((error) => { logger.warn(`Managed dcrouter readiness check failed: ${getErrorMessage(error)}`); }); return await this.getStatus(); } await this.startContainer(existingContainer.Id); await this.waitForReady(); return await this.getStatus(); } await this.createContainer(); await this.waitForReady(); return await this.getStatus(); } public async stop(): Promise { await this.ensureDockerClient(); const existingContainer = await this.getExistingContainer(); if (existingContainer && this.isContainerRunning(existingContainer)) { await this.stopContainer(existingContainer.Id); } return await this.getStatus(); } public async restart(): Promise { return await this.start({ recreate: true }); } public async getStatus(): Promise { const baseStatus: IManagedDcRouterStatus = { mode: this.getMode(), configured: this.getMode() === 'managed', running: false, healthy: false, image: this.getImage(), gatewayUrl: this.getGatewayUrl(), opsPort: this.getOpsPort(), httpPort: this.getHttpPort(), httpsPort: this.getHttpsPort(), }; try { await this.ensureDockerClient(); const existingContainer = await this.getExistingContainer(); if (!existingContainer) { return baseStatus; } const running = this.isContainerRunning(existingContainer); return { ...baseStatus, running, healthy: running ? await this.checkHealthy() : false, containerId: existingContainer.Id, }; } catch (error) { return { ...baseStatus, message: getErrorMessage(error), }; } } private async ensureDockerClient(): Promise { if (!this.dockerClient) { this.dockerClient = new plugins.docker.Docker({ socketPath: 'unix:///var/run/docker.sock', }); await this.dockerClient.start(); } } private parsePort(value: string | null, fallback: number): number { if (!value) return fallback; const port = Number(value); if (!Number.isInteger(port) || port < 1 || port > 65535) { return fallback; } return port; } private async getAbsoluteDataDir(): Promise { const dataDir = plugins.path.resolve(this.getDataDir()); await Deno.mkdir(dataDir, { recursive: true }); return dataDir; } private async createContainer(): Promise { const image = this.getImage(); const token = await this.getAdminToken(); const dataDir = await this.getAbsoluteDataDir(); await this.oneboxRef.docker.pullImage(image); const response = await this.dockerClient!.request('POST', `/containers/create?name=${containerName}`, { Image: image, Env: [ `DCROUTER_BASE_DIR=${internalBaseDir}`, `DCROUTER_ADMIN_API_TOKEN=${token}`, 'DCROUTER_ADMIN_API_TOKEN_NAME=Onebox Managed Admin Token', ], Labels: { 'managed-by': 'onebox', 'onebox-type': 'dcrouter', }, ExposedPorts: { '80/tcp': {}, '443/tcp': {}, '3000/tcp': {}, }, HostConfig: { NetworkMode: 'onebox-network', RestartPolicy: { Name: 'unless-stopped', }, Binds: [`${dataDir}:${internalBaseDir}`], PortBindings: { '80/tcp': [{ HostIp: '0.0.0.0', HostPort: String(this.getHttpPort()) }], '443/tcp': [{ HostIp: '0.0.0.0', HostPort: String(this.getHttpsPort()) }], '3000/tcp': [{ HostIp: '127.0.0.1', HostPort: String(this.getOpsPort()) }], }, }, }); if (response.statusCode >= 300) { throw new Error(`Failed to create managed dcrouter container: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`); } await this.startContainer(response.body.Id); logger.success(`Managed dcrouter container started: ${response.body.Id}`); } private async getExistingContainer(): Promise { const filters = encodeURIComponent(JSON.stringify({ name: [containerName] })); const response = await this.dockerClient!.request('GET', `/containers/json?all=true&filters=${filters}`, {}); if (response.statusCode >= 300 || !Array.isArray(response.body)) { return null; } return response.body.find((container: any) => { return container.Names?.some((name: string) => name === `/${containerName}` || name === containerName); }) ?? null; } private isContainerRunning(container: any): boolean { return container.State === 'running' || Boolean(container.Status?.toLowerCase().startsWith('up ')); } private async startContainer(containerId: string): Promise { const response = await this.dockerClient!.request('POST', `/containers/${containerId}/start`, {}); if (response.statusCode >= 300 && response.statusCode !== 304) { throw new Error(`Failed to start managed dcrouter container: HTTP ${response.statusCode}`); } } private async stopContainer(containerId: string): Promise { const response = await this.dockerClient!.request('POST', `/containers/${containerId}/stop`, {}); if (response.statusCode >= 300 && response.statusCode !== 304) { throw new Error(`Failed to stop managed dcrouter container: HTTP ${response.statusCode}`); } } private async removeExistingContainer(): Promise { const existingContainer = await this.getExistingContainer(); if (!existingContainer) { return; } const response = await this.dockerClient!.request('DELETE', `/containers/${existingContainer.Id}?force=true`, {}); if (response.statusCode >= 300) { throw new Error(`Failed to remove managed dcrouter container: HTTP ${response.statusCode}`); } } private async checkHealthy(): Promise { try { const response = await fetch(this.getGatewayUrl()); return response.ok; } catch { return false; } } private async waitForReady(maxAttempts = 30, intervalMs = 1000): Promise { for (let i = 0; i < maxAttempts; i++) { if (await this.checkHealthy()) { return; } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error('Managed dcrouter did not become ready in time'); } }