feat(admin-ui): add configurable Admin UI domain routing

This commit is contained in:
2026-05-24 14:46:35 +00:00
parent a86d83f835
commit d91fda084b
11 changed files with 551 additions and 38 deletions
+102 -17
View File
@@ -1,11 +1,17 @@
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<IService, 'id' | 'name' | 'domain' | 'status'> & {
domain: string;
};
interface IExternalGatewayConfig {
url: string;
@@ -137,15 +143,34 @@ export class ExternalGatewayManager {
}
public async syncServiceRoutes(): Promise<void> {
const adminUiRoute = this.getAdminUiRoute();
const adminUiDomain = adminUiRoute?.domain;
const services = this.database.getAllServices()
.filter((service) => service.domain && service.status === 'running');
.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)}`);
logger.warn(
`Failed to sync external gateway route for ${service.domain}: ${getErrorMessage(error)}`,
);
}
}
@@ -158,6 +183,7 @@ export class ExternalGatewayManager {
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);
}
@@ -169,7 +195,11 @@ export class ExternalGatewayManager {
domain: record.hostname,
});
} catch (error) {
logger.warn(`Failed to delete stale external gateway route for ${record.hostname}: ${getErrorMessage(error)}`);
logger.warn(
`Failed to delete stale external gateway route for ${record.hostname}: ${
getErrorMessage(error)
}`,
);
}
}
}
@@ -289,40 +319,72 @@ export class ExternalGatewayManager {
public async syncServiceRoute(service: IService): Promise<void> {
if (!service.domain) return;
await this.syncGatewayRoute({
id: service.id,
name: service.name,
domain: service.domain,
status: service.status,
});
}
public async syncAdminUiRoute(): Promise<void> {
const route = this.getAdminUiRoute();
if (!route) return;
await this.syncGatewayRoute(route);
}
public async deleteAdminUiRoute(domain: string): Promise<void> {
const normalizedDomain = normalizeHostname(domain);
if (!normalizedDomain) return;
await this.deleteServiceRoute({
name: adminUiRouteName,
domain: normalizedDomain,
});
}
private async syncGatewayRoute(route: TExternalGatewayRoute): Promise<void> {
if (!route.domain) return;
const config = await this.getConfig({ requireTarget: true });
if (!config) return;
const result = await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
'syncGatewayClientRoute',
{
ownership: this.buildGatewayClientOwnership(service, service.domain, config),
route: this.buildRoute(service, config),
enabled: service.status === 'running',
ownership: this.buildGatewayClientOwnership(route, route.domain, config),
route: this.buildRoute(route, config),
enabled: route.status === 'running',
},
config,
).catch(async () => {
return await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
'syncWorkAppRoute',
{
ownership: this.buildOwnership(service, service.domain!, config),
route: this.buildRoute(service, config),
enabled: service.status === 'running',
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 ${service.domain}`);
throw new Error(result.message || `dcrouter route sync failed for ${route.domain}`);
}
logger.success(`External gateway route ${result.action || 'synced'} for ${service.domain}`);
await this.importCertificateForDomain(service.domain).catch((error) => {
logger.debug(`External gateway certificate import skipped for ${service.domain}: ${getErrorMessage(error)}`);
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<IService, 'id' | 'name' | 'domain'>): Promise<void> {
public async deleteServiceRoute(
service: Pick<IService, 'id' | 'name' | 'domain'>,
): Promise<void> {
if (!service.domain) return;
const config = await this.getConfig({ requireTarget: false });
@@ -536,12 +598,35 @@ export class ExternalGatewayManager {
return ownership;
}
private buildRoute(service: IService, config: IExternalGatewayConfig): IDcRouterRouteConfig {
private getAdminUiRoute(): TExternalGatewayRoute | null {
const domain = normalizeHostname(this.database.getSetting('adminUiDomain') || '');
if (!domain) return null;
return {
name: this.routeName(service.domain!),
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: [service.domain!],
domains: [route.domain],
},
action: {
type: 'forward',
+43 -1
View File
@@ -10,15 +10,20 @@
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { normalizeHostname } from '../utils/domain.ts';
import { OneboxDatabase } from './database.ts';
import { SmartProxyManager } from './smartproxy.ts';
const adminUiRouteName = 'onebox-admin-ui';
const adminUiPort = 3000;
interface IProxyRoute {
domain: string;
targetHost: string;
targetPort: number;
serviceId: number;
serviceId?: number;
serviceName?: string;
routeType: 'service' | 'admin-ui';
}
export class OneboxReverseProxy {
@@ -112,6 +117,7 @@ export class OneboxReverseProxy {
targetPort,
serviceId,
serviceName,
routeType: 'service',
};
this.routes.set(domain, route);
@@ -127,6 +133,25 @@ export class OneboxReverseProxy {
}
}
async addAdminUiRoute(domain: string): Promise<void> {
const normalizedDomain = normalizeHostname(domain);
if (!normalizedDomain) return;
const targetHost = this.getAdminUiTargetHost();
const route: IProxyRoute = {
domain: normalizedDomain,
targetHost,
targetPort: adminUiPort,
serviceName: adminUiRouteName,
routeType: 'admin-ui',
};
this.routes.set(normalizedDomain, route);
const upstream = `${targetHost}:${adminUiPort}`;
await this.smartProxy.addRoute(normalizedDomain, upstream);
logger.success(`Added Admin UI proxy route: ${normalizedDomain} -> ${upstream}`);
}
/**
* Remove a route
*/
@@ -166,6 +191,11 @@ export class OneboxReverseProxy {
}
}
const adminUiDomain = this.getAdminUiDomain();
if (adminUiDomain) {
await this.addAdminUiRoute(adminUiDomain);
}
logger.success(`Loaded ${this.routes.size} proxy routes`);
} catch (error) {
logger.error(`Failed to reload routes: ${getErrorMessage(error)}`);
@@ -173,6 +203,18 @@ export class OneboxReverseProxy {
}
}
private getAdminUiDomain(): string {
return normalizeHostname(this.database.getSetting('adminUiDomain') || '');
}
private getAdminUiTargetHost(): string {
const serverIP = this.database.getSetting('serverIP');
if (!serverIP) {
logger.warn('serverIP is not configured; Admin UI proxy route will use host.docker.internal');
}
return serverIP || 'host.docker.internal';
}
/**
* Add TLS certificate for a domain
* Sends PEM content to SmartProxy via Admin API