import * as plugins from '../../plugins.js'; import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; import { requireOpsAuth } from '../helpers/auth.js'; import { CachedEmail } from '../../db/index.js'; import { type IGatewayMachineAuthContext as TAuthContext, getGatewayCredentialState, matchesGatewayHostnamePatterns, normalizeGatewayHostname, normalizeGatewayTargetHost, requireGatewayMachineAuth, } from '../helpers/gateway-client-auth.js'; type TResolvedGatewayClientOwnership = Required> & { hostname?: string; routeRef?: string; }; export class GatewayClientHandler { public typedrouter = new plugins.typedrequest.TypedRouter(); private routeSyncChains = new Map>(); private gatewayClientCredentialChains = new Map>(); 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, options: { allowCandidate?: boolean; loadManagedZoneNames?: boolean } = {}, ): Promise { return await requireGatewayMachineAuth( this.opsServerRef, request, requiredScope === 'gateway-clients:write' ? 'gateway-clients:write' : 'gateway-clients:read', options, ); } private async requireAdmin( request: { identity?: interfaces.data.IIdentity; apiToken?: string }, scope: interfaces.data.TApiTokenScope = 'gateway-clients:write', ): Promise { const auth = await requireOpsAuth(this.opsServerRef, request, { scope, requireAdminIdentity: true, requireAdminToken: true, }); return auth.userId; } private registerHandlers(): void { this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayCapabilities', async (dataArg) => { await this.requireAuth(dataArg, 'gateway-clients:read'); return { capabilities: this.getGatewayCapabilities() }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'provisionGatewayClientCredential', async (dataArg) => { try { const userId = await this.requireAdmin(dataArg, 'tokens:manage'); const clientManager = this.opsServerRef.dcRouterRef.gatewayClientManager; const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; if (!clientManager || !tokenManager) { return { success: false, message: 'Gateway client or token management not initialized' }; } const lockKey = this.normalizeGatewayClientLockKey(dataArg.provisioning.id); return await this.withGatewayClientCredentialLock(lockKey, async () => { if (dataArg.provisioning.enabled === false) { return { success: false, message: 'Cannot provision a credential for a disabled gateway client' }; } const provisioned = await clientManager.stageClientProvisioning( dataArg.provisioning, userId, ); if (!provisioned.gatewayClient.enabled) { return { success: false, message: 'Cannot provision a credential for a disabled gateway client' }; } const policy = this.buildGatewayClientTokenPolicy(provisioned.gatewayClient); const issuedAt = Date.now(); const credential = await tokenManager.createProvisionedGatewayCredentialCandidate( dataArg.credentialName?.trim() || `${provisioned.gatewayClient.name} Credential`, ['gateway-clients:read', 'gateway-clients:write'], dataArg.expiresInDays ?? null, userId, policy, provisioned.credentialSequence, provisioned.policyDigest, ); return { success: true, action: provisioned.action, gatewayClient: provisioned.gatewayClient, credential: { tokenId: credential.id, tokenValue: credential.rawToken, issuedAt, state: 'candidate' as const, finalizationRequired: true as const, }, }; }); } catch (error) { return { success: false, message: (error as Error).message }; } }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'finalizeGatewayClientCredential', async (dataArg) => { try { if ('identity' in dataArg) { return { success: false, message: 'Identity authentication cannot finalize gateway credentials' }; } const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; const clientManager = this.opsServerRef.dcRouterRef.gatewayClientManager; if (!tokenManager || !clientManager) { return { success: false, message: 'Gateway client or token management not initialized' }; } const initialToken = await tokenManager.validateToken(dataArg.apiToken); if (!initialToken) { return { success: false, message: 'Candidate gateway credential is invalid or revoked' }; } const gatewayClientId = this.requireFinalizableGatewayCredential(initialToken, dataArg.tokenId); return await this.withGatewayClientCredentialLock(gatewayClientId, async () => { const lockedToken = await tokenManager.validateToken(dataArg.apiToken); if (!lockedToken) { return { success: false, message: 'Candidate gateway credential is invalid or revoked' }; } const lockedGatewayClientId = this.requireFinalizableGatewayCredential( lockedToken, dataArg.tokenId, ); if (lockedGatewayClientId !== gatewayClientId) { return { success: false, message: 'Gateway credential ownership changed during finalization' }; } const lifecycle = lockedToken.gatewayCredentialLifecycle!; if (lifecycle.cleanupCompletedAt !== null) { return { success: true, gatewayClientId, tokenId: dataArg.tokenId, revokedCredentialCount: lifecycle.revokedCredentialCount!, finalizedAt: lifecycle.finalizedAt!, }; } const tokenBinding = lockedToken.policy!.gatewayClient!; if (lifecycle.state === 'candidate') { const stagedClient = await clientManager.getClientForProvisionedCredential( gatewayClientId, tokenBinding.type, lifecycle, ); if (!stagedClient) { return { success: false, message: 'Gateway credential policy was superseded before finalization' }; } } const activation = await tokenManager.activateProvisionedGatewayCredential( dataArg.tokenId, gatewayClientId, ); await clientManager.promoteProvisionedPolicy( gatewayClientId, tokenBinding.type, activation.lifecycle, ); const revokedCredentialCount = await tokenManager.revokeOlderProvisionedGatewayCredentials( gatewayClientId, activation.lifecycle.sequence, dataArg.tokenId, ); const completed = await tokenManager.completeProvisionedGatewayCredentialHandover( dataArg.tokenId, gatewayClientId, revokedCredentialCount, ); return { success: true, gatewayClientId, tokenId: dataArg.tokenId, revokedCredentialCount: completed.revokedCredentialCount!, finalizedAt: completed.finalizedAt!, }; }); } catch (error) { return { success: false, message: (error as Error).message }; } }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayClientContext', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:read', { allowCandidate: true }); return { context: this.getGatewayClientContext(auth), capabilities: this.getGatewayCapabilities(), }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'listGatewayClients', async (dataArg) => { await this.requireAdmin(dataArg, 'gateway-clients:read'); 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 create = async () => { 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 }; }; return dataArg.id ? await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.id), create) : await create(); } 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' }; try { return await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.id), async () => { 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' }; }); } catch (error) { return { success: false, message: (error as Error).message }; } }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'deleteGatewayClient', async (dataArg) => { await this.requireAdmin(dataArg); const manager = this.opsServerRef.dcRouterRef.gatewayClientManager; const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; if (!manager || !tokenManager) return { success: false, message: 'Gateway client or token management not initialized' }; try { return await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.id), async () => { const existing = await manager.getClient(dataArg.id); if (!existing) return { success: false, message: 'Gateway client not found' }; await tokenManager.revokeGatewayClientCredentials(dataArg.id); const success = await manager.deleteClient(dataArg.id); return { success, message: success ? undefined : 'Gateway client changed during credential cleanup; retry deletion', }; }); } catch (error) { return { success: false, message: (error as Error).message }; } }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'createGatewayClientToken', async (dataArg) => { const userId = await this.requireAdmin(dataArg, 'tokens:manage'); const manager = this.opsServerRef.dcRouterRef.gatewayClientManager; const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager; if (!manager || !tokenManager) return { success: false, message: 'Gateway client or token management not initialized' }; return await this.withGatewayClientCredentialLock(this.normalizeGatewayClientLockKey(dataArg.gatewayClientId), async () => { const gatewayClient = await manager.getClient(dataArg.gatewayClientId); if (!gatewayClient || !gatewayClient.enabled) { return { success: false, message: 'Gateway client not found or disabled' }; } const result = await tokenManager.createToken( dataArg.name?.trim() || `${gatewayClient.name} Token`, ['gateway-clients:read', 'gateway-clients:write'], dataArg.expiresInDays ?? null, userId, this.buildGatewayClientTokenPolicy(gatewayClient), ); return { success: true, tokenId: result.id, tokenValue: result.rawToken }; }); }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayClientMailOverview', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:read'); this.assertCapability(auth, 'readMail'); return await this.getGatewayClientMailOverview(auth, dataArg.limit); }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayClientMailDomainCount', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:read', { loadManagedZoneNames: false, }); this.assertCapability(auth, 'readMail'); const untypedRequest = dataArg as Record; for (const forbiddenKey of [ 'owner', 'gatewayClientId', 'gatewayClientType', 'workHosterId', 'workHosterType', 'appInstanceId', ]) { if (Object.prototype.hasOwnProperty.call(untypedRequest, forbiddenKey)) { throw new plugins.typedrequest.TypedResponseError( 'mail-domain count ownership is derived from the gateway-client credential', ); } } if ( auth.isAdmin || auth.policy?.role !== 'gatewayClient' || !auth.gatewayClient || !auth.policy.gatewayClient ) { throw new plugins.typedrequest.TypedResponseError('gateway-client credential required'); } const manager = this.opsServerRef.dcRouterRef.workAppMailManager; return { count: await manager.countMailDomains({ gatewayClientType: auth.gatewayClient.type, gatewayClientId: auth.gatewayClient.id, }), }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayClientDomains', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:read'); this.assertCapability(auth, 'readDomains'); return { domains: await this.listGatewayClientDomains(auth, dataArg.gatewayClientId) }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayClientDnsRecords', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:read'); this.assertCapability(auth, 'readDnsRecords'); return { records: await this.listGatewayClientDnsRecords(auth, dataArg.gatewayClientId) }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getGatewayClientRoutes', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:read'); this.assertCapability(auth, 'readRoutes'); return { routes: this.listGatewayClientRoutes(auth, dataArg.gatewayClientId) }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'syncGatewayClientRoute', async (dataArg) => { const auth = await this.requireAuth(dataArg, 'gateway-clients:write'); this.assertCapability(auth, 'syncRoutes'); return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete, dataArg.sourceProfileRef, dataArg.dnsMode, dataArg.dnsProxied); }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'listMailAddressBindings', async (dataArg) => { const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read'); this.assertCapability(auth, 'readMail'); const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) return { bindings: [] }; const bindings = await manager.listMailAddressBindings({ owner: this.resolveMailOwnerFilter(auth, dataArg.owner), domain: dataArg.domain, address: dataArg.address, }); return { bindings: this.filterMailBindingsAllowed(auth, bindings), }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'syncMailAddressBinding', async (dataArg) => { const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write'); this.assertCapability(auth, 'manageMail'); const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) { return { success: false, message: 'WorkApp mail manager not initialized' }; } try { const binding = { ...dataArg.binding, owner: this.resolveMailOwner(auth, dataArg.binding.owner), }; this.assertMailAddressAllowed(auth, binding.address, binding.domain); this.assertMailForwardTargetAllowed(auth, binding.inboundTarget); return await manager.syncMailAddressBinding(binding, auth.userId); } catch (error) { return { success: false, message: (error as Error).message }; } }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'deleteMailAddressBinding', async (dataArg) => { const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write'); this.assertCapability(auth, 'manageMail'); const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) { return { success: false, message: 'WorkApp mail manager not initialized' }; } if (auth.policy?.role === 'gatewayClient') { const bindings = this.filterMailBindingsAllowed(auth, await manager.listMailAddressBindings({ owner: this.resolveMailOwnerFilter(auth), })); const binding = bindings.find((candidate) => candidate.id === dataArg.id); if (!binding) return { success: true }; return await manager.deleteMailAddressBinding(binding.id, auth.userId); } return await manager.deleteMailAddressBinding(dataArg.id, auth.userId); }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'listWorkAppMailBindings', async (dataArg) => { const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read'); this.assertCapability(auth, 'readMail'); const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) return { bindings: [] }; const owner = this.resolveMailOwnerFilter(auth, dataArg.owner); const bindings = await manager.listWorkAppMailBindings(owner); return { bindings: await this.filterWorkAppMailBindingsAllowed(auth, manager, owner, bindings) }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'rotateMailCredential', async (dataArg) => { const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write'); this.assertCapability(auth, 'manageMail'); const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) { return { success: false, message: 'WorkApp mail manager not initialized' }; } const owner = this.resolveMailOwnerFilter(auth); if (auth.policy?.role === 'gatewayClient') { const credentialId = dataArg.credentialId?.trim(); const bindings = this.filterMailBindingsAllowed(auth, await manager.listMailAddressBindings({ owner })); const binding = bindings.find((bindingArg) => bindingArg.outboundIdentityId === credentialId || bindingArg.outboundCredential?.id === credentialId || bindingArg.outboundCredential?.username === credentialId); if (!binding) { return { success: false, message: 'Mail credential not found' }; } } return await manager.rotateMailCredential( dataArg.credentialId, auth.userId, owner, ); }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'enqueueMail', async (dataArg) => { const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) { return { accepted: false, message: 'WorkApp mail manager not initialized' }; } return await manager.enqueueMail( dataArg.auth || {}, dataArg.message, dataArg.outboundIdentityId, dataArg.idempotencyKey, ); }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getMailDeliveryStatus', async (dataArg) => { const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) { return { attempts: [] }; } const authData = dataArg.auth || {}; const mailSubmissionAuth = authData as plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth; if (mailSubmissionAuth.credentialId || mailSubmissionAuth.credentialSecret) { return await manager.getMailDeliveryStatusForCredential( mailSubmissionAuth, dataArg.spoolItemId, ); } const auth = await this.requireAuth(authData, 'gateway-clients:read'); this.assertCapability(auth, 'readMail'); const status = await manager.getMailDeliveryStatus(dataArg.spoolItemId); if (!this.isMailDeliveryStatusAllowed(auth, status)) { return { attempts: [] }; } return status; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'registerServiceMailEndpoint', async (dataArg, toolsArg) => { const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) { return { success: false, message: 'WorkApp mail manager not initialized' }; } const peer = toolsArg?.localData?.peer as { tags?: Set } | undefined; return await manager.registerServiceMailEndpoint( dataArg.auth || {}, peer as { tags?: Set }, dataArg.addresses, ); }, ), ); } private getGatewayCapabilities(): plugins.servezoneInterfaces.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.remoteIngressManager?.getHubSettings().enabled), }, dns: { authoritative: Boolean(dcRouter.dnsAuthorityManager?.getEffectiveZoneNames().length), providerManaged: Boolean(dcRouter.dnsManager), }, http3: { enabled: dcRouter.options.http3?.enabled !== false, }, webPush: { bindings: Boolean(dcRouter.webPushManager?.isReady), delivery: Boolean(dcRouter.webPushManager?.isReady), cancellation: Boolean(dcRouter.webPushManager?.isReady), vapidRotation: Boolean(dcRouter.webPushManager?.isReady), }, }; } private getGatewayClientContext(auth: TAuthContext): plugins.servezoneInterfaces.data.IGatewayClientContext { const policy = auth.policy; if (auth.gatewayClient && auth.credentialId) { return { role: 'gatewayClient', credentialState: getGatewayCredentialState(auth.token!), credentialId: auth.credentialId, scopes: auth.token?.scopes || [], gatewayClient: { type: auth.gatewayClient.type, id: auth.gatewayClient.id, policyGeneration: auth.gatewayClient.policyGeneration, }, hostnamePatterns: policy?.hostnamePatterns || [], allowedRouteTargets: policy?.allowedRouteTargets || [], capabilities: policy?.capabilities || {}, }; } return { role: 'admin', scopes: auth.token?.scopes || ['*'], hostnamePatterns: [], allowedRouteTargets: [], capabilities: {}, }; } private buildGatewayClientTokenPolicy( gatewayClientArg: interfaces.data.IGatewayClient, ): interfaces.data.IApiTokenPolicy { return { role: 'gatewayClient', scopes: ['gateway-clients:read', 'gateway-clients:write'], gatewayClient: { type: gatewayClientArg.type, id: gatewayClientArg.id, policyGeneration: gatewayClientArg.policyGeneration, }, hostnamePatterns: gatewayClientArg.hostnamePatterns, allowedRouteTargets: gatewayClientArg.allowedRouteTargets, capabilities: gatewayClientArg.capabilities, }; } private requireFinalizableGatewayCredential( tokenArg: interfaces.data.IStoredApiToken, expectedTokenIdArg: string, ): string { if (tokenArg.id !== expectedTokenIdArg) { throw new Error('tokenId does not match the authenticating gateway credential'); } if (tokenArg.policy?.role !== 'gatewayClient' || !tokenArg.policy.gatewayClient?.id) { throw new Error('A bound gateway-client credential is required for finalization'); } const lifecycle = tokenArg.gatewayCredentialLifecycle; const validCandidate = lifecycle?.source === 'provisioned' && lifecycle.state === 'candidate' && lifecycle.finalizedAt === null; const validActive = lifecycle?.source === 'provisioned' && lifecycle.state === 'active' && Number.isSafeInteger(lifecycle.finalizedAt) && Number(lifecycle.finalizedAt) > 0; if (!validCandidate && !validActive) { throw new Error('Only provisioned candidate or active gateway credentials can be finalized'); } return this.normalizeGatewayClientLockKey(tokenArg.policy.gatewayClient.id); } private async getGatewayClientMailOverview( auth: TAuthContext, limitArg?: number, ): Promise { const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) return { domains: [], emails: [] }; const owner = this.resolveMailOwnerFilter(auth); const bindings = this.filterMailBindingsAllowed( auth, await manager.listMailAddressBindings({ owner }), ); const ownedAddresses = new Set(bindings.map((bindingArg) => bindingArg.address.toLowerCase())); const ownedDomains = Array.from(new Set(bindings.map((bindingArg) => bindingArg.domain))) .sort((left, right) => left.localeCompare(right)); const domains = await Promise.all(ownedDomains.map(async (domainArg) => { const configured = await this.opsServerRef.dcRouterRef.emailDomainManager?.getByDomain(domainArg); return { domain: domainArg, dnsMode: configured ? 'managed' : undefined, status: configured?.reconciliation?.lifecycleStatus, }; })); const limit = Math.min(Math.max(Math.floor(limitArg ?? 100), 1), 500); const recent = this.opsServerRef.dcRouterRef.dcRouterDb?.isReady() ? await CachedEmail.findRecent(Math.max(limit * 5, 100)) : []; const emails = recent .filter((emailArg) => { const from = String(emailArg.from || '').trim().toLowerCase(); const recipients = (emailArg.to || []).map((addressArg) => addressArg.trim().toLowerCase()); return ownedAddresses.has(from) || recipients.some((addressArg) => ownedAddresses.has(addressArg)); }) .slice(0, limit) .map((emailArg) => ({ id: emailArg.id, direction: emailArg.direction, from: emailArg.from, to: emailArg.to?.[0], subject: emailArg.subject, status: emailArg.status, timestamp: emailArg.acceptedAt, })); return { domains, emails }; } 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 assertCapability( auth: TAuthContext, capability: keyof NonNullable, ): void { if (auth.isAdmin) return; const policy = auth.policy; if (!policy || policy.role !== 'gatewayClient') { throw new plugins.typedrequest.TypedResponseError('gateway-client credential required'); } if (policy.capabilities?.[capability] === true) return; throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${String(capability)}`); } private resolveGatewayClientRouteFilter( auth: TAuthContext, requestedId?: string, ): { gatewayClientId?: string; gatewayClientType?: string } { const policy = auth.policy; if (policy?.role !== 'gatewayClient') return { gatewayClientId: requestedId }; const policyClient = policy.gatewayClient; if (!policyClient) { throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding'); } if (requestedId && requestedId !== policyClient.id) { throw new plugins.typedrequest.TypedResponseError('gateway client token cannot access another gateway client'); } return { gatewayClientId: policyClient.id, gatewayClientType: policyClient.type, }; } private resolveGatewayClientOwnership( auth: TAuthContext, ownership: plugins.servezoneInterfaces.data.IGatewayClientOwnership, ): TResolvedGatewayClientOwnership { const policy = auth.policy; if (!ownership.appId?.trim()) { throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing appId'); } if (!ownership.hostname?.trim() && !ownership.routeRef?.trim()) { throw new plugins.typedrequest.TypedResponseError('gateway client ownership requires hostname or routeRef'); } 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 ? normalizeGatewayHostname(ownership.hostname) : undefined, routeRef: ownership.routeRef, }; } if (!ownership.gatewayClientType || !ownership.gatewayClientId) { throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing type or id'); } return ownership as TResolvedGatewayClientOwnership; } private resolveMailOwnerFilter( auth: TAuthContext, owner?: Partial, ): Partial | undefined { const policy = auth.policy; if (policy?.role !== 'gatewayClient') return owner; if (!policy.gatewayClient) { throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding'); } if (owner?.gatewayClientType && owner.gatewayClientType !== policy.gatewayClient.type) { throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership'); } if (owner?.gatewayClientId && owner.gatewayClientId !== policy.gatewayClient.id) { throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership'); } return { ...owner, gatewayClientType: policy.gatewayClient.type, gatewayClientId: policy.gatewayClient.id, }; } private resolveMailOwner( auth: TAuthContext, owner: plugins.servezoneInterfaces.data.IMailResourceOwner, ): plugins.servezoneInterfaces.data.IMailResourceOwner { const resolvedOwner = this.resolveMailOwnerFilter(auth, owner); if (!resolvedOwner?.gatewayClientType || !resolvedOwner.gatewayClientId) { throw new plugins.typedrequest.TypedResponseError('mail owner is missing gateway client type or id'); } return resolvedOwner as plugins.servezoneInterfaces.data.IMailResourceOwner; } private assertGatewayClientOwnership(auth: TAuthContext, ownership: TResolvedGatewayClientOwnership): void { const policy = auth.policy; if (!policy || policy.role !== 'gatewayClient') return; if (!ownership.hostname) return; if (!matchesGatewayHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [], auth.managedZoneNames)) { throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy'); } } private assertRouteTargetsAllowed( auth: TAuthContext, route?: plugins.servezoneInterfaces.data.IGatewayRouteConfig, ): void { const policy = auth.policy; if (!policy || policy.role !== 'gatewayClient' || !route) return; const allowedTargets = policy.allowedRouteTargets || []; if (allowedTargets.length === 0) { throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets'); } const targets = route.action.targets; for (const target of targets) { if (typeof target.host !== 'string') { throw new plugins.typedrequest.TypedResponseError('gateway route target host must be a string'); } const host = normalizeGatewayTargetHost(target.host); const port = Number(target.port); const allowed = allowedTargets.some((allowedTarget) => { return normalizeGatewayTargetHost(allowedTarget.host) === host && (allowedTarget.allowAnyPort === true || allowedTarget.ports.includes(port)); }); if (!allowed) { throw new plugins.typedrequest.TypedResponseError(`route target is outside token policy: ${host}:${port}`); } } } private assertMailForwardTargetAllowed( auth: TAuthContext, target?: plugins.servezoneInterfaces.data.IMailInboundTarget, ): void { if (this.isMailForwardTargetAllowed(auth, target)) return; const targetDescription = target?.smtpForward ? `${target.smtpForward.host.trim().toLowerCase()}:${Number(target.smtpForward.port)}` : 'unknown'; if ((auth.policy?.allowedRouteTargets || []).length === 0) { throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets'); } throw new plugins.typedrequest.TypedResponseError(`mail target is outside token policy: ${targetDescription}`); } private isMailForwardTargetAllowed( auth: TAuthContext, target?: plugins.servezoneInterfaces.data.IMailInboundTarget, ): boolean { const policy = auth.policy; if (!policy || policy.role !== 'gatewayClient' || !target?.smtpForward) return true; const allowedTargets = policy.allowedRouteTargets || []; if (allowedTargets.length === 0) return false; const host = normalizeGatewayTargetHost(target.smtpForward.host); const port = Number(target.smtpForward.port); return allowedTargets.some((allowedTarget) => { return normalizeGatewayTargetHost(allowedTarget.host) === host && (allowedTarget.allowAnyPort === true || allowedTarget.ports.includes(port)); }); } private assertMailAddressAllowed( auth: TAuthContext, addressArg: string, domainArg: string, ): void { if (this.isMailAddressAllowed(auth, addressArg, domainArg)) return; throw new plugins.typedrequest.TypedResponseError('mail domain is outside token policy'); } private filterMailBindingsAllowed( auth: TAuthContext, bindingsArg: plugins.servezoneInterfaces.data.IMailAddressBinding[], ): plugins.servezoneInterfaces.data.IMailAddressBinding[] { return bindingsArg.filter((bindingArg) => { try { return this.isMailAddressAllowed(auth, bindingArg.address, bindingArg.domain) && this.isMailForwardTargetAllowed(auth, bindingArg.inboundTarget); } catch { return false; } }); } private isMailDeliveryStatusAllowed( auth: TAuthContext, statusArg: plugins.servezoneInterfaces.data.IMailDeliveryStatus, ): boolean { const policy = auth.policy; if (!policy || policy.role !== 'gatewayClient') { return true; } const owner = statusArg.spoolItem?.owner; if (!owner || !policy.gatewayClient) { return false; } return owner.gatewayClientType === policy.gatewayClient.type && owner.gatewayClientId === policy.gatewayClient.id; } private async filterWorkAppMailBindingsAllowed( auth: TAuthContext, managerArg: any, ownerArg: Partial | undefined, bindingsArg: plugins.servezoneInterfaces.data.IWorkAppMailBinding[], ): Promise { if (auth.policy?.role !== 'gatewayClient') return bindingsArg; const allowedAddressBindings = this.filterMailBindingsAllowed( auth, await managerArg.listMailAddressBindings({ owner: ownerArg }), ); const allowedAddressIds = new Set(allowedAddressBindings.map((bindingArg) => bindingArg.id)); return bindingsArg .map((bindingArg) => { const addressBindingIds = (bindingArg.addressBindingIds || []).filter((idArg) => allowedAddressIds.has(idArg)); const allowedForBinding = allowedAddressBindings.filter((addressBindingArg) => addressBindingIds.includes(addressBindingArg.id)); const allowedOutboundIds = new Set(allowedForBinding .map((addressBindingArg) => addressBindingArg.outboundIdentityId) .filter((identityIdArg): identityIdArg is string => Boolean(identityIdArg))); const defaultFrom = allowedForBinding.find((addressBindingArg) => allowedOutboundIds.has(addressBindingArg.outboundIdentityId || ''))?.address || allowedForBinding[0]?.address; return { ...bindingArg, addressBindingIds, outboundIdentityIds: (bindingArg.outboundIdentityIds || []).filter((idArg) => allowedOutboundIds.has(idArg)), defaultFrom, inboundTarget: undefined, }; }) .filter((bindingArg) => (bindingArg.addressBindingIds || []).length > 0); } private isMailAddressAllowed( auth: TAuthContext, addressArg: string, domainArg: string, ): boolean { const policy = auth.policy; if (!policy || policy.role !== 'gatewayClient') return true; const normalizedDomain = normalizeGatewayHostname(domainArg); const normalizedAddress = addressArg.trim().toLowerCase(); if (!normalizedAddress.endsWith(`@${normalizedDomain}`)) { throw new plugins.typedrequest.TypedResponseError('mail address does not match domain'); } const patterns = policy.hostnamePatterns || []; return matchesGatewayHostnamePatterns(normalizedDomain, patterns, auth.managedZoneNames); } private getRouteHostnames(route: interfaces.data.IDcRouterRouteConfig): string[] { const domains = (route.match as any)?.domains; if (Array.isArray(domains)) { return domains.map((domain) => normalizeGatewayHostname(String(domain))); } if (typeof domains === 'string') { return domains.split(',').map((domain) => normalizeGatewayHostname(domain)); } return []; } private getOwnedRoutes(routeFilter: { gatewayClientId?: string; gatewayClientType?: string }): interfaces.data.IMergedRoute[] { const manager = this.opsServerRef.dcRouterRef.routeConfigManager; if (!manager) return []; return manager.getMergedRoutes().routes.filter((route) => { const metadata = route.metadata; if (!metadata) return false; if (metadata.ownerType !== 'gatewayClient') return false; if (routeFilter.gatewayClientId && metadata.gatewayClientId !== routeFilter.gatewayClientId) return false; if (routeFilter.gatewayClientType && (metadata.gatewayClientType || 'custom') !== routeFilter.gatewayClientType) return false; return true; }); } private listGatewayClientRoutes( auth: TAuthContext, requestedGatewayClientId?: string, ): plugins.servezoneInterfaces.data.IGatewayClientRoute[] { const routeFilter = this.resolveGatewayClientRouteFilter(auth, requestedGatewayClientId); return this.getOwnedRoutes(routeFilter) .map((routeArg) => this.toGatewayClientRoute(routeArg)) .filter((routeArg): routeArg is plugins.servezoneInterfaces.data.IGatewayClientRoute => { if (!routeArg) return false; if (auth.policy?.role !== 'gatewayClient') return true; return !routeArg.ownership.hostname || matchesGatewayHostnamePatterns(routeArg.ownership.hostname, auth.policy.hostnamePatterns || [], auth.managedZoneNames); }); } private toGatewayClientRoute( routeArg: interfaces.data.IMergedRoute, ): plugins.servezoneInterfaces.data.IGatewayClientRoute | null { const metadata = routeArg.metadata; if (!metadata || metadata.ownerType !== 'gatewayClient') return null; const ownership = this.toGatewayClientOwnership(routeArg); if (!ownership.hostname && !ownership.routeRef) return null; const route = structuredClone(routeArg.route) as any; delete route.security; return { id: routeArg.id, name: route.name || routeArg.id, enabled: routeArg.enabled, ownership, route, sourceProfileRef: metadata.sourceBindings?.find((bindingArg) => bindingArg.sourceProfileRef)?.sourceProfileRef, dnsMode: metadata.gatewayDnsMode, dnsProxied: metadata.gatewayDnsProxied, managePath: `/routes/${routeArg.id}`, }; } private toGatewayClientOwnership( routeArg: interfaces.data.IMergedRoute, ): plugins.servezoneInterfaces.data.IGatewayClientOwnership { const metadata = routeArg.metadata!; const ownership: plugins.servezoneInterfaces.data.IGatewayClientOwnership = { gatewayClientType: metadata.gatewayClientType || 'custom', gatewayClientId: metadata.gatewayClientId || '', appId: metadata.gatewayClientAppId || '', }; const prefix = `${ownership.gatewayClientType}:${ownership.gatewayClientId}:${ownership.appId}:`; const routeKey = metadata.externalKey?.startsWith(prefix) ? metadata.externalKey.slice(prefix.length) : ''; const combinedOwnership = this.parseCombinedGatewayClientRouteKey(routeKey); if (combinedOwnership) { ownership.hostname = combinedOwnership.hostname; ownership.routeRef = combinedOwnership.routeRef; } else if (routeKey.startsWith('v2:host-route:')) { // A malformed versioned key must not be exposed as a hostname. ownership.hostname = this.getRouteHostnames(routeArg.route)[0]; } else if (routeKey.startsWith('route:')) { ownership.routeRef = routeKey.slice('route:'.length); } else if (routeKey) { ownership.hostname = routeKey; } else { ownership.hostname = this.getRouteHostnames(routeArg.route)[0]; } return ownership; } private async listGatewayClientDomains( auth: TAuthContext, requestedGatewayClientId?: string, ): Promise { const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return []; const routeFilter = this.resolveGatewayClientRouteFilter(auth, requestedGatewayClientId); const ownedRoutes = this.getOwnedRoutes(routeFilter); const routeHostnames = ownedRoutes.flatMap((route) => this.getRouteHostnames(route.route)); const docs = await dnsManager.listDomains(); return docs .filter((domainDoc) => { if (!auth.policy || auth.policy.role !== 'gatewayClient') return true; return routeHostnames.some((hostname) => this.isHostnameInDomain(hostname, domainDoc.name)); }) .map((domainDoc) => { const domain = dnsManager.toPublicDomain(domainDoc); const canManageDnsRecords = domain.source === 'dcrouter' || Boolean(domain.providerId); const serviceCount = routeHostnames.filter((hostname) => this.isHostnameInDomain(hostname, domain.name)).length; return { ...domain, serviceCount, managePath: `/domains/${domain.id}`, capabilities: { canCreateSubdomains: canManageDnsRecords, canManageDnsRecords, canIssueCertificates: Boolean(this.opsServerRef.dcRouterRef.smartProxy), canHostEmail: Boolean(this.opsServerRef.dcRouterRef.emailDomainManager), }, } satisfies plugins.servezoneInterfaces.data.IGatewayDomain; }); } private async listGatewayClientDnsRecords( auth: TAuthContext, requestedGatewayClientId?: string, ): Promise { const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return []; const routeFilter = this.resolveGatewayClientRouteFilter(auth, requestedGatewayClientId); const ownedRoutes = this.getOwnedRoutes(routeFilter); const domains = await dnsManager.listDomains(); const records: plugins.servezoneInterfaces.data.IGatewayDnsRecord[] = []; for (const route of ownedRoutes) { const metadata = route.metadata; if (!metadata) continue; const gatewayClientType = metadata.gatewayClientType || 'custom'; const routeGatewayClientId = metadata.gatewayClientId || ''; const appId = metadata.gatewayClientAppId || ''; for (const hostname of this.getRouteHostnames(route.route)) { if (auth.policy?.role === 'gatewayClient' && !matchesGatewayHostnamePatterns(hostname, auth.policy.hostnamePatterns || [], auth.managedZoneNames)) { continue; } const domainDoc = domains.find((domain) => this.isHostnameInDomain(hostname, domain.name)); const domainRecords = domainDoc ? await dnsManager.listRecordsForDomain(domainDoc.id) : []; const matchingRecords = domainRecords.filter((record) => { try { return normalizeGatewayHostname(record.name) === hostname; } catch { return false; } }); if (matchingRecords.length === 0) { records.push({ id: `missing:${hostname}`, domainId: domainDoc?.id || '', domainName: domainDoc?.name, name: hostname, type: 'MISSING', value: '', ttl: 0, source: 'local', status: 'missing', gatewayClientType, gatewayClientId: routeGatewayClientId, appId, hostname, routeId: route.id, managePath: domainDoc ? `/domains/${domainDoc.id}/dns` : '/domains', createdAt: route.createdAt || 0, updatedAt: route.updatedAt || 0, createdBy: '', }); continue; } for (const recordDoc of matchingRecords) { const record = dnsManager.toPublicRecord(recordDoc); records.push({ ...record, domainName: domainDoc?.name, status: 'active', gatewayClientType, gatewayClientId: routeGatewayClientId, appId, hostname, routeId: route.id, managePath: `/dns-records/${record.id}`, }); } } } return records; } private isHostnameInDomain(hostname: string, domainName: string): boolean { const normalizedHostname = normalizeGatewayHostname(hostname); const normalizedDomainName = normalizeGatewayHostname(domainName); return normalizedHostname === normalizedDomainName || normalizedHostname.endsWith(`.${normalizedDomainName}`); } private async syncGatewayClientRoute( auth: TAuthContext, ownership: plugins.servezoneInterfaces.data.IGatewayClientOwnership, route?: plugins.servezoneInterfaces.data.IGatewayRouteConfig, enabled?: boolean, deleteRoute?: boolean, sourceProfileRef?: string, dnsModeArg?: plugins.servezoneInterfaces.data.TGatewayRouteDnsMode, dnsProxiedArg?: boolean, ): Promise { const validatedRoute = route === undefined ? undefined : this.validateGatewayRouteConfig(route); const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership); this.assertGatewayClientOwnership(auth, resolvedOwnership); this.assertGatewayClientRouteOwnership(resolvedOwnership, validatedRoute); this.assertRouteTargetsAllowed(auth, validatedRoute); if (dnsModeArg === 'observe') this.assertCapability(auth, 'readDnsRecords'); if (dnsModeArg === 'reconcile') this.assertCapability(auth, 'syncDnsRecords'); const manager = this.opsServerRef.dcRouterRef.routeConfigManager; if (!manager) { return { success: false, message: 'Route management not initialized' }; } const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership); return await this.withRouteSyncLock(externalKey, async () => { const existingRoute = manager.findApiRouteByExternalKey(externalKey); const dnsMode = dnsModeArg || existingRoute?.metadata?.gatewayDnsMode || 'skip'; const dnsProxied = dnsProxiedArg ?? existingRoute?.metadata?.gatewayDnsProxied; const includeDns = dnsModeArg !== undefined || dnsMode !== 'skip'; if (dnsMode === 'observe') this.assertCapability(auth, 'readDnsRecords'); if (dnsMode === 'reconcile') this.assertCapability(auth, 'syncDnsRecords'); const reconcileDnsFor = async ( hostnameArg: string | undefined, gatewayClientIdArg: string, externalKeyArg: string, deleteArg = false, effectiveModeArg = dnsMode, proxiedArg = dnsProxied, ) => { const reconciler = this.opsServerRef.dcRouterRef.gatewayRouteDnsReconciler; if (!reconciler) { return { success: effectiveModeArg === 'skip', retryable: effectiveModeArg !== 'skip', mode: effectiveModeArg, status: effectiveModeArg === 'skip' ? 'skipped' : 'zone-unavailable', hostname: hostnameArg, checkedAt: Date.now(), message: effectiveModeArg === 'skip' ? undefined : 'Gateway route DNS reconciliation is not initialized', } satisfies plugins.servezoneInterfaces.data.IGatewayRouteDnsResult; } try { return await reconciler.reconcileRoute({ externalKey: externalKeyArg, hostname: hostnameArg, mode: effectiveModeArg, gatewayClientId: gatewayClientIdArg, delete: deleteArg, ...(!deleteArg && typeof proxiedArg === 'boolean' ? { proxied: proxiedArg } : {}), }); } catch (error) { return { success: false, retryable: true, mode: effectiveModeArg, status: 'mutation-failed', hostname: hostnameArg, checkedAt: Date.now(), message: `Gateway route DNS reconciliation failed: ${(error as Error).message}`, } satisfies plugins.servezoneInterfaces.data.IGatewayRouteDnsResult; } }; const reconcileDns = async (deleteArg = false, effectiveModeArg = dnsMode) => { const proxied = deleteArg ? undefined : dnsProxied; return await reconcileDnsFor( resolvedOwnership.hostname, resolvedOwnership.gatewayClientId, externalKey, deleteArg, effectiveModeArg, proxied, ); }; if (deleteRoute) { if (!existingRoute) { const dns = await reconcileDns(true, dnsMode); return dns.success ? { success: true, action: 'unchanged', ...(includeDns ? { dns } : {}) } : { success: false, dns, message: dns.message }; } const snapshot = structuredClone(existingRoute); const existingDnsMode = snapshot.metadata?.gatewayDnsMode || dnsMode; const existingDnsProxied = snapshot.metadata?.gatewayDnsProxied; const disableResult = await manager.updateManagedRoute(existingRoute.id, { enabled: false, }); if (!disableResult.success) { return { success: false, routeId: existingRoute.id, message: disableResult.message }; } const dns = await reconcileDnsFor( this.getRouteHostnames(snapshot.route)[0] || resolvedOwnership.hostname, snapshot.metadata?.gatewayClientId || resolvedOwnership.gatewayClientId, snapshot.metadata?.externalKey || externalKey, true, existingDnsMode, ); if (!dns.success) { const rollback = await manager.updateManagedRoute(existingRoute.id, { route: snapshot.route, enabled: snapshot.enabled, metadata: snapshot.metadata, }, { replaceMetadata: true, replaceRoute: true }); if (!rollback.success) { dns.status = 'compensation-failed'; dns.message = `${dns.message || 'DNS cleanup failed'}; route rollback failed: ${rollback.message || 'unknown error'}`; } return { success: false, routeId: existingRoute.id, dns, message: dns.message }; } const result = await manager.deleteManagedRoute(existingRoute.id); if (result.success) { return { success: true, action: 'deleted', routeId: existingRoute.id, ...(includeDns ? { dns } : {}) }; } const rollback = await manager.updateManagedRoute(existingRoute.id, { route: snapshot.route, enabled: snapshot.enabled, metadata: snapshot.metadata, }, { replaceMetadata: true, replaceRoute: true }); let restoredDns = dns; if (rollback.success && snapshot.enabled && existingDnsMode === 'reconcile') { restoredDns = await reconcileDnsFor( this.getRouteHostnames(snapshot.route)[0] || resolvedOwnership.hostname, snapshot.metadata?.gatewayClientId || resolvedOwnership.gatewayClientId, snapshot.metadata?.externalKey || externalKey, false, 'reconcile', existingDnsProxied, ); } return { success: false, dns: rollback.success && restoredDns.success ? dns : { ...restoredDns, status: 'compensation-failed', message: !rollback.success ? `${result.message || 'Route deletion failed'}; route rollback failed: ${rollback.message || 'unknown error'}` : `${result.message || 'Route deletion failed'}; DNS restore failed: ${restoredDns.message || 'unknown error'}`, }, message: result.message, }; } if (!validatedRoute) { return { success: false, message: 'route is required unless delete=true' }; } const sourceBindings = this.getManagedRouteSourceBindings(sourceProfileRef); if (!sourceBindings) { return { success: false, message: sourceProfileRef?.trim() ? `source profile '${sourceProfileRef}' not found` : 'STANDARD source profile not found', }; } const metadata: interfaces.data.IRouteMetadata = { sourceBindings, ownerType: 'gatewayClient', gatewayClientType: resolvedOwnership.gatewayClientType, gatewayClientId: resolvedOwnership.gatewayClientId, gatewayClientAppId: resolvedOwnership.appId, externalKey, gatewayDnsMode: dnsMode, ...(typeof dnsProxied === 'boolean' ? { gatewayDnsProxied: dnsProxied } : {}), }; const normalizedRoute = this.normalizeGatewayClientRoute(validatedRoute, resolvedOwnership, externalKey); if ( validatedRoute.remoteIngress === undefined && existingRoute?.route.remoteIngress ) { normalizedRoute.remoteIngress = structuredClone(existingRoute.route.remoteIngress); } if (!normalizedRoute.ingress) { const existingIngress = existingRoute?.route.ingress; normalizedRoute.ingress = existingIngress ? structuredClone(existingIngress) : { directHub: true, smartVpn: true, }; console.warn( `Gateway client route '${externalKey}' omitted route.ingress; ` + `${existingIngress ? 'preserved stored policy' : 'applied legacy directHub+smartVpn compatibility policy'}`, ); } this.assertGatewayRouteHasIngressPath(normalizedRoute); if (existingRoute) { const snapshot = structuredClone(existingRoute); const snapshotHostname = this.getRouteHostnames(snapshot.route)[0]; const snapshotDnsMode = snapshot.metadata?.gatewayDnsMode || 'skip'; const snapshotDnsProxied = snapshot.metadata?.gatewayDnsProxied; const snapshotGatewayClientId = snapshot.metadata?.gatewayClientId || resolvedOwnership.gatewayClientId; const snapshotExternalKey = snapshot.metadata?.externalKey || externalKey; const shouldCleanPreviousDns = snapshotDnsMode === 'reconcile' && Boolean(snapshotHostname) && ( snapshotHostname !== resolvedOwnership.hostname || dnsMode !== 'reconcile' ); const result = await manager.updateManagedRoute(existingRoute.id, { route: normalizedRoute, enabled: enabled ?? true, metadata, }, { replaceMetadata: true, replaceRoute: true, }); if (!result.success) return { success: false, message: result.message }; if (shouldCleanPreviousDns) { const cleanupDns = await reconcileDnsFor( snapshotHostname, snapshotGatewayClientId, snapshotExternalKey, true, 'reconcile', ); if (!cleanupDns.success) { const compensation = await manager.updateManagedRoute(existingRoute.id, { route: snapshot.route, enabled: snapshot.enabled, metadata: snapshot.metadata, }, { replaceMetadata: true, replaceRoute: true }); if (compensation.success && snapshot.enabled) { const restoredDns = await reconcileDnsFor( snapshotHostname, snapshotGatewayClientId, snapshotExternalKey, false, 'reconcile', snapshotDnsProxied, ); if (!restoredDns.success) { cleanupDns.status = 'compensation-failed'; cleanupDns.message = `${cleanupDns.message || 'DNS cleanup failed'}; DNS restore failed: ${restoredDns.message || 'unknown error'}`; } } else if (!compensation.success) { cleanupDns.status = 'compensation-failed'; cleanupDns.message = `${cleanupDns.message || 'DNS cleanup failed'}; route rollback failed: ${compensation.message || 'unknown error'}`; } return { success: false, routeId: existingRoute.id, dns: cleanupDns, message: cleanupDns.message }; } } const dns = await reconcileDns((enabled ?? true) === false); if (dns.success) return { success: true, action: 'updated', routeId: existingRoute.id, ...(includeDns ? { dns } : {}) }; const compensation = await manager.updateManagedRoute(existingRoute.id, { route: snapshot.route, enabled: snapshot.enabled, metadata: snapshot.metadata, }, { replaceMetadata: true, replaceRoute: true }); if (compensation.success && shouldCleanPreviousDns && snapshot.enabled) { const restoredDns = await reconcileDnsFor( snapshotHostname, snapshotGatewayClientId, snapshotExternalKey, false, 'reconcile', snapshotDnsProxied, ); if (!restoredDns.success) { dns.status = 'compensation-failed'; dns.message = `${dns.message || 'DNS reconciliation failed'}; DNS restore failed: ${restoredDns.message || 'unknown error'}`; } } else if (!compensation.success) { dns.status = 'compensation-failed'; dns.message = `${dns.message || 'DNS reconciliation failed'}; route rollback failed: ${compensation.message || 'unknown error'}`; } return { success: false, routeId: existingRoute.id, dns, message: dns.message }; } const routeId = await manager.createManagedRoute(normalizedRoute, auth.userId, enabled ?? true, metadata); const dns = await reconcileDns((enabled ?? true) === false); if (dns.success) return { success: true, action: 'created', routeId, ...(includeDns ? { dns } : {}) }; const compensation = await manager.deleteManagedRoute(routeId); if (!compensation.success) { dns.status = 'compensation-failed'; dns.message = `${dns.message || 'DNS reconciliation failed'}; route rollback failed: ${compensation.message || 'unknown error'}`; } return { success: false, routeId, dns, message: dns.message }; }); } private normalizeGatewayClientLockKey(idArg: string): string { const normalized = idArg.trim().toLowerCase() .replace(/[^a-z0-9._-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); if (!normalized) throw new Error('gateway client id is required'); return normalized; } private async withGatewayClientCredentialLock( keyArg: string, taskArg: () => Promise, ): Promise { const previous = this.gatewayClientCredentialChains.get(keyArg) || Promise.resolve(); const run = previous.catch(() => undefined).then(taskArg); const settled = run.then(() => undefined, () => undefined); this.gatewayClientCredentialChains.set(keyArg, settled); try { return await run; } finally { if (this.gatewayClientCredentialChains.get(keyArg) === settled) { this.gatewayClientCredentialChains.delete(keyArg); } } } private async withRouteSyncLock(keyArg: string, taskArg: () => Promise): Promise { const previous = this.routeSyncChains.get(keyArg) || Promise.resolve(); const run = previous .catch(() => undefined) .then(taskArg); const settled = run.then( () => undefined, () => undefined, ); this.routeSyncChains.set(keyArg, settled); try { return await run; } finally { if (this.routeSyncChains.get(keyArg) === settled) { this.routeSyncChains.delete(keyArg); } } } private buildGatewayClientExternalKey(ownership: TResolvedGatewayClientOwnership): string { const hostname = ownership.hostname ? normalizeGatewayHostname(ownership.hostname) : undefined; const routeRef = ownership.routeRef?.trim(); let routeKey: string; if (hostname && routeRef) { const payload = Buffer.from(JSON.stringify([hostname, routeRef]), 'utf8').toString('base64url'); routeKey = `v2:host-route:${payload}`; } else if (hostname) { routeKey = hostname; } else { routeKey = `route:${routeRef}`; } return [ ownership.gatewayClientType, ownership.gatewayClientId, ownership.appId.trim(), routeKey, ].map((part) => part.trim()).join(':'); } private parseCombinedGatewayClientRouteKey( routeKeyArg: string, ): { hostname: string; routeRef: string } | undefined { const prefix = 'v2:host-route:'; if (!routeKeyArg.startsWith(prefix)) { return undefined; } const payload = routeKeyArg.slice(prefix.length); if (!payload || !/^[A-Za-z0-9_-]+$/.test(payload)) { return undefined; } try { const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); if ( !Array.isArray(parsed) || parsed.length !== 2 || parsed.some((value) => typeof value !== 'string' || !value.trim()) ) { return undefined; } return { hostname: parsed[0].trim().toLowerCase(), routeRef: parsed[1].trim(), }; } catch { return undefined; } } private validateGatewayRouteConfig( routeArg: unknown, ): plugins.servezoneInterfaces.data.IGatewayRouteConfig { const fail = (messageArg: string): never => { throw new plugins.typedrequest.TypedResponseError(`invalid gateway route: ${messageArg}`); }; const requireObject = (valueArg: unknown, labelArg: string): Record => { if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) { return fail(`${labelArg} must be an object`); } const prototype = Object.getPrototypeOf(valueArg); if (prototype !== Object.prototype && prototype !== null) { return fail(`${labelArg} must be a plain object`); } return valueArg as Record; }; const requireOnlyKeys = ( valueArg: Record, allowedArg: readonly string[], labelArg: string, ): void => { const unknownKeys = Object.keys(valueArg).filter((keyArg) => !allowedArg.includes(keyArg)); if (unknownKeys.length > 0) fail(`${labelArg} contains unsupported field(s): ${unknownKeys.join(', ')}`); }; const requireString = (valueArg: unknown, labelArg: string): string => { if (typeof valueArg !== 'string' || !valueArg.trim()) return fail(`${labelArg} must be a non-empty string`); return valueArg.trim(); }; const requireBoolean = (valueArg: unknown, labelArg: string): boolean => { if (typeof valueArg !== 'boolean') return fail(`${labelArg} must be a boolean`); return valueArg; }; const requirePort = (valueArg: unknown, labelArg: string): number => { if (!Number.isSafeInteger(valueArg) || Number(valueArg) < 1 || Number(valueArg) > 65535) { return fail(`${labelArg} must be an integer from 1 through 65535`); } return Number(valueArg); }; const route = requireObject(routeArg, 'route'); requireOnlyKeys(route, ['name', 'match', 'action', 'priority', 'managedRouteKind', 'ingress', 'remoteIngress'], 'route'); const name = requireString(route.name, 'route.name'); const match = requireObject(route.match, 'route.match'); requireOnlyKeys(match, ['ports', 'domains', 'transport', 'path'], 'route.match'); const portValues = match.ports; if (!Array.isArray(portValues) || portValues.length === 0) { fail('route.match.ports must be a non-empty array'); } const ports = (portValues as unknown[]).map((portArg, indexArg) => requirePort(portArg, `route.match.ports[${indexArg}]`)); let domains: string[] | undefined; if (match.domains !== undefined) { const domainValues = match.domains; if (!Array.isArray(domainValues) || domainValues.length === 0) { fail('route.match.domains must be a non-empty array when present'); } domains = (domainValues as unknown[]).map((domainArg, indexArg) => { return normalizeGatewayHostname(requireString(domainArg, `route.match.domains[${indexArg}]`)); }); } let transport: 'tcp' | 'udp' | 'all' | undefined; if (match.transport !== undefined) { if (match.transport !== 'tcp' && match.transport !== 'udp' && match.transport !== 'all') { fail('route.match.transport is unsupported'); } transport = match.transport as 'tcp' | 'udp' | 'all'; } const path = match.path === undefined ? undefined : requireString(match.path, 'route.match.path'); const action = requireObject(route.action, 'route.action'); requireOnlyKeys(action, ['type', 'targets', 'tls', 'websocket'], 'route.action'); if (action.type !== 'forward') fail('route.action.type must be forward'); const targetValues = action.targets; if (!Array.isArray(targetValues) || targetValues.length === 0) { fail('route.action.targets must be a non-empty array'); } const targets = (targetValues as unknown[]).map((targetArg, indexArg) => { const target = requireObject(targetArg, `route.action.targets[${indexArg}]`); requireOnlyKeys(target, ['host', 'port'], `route.action.targets[${indexArg}]`); return { host: normalizeGatewayTargetHost(requireString(target.host, `route.action.targets[${indexArg}].host`)), port: requirePort(target.port, `route.action.targets[${indexArg}].port`), }; }); let tls: plugins.servezoneInterfaces.data.IGatewayRouteTls | undefined; if (action.tls !== undefined) { const tlsValue = requireObject(action.tls, 'route.action.tls'); requireOnlyKeys(tlsValue, ['mode', 'certificate'], 'route.action.tls'); if (tlsValue.mode !== 'terminate' && tlsValue.mode !== 'passthrough' && tlsValue.mode !== 'terminate-and-reencrypt') fail('route.action.tls.mode is unsupported'); if (tlsValue.certificate !== undefined && tlsValue.certificate !== 'auto') { fail('route.action.tls.certificate must be auto when present'); } tls = { mode: tlsValue.mode as 'terminate' | 'passthrough' | 'terminate-and-reencrypt', ...(tlsValue.certificate === 'auto' ? { certificate: 'auto' as const } : {}), }; } let websocket: { enabled: boolean } | undefined; if (action.websocket !== undefined) { const websocketValue = requireObject(action.websocket, 'route.action.websocket'); requireOnlyKeys(websocketValue, ['enabled'], 'route.action.websocket'); websocket = { enabled: requireBoolean(websocketValue.enabled, 'route.action.websocket.enabled') }; } let priority: number | undefined; if (route.priority !== undefined) { if (!Number.isSafeInteger(route.priority)) fail('route.priority must be a safe integer'); priority = Number(route.priority); } let managedRouteKind: 'letsencrypt-http01-forward' | undefined; if (route.managedRouteKind !== undefined) { if (route.managedRouteKind !== 'letsencrypt-http01-forward') fail('route.managedRouteKind is unsupported'); managedRouteKind = route.managedRouteKind as 'letsencrypt-http01-forward'; } let ingress: { directHub: boolean; smartVpn: boolean } | undefined; if (route.ingress !== undefined) { const ingressValue = requireObject(route.ingress, 'route.ingress'); requireOnlyKeys(ingressValue, ['directHub', 'smartVpn'], 'route.ingress'); ingress = { directHub: requireBoolean(ingressValue.directHub, 'route.ingress.directHub'), smartVpn: requireBoolean(ingressValue.smartVpn, 'route.ingress.smartVpn'), }; } let remoteIngress: { enabled: boolean; edgeFilter?: string[] } | undefined; if (route.remoteIngress !== undefined) { const remoteValue = requireObject(route.remoteIngress, 'route.remoteIngress'); requireOnlyKeys(remoteValue, ['enabled', 'edgeFilter'], 'route.remoteIngress'); let edgeFilter: string[] | undefined; if (remoteValue.edgeFilter !== undefined) { const edgeFilterValues = remoteValue.edgeFilter; if (!Array.isArray(edgeFilterValues)) fail('route.remoteIngress.edgeFilter must be an array'); edgeFilter = (edgeFilterValues as unknown[]).map((entryArg, indexArg) => { return requireString(entryArg, `route.remoteIngress.edgeFilter[${indexArg}]`); }); } remoteIngress = { enabled: requireBoolean(remoteValue.enabled, 'route.remoteIngress.enabled'), ...(edgeFilter ? { edgeFilter } : {}), }; } return { name, match: { ports, ...(domains ? { domains } : {}), ...(transport ? { transport } : {}), ...(path ? { path } : {}) }, action: { type: 'forward', targets, ...(tls ? { tls } : {}), ...(websocket ? { websocket } : {}) }, ...(priority !== undefined ? { priority } : {}), ...(managedRouteKind ? { managedRouteKind } : {}), ...(ingress ? { ingress } : {}), ...(remoteIngress ? { remoteIngress } : {}), }; } private normalizeGatewayClientRoute( route: interfaces.data.IDcRouterRouteConfig, ownership: TResolvedGatewayClientOwnership, externalKey: string, ): interfaces.data.IDcRouterRouteConfig { const normalizedRoute = structuredClone(route); delete normalizedRoute.security; normalizedRoute.match = { ...normalizedRoute.match, ...(this.getRouteHostnames(normalizedRoute).length ? { domains: this.getRouteHostnames(normalizedRoute) } : {}), }; if (normalizedRoute.action?.targets) { normalizedRoute.action.targets = normalizedRoute.action.targets.map((targetArg) => ({ ...targetArg, host: normalizeGatewayTargetHost(targetArg.host as string), })); } if (ownership.hostname && this.getRouteHostnames(normalizedRoute).length === 0) { normalizedRoute.match = { ...normalizedRoute.match, domains: [ownership.hostname], } as any; } if (!normalizedRoute.name) { normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`; } return normalizedRoute; } private assertGatewayRouteHasIngressPath( routeArg: interfaces.data.IDcRouterRouteConfig, ): void { if ( routeArg.ingress?.directHub || routeArg.ingress?.smartVpn || routeArg.remoteIngress?.enabled ) { return; } throw new plugins.typedrequest.TypedResponseError( 'gateway route must enable directHub, smartVpn, or RemoteIngress', ); } private assertGatewayClientRouteOwnership( ownership: TResolvedGatewayClientOwnership, route?: interfaces.data.IDcRouterRouteConfig, ): void { if (!route) return; const routeDomains = this.getRouteHostnames(route); if (!routeDomains.length) return; if (!ownership.hostname) { throw new plugins.typedrequest.TypedResponseError('routeRef ownership cannot include domains'); } const ownedHostname = ownership.hostname.trim().toLowerCase(); if (routeDomains.length !== 1) { throw new plugins.typedrequest.TypedResponseError('hostname ownership requires exactly one route domain'); } if (routeDomains.some((domainArg) => domainArg.toLowerCase() !== ownedHostname)) { throw new plugins.typedrequest.TypedResponseError('route domains must match ownership hostname'); } } private getManagedRouteSourceBindings( requestedProfileRefArg?: string, ): interfaces.data.IRouteSourceBinding[] | undefined { const resolver = this.opsServerRef.dcRouterRef.referenceResolver; const profiles = resolver?.listProfiles() || []; const wantedRef = (requestedProfileRefArg?.trim() || 'standard').toLowerCase(); const profile = profiles.find((profileArg: interfaces.data.ISourceProfile) => { return profileArg.id.trim().toLowerCase() === wantedRef; }) || profiles.find((profileArg: interfaces.data.ISourceProfile) => { return profileArg.name.trim().toLowerCase() === wantedRef; }); if (!profile) { return undefined; } return [{ sourceProfileRef: profile.id, sourceProfileName: profile.name, }]; } }