import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; import { normalizeHostname } from '../utils/domain.ts'; import { OneboxDatabase } from './database.ts'; import type { IDomain, IService } from '../types.ts'; import type { TDcRouterMode } from './managed-dcrouter.ts'; const adminUiRouteName = 'onebox-admin-ui'; type TWorkHosterType = 'onebox'; type TExternalGatewayRoute = Pick & { domain: string; }; interface IExternalGatewayConfig { url: string; apiToken: string; gatewayClientType?: TWorkHosterType; gatewayClientId?: string; /** @deprecated Use gatewayClientId. */ workHosterId?: string; targetHost?: string; targetPort?: number; } interface IGatewayClientContextResponse { context: { role: 'admin' | 'gatewayClient' | 'operator'; gatewayClient?: { type: 'onebox' | 'cloudly' | 'custom'; id: string; }; }; } interface IWorkHosterDomain { id?: string; name: string; source?: 'dcrouter' | 'provider'; authoritative?: boolean; providerId?: string; serviceCount?: number; managePath?: string; manageUrl?: string; capabilities?: { canCreateSubdomains: boolean; canManageDnsRecords: boolean; canIssueCertificates: boolean; canHostEmail: boolean; }; } interface IGatewayDnsRecord { id: string; domainId: string; domainName?: string; name: string; type: string; value: string; ttl: number; source: string; status: 'active' | 'missing'; gatewayClientType: 'onebox' | 'cloudly' | 'custom'; gatewayClientId: string; appId: string; hostname: string; routeId?: string; serviceName?: string; managePath?: string; manageUrl?: string; } interface IWorkAppRouteOwnership { workHosterType: TWorkHosterType; workHosterId: string; workAppId: string; hostname: string; } interface IGatewayClientOwnership { gatewayClientType?: TWorkHosterType; gatewayClientId?: string; appId: string; hostname: string; } interface IWorkAppRouteSyncResult { success: boolean; action?: 'created' | 'updated' | 'deleted' | 'unchanged'; routeId?: string; message?: string; } interface IDcRouterCertificateExport { success: boolean; cert?: { id: string; domainName: string; created: number; validUntil: number; privateKey: string; publicKey: string; csr: string; }; message?: string; } interface IDcRouterRouteConfig { name: string; match: { ports: number[]; domains: string[]; }; action: { type: 'forward'; targets: Array<{ host: string; port: number }>; tls: { mode: 'terminate'; certificate: 'auto'; }; websocket: { enabled: boolean; }; }; } export class ExternalGatewayManager { private database: OneboxDatabase; constructor(private oneboxRef: any) { this.database = oneboxRef.database; } public async init(): Promise { if (!(await this.isConfigured())) { logger.info('External dcrouter gateway not configured'); return; } await this.syncDomains(); await this.syncServiceRoutes(); } public async syncServiceRoutes(): Promise { const adminUiRoute = this.getAdminUiRoute(); const adminUiDomain = adminUiRoute?.domain; const services = this.database.getAllServices() .filter((service) => service.domain && service.status === 'running' && service.domain !== adminUiDomain ); const activeHostnames = new Set(services.map((service) => service.domain!)); if (adminUiRoute) { activeHostnames.add(adminUiRoute.domain); try { await this.syncGatewayRoute(adminUiRoute); } catch (error) { logger.warn( `Failed to sync external gateway route for ${adminUiRoute.domain}: ${ getErrorMessage(error) }`, ); } } for (const service of services) { try { await this.syncServiceRoute(service); } catch (error) { logger.warn( `Failed to sync external gateway route for ${service.domain}: ${getErrorMessage(error)}`, ); } } await this.deleteStaleServiceRoutes(activeHostnames); } private async deleteStaleServiceRoutes(activeHostnamesArg: Set): Promise { const records = await this.getGatewayDnsRecords(); const staleRecordsByHostname = new Map(); for (const record of records) { if (!record.hostname || activeHostnamesArg.has(record.hostname)) continue; if (this.shouldPreserveUnconfiguredAdminUiRecord(record)) continue; if (!record.routeId && !record.appId && !record.serviceName) continue; staleRecordsByHostname.set(record.hostname, record); } for (const record of staleRecordsByHostname.values()) { try { await this.deleteServiceRoute({ name: record.serviceName || record.appId, domain: record.hostname, }); } catch (error) { logger.warn( `Failed to delete stale external gateway route for ${record.hostname}: ${ getErrorMessage(error) }`, ); } } } public async isConfigured(): Promise { if (this.getMode() === 'disabled') { return false; } const mode = this.getMode(); const url = mode === 'managed' ? this.oneboxRef.managedDcRouter.getGatewayUrl() : this.normalizeUrl(this.database.getSetting('dcrouterGatewayUrl') || ''); const apiToken = mode === 'managed' ? await this.oneboxRef.managedDcRouter.getAdminToken() : await this.database.getSecretSetting('dcrouterGatewayApiToken'); return Boolean(url && apiToken); } public async syncDomains(): Promise { if (!(await this.isConfigured())) { return this.database.getDomainsByProvider('dcrouter'); } const response = { domains: await this.getGatewayDomains() }; const activeDomainNames = new Set(); const now = Date.now(); for (const gatewayDomain of response.domains) { const domainName = gatewayDomain.name.trim().toLowerCase(); if (!domainName) continue; activeDomainNames.add(domainName); const existingDomain = this.database.getDomainByName(domainName); const defaultWildcard = gatewayDomain.capabilities?.canIssueCertificates !== false; if (existingDomain) { this.database.updateDomain(existingDomain.id!, { dnsProvider: 'dcrouter', isObsolete: false, defaultWildcard, updatedAt: now, }); } else { this.database.createDomain({ domain: domainName, dnsProvider: 'dcrouter', isObsolete: false, defaultWildcard, createdAt: now, updatedAt: now, }); } } for (const domain of this.database.getDomainsByProvider('dcrouter')) { if (!activeDomainNames.has(domain.domain)) { this.database.updateDomain(domain.id!, { isObsolete: true, updatedAt: now, }); } } logger.success(`Synced ${activeDomainNames.size} domain(s) from external dcrouter gateway`); return this.database.getDomainsByProvider('dcrouter'); } public async getGatewayDomains(): Promise { const config = await this.getConfig({ requireTarget: false }); if (!config) return []; try { const response = await this.fireDcRouterRequest<{ domains: IWorkHosterDomain[] }>( 'getGatewayClientDomains', config.gatewayClientId ? { gatewayClientId: config.gatewayClientId } : {}, config, ); return response.domains.map((domain) => ({ ...domain, manageUrl: this.buildManageUrl(config, domain.managePath), })); } catch (error) { logger.debug(`Falling back to legacy gateway domain API: ${getErrorMessage(error)}`); const response = await this.fireDcRouterRequest<{ domains: IWorkHosterDomain[] }>( 'getWorkHosterDomains', {}, config, ); return response.domains.map((domain) => ({ ...domain, manageUrl: this.buildManageUrl(config, domain.managePath), })); } } public async getGatewayDnsRecords(): Promise { const config = await this.getConfig({ requireTarget: false }); if (!config) return []; try { const response = await this.fireDcRouterRequest<{ records: IGatewayDnsRecord[] }>( 'getGatewayClientDnsRecords', config.gatewayClientId ? { gatewayClientId: config.gatewayClientId } : {}, config, ); return response.records.map((record) => ({ ...record, serviceName: record.serviceName || record.appId, manageUrl: this.buildManageUrl(config, record.managePath), })); } catch (error) { logger.warn(`Failed to fetch gateway DNS records: ${getErrorMessage(error)}`); return []; } } public async syncServiceRoute(service: IService): Promise { if (!service.domain) return; await this.syncGatewayRoute({ id: service.id, name: service.name, domain: service.domain, status: service.status, }); } public async syncAdminUiRoute(): Promise { const route = this.getAdminUiRoute(); if (!route) return; await this.syncGatewayRoute(route); } public async deleteAdminUiRoute(domain: string): Promise { const normalizedDomain = normalizeHostname(domain); if (!normalizedDomain) return; await this.deleteServiceRoute({ name: adminUiRouteName, domain: normalizedDomain, }); } private async syncGatewayRoute(route: TExternalGatewayRoute): Promise { if (!route.domain) return; const config = await this.getConfig({ requireTarget: true }); if (!config) return; const result = await this.fireDcRouterRequest( 'syncGatewayClientRoute', { ownership: this.buildGatewayClientOwnership(route, route.domain, config), route: this.buildRoute(route, config), enabled: route.status === 'running', }, config, ).catch(async () => { return await this.fireDcRouterRequest( 'syncWorkAppRoute', { ownership: this.buildOwnership(route, route.domain, config), route: this.buildRoute(route, config), enabled: route.status === 'running', }, config, ); }); if (!result.success) { throw new Error(result.message || `dcrouter route sync failed for ${route.domain}`); } logger.success(`External gateway route ${result.action || 'synced'} for ${route.domain}`); await this.importCertificateForDomain(route.domain).catch((error) => { logger.debug( `External gateway certificate import skipped for ${route.domain}: ${ getErrorMessage(error) }`, ); }); } public async deleteServiceRoute( service: Pick, ): Promise { if (!service.domain) return; const config = await this.getConfig({ requireTarget: false }); if (!config) return; const result = await this.fireDcRouterRequest( 'syncGatewayClientRoute', { ownership: this.buildGatewayClientOwnership(service, service.domain, config), delete: true, }, config, ).catch(async () => { return await this.fireDcRouterRequest( 'syncWorkAppRoute', { ownership: this.buildOwnership(service, service.domain!, config), delete: true, }, config, ); }); if (!result.success) { throw new Error(result.message || `dcrouter route delete failed for ${service.domain}`); } logger.info(`External gateway route ${result.action || 'deleted'} for ${service.domain}`); } public async importCertificateForDomain(domain: string): Promise { const config = await this.getConfig({ requireTarget: false }); if (!config) return false; const result = await this.fireDcRouterRequest( 'exportCertificate', { domain }, config, ); if (!result.success || !result.cert) { return false; } const now = Date.now(); const existingCertificate = this.database.getSSLCertificate(domain); if (existingCertificate) { this.database.updateSSLCertificate(domain, { certPem: result.cert.publicKey, keyPem: result.cert.privateKey, fullchainPem: result.cert.publicKey, expiryDate: result.cert.validUntil, updatedAt: now, }); } else { await this.database.createSSLCertificate({ domain, certPem: result.cert.publicKey, keyPem: result.cert.privateKey, fullchainPem: result.cert.publicKey, expiryDate: result.cert.validUntil, issuer: 'dcrouter', createdAt: now, updatedAt: now, }); } await this.oneboxRef.reverseProxy.reloadCertificates(); logger.success(`Imported external gateway certificate for ${domain}`); return true; } private async getConfig(options: { requireTarget?: boolean } = {}): Promise { const mode = this.getMode(); if (mode === 'disabled') { return null; } const url = mode === 'managed' ? this.oneboxRef.managedDcRouter.getGatewayUrl() : this.normalizeUrl(this.database.getSetting('dcrouterGatewayUrl') || ''); const apiToken = mode === 'managed' ? await this.oneboxRef.managedDcRouter.getAdminToken() : await this.database.getSecretSetting('dcrouterGatewayApiToken'); if (!url || !apiToken) { return null; } const config: IExternalGatewayConfig = { url, apiToken, }; const contextClient = await this.getGatewayClientFromToken(config); if (contextClient) { config.gatewayClientType = contextClient.type; config.gatewayClientId = contextClient.id; config.workHosterId = contextClient.id; } else { const fallbackGatewayClientId = mode === 'managed' ? this.oneboxRef.managedDcRouter.ensureGatewayClientId() : this.getStoredGatewayClientId(); if (fallbackGatewayClientId) { config.gatewayClientType = 'onebox'; config.gatewayClientId = fallbackGatewayClientId; config.workHosterId = fallbackGatewayClientId; } } if (options.requireTarget !== false) { if (mode === 'managed') { const target = this.oneboxRef.managedDcRouter.getRouteTarget(); config.targetHost = target.host; config.targetPort = target.port; } else { config.targetHost = this.database.getSetting('dcrouterTargetHost') || this.database.getSetting('serverIP') || undefined; const targetPort = this.parsePort( this.database.getSetting('dcrouterTargetPort') || this.database.getSetting('httpPort') || '80', ); config.targetPort = targetPort; } if (!config.targetHost) { throw new Error('dcrouterTargetHost or serverIP must be configured for external gateway route sync'); } } return config; } private getMode(): TDcRouterMode { return this.oneboxRef.managedDcRouter?.getMode?.() || 'external'; } private async requireConfig(options: { requireTarget?: boolean } = {}): Promise { const config = await this.getConfig(options); if (!config) { throw new Error('External dcrouter gateway is not configured'); } return config; } private normalizeUrl(url: string): string { const trimmedUrl = url.trim().replace(/\/+$/, ''); if (!trimmedUrl) return ''; if (/^https?:\/\//.test(trimmedUrl)) return trimmedUrl; return `https://${trimmedUrl}`; } private parsePort(portValue: string): number { const port = Number(portValue); if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error(`Invalid dcrouter target port: ${portValue}`); } return port; } private getStoredGatewayClientId(): string { return this.database.getSetting('dcrouterGatewayClientId') || this.database.getSetting('dcrouterWorkHosterId') || ''; } private async getGatewayClientFromToken(config: IExternalGatewayConfig): Promise<{ type: TWorkHosterType; id: string } | null> { try { const response = await this.fireDcRouterRequest( 'getGatewayClientContext', {}, config, ); const gatewayClient = response.context.gatewayClient; if (!gatewayClient) return null; if (gatewayClient.type !== 'onebox') { throw new Error(`dcrouter token is bound to unsupported gateway client type: ${gatewayClient.type}`); } return { type: gatewayClient.type, id: gatewayClient.id }; } catch (error) { logger.debug(`dcrouter gateway client context unavailable: ${getErrorMessage(error)}`); return null; } } private buildOwnership( service: Pick, hostname: string, config: IExternalGatewayConfig, ): IWorkAppRouteOwnership { return { workHosterType: 'onebox', workHosterId: config.gatewayClientId || '', workAppId: service.name || `service-${service.id}`, hostname, }; } private buildGatewayClientOwnership( service: Pick, hostname: string, config: IExternalGatewayConfig, ): IGatewayClientOwnership { const ownership: IGatewayClientOwnership = { gatewayClientType: config.gatewayClientType || 'onebox', appId: service.name || `service-${service.id}`, hostname, }; if (config.gatewayClientId) { ownership.gatewayClientId = config.gatewayClientId; } return ownership; } private getAdminUiRoute(): TExternalGatewayRoute | null { const domain = normalizeHostname(this.database.getSetting('adminUiDomain') || ''); if (!domain) return null; return { id: 0, name: adminUiRouteName, domain, status: 'running', }; } private isAdminUiRecord(record: IGatewayDnsRecord): boolean { const ownerName = record.serviceName || record.appId; return ownerName === adminUiRouteName || ownerName === 'onebox'; } private shouldPreserveUnconfiguredAdminUiRecord(record: IGatewayDnsRecord): boolean { return this.database.getSetting('adminUiDomain') === null && this.isAdminUiRecord(record); } private buildRoute( route: TExternalGatewayRoute, config: IExternalGatewayConfig, ): IDcRouterRouteConfig { return { name: this.routeName(route.domain), match: { ports: [443], domains: [route.domain], }, action: { type: 'forward', targets: [{ host: config.targetHost!, port: config.targetPort! }], tls: { mode: 'terminate', certificate: 'auto', }, websocket: { enabled: true, }, }, }; } private routeName(domain: string): string { return `onebox-${domain.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '')}`; } private buildManageUrl(config: IExternalGatewayConfig, managePath?: string): string { const normalizedPath = managePath?.startsWith('/') ? managePath : managePath ? `/${managePath}` : ''; return `${config.url}${normalizedPath}`; } private async fireDcRouterRequest( method: string, requestData: Record, config: IExternalGatewayConfig, ): Promise { const typedRequest = new plugins.typedrequest.TypedRequest( `${config.url}/typedrequest`, method, ); return await typedRequest.fire({ ...requestData, apiToken: config.apiToken, }) as TResponse; } }