fix(external-gateway): derive gateway client identity from the dcrouter token and make the settings UI read-only

This commit is contained in:
2026-05-09 22:36:26 +00:00
parent b9c90eca3d
commit 15574b8629
7 changed files with 135 additions and 34 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-05-09 - 1.26.1 - fix(external-gateway)
derive gateway client identity from the dcrouter token and make the settings UI read-only
- Resolves external gateway ownership and domain sync to use the gateway client context returned by dcrouter instead of a locally entered client ID.
- Falls back to stored gateway client settings only when token context is unavailable.
- Removes editable Gateway Client ID fields from settings and shows them as diagnostic read-only values for managed and external modes.
- Updates external gateway tests to validate token-derived gateway client IDs and admin-token behavior.
## 2026-05-09 - 1.26.0 - feat(dcrouter) ## 2026-05-09 - 1.26.0 - feat(dcrouter)
add managed local dcrouter mode with status controls and gateway integration add managed local dcrouter mode with status controls and gateway integration
+18 -5
View File
@@ -62,8 +62,6 @@ class FakeDatabase {
const makeOneboxRef = () => { const makeOneboxRef = () => {
const database = new FakeDatabase(); const database = new FakeDatabase();
database.settings.set('dcrouterGatewayUrl', 'https://edge.example.com'); database.settings.set('dcrouterGatewayUrl', 'https://edge.example.com');
database.settings.set('dcrouterGatewayClientId', 'onebox-1');
database.settings.set('dcrouterWorkHosterId', 'onebox-1');
database.secretSettings.set('dcrouterGatewayApiToken', 'dcr-token'); database.secretSettings.set('dcrouterGatewayApiToken', 'dcr-token');
let reloadCount = 0; let reloadCount = 0;
@@ -94,8 +92,11 @@ Deno.test('ExternalGatewayManager syncs dcrouter domains into Onebox domains', a
const manager = new ExternalGatewayManager(oneboxRef as any); const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => { (manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
assertEquals(method, 'getGatewayClientDomains'); assertEquals(method, 'getGatewayClientDomains');
assertEquals(requestData.gatewayClientId, 'onebox-1'); assertEquals(requestData.gatewayClientId, 'onebox-token');
return { return {
domains: [ domains: [
{ {
@@ -139,6 +140,9 @@ Deno.test('ExternalGatewayManager syncs service routes to dcrouter gatewayClient
const requests: Array<{ method: string; requestData: Record<string, unknown> }> = []; const requests: Array<{ method: string; requestData: Record<string, unknown> }> = [];
const manager = new ExternalGatewayManager(oneboxRef as any); const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => { (manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
requests.push({ method, requestData }); requests.push({ method, requestData });
if (method === 'exportCertificate') { if (method === 'exportCertificate') {
return { success: false }; return { success: false };
@@ -154,7 +158,7 @@ Deno.test('ExternalGatewayManager syncs service routes to dcrouter gatewayClient
assertEquals(ownership, { assertEquals(ownership, {
gatewayClientType: 'onebox', gatewayClientType: 'onebox',
gatewayClientId: 'onebox-1', gatewayClientId: 'onebox-token',
appId: 'hello', appId: 'hello',
hostname: 'hello.example.com', hostname: 'hello.example.com',
}); });
@@ -189,6 +193,9 @@ Deno.test('ExternalGatewayManager uses managed dcrouter local target in managed
let syncRequest: Record<string, unknown> | null = null; let syncRequest: Record<string, unknown> | null = null;
const manager = new ExternalGatewayManager(oneboxRef as any); const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>, config: any) => { (manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>, config: any) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'admin' } };
}
if (method === 'exportCertificate') { if (method === 'exportCertificate') {
return { success: false }; return { success: false };
} }
@@ -213,6 +220,9 @@ Deno.test('ExternalGatewayManager deletes service routes through dcrouter gatewa
let deleteRequest: Record<string, unknown> | null = null; let deleteRequest: Record<string, unknown> | null = null;
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => { (manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
assertEquals(method, 'syncGatewayClientRoute'); assertEquals(method, 'syncGatewayClientRoute');
deleteRequest = requestData; deleteRequest = requestData;
return { success: true, action: 'deleted', routeId: 'route-1' }; return { success: true, action: 'deleted', routeId: 'route-1' };
@@ -227,7 +237,7 @@ Deno.test('ExternalGatewayManager deletes service routes through dcrouter gatewa
assert(deleteRequest); assert(deleteRequest);
const capturedDeleteRequest = deleteRequest as Record<string, unknown>; const capturedDeleteRequest = deleteRequest as Record<string, unknown>;
assertEquals(capturedDeleteRequest.delete, true); assertEquals(capturedDeleteRequest.delete, true);
assertEquals((capturedDeleteRequest.ownership as any).gatewayClientId, 'onebox-1'); assertEquals((capturedDeleteRequest.ownership as any).gatewayClientId, 'onebox-token');
assertEquals((capturedDeleteRequest.ownership as any).hostname, 'hello.example.com'); assertEquals((capturedDeleteRequest.ownership as any).hostname, 'hello.example.com');
}); });
@@ -235,6 +245,9 @@ Deno.test('ExternalGatewayManager imports exported dcrouter certificates into On
const oneboxRef = makeOneboxRef(); const oneboxRef = makeOneboxRef();
const manager = new ExternalGatewayManager(oneboxRef as any); const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => { (manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
assertEquals(method, 'exportCertificate'); assertEquals(method, 'exportCertificate');
assertEquals(requestData.domain, 'hello.example.com'); assertEquals(requestData.domain, 'hello.example.com');
return { return {
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/onebox', name: '@serve.zone/onebox',
version: '1.26.0', version: '1.26.1',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
} }
+68 -23
View File
@@ -10,13 +10,24 @@ type TWorkHosterType = 'onebox';
interface IExternalGatewayConfig { interface IExternalGatewayConfig {
url: string; url: string;
apiToken: string; apiToken: string;
gatewayClientId: string; gatewayClientType?: TWorkHosterType;
gatewayClientId?: string;
/** @deprecated Use gatewayClientId. */ /** @deprecated Use gatewayClientId. */
workHosterId: string; workHosterId?: string;
targetHost?: string; targetHost?: string;
targetPort?: number; targetPort?: number;
} }
interface IGatewayClientContextResponse {
context: {
role: 'admin' | 'gatewayClient' | 'operator';
gatewayClient?: {
type: 'onebox' | 'cloudly' | 'custom';
id: string;
};
};
}
interface IWorkHosterDomain { interface IWorkHosterDomain {
id?: string; id?: string;
name: string; name: string;
@@ -62,8 +73,8 @@ interface IWorkAppRouteOwnership {
} }
interface IGatewayClientOwnership { interface IGatewayClientOwnership {
gatewayClientType: TWorkHosterType; gatewayClientType?: TWorkHosterType;
gatewayClientId: string; gatewayClientId?: string;
appId: string; appId: string;
hostname: string; hostname: string;
} }
@@ -128,8 +139,14 @@ export class ExternalGatewayManager {
if (this.getMode() === 'disabled') { if (this.getMode() === 'disabled') {
return false; return false;
} }
const config = await this.getConfig({ requireTarget: false }); const mode = this.getMode();
return Boolean(config); 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<IDomain[]> { public async syncDomains(): Promise<IDomain[]> {
@@ -188,7 +205,7 @@ export class ExternalGatewayManager {
try { try {
const response = await this.fireDcRouterRequest<{ domains: IWorkHosterDomain[] }>( const response = await this.fireDcRouterRequest<{ domains: IWorkHosterDomain[] }>(
'getGatewayClientDomains', 'getGatewayClientDomains',
{ gatewayClientId: config.gatewayClientId }, config.gatewayClientId ? { gatewayClientId: config.gatewayClientId } : {},
config, config,
); );
return response.domains.map((domain) => ({ return response.domains.map((domain) => ({
@@ -216,7 +233,7 @@ export class ExternalGatewayManager {
try { try {
const response = await this.fireDcRouterRequest<{ records: IGatewayDnsRecord[] }>( const response = await this.fireDcRouterRequest<{ records: IGatewayDnsRecord[] }>(
'getGatewayClientDnsRecords', 'getGatewayClientDnsRecords',
{ gatewayClientId: config.gatewayClientId }, config.gatewayClientId ? { gatewayClientId: config.gatewayClientId } : {},
config, config,
); );
return response.records.map((record) => ({ return response.records.map((record) => ({
@@ -355,16 +372,27 @@ export class ExternalGatewayManager {
return null; return null;
} }
const gatewayClientId = mode === 'managed'
? this.oneboxRef.managedDcRouter.ensureGatewayClientId()
: this.ensureGatewayClientId();
const config: IExternalGatewayConfig = { const config: IExternalGatewayConfig = {
url, url,
apiToken, apiToken,
gatewayClientId,
workHosterId: gatewayClientId,
}; };
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 (options.requireTarget !== false) {
if (mode === 'managed') { if (mode === 'managed') {
const target = this.oneboxRef.managedDcRouter.getRouteTarget(); const target = this.oneboxRef.managedDcRouter.getRouteTarget();
@@ -417,13 +445,27 @@ export class ExternalGatewayManager {
return port; return port;
} }
private ensureGatewayClientId(): string { private getStoredGatewayClientId(): string {
let gatewayClientId = this.database.getSetting('dcrouterGatewayClientId') || this.database.getSetting('dcrouterWorkHosterId'); return this.database.getSetting('dcrouterGatewayClientId') || this.database.getSetting('dcrouterWorkHosterId') || '';
if (!gatewayClientId) { }
gatewayClientId = crypto.randomUUID();
this.database.setSetting('dcrouterGatewayClientId', gatewayClientId); private async getGatewayClientFromToken(config: IExternalGatewayConfig): Promise<{ type: TWorkHosterType; id: string } | null> {
try {
const response = await this.fireDcRouterRequest<IGatewayClientContextResponse>(
'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;
} }
return gatewayClientId;
} }
private buildOwnership( private buildOwnership(
@@ -433,7 +475,7 @@ export class ExternalGatewayManager {
): IWorkAppRouteOwnership { ): IWorkAppRouteOwnership {
return { return {
workHosterType: 'onebox', workHosterType: 'onebox',
workHosterId: config.gatewayClientId, workHosterId: config.gatewayClientId || '',
workAppId: service.name || `service-${service.id}`, workAppId: service.name || `service-${service.id}`,
hostname, hostname,
}; };
@@ -444,12 +486,15 @@ export class ExternalGatewayManager {
hostname: string, hostname: string,
config: IExternalGatewayConfig, config: IExternalGatewayConfig,
): IGatewayClientOwnership { ): IGatewayClientOwnership {
return { const ownership: IGatewayClientOwnership = {
gatewayClientType: 'onebox', gatewayClientType: config.gatewayClientType || 'onebox',
gatewayClientId: config.gatewayClientId,
appId: service.name || `service-${service.id}`, appId: service.name || `service-${service.id}`,
hostname, hostname,
}; };
if (config.gatewayClientId) {
ownership.gatewayClientId = config.gatewayClientId;
}
return ownership;
} }
private buildRoute(service: IService, config: IExternalGatewayConfig): IDcRouterRouteConfig { private buildRoute(service: IService, config: IExternalGatewayConfig): IDcRouterRouteConfig {
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/onebox', name: '@serve.zone/onebox',
version: '1.26.0', version: '1.26.1',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
} }
+38 -3
View File
@@ -144,6 +144,32 @@ export class ObViewSettings extends DeesElement {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.gateway-readonly {
padding: 10px 12px;
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
border-radius: 8px;
background: ${cssManager.bdTheme('#fafafa', '#18181b')};
}
.gateway-readonly-label {
font-size: 12px;
font-weight: 600;
color: ${cssManager.bdTheme('#52525b', '#d4d4d8')};
}
.gateway-readonly-value {
margin-top: 4px;
font-size: 13px;
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
word-break: break-all;
}
.gateway-readonly-hint {
margin-top: 4px;
font-size: 12px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
dees-input-text { dees-input-text {
width: 100%; width: 100%;
} }
@@ -240,11 +266,11 @@ export class ObViewSettings extends DeesElement {
${this.renderGatewayInput('dcrouterManagedOpsPort', 'Local Ops Port', String(settings?.dcrouterManagedOpsPort || 3300), 'Bound to 127.0.0.1 for Onebox to call dcrouter APIs.')} ${this.renderGatewayInput('dcrouterManagedOpsPort', 'Local Ops Port', String(settings?.dcrouterManagedOpsPort || 3300), 'Bound to 127.0.0.1 for Onebox to call dcrouter APIs.')}
${this.renderGatewayInput('dcrouterManagedHttpPort', 'Public HTTP Port', String(settings?.dcrouterManagedHttpPort || 80), 'Host port owned by dcrouter for HTTP ingress.')} ${this.renderGatewayInput('dcrouterManagedHttpPort', 'Public HTTP Port', String(settings?.dcrouterManagedHttpPort || 80), 'Host port owned by dcrouter for HTTP ingress.')}
${this.renderGatewayInput('dcrouterManagedHttpsPort', 'Public HTTPS Port', String(settings?.dcrouterManagedHttpsPort || 443), 'Host port owned by dcrouter for HTTPS ingress.')} ${this.renderGatewayInput('dcrouterManagedHttpsPort', 'Public HTTPS Port', String(settings?.dcrouterManagedHttpsPort || 443), 'Host port owned by dcrouter for HTTPS ingress.')}
${this.renderGatewayInput('dcrouterGatewayClientId', 'Gateway Client ID', settings?.dcrouterGatewayClientId || settings?.dcrouterWorkHosterId || '', 'Leave empty to let Onebox create a stable ID.')} ${this.renderGatewayReadonly('Gateway Client ID', settings?.dcrouterGatewayClientId || settings?.dcrouterWorkHosterId || 'Created when managed dcrouter starts', 'Diagnostic only. Onebox manages this local client automatically.')}
` : mode === 'external' ? html` ` : mode === 'external' ? html`
${this.renderGatewayInput('dcrouterGatewayUrl', 'Gateway URL', settings?.dcrouterGatewayUrl || '', 'Base URL of the dcrouter OpsServer.')} ${this.renderGatewayInput('dcrouterGatewayUrl', 'Gateway URL', settings?.dcrouterGatewayUrl || '', 'Base URL of the dcrouter OpsServer.')}
${this.renderGatewayInput('dcrouterGatewayApiToken', 'API Token', settings?.dcrouterGatewayApiToken || '', 'Requires gateway-client access in dcrouter.', true)} ${this.renderGatewayInput('dcrouterGatewayApiToken', 'API Token', settings?.dcrouterGatewayApiToken || '', 'Requires gateway-client access in dcrouter.', true)}
${this.renderGatewayInput('dcrouterGatewayClientId', 'Gateway Client ID', settings?.dcrouterGatewayClientId || settings?.dcrouterWorkHosterId || '', 'Leave empty to let Onebox create a stable ID.')} ${this.renderGatewayReadonly('Gateway Client ID', settings?.dcrouterGatewayClientId || settings?.dcrouterWorkHosterId || 'Derived from token', 'Configure this in dcrouter Gateway Clients, not in Onebox.')}
${this.renderGatewayInput('dcrouterTargetHost', 'Target Host', settings?.dcrouterTargetHost || '', 'Defaults to the configured server IP when empty.')} ${this.renderGatewayInput('dcrouterTargetHost', 'Target Host', settings?.dcrouterTargetHost || '', 'Defaults to the configured server IP when empty.')}
${this.renderGatewayInput('dcrouterTargetPort', 'Target Port', String(settings?.dcrouterTargetPort || 80), 'Internal HTTP port dcrouter forwards to.')} ${this.renderGatewayInput('dcrouterTargetPort', 'Target Port', String(settings?.dcrouterTargetPort || 80), 'Internal HTTP port dcrouter forwards to.')}
` : html` ` : html`
@@ -316,6 +342,16 @@ export class ObViewSettings extends DeesElement {
`; `;
} }
private renderGatewayReadonly(label: string, value: string, hint: string): TemplateResult {
return html`
<div class="gateway-readonly">
<div class="gateway-readonly-label">${label}</div>
<div class="gateway-readonly-value">${value}</div>
<div class="gateway-readonly-hint">${hint}</div>
</div>
`;
}
private updateGatewayDraft( private updateGatewayDraft(
key: keyof NonNullable<appstate.ISettingsState['settings']>, key: keyof NonNullable<appstate.ISettingsState['settings']>,
value: string, value: string,
@@ -351,7 +387,6 @@ export class ObViewSettings extends DeesElement {
dcrouterManagedDataDir: settings.dcrouterManagedDataDir || './.nogit/dcrouter-data', dcrouterManagedDataDir: settings.dcrouterManagedDataDir || './.nogit/dcrouter-data',
dcrouterGatewayUrl: settings.dcrouterGatewayUrl || '', dcrouterGatewayUrl: settings.dcrouterGatewayUrl || '',
dcrouterGatewayApiToken: settings.dcrouterGatewayApiToken || '', dcrouterGatewayApiToken: settings.dcrouterGatewayApiToken || '',
dcrouterGatewayClientId: settings.dcrouterGatewayClientId || settings.dcrouterWorkHosterId || '',
dcrouterTargetHost: settings.dcrouterTargetHost || '', dcrouterTargetHost: settings.dcrouterTargetHost || '',
dcrouterTargetPort: Number(settings.dcrouterTargetPort) || 80, dcrouterTargetPort: Number(settings.dcrouterTargetPort) || 80,
}, },