From a22cc1c0eb0c8294fefe3120dfd24b9acb7c085e Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 29 Apr 2026 15:18:14 +0000 Subject: [PATCH] feat: add workhoster gateway API --- test/test.certificate-api-token.node.ts | 155 ++++++++++ test/test.workhoster-handler.node.ts | 288 +++++++++++++++++++ ts/config/classes.route-config-manager.ts | 24 ++ ts/opsserver/classes.opsserver.ts | 4 +- ts/opsserver/handlers/certificate.handler.ts | 57 +++- ts/opsserver/handlers/index.ts | 3 +- ts/opsserver/handlers/workhoster.handler.ts | 189 ++++++++++++ ts_apiclient/classes.dcrouterapiclient.ts | 3 + ts_apiclient/classes.workhoster.ts | 49 ++++ ts_apiclient/index.ts | 1 + ts_interfaces/data/index.ts | 1 + ts_interfaces/data/route-management.ts | 13 +- ts_interfaces/data/workhoster.ts | 56 ++++ ts_interfaces/requests/certificate.ts | 18 +- ts_interfaces/requests/index.ts | 1 + ts_interfaces/requests/workhoster.ts | 53 ++++ ts_web/elements/access/ops-view-apitokens.ts | 12 +- 17 files changed, 905 insertions(+), 22 deletions(-) create mode 100644 test/test.certificate-api-token.node.ts create mode 100644 test/test.workhoster-handler.node.ts create mode 100644 ts/opsserver/handlers/workhoster.handler.ts create mode 100644 ts_apiclient/classes.workhoster.ts create mode 100644 ts_interfaces/data/workhoster.ts create mode 100644 ts_interfaces/requests/workhoster.ts diff --git a/test/test.certificate-api-token.node.ts b/test/test.certificate-api-token.node.ts new file mode 100644 index 0000000..3c3d738 --- /dev/null +++ b/test/test.certificate-api-token.node.ts @@ -0,0 +1,155 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js'; +import { AcmeCertDoc, DcRouterDb } from '../ts/db/index.js'; +import * as plugins from '../ts/plugins.js'; +import * as interfaces from '../ts_interfaces/index.js'; + +type TScope = interfaces.data.TApiTokenScope; + +const createTestDb = async () => { + const storagePath = plugins.path.join( + plugins.os.tmpdir(), + `dcrouter-cert-api-token-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + + DcRouterDb.resetInstance(); + const db = DcRouterDb.getInstance({ + storagePath, + dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`, + }); + await db.start(); + await db.getDb().mongoDb.createCollection('__test_init'); + + return { + async cleanup() { + await db.stop(); + DcRouterDb.resetInstance(); + await plugins.fs.promises.rm(storagePath, { recursive: true, force: true }); + }, + }; +}; + +const makeApiTokenManager = (scopes: TScope[]) => { + const token = { + id: 'token-1', + name: 'certificate-test-token', + scopes, + createdBy: 'token-user', + createdAt: Date.now(), + expiresAt: null, + lastUsedAt: null, + enabled: true, + } as interfaces.data.IStoredApiToken; + + return { + validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null, + hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => storedToken.scopes.includes(scope), + }; +}; + +const setupHandler = (scopes: TScope[]) => { + const typedrouter = new plugins.typedrequest.TypedRouter(); + const opsServerRef: any = { + typedrouter, + adminHandler: { + adminIdentityGuard: { + exec: async () => false, + }, + }, + dcRouterRef: { + apiTokenManager: makeApiTokenManager(scopes), + certificateStatusMap: new Map(), + smartProxy: { + routeManager: { getRoutes: () => [] }, + }, + certProvisionScheduler: null, + }, + }; + + new CertificateHandler(opsServerRef); + return { typedrouter, opsServerRef }; +}; + +const fireTypedRequest = async ( + router: plugins.typedrequest.TypedRouter, + method: string, + request: Record, +) => { + return await router.routeAndAddResponse({ + method, + request, + response: {}, + correlation: { + id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + phase: 'request', + }, + } as any, { localRequest: true, skipHooks: true }) as any; +}; + +const testDbPromise = createTestDb(); + +tap.test('CertificateHandler allows API-token export with certificates:read', async () => { + await testDbPromise; + + const certDoc = new AcmeCertDoc(); + certDoc.id = 'cert-1'; + certDoc.domainName = 'example.com'; + certDoc.created = 1; + certDoc.validUntil = 2; + certDoc.privateKey = '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----'; + certDoc.publicKey = '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----'; + certDoc.csr = ''; + await certDoc.save(); + + const { typedrouter } = setupHandler(['certificates:read']); + const result = await fireTypedRequest(typedrouter, 'exportCertificate', { + apiToken: 'valid-token', + domain: 'example.com', + }); + + expect(result.error).toBeUndefined(); + expect(result.response.success).toEqual(true); + expect(result.response.cert.domainName).toEqual('example.com'); + expect(result.response.cert.privateKey).toContain('BEGIN PRIVATE KEY'); + expect(result.response.cert.publicKey).toContain('BEGIN CERTIFICATE'); +}); + +tap.test('CertificateHandler rejects API-token export without certificates:read', async () => { + const { typedrouter } = setupHandler(['certificates:write']); + const result = await fireTypedRequest(typedrouter, 'exportCertificate', { + apiToken: 'valid-token', + domain: 'example.com', + }); + + expect(result.error?.text).toEqual('insufficient scope'); +}); + +tap.test('CertificateHandler allows API-token import with certificates:write', async () => { + await testDbPromise; + + const { typedrouter, opsServerRef } = setupHandler(['certificates:write']); + const result = await fireTypedRequest(typedrouter, 'importCertificate', { + apiToken: 'valid-token', + cert: { + id: 'cert-2', + domainName: 'imported.example.com', + created: 3, + validUntil: 4, + privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----', + publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----', + csr: '', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.response.success).toEqual(true); + expect((await AcmeCertDoc.findByDomain('imported.example.com'))?.id).toEqual('cert-2'); + expect(opsServerRef.dcRouterRef.certificateStatusMap.get('imported.example.com')?.status).toEqual('valid'); +}); + +tap.test('cleanup test db', async () => { + const testDb = await testDbPromise; + await testDb.cleanup(); +}); + +export default tap.start(); diff --git a/test/test.workhoster-handler.node.ts b/test/test.workhoster-handler.node.ts new file mode 100644 index 0000000..35d64b0 --- /dev/null +++ b/test/test.workhoster-handler.node.ts @@ -0,0 +1,288 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { WorkHosterHandler } from '../ts/opsserver/handlers/workhoster.handler.js'; +import * as plugins from '../ts/plugins.js'; +import * as interfaces from '../ts_interfaces/index.js'; + +type TScope = interfaces.data.TApiTokenScope; + +const fireTypedRequest = async ( + router: plugins.typedrequest.TypedRouter, + method: string, + request: Record, +) => { + return await router.routeAndAddResponse({ + method, + request, + response: {}, + correlation: { + id: `${method}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + phase: 'request', + }, + } as any, { localRequest: true, skipHooks: true }) as any; +}; + +const makeApiTokenManager = (scopes: TScope[]) => { + const token = { + id: 'token-1', + name: 'workhoster-test-token', + scopes, + createdBy: 'token-user', + createdAt: Date.now(), + expiresAt: null, + lastUsedAt: null, + enabled: true, + } as interfaces.data.IStoredApiToken; + + return { + validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null, + hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => storedToken.scopes.includes(scope), + }; +}; + +const makeRouteConfigManager = () => { + const routes = new Map(); + let nextRouteNumber = 1; + + return { + routes, + manager: { + findApiRouteByExternalKey: (externalKey: string) => { + return Array.from(routes.values()).find((route) => + route.origin === 'api' && route.metadata?.externalKey === externalKey, + ); + }, + createRoute: async ( + route: interfaces.data.IDcRouterRouteConfig, + createdBy: string, + enabled = true, + metadata?: interfaces.data.IRouteMetadata, + ) => { + const id = `route-${nextRouteNumber++}`; + routes.set(id, { + id, + route, + enabled, + createdBy, + createdAt: Date.now(), + updatedAt: Date.now(), + origin: 'api', + metadata, + }); + return id; + }, + updateRoute: async ( + id: string, + patch: { + route?: Partial; + enabled?: boolean; + metadata?: Partial; + }, + ) => { + const storedRoute = routes.get(id); + if (!storedRoute) return { success: false, message: 'Route not found' }; + if (patch.route) { + storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig; + } + if (patch.enabled !== undefined) { + storedRoute.enabled = patch.enabled; + } + if (patch.metadata) { + storedRoute.metadata = { ...storedRoute.metadata, ...patch.metadata }; + } + storedRoute.updatedAt = Date.now(); + return { success: true }; + }, + deleteRoute: async (id: string) => { + const deleted = routes.delete(id); + return deleted ? { success: true } : { success: false, message: 'Route not found' }; + }, + }, + }; +}; + +const setupHandler = (options: { + scopes: TScope[]; + dcRouterRef?: Record; +}) => { + const typedrouter = new plugins.typedrequest.TypedRouter(); + const opsServerRef: any = { + typedrouter, + adminHandler: { + adminIdentityGuard: { + exec: async () => false, + }, + }, + dcRouterRef: { + options: {}, + apiTokenManager: makeApiTokenManager(options.scopes), + ...options.dcRouterRef, + }, + }; + + new WorkHosterHandler(opsServerRef); + return { typedrouter, opsServerRef }; +}; + +tap.test('WorkHosterHandler exposes capabilities and managed domains with workhosters:read', async () => { + const { typedrouter } = setupHandler({ + scopes: ['workhosters:read'], + dcRouterRef: { + options: { + remoteIngressConfig: { enabled: true }, + dnsScopes: ['example.com'], + http3: { enabled: false }, + }, + routeConfigManager: {}, + smartProxy: {}, + emailDomainManager: {}, + emailServer: {}, + dnsManager: { + listDomains: async () => [ + { id: 'domain-1', name: 'example.com', source: 'dcrouter', authoritative: true }, + { id: 'domain-2', name: 'provider.example', source: 'provider', providerId: 'cloudflare-1', authoritative: false }, + ], + toPublicDomain: (domainDoc: any) => ({ + ...domainDoc, + createdAt: 1, + updatedAt: 1, + createdBy: 'test', + }), + }, + }, + }); + + const capabilitiesResult = await fireTypedRequest(typedrouter, 'getGatewayCapabilities', { + apiToken: 'valid-token', + }); + expect(capabilitiesResult.error).toBeUndefined(); + expect(capabilitiesResult.response.capabilities.routes.idempotentSync).toEqual(true); + expect(capabilitiesResult.response.capabilities.domains.read).toEqual(true); + expect(capabilitiesResult.response.capabilities.certificates.export).toEqual(true); + expect(capabilitiesResult.response.capabilities.email.inbound).toEqual(true); + expect(capabilitiesResult.response.capabilities.remoteIngress.enabled).toEqual(true); + expect(capabilitiesResult.response.capabilities.dns.authoritative).toEqual(true); + expect(capabilitiesResult.response.capabilities.http3.enabled).toEqual(false); + + const domainsResult = await fireTypedRequest(typedrouter, 'getWorkHosterDomains', { + apiToken: 'valid-token', + }); + expect(domainsResult.error).toBeUndefined(); + expect(domainsResult.response.domains.length).toEqual(2); + expect(domainsResult.response.domains[0].capabilities.canCreateSubdomains).toEqual(true); + expect(domainsResult.response.domains[1].capabilities.canManageDnsRecords).toEqual(true); + expect(domainsResult.response.domains[1].capabilities.canIssueCertificates).toEqual(true); + expect(domainsResult.response.domains[1].capabilities.canHostEmail).toEqual(true); +}); + +tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:write', async () => { + const routeConfig = makeRouteConfigManager(); + const { typedrouter } = setupHandler({ + scopes: ['workhosters:write'], + dcRouterRef: { + options: {}, + routeConfigManager: routeConfig.manager, + }, + }); + const ownership: interfaces.data.IWorkAppRouteOwnership = { + workHosterType: 'onebox', + workHosterId: 'box-1', + workAppId: 'app-1', + hostname: 'app.example.com', + }; + + const createResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', { + apiToken: 'valid-token', + ownership, + 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.size).toEqual(1); + + const createdRoute = routeConfig.routes.get('route-1')!; + expect(createdRoute.createdBy).toEqual('token-user'); + expect(createdRoute.route.name?.startsWith('workapp-onebox-box-1-app-1-app-example-com')).toEqual(true); + expect(createdRoute.metadata).toEqual({ + ownerType: 'workhoster', + workHosterType: 'onebox', + workHosterId: 'box-1', + workAppId: 'app-1', + externalKey: 'onebox:box-1:app-1:app.example.com', + }); + + const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', { + apiToken: 'valid-token', + ownership, + enabled: false, + route: { + name: 'updated-workapp-route', + match: { ports: [443], domains: ['app.example.com'] }, + action: { + type: 'forward', + targets: [{ host: '10.0.0.3', port: 3000 }], + tls: { mode: 'terminate', certificate: 'auto' }, + }, + }, + }); + + expect(updateResult.error).toBeUndefined(); + expect(updateResult.response).toEqual({ success: true, action: 'updated', routeId: 'route-1' }); + expect(routeConfig.routes.size).toEqual(1); + expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false); + expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route'); + expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3'); + + const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', { + apiToken: 'valid-token', + ownership, + delete: true, + }); + + expect(deleteResult.error).toBeUndefined(); + expect(deleteResult.response).toEqual({ success: true, action: 'deleted', routeId: 'route-1' }); + expect(routeConfig.routes.size).toEqual(0); + + const unchangedResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', { + apiToken: 'valid-token', + ownership, + delete: true, + }); + + expect(unchangedResult.error).toBeUndefined(); + expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' }); +}); + +tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => { + const routeConfig = makeRouteConfigManager(); + const { typedrouter } = setupHandler({ + scopes: ['workhosters:read'], + dcRouterRef: { + options: {}, + routeConfigManager: routeConfig.manager, + }, + }); + + const result = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', { + apiToken: 'valid-token', + ownership: { + workHosterType: 'onebox', + workHosterId: 'box-1', + workAppId: 'app-1', + hostname: 'app.example.com', + }, + delete: true, + }); + + expect(result.error?.text).toEqual('insufficient scope'); + expect(routeConfig.routes.size).toEqual(0); +}); + +export default tap.start(); diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 7d993ea..4a877fb 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -256,6 +256,15 @@ export class RouteConfigManager { return this.updateRoute(id, { enabled }); } + public findApiRouteByExternalKey(externalKey: string): IRoute | undefined { + for (const route of this.routes.values()) { + if (route.origin === 'api' && route.metadata?.externalKey === externalKey) { + return route; + } + } + return undefined; + } + // ========================================================================= // Private: seed routes from constructor config // ========================================================================= @@ -443,6 +452,15 @@ export class RouteConfigManager { lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt) ? metadata.lastResolvedAt : undefined, + ownerType: metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system' + ? metadata.ownerType + : undefined, + workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom' + ? metadata.workHosterType + : undefined, + workHosterId: normalizeString(metadata.workHosterId), + workAppId: normalizeString(metadata.workAppId), + externalKey: normalizeString(metadata.externalKey), }; if (!normalized.sourceProfileRef) { @@ -454,6 +472,12 @@ export class RouteConfigManager { if (!normalized.sourceProfileRef && !normalized.networkTargetRef) { normalized.lastResolvedAt = undefined; } + if (normalized.ownerType !== 'workhoster') { + normalized.workHosterType = undefined; + normalized.workHosterId = undefined; + normalized.workAppId = undefined; + normalized.externalKey = undefined; + } if (Object.values(normalized).every((value) => value === undefined)) { return undefined; diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index c1a090b..fceff3a 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -38,6 +38,7 @@ export class OpsServer { private dnsRecordHandler!: handlers.DnsRecordHandler; private acmeConfigHandler!: handlers.AcmeConfigHandler; private emailDomainHandler!: handlers.EmailDomainHandler; + private workHosterHandler!: handlers.WorkHosterHandler; constructor(dcRouterRefArg: DcRouter) { this.dcRouterRef = dcRouterRefArg; @@ -106,6 +107,7 @@ export class OpsServer { this.dnsRecordHandler = new handlers.DnsRecordHandler(this); this.acmeConfigHandler = new handlers.AcmeConfigHandler(this); this.emailDomainHandler = new handlers.EmailDomainHandler(this); + this.workHosterHandler = new handlers.WorkHosterHandler(this); console.log('✅ OpsServer TypedRequest handlers initialized'); } @@ -119,4 +121,4 @@ export class OpsServer { await this.server.stop(); } } -} \ No newline at end of file +} diff --git a/ts/opsserver/handlers/certificate.handler.ts b/ts/opsserver/handlers/certificate.handler.ts index 5232bda..365f0ff 100644 --- a/ts/opsserver/handlers/certificate.handler.ts +++ b/ts/opsserver/handlers/certificate.handler.ts @@ -26,21 +26,51 @@ export function deriveCertDomainName(domain: string): string | undefined { } export class CertificateHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter?.addTypedRouter(this.typedrouter); this.registerHandlers(); } - private registerHandlers(): void { - const viewRouter = this.opsServerRef.viewRouter; - const adminRouter = this.opsServerRef.adminRouter; + private async requireAuth( + request: { identity?: interfaces.data.IIdentity; apiToken?: string }, + requiredScope?: interfaces.data.TApiTokenScope, + ): Promise { + if (request.identity?.jwt) { + try { + const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ + identity: request.identity, + }); + if (isAdmin) return request.identity.userId; + } catch { /* fall through */ } + } - // ---- Read endpoints (viewRouter — valid identity required via middleware) ---- + if (request.apiToken) { + const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (tokenManager) { + const token = await tokenManager.validateToken(request.apiToken); + if (token) { + if (!requiredScope || tokenManager.hasScope(token, requiredScope)) { + return token.createdBy; + } + throw new plugins.typedrequest.TypedResponseError('insufficient scope'); + } + } + } + + throw new plugins.typedrequest.TypedResponseError('unauthorized'); + } + + private registerHandlers(): void { + const router = this.typedrouter; // Get Certificate Overview - viewRouter.addTypedHandler( + router.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getCertificateOverview', async (dataArg) => { + await this.requireAuth(dataArg, 'certificates:read'); const certificates = await this.buildCertificateOverview(); const summary = this.buildSummary(certificates); return { certificates, summary }; @@ -48,53 +78,56 @@ export class CertificateHandler { ) ); - // ---- Write endpoints (adminRouter — admin identity required via middleware) ---- - // Legacy route-based reprovision (backward compat) - adminRouter.addTypedHandler( + router.addTypedHandler( new plugins.typedrequest.TypedHandler( 'reprovisionCertificate', async (dataArg) => { + await this.requireAuth(dataArg, 'certificates:write'); return this.reprovisionCertificateByRoute(dataArg.routeName); } ) ); // Domain-based reprovision (preferred) - adminRouter.addTypedHandler( + router.addTypedHandler( new plugins.typedrequest.TypedHandler( 'reprovisionCertificateDomain', async (dataArg) => { + await this.requireAuth(dataArg, 'certificates:write'); return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew); } ) ); // Delete certificate - adminRouter.addTypedHandler( + router.addTypedHandler( new plugins.typedrequest.TypedHandler( 'deleteCertificate', async (dataArg) => { + await this.requireAuth(dataArg, 'certificates:write'); return this.deleteCertificate(dataArg.domain); } ) ); // Export certificate - adminRouter.addTypedHandler( + router.addTypedHandler( new plugins.typedrequest.TypedHandler( 'exportCertificate', async (dataArg) => { + await this.requireAuth(dataArg, 'certificates:read'); return this.exportCertificate(dataArg.domain); } ) ); // Import certificate - adminRouter.addTypedHandler( + router.addTypedHandler( new plugins.typedrequest.TypedHandler( 'importCertificate', async (dataArg) => { + await this.requireAuth(dataArg, 'certificates:write'); return this.importCertificate(dataArg.cert); } ) diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index e6c7216..3201400 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -18,4 +18,5 @@ export * from './dns-provider.handler.js'; export * from './domain.handler.js'; export * from './dns-record.handler.js'; export * from './acme-config.handler.js'; -export * from './email-domain.handler.js'; \ No newline at end of file +export * from './email-domain.handler.js'; +export * from './workhoster.handler.js'; diff --git a/ts/opsserver/handlers/workhoster.handler.ts b/ts/opsserver/handlers/workhoster.handler.ts new file mode 100644 index 0000000..dfb2cdc --- /dev/null +++ b/ts/opsserver/handlers/workhoster.handler.ts @@ -0,0 +1,189 @@ +import * as plugins from '../../plugins.js'; +import type { OpsServer } from '../classes.opsserver.js'; +import * as interfaces from '../../../ts_interfaces/index.js'; + +export class WorkHosterHandler { + public typedrouter = new plugins.typedrequest.TypedRouter(); + + constructor(private opsServerRef: OpsServer) { + this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); + this.registerHandlers(); + } + + private async requireAuth( + request: { identity?: interfaces.data.IIdentity; apiToken?: string }, + requiredScope?: interfaces.data.TApiTokenScope, + ): Promise { + if (request.identity?.jwt) { + try { + const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ + identity: request.identity, + }); + if (isAdmin) return request.identity.userId; + } catch { /* fall through */ } + } + + if (request.apiToken) { + const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; + if (tokenManager) { + const token = await tokenManager.validateToken(request.apiToken); + if (token) { + if (!requiredScope || tokenManager.hasScope(token, requiredScope)) { + return token.createdBy; + } + throw new plugins.typedrequest.TypedResponseError('insufficient scope'); + } + } + } + + throw new plugins.typedrequest.TypedResponseError('unauthorized'); + } + + private registerHandlers(): void { + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getGatewayCapabilities', + async (dataArg) => { + await this.requireAuth(dataArg, 'workhosters:read'); + return { capabilities: this.getGatewayCapabilities() }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getWorkHosterDomains', + async (dataArg) => { + await this.requireAuth(dataArg, 'workhosters:read'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { domains: [] }; + + const docs = await dnsManager.listDomains(); + const domains = docs.map((domainDoc) => { + const domain = dnsManager.toPublicDomain(domainDoc); + const canManageDnsRecords = domain.source === 'dcrouter' || Boolean(domain.providerId); + return { + ...domain, + capabilities: { + canCreateSubdomains: canManageDnsRecords, + canManageDnsRecords, + canIssueCertificates: Boolean(this.opsServerRef.dcRouterRef.smartProxy), + canHostEmail: Boolean(this.opsServerRef.dcRouterRef.emailDomainManager), + }, + } satisfies interfaces.data.IWorkHosterDomain; + }); + return { domains }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'syncWorkAppRoute', + async (dataArg) => { + const userId = await this.requireAuth(dataArg, 'workhosters:write'); + const manager = this.opsServerRef.dcRouterRef.routeConfigManager; + if (!manager) { + return { success: false, message: 'Route management not initialized' }; + } + + const externalKey = this.buildExternalKey(dataArg.ownership); + const existingRoute = manager.findApiRouteByExternalKey(externalKey); + + if (dataArg.delete) { + if (!existingRoute) { + return { success: true, action: 'unchanged' }; + } + const result = await manager.deleteRoute(existingRoute.id); + return result.success + ? { success: true, action: 'deleted', routeId: existingRoute.id } + : { success: false, message: result.message }; + } + + if (!dataArg.route) { + return { success: false, message: 'route is required unless delete=true' }; + } + + const metadata: interfaces.data.IRouteMetadata = { + ownerType: 'workhoster', + workHosterType: dataArg.ownership.workHosterType, + workHosterId: dataArg.ownership.workHosterId, + workAppId: dataArg.ownership.workAppId, + externalKey, + }; + const route = this.normalizeWorkAppRoute(dataArg.route, dataArg.ownership, externalKey); + + if (existingRoute) { + const result = await manager.updateRoute(existingRoute.id, { + route, + enabled: dataArg.enabled ?? true, + metadata, + }); + return result.success + ? { success: true, action: 'updated', routeId: existingRoute.id } + : { success: false, message: result.message }; + } + + const routeId = await manager.createRoute(route, userId, dataArg.enabled ?? true, metadata); + return { success: true, action: 'created', routeId }; + }, + ), + ); + } + + private getGatewayCapabilities(): interfaces.data.IGatewayCapabilities { + const dcRouter = this.opsServerRef.dcRouterRef; + return { + routes: { + read: Boolean(dcRouter.routeConfigManager), + write: Boolean(dcRouter.routeConfigManager), + idempotentSync: Boolean(dcRouter.routeConfigManager), + }, + domains: { + read: Boolean(dcRouter.dnsManager), + write: Boolean(dcRouter.dnsManager), + }, + certificates: { + read: Boolean(dcRouter.smartProxy), + export: Boolean(dcRouter.smartProxy), + forceRenew: Boolean(dcRouter.smartProxy), + }, + email: { + domains: Boolean(dcRouter.emailDomainManager), + inbound: Boolean(dcRouter.emailServer), + outbound: Boolean(dcRouter.emailServer), + }, + remoteIngress: { + enabled: Boolean(dcRouter.options.remoteIngressConfig?.enabled), + }, + dns: { + authoritative: Boolean(dcRouter.options.dnsScopes?.length), + providerManaged: Boolean(dcRouter.dnsManager), + }, + http3: { + enabled: dcRouter.options.http3?.enabled !== false, + }, + }; + } + + private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string { + return [ + ownership.workHosterType, + ownership.workHosterId, + ownership.workAppId, + ownership.hostname, + ].map((part) => part.trim()).join(':'); + } + + private normalizeWorkAppRoute( + route: interfaces.data.IDcRouterRouteConfig, + ownership: interfaces.data.IWorkAppRouteOwnership, + externalKey: string, + ): interfaces.data.IDcRouterRouteConfig { + const normalizedRoute = { ...route }; + if (!normalizedRoute.name) { + normalizedRoute.name = `workapp-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`; + } + return normalizedRoute; + } +} diff --git a/ts_apiclient/classes.dcrouterapiclient.ts b/ts_apiclient/classes.dcrouterapiclient.ts index 16e4574..d486b32 100644 --- a/ts_apiclient/classes.dcrouterapiclient.ts +++ b/ts_apiclient/classes.dcrouterapiclient.ts @@ -10,6 +10,7 @@ import { ConfigManager } from './classes.config.js'; import { LogManager } from './classes.logs.js'; import { EmailManager } from './classes.email.js'; import { RadiusManager } from './classes.radius.js'; +import { WorkHosterManager } from './classes.workhoster.js'; export interface IDcRouterApiClientOptions { baseUrl: string; @@ -31,6 +32,7 @@ export class DcRouterApiClient { public logs: LogManager; public emails: EmailManager; public radius: RadiusManager; + public workHosters: WorkHosterManager; constructor(options: IDcRouterApiClientOptions) { this.baseUrl = options.baseUrl.replace(/\/+$/, ''); @@ -45,6 +47,7 @@ export class DcRouterApiClient { this.logs = new LogManager(this); this.emails = new EmailManager(this); this.radius = new RadiusManager(this); + this.workHosters = new WorkHosterManager(this); } // ===================== diff --git a/ts_apiclient/classes.workhoster.ts b/ts_apiclient/classes.workhoster.ts new file mode 100644 index 0000000..c13da90 --- /dev/null +++ b/ts_apiclient/classes.workhoster.ts @@ -0,0 +1,49 @@ +import * as interfaces from '../ts_interfaces/index.js'; +import type { DcRouterApiClient } from './classes.dcrouterapiclient.js'; + +export class WorkHosterManager { + constructor(private clientRef: DcRouterApiClient) {} + + public async getCapabilities(): Promise { + const response = await this.clientRef.request( + 'getGatewayCapabilities', + this.clientRef.buildRequestPayload() as any, + ); + return response.capabilities; + } + + public async getDomains(): Promise { + const response = await this.clientRef.request( + 'getWorkHosterDomains', + this.clientRef.buildRequestPayload() as any, + ); + return response.domains; + } + + public async syncRoute(options: { + ownership: interfaces.data.IWorkAppRouteOwnership; + route: interfaces.data.IDcRouterRouteConfig; + enabled?: boolean; + }): Promise { + return this.clientRef.request( + 'syncWorkAppRoute', + this.clientRef.buildRequestPayload({ + ownership: options.ownership, + route: options.route, + enabled: options.enabled, + }) as any, + ); + } + + public async deleteRoute( + ownership: interfaces.data.IWorkAppRouteOwnership, + ): Promise { + return this.clientRef.request( + 'syncWorkAppRoute', + this.clientRef.buildRequestPayload({ + ownership, + delete: true, + }) as any, + ); + } +} diff --git a/ts_apiclient/index.ts b/ts_apiclient/index.ts index b22d5e2..95dd611 100644 --- a/ts_apiclient/index.ts +++ b/ts_apiclient/index.ts @@ -7,6 +7,7 @@ export { Certificate, CertificateManager, type ICertificateSummary } from './cla export { ApiToken, ApiTokenBuilder, ApiTokenManager } from './classes.apitoken.js'; export { RemoteIngress, RemoteIngressBuilder, RemoteIngressManager } from './classes.remoteingress.js'; export { Email, EmailManager } from './classes.email.js'; +export { WorkHosterManager } from './classes.workhoster.js'; // Read-only managers export { StatsManager } from './classes.stats.js'; diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index a3349bc..17a0bef 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -6,6 +6,7 @@ export * from './target-profile.js'; export * from './vpn.js'; export * from './dns-provider.js'; export * from './domain.js'; +export * from './workhoster.js'; export * from './dns-record.js'; export * from './acme-config.js'; export * from './email-domain.js'; diff --git a/ts_interfaces/data/route-management.ts b/ts_interfaces/data/route-management.ts index d77c027..b8d623e 100644 --- a/ts_interfaces/data/route-management.ts +++ b/ts_interfaces/data/route-management.ts @@ -11,6 +11,7 @@ export type IRouteSecurity = NonNullable; export type TApiTokenScope = | 'routes:read' | 'routes:write' | 'config:read' + | 'certificates:read' | 'certificates:write' | 'tokens:read' | 'tokens:manage' | 'source-profiles:read' | 'source-profiles:write' | 'target-profiles:read' | 'target-profiles:write' @@ -18,7 +19,11 @@ export type TApiTokenScope = | 'dns-providers:read' | 'dns-providers:write' | 'domains:read' | 'domains:write' | 'dns-records:read' | 'dns-records:write' - | 'acme-config:read' | 'acme-config:write'; + | 'acme-config:read' | 'acme-config:write' + | 'email-domains:read' | 'email-domains:write' + | 'workhosters:read' | 'workhosters:write'; + +export type TWorkHosterType = 'onebox' | 'cloudly' | 'custom'; // ============================================================================ // Source Profile Types (source-side: who can access) @@ -80,6 +85,12 @@ export interface IRouteMetadata { networkTargetName?: string; /** Timestamp of last reference resolution. */ lastResolvedAt?: number; + /** External route ownership, used by WorkHoster reconciliation. */ + ownerType?: 'workhoster' | 'operator' | 'system'; + workHosterType?: TWorkHosterType; + workHosterId?: string; + workAppId?: string; + externalKey?: string; } /** diff --git a/ts_interfaces/data/workhoster.ts b/ts_interfaces/data/workhoster.ts new file mode 100644 index 0000000..b7f5c52 --- /dev/null +++ b/ts_interfaces/data/workhoster.ts @@ -0,0 +1,56 @@ +import type { IDomain } from './domain.js'; + +export interface IGatewayCapabilities { + routes: { + read: boolean; + write: boolean; + idempotentSync: boolean; + }; + domains: { + read: boolean; + write: boolean; + }; + certificates: { + read: boolean; + export: boolean; + forceRenew: boolean; + }; + email: { + domains: boolean; + inbound: boolean; + outbound: boolean; + }; + remoteIngress: { + enabled: boolean; + }; + dns: { + authoritative: boolean; + providerManaged: boolean; + }; + http3: { + enabled: boolean; + }; +} + +export interface IWorkHosterDomain extends IDomain { + capabilities: { + canCreateSubdomains: boolean; + canManageDnsRecords: boolean; + canIssueCertificates: boolean; + canHostEmail: boolean; + }; +} + +export interface IWorkAppRouteOwnership { + workHosterType: 'onebox' | 'cloudly' | 'custom'; + workHosterId: string; + workAppId: string; + hostname: string; +} + +export interface IWorkAppRouteSyncResult { + success: boolean; + action?: 'created' | 'updated' | 'deleted' | 'unchanged'; + routeId?: string; + message?: string; +} diff --git a/ts_interfaces/requests/certificate.ts b/ts_interfaces/requests/certificate.ts index 4cd9a55..e3aeb8b 100644 --- a/ts_interfaces/requests/certificate.ts +++ b/ts_interfaces/requests/certificate.ts @@ -28,7 +28,8 @@ export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfa > { method: 'getCertificateOverview'; request: { - identity: authInterfaces.IIdentity; + identity?: authInterfaces.IIdentity; + apiToken?: string; }; response: { certificates: ICertificateInfo[]; @@ -50,7 +51,8 @@ export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfa > { method: 'reprovisionCertificate'; request: { - identity: authInterfaces.IIdentity; + identity?: authInterfaces.IIdentity; + apiToken?: string; routeName: string; }; response: { @@ -66,7 +68,8 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI > { method: 'reprovisionCertificateDomain'; request: { - identity: authInterfaces.IIdentity; + identity?: authInterfaces.IIdentity; + apiToken?: string; domain: string; forceRenew?: boolean; }; @@ -83,7 +86,8 @@ export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.i > { method: 'deleteCertificate'; request: { - identity: authInterfaces.IIdentity; + identity?: authInterfaces.IIdentity; + apiToken?: string; domain: string; }; response: { @@ -99,7 +103,8 @@ export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.i > { method: 'exportCertificate'; request: { - identity: authInterfaces.IIdentity; + identity?: authInterfaces.IIdentity; + apiToken?: string; domain: string; }; response: { @@ -124,7 +129,8 @@ export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.i > { method: 'importCertificate'; request: { - identity: authInterfaces.IIdentity; + identity?: authInterfaces.IIdentity; + apiToken?: string; cert: { id: string; domainName: string; diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index bc64d91..756e3ab 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -19,4 +19,5 @@ export * from './domains.js'; export * from './dns-records.js'; export * from './acme-config.js'; export * from './email-domains.js'; +export * from './workhoster.js'; export * from './security-policy.js'; diff --git a/ts_interfaces/requests/workhoster.ts b/ts_interfaces/requests/workhoster.ts new file mode 100644 index 0000000..bb1d07e --- /dev/null +++ b/ts_interfaces/requests/workhoster.ts @@ -0,0 +1,53 @@ +import * as plugins from '../plugins.js'; +import type * as authInterfaces from '../data/auth.js'; +import type { + IGatewayCapabilities, + IWorkAppRouteOwnership, + IWorkAppRouteSyncResult, + IWorkHosterDomain, +} from '../data/workhoster.js'; +import type { IDcRouterRouteConfig } from '../data/remoteingress.js'; + +export interface IReq_GetGatewayCapabilities extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetGatewayCapabilities +> { + method: 'getGatewayCapabilities'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + }; + response: { + capabilities: IGatewayCapabilities; + }; +} + +export interface IReq_GetWorkHosterDomains extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetWorkHosterDomains +> { + method: 'getWorkHosterDomains'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + }; + response: { + domains: IWorkHosterDomain[]; + }; +} + +export interface IReq_SyncWorkAppRoute extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_SyncWorkAppRoute +> { + method: 'syncWorkAppRoute'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + ownership: IWorkAppRouteOwnership; + route?: IDcRouterRouteConfig; + enabled?: boolean; + delete?: boolean; + }; + response: IWorkAppRouteSyncResult; +} diff --git a/ts_web/elements/access/ops-view-apitokens.ts b/ts_web/elements/access/ops-view-apitokens.ts index 78a7082..b3eb746 100644 --- a/ts_web/elements/access/ops-view-apitokens.ts +++ b/ts_web/elements/access/ops-view-apitokens.ts @@ -199,12 +199,22 @@ export class OpsViewApiTokens extends DeesElement { private async showCreateTokenDialog() { const { DeesModal } = await import('@design.estate/dees-catalog'); - const allScopes: TApiTokenScope[] = [ + const allScopes = [ 'routes:read', 'routes:write', 'config:read', + 'certificates:read', + 'certificates:write', 'tokens:read', 'tokens:manage', + 'domains:read', + 'domains:write', + 'dns-records:read', + 'dns-records:write', + 'email-domains:read', + 'email-domains:write', + 'workhosters:read', + 'workhosters:write', ]; await DeesModal.createAndShow({