diff --git a/changelog.md b/changelog.md index 2439231..9b8da41 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-05-09 - 13.28.0 - feat(gateway-clients) +add managed gateway client administration and token-bound route ownership + +- introduce persistent gateway client management with create, update, delete, list, and scoped token creation flows +- add gateway client context and ownership resolution so token-bound clients can sync routes without spoofing another client +- surface gateway client administration in the ops dashboard with a new Access > Gateway Clients view +- mark certificate provisioning backoff failures as failed and expose root-cause errors with DNS management guidance in the certificates view + ## 2026-05-09 - 13.27.1 - fix(docker) configure pnpm to use the verdaccio registry during Docker builds diff --git a/test/test.certificate-api-token.node.ts b/test/test.certificate-api-token.node.ts index 3c3d738..91da9a8 100644 --- a/test/test.certificate-api-token.node.ts +++ b/test/test.certificate-api-token.node.ts @@ -47,7 +47,11 @@ const makeApiTokenManager = (scopes: TScope[]) => { }; }; -const setupHandler = (scopes: TScope[]) => { +const setupHandler = (scopes: TScope[], options?: { + routes?: any[]; + certProvisionScheduler?: any; + certProvisionFunction?: (...args: any[]) => any; +}) => { const typedrouter = new plugins.typedrequest.TypedRouter(); const opsServerRef: any = { typedrouter, @@ -60,9 +64,13 @@ const setupHandler = (scopes: TScope[]) => { apiTokenManager: makeApiTokenManager(scopes), certificateStatusMap: new Map(), smartProxy: { - routeManager: { getRoutes: () => [] }, + settings: options?.certProvisionFunction ? { + certProvisionFunction: options.certProvisionFunction, + } : {}, + routeManager: { getRoutes: () => options?.routes ?? [] }, + getCertificateStatus: async () => null, }, - certProvisionScheduler: null, + certProvisionScheduler: options?.certProvisionScheduler ?? null, }, }; @@ -147,6 +155,43 @@ tap.test('CertificateHandler allows API-token import with certificates:write', a expect(opsServerRef.dcRouterRef.certificateStatusMap.get('imported.example.com')?.status).toEqual('valid'); }); +tap.test('CertificateHandler reports active certificate backoff as failed with root cause', async () => { + await testDbPromise; + + const lastError = 'DNS-01 failed for stack.gallery: DnsManager: no managed domain found for _acme-challenge.stack.gallery.'; + const retryAfter = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const { typedrouter } = setupHandler(['certificates:read'], { + certProvisionFunction: async () => 'http01', + certProvisionScheduler: { + getBackoffInfo: async (domain: string) => domain === 'stack.gallery' + ? { failures: 11, retryAfter, lastError } + : null, + }, + routes: [ + { + name: 'stack-gallery', + match: { domains: ['stack.gallery'] }, + action: { + tls: { + mode: 'terminate', + certificate: 'auto', + }, + }, + }, + ], + }); + + const result = await fireTypedRequest(typedrouter, 'getCertificateOverview', { + apiToken: 'valid-token', + }); + + expect(result.error).toBeUndefined(); + expect(result.response.summary.failed).toEqual(1); + expect(result.response.certificates[0].status).toEqual('failed'); + expect(result.response.certificates[0].error).toEqual(lastError); + expect(result.response.certificates[0].backoffInfo.failures).toEqual(11); +}); + tap.test('cleanup test db', async () => { const testDb = await testDbPromise; await testDb.cleanup(); diff --git a/test/test.workhoster-handler.node.ts b/test/test.workhoster-handler.node.ts index ee260f7..5388f0d 100644 --- a/test/test.workhoster-handler.node.ts +++ b/test/test.workhoster-handler.node.ts @@ -21,7 +21,10 @@ const fireTypedRequest = async ( } as any, { localRequest: true, skipHooks: true }) as any; }; -const makeApiTokenManager = (scopes: TScope[]) => { +const makeApiTokenManager = ( + scopes: TScope[], + policy?: interfaces.data.IApiTokenPolicy, +) => { const token = { id: 'token-1', name: 'workhoster-test-token', @@ -31,12 +34,26 @@ const makeApiTokenManager = (scopes: TScope[]) => { expiresAt: null, lastUsedAt: null, enabled: true, + policy, } as interfaces.data.IStoredApiToken; return { validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null, hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => { + if (storedToken.policy?.role === 'admin') return true; + const isGatewayClientToken = storedToken.policy?.role === 'gatewayClient'; + const gatewayClientAllowedScopes = new Set([ + 'gateway-clients:read', + 'gateway-clients:write', + 'workhosters:read', + 'workhosters:write', + ]); + if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) return false; + if (!isGatewayClientToken && storedToken.scopes.includes('*')) return true; const scopes = new Set(storedToken.scopes); + for (const policyScope of storedToken.policy?.scopes || []) { + scopes.add(policyScope); + } const compatibilityAliases: Partial> = { 'gateway-clients:read': ['workhosters:read'], 'gateway-clients:write': ['workhosters:write'], @@ -111,6 +128,8 @@ const makeRouteConfigManager = () => { const setupHandler = (options: { scopes: TScope[]; + policy?: interfaces.data.IApiTokenPolicy; + isAdmin?: boolean; dcRouterRef?: Record; }) => { const typedrouter = new plugins.typedrequest.TypedRouter(); @@ -118,12 +137,12 @@ const setupHandler = (options: { typedrouter, adminHandler: { adminIdentityGuard: { - exec: async () => false, + exec: async () => Boolean(options.isAdmin), }, }, dcRouterRef: { options: {}, - apiTokenManager: makeApiTokenManager(options.scopes), + apiTokenManager: makeApiTokenManager(options.scopes, options.policy), ...options.dcRouterRef, }, }; @@ -274,6 +293,153 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' }); }); +tap.test('WorkHosterHandler exposes gateway client context for token-bound clients', async () => { + const { typedrouter } = setupHandler({ + scopes: ['gateway-clients:read'], + policy: { + role: 'gatewayClient', + gatewayClient: { type: 'onebox', id: 'box-policy' }, + hostnamePatterns: ['*.example.com'], + allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }], + capabilities: { + readDomains: true, + readDnsRecords: true, + syncRoutes: true, + }, + }, + dcRouterRef: { options: {} }, + }); + + const result = await fireTypedRequest(typedrouter, 'getGatewayClientContext', { + apiToken: 'valid-token', + }); + + expect(result.error).toBeUndefined(); + expect(result.response.context.gatewayClient).toEqual({ type: 'onebox', id: 'box-policy' }); + expect(result.response.context.hostnamePatterns).toEqual(['*.example.com']); + expect(result.response.context.capabilities.syncRoutes).toEqual(true); +}); + +tap.test('WorkHosterHandler derives route ownership from gateway client token policy', async () => { + const routeConfig = makeRouteConfigManager(); + const { typedrouter } = setupHandler({ + scopes: ['gateway-clients:write'], + policy: { + role: 'gatewayClient', + gatewayClient: { type: 'onebox', id: 'box-policy' }, + hostnamePatterns: ['*.example.com'], + allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }], + capabilities: { syncRoutes: true }, + }, + dcRouterRef: { + options: {}, + routeConfigManager: routeConfig.manager, + }, + }); + + const createResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', { + apiToken: 'valid-token', + ownership: { + appId: 'app-1', + hostname: 'app.example.com', + }, + route: { + match: { ports: [443], domains: ['app.example.com'] }, + action: { + type: 'forward', + targets: [{ host: '10.0.0.2', port: 8080 }], + tls: { mode: 'terminate', certificate: 'auto' }, + }, + }, + }); + + expect(createResult.error).toBeUndefined(); + expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' }); + expect(routeConfig.routes.get('route-1')?.metadata?.gatewayClientId).toEqual('box-policy'); + expect(routeConfig.routes.get('route-1')?.metadata?.externalKey).toEqual('onebox:box-policy:app-1:app.example.com'); + + const spoofResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', { + apiToken: 'valid-token', + ownership: { + gatewayClientType: 'onebox', + gatewayClientId: 'other-box', + appId: 'app-1', + hostname: 'app.example.com', + }, + delete: true, + }); + + expect(spoofResult.error?.text).toEqual('gateway client token cannot act for this ownership'); +}); + +tap.test('WorkHosterHandler manages durable gateway clients and creates scoped tokens', async () => { + const identity: interfaces.data.IIdentity = { + jwt: 'admin-jwt', + userId: 'admin-user', + name: 'admin', + expiresAt: Date.now() + 3600000, + }; + const gatewayClient: interfaces.data.IGatewayClient = { + id: 'onebox-main', + type: 'onebox', + name: 'Main Onebox', + hostnamePatterns: ['*.apps.example.com'], + allowedRouteTargets: [{ host: 'onebox-smartproxy', ports: [80] }], + capabilities: { readDomains: true, readDnsRecords: true, syncRoutes: true }, + enabled: true, + createdAt: 1, + updatedAt: 1, + createdBy: 'admin-user', + }; + let createdTokenPolicy: interfaces.data.IApiTokenPolicy | undefined; + const { typedrouter } = setupHandler({ + scopes: [], + isAdmin: true, + dcRouterRef: { + options: {}, + gatewayClientManager: { + listClients: async () => [gatewayClient], + getClient: async (id: string) => id === gatewayClient.id ? gatewayClient : null, + }, + apiTokenManager: { + listTokens: () => [{ + id: 'token-1', + name: 'token', + scopes: ['gateway-clients:read'], + policy: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-main' } }, + createdAt: 1, + expiresAt: null, + lastUsedAt: null, + enabled: true, + }], + createToken: async ( + _name: string, + _scopes: TScope[], + _expiresInDays: number | null, + _createdBy: string, + policy?: interfaces.data.IApiTokenPolicy, + ) => { + createdTokenPolicy = policy; + return { id: 'new-token', rawToken: 'dcr_created' }; + }, + }, + }, + }); + + const listResult = await fireTypedRequest(typedrouter, 'listGatewayClients', { identity }); + expect(listResult.error).toBeUndefined(); + expect(listResult.response.gatewayClients[0].tokenCount).toEqual(1); + + const tokenResult = await fireTypedRequest(typedrouter, 'createGatewayClientToken', { + identity, + gatewayClientId: 'onebox-main', + }); + expect(tokenResult.error).toBeUndefined(); + expect(tokenResult.response.tokenValue).toEqual('dcr_created'); + expect(createdTokenPolicy?.gatewayClient).toEqual({ type: 'onebox', id: 'onebox-main' }); + expect(createdTokenPolicy?.allowedRouteTargets).toEqual([{ host: 'onebox-smartproxy', ports: [80] }]); +}); + tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => { const routeConfig = makeRouteConfigManager(); const { typedrouter } = setupHandler({ diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index cb988c4..2578485 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.27.1', + version: '13.28.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 3dd5875..22b64b8 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -25,7 +25,7 @@ import { MetricsManager } from './monitoring/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; -import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; +import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; import type { TIpAllowEntry } from './config/classes.route-config-manager.js'; import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js'; import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js'; @@ -276,6 +276,7 @@ export class DcRouter { // Programmatic config API public routeConfigManager?: RouteConfigManager; public apiTokenManager?: ApiTokenManager; + public gatewayClientManager?: GatewayClientManager; public referenceResolver?: ReferenceResolver; public targetProfileManager?: TargetProfileManager; @@ -617,6 +618,8 @@ export class DcRouter { ); this.apiTokenManager = new ApiTokenManager(); await this.apiTokenManager.initialize(); + this.gatewayClientManager = new GatewayClientManager(); + await this.gatewayClientManager.initialize(); await this.routeConfigManager.initialize( this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[], @@ -634,6 +637,7 @@ export class DcRouter { .withStop(async () => { this.routeConfigManager = undefined; this.apiTokenManager = undefined; + this.gatewayClientManager = undefined; this.referenceResolver = undefined; this.targetProfileManager = undefined; }) @@ -1101,6 +1105,7 @@ export class DcRouter { }); const scheduler = this.certProvisionScheduler; + smartProxyConfig.certProvisionFallbackToAcme = false; smartProxyConfig.certProvisionFunction = async (domain, eventComms) => { // If SmartAcme is not yet ready (still starting or retrying), fall back to HTTP-01 if (!this.smartAcmeReady) { @@ -1149,10 +1154,10 @@ export class DcRouter { await scheduler.clearBackoff(domain); return result; } catch (err: unknown) { - // Record failure for backoff tracking - await scheduler.recordFailure(domain, (err as Error).message); - eventComms.warn(`SmartAcme DNS-01 failed for ${domain}: ${(err as Error).message}, falling back to http-01`); - return 'http01'; + const message = `DNS-01 failed for ${domain}: ${(err as Error).message}`; + await scheduler.recordFailure(domain, message); + eventComms.warn(message); + throw new Error(message); } }; } diff --git a/ts/config/classes.gateway-client-manager.ts b/ts/config/classes.gateway-client-manager.ts new file mode 100644 index 0000000..b80a939 --- /dev/null +++ b/ts/config/classes.gateway-client-manager.ts @@ -0,0 +1,117 @@ +import * as plugins from '../plugins.js'; +import { GatewayClientDoc } from '../db/index.js'; +import type { IGatewayClient } from '../../ts_interfaces/data/workhoster.js'; + +const defaultCapabilities: IGatewayClient['capabilities'] = { + readDomains: true, + readDnsRecords: true, + syncRoutes: true, + syncDnsRecords: false, + requestCertificates: false, +}; + +export class GatewayClientManager { + public async initialize(): Promise {} + + public async listClients(): Promise { + const docs = await GatewayClientDoc.findAll(); + return docs.map((doc) => this.toPublicClient(doc)); + } + + public async getClient(id: string): Promise { + const doc = await GatewayClientDoc.findById(id); + return doc ? this.toPublicClient(doc) : null; + } + + public async createClient(options: { + id?: string; + type: IGatewayClient['type']; + name: string; + description?: string; + hostnamePatterns?: string[]; + allowedRouteTargets?: IGatewayClient['allowedRouteTargets']; + capabilities?: IGatewayClient['capabilities']; + createdBy: string; + }): Promise { + const id = this.normalizeId(options.id || `${options.type}-${plugins.uuid.v4()}`); + if (!id) { + throw new Error('gateway client id is required'); + } + if (await GatewayClientDoc.findById(id)) { + throw new Error('gateway client already exists'); + } + + const now = Date.now(); + const doc = new GatewayClientDoc(); + doc.id = id; + doc.type = options.type; + doc.name = options.name.trim(); + doc.description = options.description?.trim() || undefined; + doc.hostnamePatterns = this.normalizeStringList(options.hostnamePatterns || []); + doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(options.allowedRouteTargets || []); + doc.capabilities = { ...defaultCapabilities, ...(options.capabilities || {}) }; + doc.enabled = true; + doc.createdAt = now; + doc.updatedAt = now; + doc.createdBy = options.createdBy; + await doc.save(); + return this.toPublicClient(doc); + } + + public async updateClient( + id: string, + patch: Partial>, + ): Promise { + const doc = await GatewayClientDoc.findById(id); + if (!doc) return null; + if (patch.name !== undefined) doc.name = patch.name.trim(); + if (patch.description !== undefined) doc.description = patch.description.trim() || undefined; + if (patch.hostnamePatterns !== undefined) doc.hostnamePatterns = this.normalizeStringList(patch.hostnamePatterns); + if (patch.allowedRouteTargets !== undefined) doc.allowedRouteTargets = this.normalizeAllowedRouteTargets(patch.allowedRouteTargets); + if (patch.capabilities !== undefined) doc.capabilities = { ...defaultCapabilities, ...patch.capabilities }; + if (patch.enabled !== undefined) doc.enabled = patch.enabled; + doc.updatedAt = Date.now(); + await doc.save(); + return this.toPublicClient(doc); + } + + public async deleteClient(id: string): Promise { + const doc = await GatewayClientDoc.findById(id); + if (!doc) return false; + await doc.delete(); + return true; + } + + private normalizeId(id: string): string { + return id.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + } + + private normalizeStringList(values: string[]): string[] { + return values.map((value) => value.trim().toLowerCase()).filter(Boolean); + } + + private normalizeAllowedRouteTargets(targets: IGatewayClient['allowedRouteTargets']): IGatewayClient['allowedRouteTargets'] { + return targets + .map((target) => ({ + host: target.host.trim().toLowerCase(), + ports: target.ports.filter((port) => Number.isInteger(port) && port > 0 && port <= 65535), + })) + .filter((target) => target.host && target.ports.length > 0); + } + + private toPublicClient(doc: GatewayClientDoc): IGatewayClient { + return { + id: doc.id, + type: doc.type, + name: doc.name, + description: doc.description, + hostnamePatterns: doc.hostnamePatterns || [], + allowedRouteTargets: doc.allowedRouteTargets || [], + capabilities: doc.capabilities || {}, + enabled: doc.enabled, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + createdBy: doc.createdBy, + }; + } +} diff --git a/ts/config/index.ts b/ts/config/index.ts index d207d62..eab2097 100644 --- a/ts/config/index.ts +++ b/ts/config/index.ts @@ -2,6 +2,7 @@ export * from './validator.js'; export { RouteConfigManager } from './classes.route-config-manager.js'; export { ApiTokenManager } from './classes.api-token-manager.js'; +export { GatewayClientManager } from './classes.gateway-client-manager.js'; export { ReferenceResolver } from './classes.reference-resolver.js'; export { DbSeeder } from './classes.db-seeder.js'; -export { TargetProfileManager } from './classes.target-profile-manager.js'; \ No newline at end of file +export { TargetProfileManager } from './classes.target-profile-manager.js'; diff --git a/ts/db/documents/classes.gateway-client.doc.ts b/ts/db/documents/classes.gateway-client.doc.ts new file mode 100644 index 0000000..c98187d --- /dev/null +++ b/ts/db/documents/classes.gateway-client.doc.ts @@ -0,0 +1,54 @@ +import * as plugins from '../../plugins.js'; +import { DcRouterDb } from '../classes.dcrouter-db.js'; +import type { IApiTokenPolicy, TGatewayClientType } from '../../../ts_interfaces/data/route-management.js'; + +const getDb = () => DcRouterDb.getInstance().getDb(); + +@plugins.smartdata.Collection(() => getDb()) +export class GatewayClientDoc extends plugins.smartdata.SmartDataDbDoc { + @plugins.smartdata.unI() + @plugins.smartdata.svDb() + public id!: string; + + @plugins.smartdata.svDb() + public type!: TGatewayClientType; + + @plugins.smartdata.svDb() + public name: string = ''; + + @plugins.smartdata.svDb() + public description?: string; + + @plugins.smartdata.svDb() + public hostnamePatterns: string[] = []; + + @plugins.smartdata.svDb() + public allowedRouteTargets: NonNullable = []; + + @plugins.smartdata.svDb() + public capabilities: NonNullable = {}; + + @plugins.smartdata.svDb() + public enabled: boolean = true; + + @plugins.smartdata.svDb() + public createdAt!: number; + + @plugins.smartdata.svDb() + public updatedAt!: number; + + @plugins.smartdata.svDb() + public createdBy!: string; + + constructor() { + super(); + } + + public static async findById(id: string): Promise { + return await GatewayClientDoc.getInstance({ id }); + } + + public static async findAll(): Promise { + return await GatewayClientDoc.getInstances({}); + } +} diff --git a/ts/db/documents/index.ts b/ts/db/documents/index.ts index 4a1914f..bed5a94 100644 --- a/ts/db/documents/index.ts +++ b/ts/db/documents/index.ts @@ -8,6 +8,7 @@ export * from './classes.security-policy-audit.doc.js'; // Config document classes export * from './classes.route.doc.js'; export * from './classes.api-token.doc.js'; +export * from './classes.gateway-client.doc.js'; export * from './classes.source-profile.doc.js'; export * from './classes.target-profile.doc.js'; export * from './classes.network-target.doc.js'; diff --git a/ts/opsserver/handlers/certificate.handler.ts b/ts/opsserver/handlers/certificate.handler.ts index 365f0ff..e5806dd 100644 --- a/ts/opsserver/handlers/certificate.handler.ts +++ b/ts/opsserver/handlers/certificate.handler.ts @@ -307,6 +307,11 @@ export class CertificateHandler { } } + if (backoffInfo && status !== 'valid' && status !== 'expiring') { + status = 'failed'; + error = error || backoffInfo.lastError; + } + certificates.push({ domain, routeNames: info.routeNames, diff --git a/ts/opsserver/handlers/workhoster.handler.ts b/ts/opsserver/handlers/workhoster.handler.ts index 8b12c6d..b58f564 100644 --- a/ts/opsserver/handlers/workhoster.handler.ts +++ b/ts/opsserver/handlers/workhoster.handler.ts @@ -45,6 +45,16 @@ export class WorkHosterHandler { throw new plugins.typedrequest.TypedResponseError('unauthorized'); } + private async requireAdmin(request: { identity?: interfaces.data.IIdentity }): Promise { + if (request.identity?.jwt) { + const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ + identity: request.identity, + }); + if (isAdmin) return request.identity.userId; + } + throw new plugins.typedrequest.TypedResponseError('admin identity required'); + } + private registerHandlers(): void { this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( @@ -56,6 +66,122 @@ export class WorkHosterHandler { ), ); + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getGatewayClientContext', + async (dataArg) => { + const auth = await this.requireAuth(dataArg, 'gateway-clients:read'); + return { + context: this.getGatewayClientContext(auth), + capabilities: this.getGatewayCapabilities(), + }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'listGatewayClients', + async (dataArg) => { + await this.requireAdmin(dataArg); + return { gatewayClients: await this.listManagedGatewayClients() }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createGatewayClient', + async (dataArg) => { + const userId = await this.requireAdmin(dataArg); + const manager = this.opsServerRef.dcRouterRef.gatewayClientManager; + if (!manager) return { success: false, message: 'Gateway client management not initialized' }; + try { + const gatewayClient = await manager.createClient({ + id: dataArg.id, + type: dataArg.type, + name: dataArg.name, + description: dataArg.description, + hostnamePatterns: dataArg.hostnamePatterns, + allowedRouteTargets: dataArg.allowedRouteTargets, + capabilities: dataArg.capabilities, + createdBy: userId, + }); + return { success: true, gatewayClient }; + } catch (error) { + return { success: false, message: (error as Error).message }; + } + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateGatewayClient', + async (dataArg) => { + await this.requireAdmin(dataArg); + const manager = this.opsServerRef.dcRouterRef.gatewayClientManager; + if (!manager) return { success: false, message: 'Gateway client management not initialized' }; + const gatewayClient = await manager.updateClient(dataArg.id, { + name: dataArg.name, + description: dataArg.description, + hostnamePatterns: dataArg.hostnamePatterns, + allowedRouteTargets: dataArg.allowedRouteTargets, + capabilities: dataArg.capabilities, + enabled: dataArg.enabled, + }); + return gatewayClient + ? { success: true, gatewayClient } + : { success: false, message: 'Gateway client not found' }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteGatewayClient', + async (dataArg) => { + await this.requireAdmin(dataArg); + const manager = this.opsServerRef.dcRouterRef.gatewayClientManager; + if (!manager) return { success: false, message: 'Gateway client management not initialized' }; + const success = await manager.deleteClient(dataArg.id); + return { success, message: success ? undefined : 'Gateway client not found' }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createGatewayClientToken', + async (dataArg) => { + const userId = await this.requireAdmin(dataArg); + const gatewayClient = await this.opsServerRef.dcRouterRef.gatewayClientManager?.getClient(dataArg.gatewayClientId); + const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (!gatewayClient || !gatewayClient.enabled) { + return { success: false, message: 'Gateway client not found or disabled' }; + } + if (!tokenManager) { + return { success: false, message: 'Token management not initialized' }; + } + const result = await tokenManager.createToken( + dataArg.name?.trim() || `${gatewayClient.name} Token`, + ['gateway-clients:read', 'gateway-clients:write'], + dataArg.expiresInDays ?? null, + userId, + { + role: 'gatewayClient', + scopes: ['gateway-clients:read', 'gateway-clients:write'], + gatewayClient: { type: gatewayClient.type, id: gatewayClient.id }, + hostnamePatterns: gatewayClient.hostnamePatterns, + allowedRouteTargets: gatewayClient.allowedRouteTargets, + capabilities: gatewayClient.capabilities, + }, + ); + return { success: true, tokenId: result.id, tokenValue: result.rawToken }; + }, + ), + ); + this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayClientDomains', @@ -183,6 +309,30 @@ export class WorkHosterHandler { }; } + private getGatewayClientContext(auth: TAuthContext): interfaces.data.IGatewayClientContext { + const policy = auth.token?.policy; + const role = auth.isAdmin ? 'admin' : policy?.role || 'operator'; + return { + role, + scopes: auth.token?.scopes || ['*'], + gatewayClient: policy?.gatewayClient, + hostnamePatterns: policy?.hostnamePatterns || [], + allowedRouteTargets: policy?.allowedRouteTargets || [], + capabilities: policy?.capabilities || {}, + }; + } + + private async listManagedGatewayClients(): Promise { + const manager = this.opsServerRef.dcRouterRef.gatewayClientManager; + if (!manager) return []; + const clients = await manager.listClients(); + const tokens = this.opsServerRef.dcRouterRef.apiTokenManager?.listTokens() || []; + return clients.map((client) => ({ + ...client, + tokenCount: tokens.filter((token) => token.policy?.gatewayClient?.id === client.id).length, + })); + } + private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string { return [ ownership.workHosterType, @@ -212,15 +362,38 @@ export class WorkHosterHandler { return policyClient.id; } - private assertGatewayClientOwnership(auth: TAuthContext, ownership: interfaces.data.IGatewayClientOwnership): void { + private resolveGatewayClientOwnership( + auth: TAuthContext, + ownership: interfaces.data.IGatewayClientOwnership, + ): Required { + const policy = auth.token?.policy; + if (policy?.role === 'gatewayClient') { + if (!policy.gatewayClient) { + throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding'); + } + if (ownership.gatewayClientType && ownership.gatewayClientType !== policy.gatewayClient.type) { + throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership'); + } + if (ownership.gatewayClientId && ownership.gatewayClientId !== policy.gatewayClient.id) { + throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership'); + } + return { + gatewayClientType: policy.gatewayClient.type, + gatewayClientId: policy.gatewayClient.id, + appId: ownership.appId, + hostname: ownership.hostname, + }; + } + + if (!ownership.gatewayClientType || !ownership.gatewayClientId) { + throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing type or id'); + } + return ownership as Required; + } + + private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required): void { const policy = auth.token?.policy; if (!policy || policy.role !== 'gatewayClient') return; - if (!policy.gatewayClient) { - throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding'); - } - if (ownership.gatewayClientType !== policy.gatewayClient.type || ownership.gatewayClientId !== policy.gatewayClient.id) { - throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership'); - } if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) { throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy'); } @@ -403,7 +576,8 @@ export class WorkHosterHandler { enabled?: boolean, deleteRoute?: boolean, ): Promise { - this.assertGatewayClientOwnership(auth, ownership); + const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership); + this.assertGatewayClientOwnership(auth, resolvedOwnership); this.assertRouteTargetsAllowed(auth, route); const manager = this.opsServerRef.dcRouterRef.routeConfigManager; @@ -411,7 +585,7 @@ export class WorkHosterHandler { return { success: false, message: 'Route management not initialized' }; } - const externalKey = this.buildGatewayClientExternalKey(ownership); + const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership); const existingRoute = manager.findApiRouteByExternalKey(externalKey); if (deleteRoute) { @@ -430,15 +604,15 @@ export class WorkHosterHandler { const metadata: interfaces.data.IRouteMetadata = { ownerType: 'gatewayClient', - gatewayClientType: ownership.gatewayClientType, - gatewayClientId: ownership.gatewayClientId, - gatewayClientAppId: ownership.appId, - workHosterType: ownership.gatewayClientType, - workHosterId: ownership.gatewayClientId, - workAppId: ownership.appId, + gatewayClientType: resolvedOwnership.gatewayClientType, + gatewayClientId: resolvedOwnership.gatewayClientId, + gatewayClientAppId: resolvedOwnership.appId, + workHosterType: resolvedOwnership.gatewayClientType, + workHosterId: resolvedOwnership.gatewayClientId, + workAppId: resolvedOwnership.appId, externalKey, }; - const normalizedRoute = this.normalizeGatewayClientRoute(route, ownership, externalKey); + const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey); if (existingRoute) { const result = await manager.updateRoute(existingRoute.id, { @@ -455,7 +629,7 @@ export class WorkHosterHandler { return { success: true, action: 'created', routeId }; } - private buildGatewayClientExternalKey(ownership: interfaces.data.IGatewayClientOwnership): string { + private buildGatewayClientExternalKey(ownership: Required): string { return [ ownership.gatewayClientType, ownership.gatewayClientId, @@ -478,7 +652,7 @@ export class WorkHosterHandler { private normalizeGatewayClientRoute( route: interfaces.data.IDcRouterRouteConfig, - ownership: interfaces.data.IGatewayClientOwnership, + ownership: Required, externalKey: string, ): interfaces.data.IDcRouterRouteConfig { const normalizedRoute = { ...route }; diff --git a/ts_apiclient/classes.workhoster.ts b/ts_apiclient/classes.workhoster.ts index c13da90..fae0a15 100644 --- a/ts_apiclient/classes.workhoster.ts +++ b/ts_apiclient/classes.workhoster.ts @@ -12,6 +12,14 @@ export class WorkHosterManager { return response.capabilities; } + public async getGatewayClientContext(): Promise { + const response = await this.clientRef.request( + 'getGatewayClientContext', + this.clientRef.buildRequestPayload() as any, + ); + return response.context; + } + public async getDomains(): Promise { const response = await this.clientRef.request( 'getWorkHosterDomains', diff --git a/ts_interfaces/data/workhoster.ts b/ts_interfaces/data/workhoster.ts index adfae29..93aa542 100644 --- a/ts_interfaces/data/workhoster.ts +++ b/ts_interfaces/data/workhoster.ts @@ -1,6 +1,6 @@ import type { IDomain } from './domain.js'; import type { IDnsRecord, TDnsRecordType } from './dns-record.js'; -import type { TGatewayClientType } from './route-management.js'; +import type { IApiTokenPolicy, TApiTokenScope, TGatewayClientType } from './route-management.js'; export interface IGatewayCapabilities { routes: { @@ -34,6 +34,33 @@ export interface IGatewayCapabilities { }; } +export interface IGatewayClientContext { + role: IApiTokenPolicy['role']; + scopes: TApiTokenScope[]; + gatewayClient?: { + type: TGatewayClientType; + id: string; + }; + hostnamePatterns: string[]; + allowedRouteTargets: NonNullable; + capabilities: NonNullable; +} + +export interface IGatewayClient { + id: string; + type: TGatewayClientType; + name: string; + description?: string; + hostnamePatterns: string[]; + allowedRouteTargets: NonNullable; + capabilities: NonNullable; + enabled: boolean; + tokenCount?: number; + createdAt: number; + updatedAt: number; + createdBy: string; +} + export interface IGatewayClientDomain extends IDomain { capabilities: { canCreateSubdomains: boolean; @@ -49,8 +76,8 @@ export interface IGatewayClientDomain extends IDomain { export type IWorkHosterDomain = IGatewayClientDomain; export interface IGatewayClientOwnership { - gatewayClientType: TGatewayClientType; - gatewayClientId: string; + gatewayClientType?: TGatewayClientType; + gatewayClientId?: string; appId: string; hostname: string; } diff --git a/ts_interfaces/requests/workhoster.ts b/ts_interfaces/requests/workhoster.ts index fb4b3cb..f3d3ee9 100644 --- a/ts_interfaces/requests/workhoster.ts +++ b/ts_interfaces/requests/workhoster.ts @@ -2,6 +2,8 @@ import * as plugins from '../plugins.js'; import type * as authInterfaces from '../data/auth.js'; import type { IGatewayClientDnsRecord, + IGatewayClientContext, + IGatewayClient, IGatewayClientDomain, IGatewayClientOwnership, IGatewayClientRouteSyncResult, @@ -30,6 +32,112 @@ export interface IReq_GetGatewayCapabilities extends plugins.typedrequestInterfa }; } +export interface IReq_GetGatewayClientContext extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetGatewayClientContext +> { + method: 'getGatewayClientContext'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + }; + response: { + context: IGatewayClientContext; + capabilities: IGatewayCapabilities; + }; +} + +export interface IReq_ListGatewayClients extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ListGatewayClients +> { + method: 'listGatewayClients'; + request: { + identity: authInterfaces.IIdentity; + }; + response: { + gatewayClients: IGatewayClient[]; + }; +} + +export interface IReq_CreateGatewayClient extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateGatewayClient +> { + method: 'createGatewayClient'; + request: { + identity: authInterfaces.IIdentity; + id?: string; + type: IGatewayClient['type']; + name: string; + description?: string; + hostnamePatterns?: string[]; + allowedRouteTargets?: IGatewayClient['allowedRouteTargets']; + capabilities?: IGatewayClient['capabilities']; + }; + response: { + success: boolean; + gatewayClient?: IGatewayClient; + message?: string; + }; +} + +export interface IReq_UpdateGatewayClient extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateGatewayClient +> { + method: 'updateGatewayClient'; + request: { + identity: authInterfaces.IIdentity; + id: string; + name?: string; + description?: string; + hostnamePatterns?: string[]; + allowedRouteTargets?: IGatewayClient['allowedRouteTargets']; + capabilities?: IGatewayClient['capabilities']; + enabled?: boolean; + }; + response: { + success: boolean; + gatewayClient?: IGatewayClient; + message?: string; + }; +} + +export interface IReq_DeleteGatewayClient extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteGatewayClient +> { + method: 'deleteGatewayClient'; + request: { + identity: authInterfaces.IIdentity; + id: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +export interface IReq_CreateGatewayClientToken extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateGatewayClientToken +> { + method: 'createGatewayClientToken'; + request: { + identity: authInterfaces.IIdentity; + gatewayClientId: string; + name?: string; + expiresInDays?: number | null; + }; + response: { + success: boolean; + tokenId?: string; + tokenValue?: string; + message?: string; + }; +} + export interface IReq_GetWorkHosterDomains extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, IReq_GetWorkHosterDomains diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index cb988c4..2578485 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.27.1', + version: '13.28.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 96a1888..341007b 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -285,6 +285,7 @@ export interface IRouteManagementState { mergedRoutes: interfaces.data.IMergedRoute[]; warnings: interfaces.data.IRouteWarning[]; apiTokens: interfaces.data.IApiTokenInfo[]; + gatewayClients: interfaces.data.IGatewayClient[]; isLoading: boolean; error: string | null; lastUpdated: number; @@ -296,6 +297,7 @@ export const routeManagementStatePart = await appState.getStatePart => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ListGatewayClients + >('/typedrequest', 'listGatewayClients'); + const response = await request.fire({ identity: context.identity }); + return { + ...currentState, + gatewayClients: response.gatewayClients, + error: null, + lastUpdated: Date.now(), + }; + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to fetch gateway clients', + }; + } +}); + +export async function createGatewayClient(data: { + id?: string; + type: interfaces.data.IGatewayClient['type']; + name: string; + description?: string; + hostnamePatterns?: string[]; + allowedRouteTargets?: interfaces.data.IGatewayClient['allowedRouteTargets']; +}) { + const context = getActionContext(); + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateGatewayClient + >('/typedrequest', 'createGatewayClient'); + return request.fire({ + identity: context.identity!, + capabilities: { + readDomains: true, + readDnsRecords: true, + syncRoutes: true, + syncDnsRecords: false, + requestCertificates: false, + }, + ...data, + }); +} + +export const updateGatewayClientAction = routeManagementStatePart.createAction<{ + id: string; + name?: string; + description?: string; + hostnamePatterns?: string[]; + allowedRouteTargets?: interfaces.data.IGatewayClient['allowedRouteTargets']; + enabled?: boolean; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_UpdateGatewayClient + >('/typedrequest', 'updateGatewayClient'); + await request.fire({ identity: context.identity!, ...dataArg }); + return await actionContext!.dispatch(fetchGatewayClientsAction, null); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to update gateway client', + }; + } +}); + +export const deleteGatewayClientAction = routeManagementStatePart.createAction( + async (statePartArg, gatewayClientId, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteGatewayClient + >('/typedrequest', 'deleteGatewayClient'); + await request.fire({ identity: context.identity!, id: gatewayClientId }); + return await actionContext!.dispatch(fetchGatewayClientsAction, null); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to delete gateway client', + }; + } + }, +); + +export async function createGatewayClientToken( + gatewayClientId: string, + name?: string, + expiresInDays?: number | null, +) { + const context = getActionContext(); + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateGatewayClientToken + >('/typedrequest', 'createGatewayClientToken'); + return request.fire({ + identity: context.identity!, + gatewayClientId, + name, + expiresInDays, + }); +} + // Users (read-only list) export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise => { const context = getActionContext(); diff --git a/ts_web/elements/access/ops-view-apitokens.ts b/ts_web/elements/access/ops-view-apitokens.ts index 62bbc90..f949f3c 100644 --- a/ts_web/elements/access/ops-view-apitokens.ts +++ b/ts_web/elements/access/ops-view-apitokens.ts @@ -20,6 +20,7 @@ export class OpsViewApiTokens extends DeesElement { mergedRoutes: [], warnings: [], apiTokens: [], + gatewayClients: [], isLoading: false, error: null, lastUpdated: 0, diff --git a/ts_web/elements/access/ops-view-gatewayclients.ts b/ts_web/elements/access/ops-view-gatewayclients.ts new file mode 100644 index 0000000..e914f53 --- /dev/null +++ b/ts_web/elements/access/ops-view-gatewayclients.ts @@ -0,0 +1,250 @@ +import * as appstate from '../../appstate.js'; +import * as interfaces from '../../../dist_ts_interfaces/index.js'; +import { viewHostCss } from '../shared/css.js'; + +import { + DeesElement, + css, + cssManager, + customElement, + html, + state, + type TemplateResult, +} from '@design.estate/dees-element'; + +@customElement('ops-view-gatewayclients') +export class OpsViewGatewayClients extends DeesElement { + @state() accessor routeState: appstate.IRouteManagementState = { + mergedRoutes: [], + warnings: [], + apiTokens: [], + gatewayClients: [], + isLoading: false, + error: null, + lastUpdated: 0, + }; + + constructor() { + super(); + const sub = appstate.routeManagementStatePart + .select((s) => s) + .subscribe((routeState) => { + this.routeState = routeState; + }); + this.rxSubscriptions.push(sub); + + const loginSub = appstate.loginStatePart + .select((s) => s.isLoggedIn) + .subscribe((isLoggedIn) => { + if (isLoggedIn) { + appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null); + } + }); + this.rxSubscriptions.push(loginSub); + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + .pill { + display: inline-flex; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + background: ${cssManager.bdTheme('rgba(37, 99, 235, 0.1)', 'rgba(96, 165, 250, 0.14)')}; + color: ${cssManager.bdTheme('#1d4ed8', '#93c5fd')}; + margin-right: 4px; + margin-bottom: 2px; + } + `, + ]; + + public render(): TemplateResult { + return html` + Gateway Clients + ({ + name: client.name, + id: client.id, + type: client.type, + hostnames: this.renderPills(client.hostnamePatterns), + targets: this.renderTargets(client.allowedRouteTargets), + tokens: client.tokenCount || 0, + status: client.enabled ? 'Active' : 'Disabled', + })} + .dataActions=${[ + { + name: 'Create Client', + iconName: 'lucide:plus', + type: ['header'], + actionFunc: async () => await this.showCreateClientDialog(), + }, + { + name: 'Create Token', + iconName: 'lucide:keyRound', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => await this.showCreateTokenDialog(actionData.item), + }, + { + name: 'Enable', + iconName: 'lucide:play', + type: ['inRow', 'contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled, + actionFunc: async (actionData: any) => { + await appstate.routeManagementStatePart.dispatchAction(appstate.updateGatewayClientAction, { + id: actionData.item.id, + enabled: true, + }); + }, + }, + { + name: 'Disable', + iconName: 'lucide:pause', + type: ['inRow', 'contextmenu'] as any, + actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled, + actionFunc: async (actionData: any) => { + await appstate.routeManagementStatePart.dispatchAction(appstate.updateGatewayClientAction, { + id: actionData.item.id, + enabled: false, + }); + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + await appstate.routeManagementStatePart.dispatchAction(appstate.deleteGatewayClientAction, actionData.item.id); + }, + }, + ]} + > + `; + } + + private renderPills(values: string[]): TemplateResult { + if (!values.length) return html`None`; + return html`${values.map((value) => html`${value}`)}`; + } + + private renderTargets(targets: interfaces.data.IGatewayClient['allowedRouteTargets']): TemplateResult { + if (!targets.length) return html`None`; + return html`${targets.map((target) => html`${target.host}:${target.ports.join(',')}`)}`; + } + + private async showCreateClientDialog(): Promise { + const { DeesModal } = await import('@design.estate/dees-catalog'); + await DeesModal.createAndShow({ + heading: 'Create Gateway Client', + content: html` + + + + + + + + + `, + menuOptions: [ + { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() }, + { + name: 'Create', + iconName: 'lucide:plus', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const formData = await (form as any).collectFormData(); + const name = String(formData.name || '').trim(); + if (!name) return; + await modalArg.destroy(); + await appstate.createGatewayClient({ + id: String(formData.id || '').trim() || undefined, + type: this.normalizeClientType(String(formData.type || 'onebox')), + name, + description: String(formData.description || '').trim() || undefined, + hostnamePatterns: this.parseList(String(formData.hostnamePatterns || '')), + allowedRouteTargets: this.parseAllowedRouteTargets(String(formData.allowedRouteTarget || '')), + }); + await appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null); + }, + }, + ], + }); + } + + private async showCreateTokenDialog(client: interfaces.data.IGatewayClient): Promise { + const { DeesModal } = await import('@design.estate/dees-catalog'); + await DeesModal.createAndShow({ + heading: `Create Token for ${client.name}`, + content: html` +
+ The token will be shown once. Configure Onebox with the dcrouter URL and this token. +
+ + + + + `, + menuOptions: [ + { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() }, + { + name: 'Create Token', + iconName: 'lucide:key', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const formData = await (form as any).collectFormData(); + const expiresInDays = formData.expiresInDays ? parseInt(formData.expiresInDays, 10) : null; + await modalArg.destroy(); + const response = await appstate.createGatewayClientToken( + client.id, + String(formData.name || '').trim() || undefined, + expiresInDays, + ); + await appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null); + if (response.success && response.tokenValue) { + await DeesModal.createAndShow({ + heading: 'Gateway Client Token Created', + content: html` +

Copy this token now. It will not be shown again.

+
+ ${response.tokenValue} +
+ `, + menuOptions: [ + { name: 'Done', iconName: 'lucide:check', action: async (m: any) => await m.destroy() }, + ], + }); + } + }, + }, + ], + }); + } + + private normalizeClientType(value: string): interfaces.data.IGatewayClient['type'] { + const normalized = value.trim().toLowerCase(); + if (normalized === 'cloudly' || normalized === 'custom') return normalized; + return 'onebox'; + } + + private parseList(value: string): string[] { + return value.split(',').map((entry) => entry.trim()).filter(Boolean); + } + + private parseAllowedRouteTargets(value: string): interfaces.data.IGatewayClient['allowedRouteTargets'] { + const target = value.trim(); + if (!target.includes(':')) return []; + const [host, portsValue] = target.split(':'); + const ports = portsValue.split(',').map((port) => Number(port.trim())).filter((port) => Number.isInteger(port)); + return host.trim() && ports.length ? [{ host: host.trim(), ports }] : []; + } +} diff --git a/ts_web/elements/domains/ops-view-certificates.ts b/ts_web/elements/domains/ops-view-certificates.ts index 5004e0f..92d4c21 100644 --- a/ts_web/elements/domains/ops-view-certificates.ts +++ b/ts_web/elements/domains/ops-view-certificates.ts @@ -11,6 +11,7 @@ import * as appstate from '../../appstate.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js'; import { viewHostCss } from '../shared/css.js'; import { type IStatsTile } from '@design.estate/dees-catalog'; +import { appRouter } from '../../router.js'; declare global { interface HTMLElementTagNameMap { @@ -26,6 +27,9 @@ export class OpsViewCertificates extends DeesElement { @state() accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!; + @state() + accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!; + constructor() { super(); const certSub = appstate.certificateStatePart.select().subscribe((newState) => { @@ -36,12 +40,19 @@ export class OpsViewCertificates extends DeesElement { this.acmeState = newState; }); this.rxSubscriptions.push(acmeSub); + const domainsSub = appstate.domainsStatePart.select().subscribe((newState) => { + this.domainsState = newState; + }); + this.rxSubscriptions.push(domainsSub); } async connectedCallback() { await super.connectedCallback(); - await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null); - await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null); + await Promise.all([ + appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null), + appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null), + appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null), + ]); } public static styles = [ @@ -127,10 +138,16 @@ export class OpsViewCertificates extends DeesElement { .errorText { font-size: 12px; color: ${cssManager.bdTheme('#991b1b', '#f87171')}; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + max-width: 420px; + line-height: 1.35; + white-space: normal; + } + + .errorStack { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; } .backoffIndicator { @@ -160,6 +177,39 @@ export class OpsViewCertificates extends DeesElement { .expiryInfo .daysLeft.danger { color: ${cssManager.bdTheme('#991b1b', '#f87171')}; } + + .dnsWarningPanel { + border: 1px solid ${cssManager.bdTheme('#fed7aa', '#7c2d12')}; + border-radius: 12px; + padding: 16px; + background: ${cssManager.bdTheme('#fff7ed', '#1c1917')}; + color: ${cssManager.bdTheme('#7c2d12', '#fdba74')}; + } + + .dnsWarningTitle { + font-weight: 700; + margin-bottom: 6px; + } + + .dnsWarningText { + font-size: 13px; + line-height: 1.45; + color: ${cssManager.bdTheme('#9a3412', '#fed7aa')}; + } + + .dnsWarningList { + margin: 12px 0 0; + padding-left: 18px; + font-size: 13px; + line-height: 1.5; + } + + .dnsWarningActions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; + } `, ]; @@ -172,11 +222,102 @@ export class OpsViewCertificates extends DeesElement {
${this.renderStatsTiles(summary)} ${this.renderAcmeSettingsTile()} + ${this.renderManagedDomainWarnings()} ${this.renderCertificateTable()}
`; } + private renderManagedDomainWarnings(): TemplateResult { + const issues = this.getMissingManagedDomainIssues(); + if (issues.length === 0) { + return html``; + } + + const shownIssues = issues.slice(0, 6); + const remaining = issues.length - shownIssues.length; + + return html` +
+
DNS-01 certificate provisioning needs managed DNS domains
+
+ DcRouter can only create ACME TXT records for domains listed under Domains > Domains. + Add the zone directly or import it from a DNS provider before reprovisioning certificates. +
+
    + ${shownIssues.map((issue) => html` +
  • + ${issue.domain}: no managed DNS domain covers + ${issue.challengeHost}. Add/import ${issue.requiredDomain} + or a parent zone. +
  • + `)} + ${remaining > 0 ? html`
  • ${remaining} more domain${remaining === 1 ? '' : 's'} need managed DNS.
  • ` : ''} +
+
+ appRouter.navigateToView('domains', 'domains')}>Manage Domains + appRouter.navigateToView('domains', 'providers')}>DNS Providers +
+
+ `; + } + + private getMissingManagedDomainIssues(): Array<{ + domain: string; + challengeHost: string; + requiredDomain: string; + }> { + const managedDomains = this.domainsState.domains + .map((domain) => this.normalizeDomain(domain.name)) + .filter(Boolean); + const issues: Array<{ domain: string; challengeHost: string; requiredDomain: string }> = []; + const seen = new Set(); + + for (const cert of this.certState.certificates) { + if (!cert.canReprovision || (cert.source !== 'acme' && cert.source !== 'provision-function')) { + continue; + } + + const requiredDomain = this.getAcmeChallengeDomain(cert.domain); + if (!requiredDomain) { + continue; + } + + const covered = managedDomains.some((managedDomain) => + requiredDomain === managedDomain || requiredDomain.endsWith(`.${managedDomain}`), + ); + if (covered) { + continue; + } + + const key = `${cert.domain}:${requiredDomain}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + issues.push({ + domain: cert.domain, + challengeHost: `_acme-challenge.${requiredDomain}`, + requiredDomain, + }); + } + + return issues; + } + + private getAcmeChallengeDomain(domain: string): string { + const normalized = this.normalizeDomain(domain).replace(/^\*\.?/, ''); + const parts = normalized.split('.').filter(Boolean); + if (parts.length >= 2 && parts.length <= 3) { + return parts.slice(-2).join('.'); + } + return normalized; + } + + private normalizeDomain(domain: string): string { + return domain.trim().toLowerCase().replace(/^\*\.?/, '').replace(/\.$/, ''); + } + private renderAcmeSettingsTile(): TemplateResult { const config = this.acmeState.config; @@ -349,11 +490,7 @@ export class OpsViewCertificates extends DeesElement { Status: this.renderStatusBadge(cert.status), Source: this.renderSourceBadge(cert.source), Expires: this.renderExpiry(cert.expiryDate), - Error: cert.backoffInfo - ? html`${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}` - : cert.error - ? html`${cert.error}` - : '', + Error: this.renderError(cert), })} .dataActions=${[ { @@ -632,6 +769,24 @@ export class OpsViewCertificates extends DeesElement { `; } + private renderError(cert: interfaces.requests.ICertificateInfo): TemplateResult | string { + if (cert.backoffInfo) { + const message = cert.backoffInfo.lastError || cert.error; + return html` + + ${message ? html`${message}` : ''} + + ${cert.backoffInfo.failures} failure${cert.backoffInfo.failures === 1 ? '' : 's'}, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)} + + + `; + } + if (cert.error) { + return html`${cert.error}`; + } + return ''; + } + private formatRetryTime(retryAfter?: string): string { if (!retryAfter) return 'soon'; const retryDate = new Date(retryAfter); diff --git a/ts_web/elements/network/ops-view-routes.ts b/ts_web/elements/network/ops-view-routes.ts index a83b3f5..2c55fac 100644 --- a/ts_web/elements/network/ops-view-routes.ts +++ b/ts_web/elements/network/ops-view-routes.ts @@ -129,6 +129,7 @@ export class OpsViewRoutes extends DeesElement { mergedRoutes: [], warnings: [], apiTokens: [], + gatewayClients: [], isLoading: false, error: null, lastUpdated: 0, diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index 3797a85..703bdfe 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -35,6 +35,7 @@ import { OpsViewEmailSecurity } from './email/ops-view-email-security.js'; import { OpsViewEmailDomains } from './email/ops-view-email-domains.js'; // Access group +import { OpsViewGatewayClients } from './access/ops-view-gatewayclients.js'; import { OpsViewApiTokens } from './access/ops-view-apitokens.js'; import { OpsViewUsers } from './access/ops-view-users.js'; @@ -121,6 +122,7 @@ export class OpsDashboard extends DeesElement { name: 'Access', iconName: 'lucide:keyRound', subViews: [ + { slug: 'gatewayclients', name: 'Gateway Clients', iconName: 'lucide:plugZap', element: OpsViewGatewayClients }, { slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens }, { slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers }, ], diff --git a/ts_web/router.ts b/ts_web/router.ts index 5989824..0e6c235 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -11,7 +11,7 @@ const subviewMap: Record = { overview: ['stats', 'configuration'] as const, network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const, email: ['log', 'security', 'domains'] as const, - access: ['apitokens', 'users'] as const, + access: ['gatewayclients', 'apitokens', 'users'] as const, security: ['overview', 'blocked', 'authentication'] as const, domains: ['providers', 'domains', 'dns', 'certificates'] as const, }; @@ -21,7 +21,7 @@ const defaultSubview: Record = { overview: 'stats', network: 'activity', email: 'log', - access: 'apitokens', + access: 'gatewayclients', security: 'overview', domains: 'domains', };