import * as plugins from '../../plugins.js'; import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; import { logger } from '../../logger.js'; import { requireOpsAuth } from '../helpers/auth.js'; import { normalizeGatewayHostname } from '../helpers/gateway-client-auth.js'; import { getActiveGatewayDnsClaims } from '../../dns/classes.gateway-route-dns-reconciler.js'; /** * CRUD handlers for DnsRecordDoc. */ export class DnsRecordHandler { 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 { const auth = await requireOpsAuth(this.opsServerRef, request, { scope: requiredScope, requireAdminIdentity: requiredScope?.endsWith(':write'), }); return auth.userId; } private registerHandlers(): void { // Get records by domain this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getDnsRecords', async (dataArg) => { await this.requireAuth(dataArg, 'dns-records:read'); const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return { records: [] }; const docs = await dnsManager.listRecordsForDomain(dataArg.domainId); return { records: docs.map((d) => dnsManager.toPublicRecord(d)) }; }, ), ); // Get single record this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getDnsRecord', async (dataArg) => { await this.requireAuth(dataArg, 'dns-records:read'); const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return { record: null }; const doc = await dnsManager.getRecord(dataArg.id); return { record: doc ? dnsManager.toPublicRecord(doc) : null }; }, ), ); // Create record this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'createDnsRecord', async (dataArg) => { const userId = await this.requireAuth(dataArg, 'dns-records:write'); const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; const result = await dnsManager.runManagedMailDnsMutationExclusive(async () => { const claimError = await this.rejectClaimedHostnameMutation( dataArg.domainId, dataArg.name, dataArg.type, userId, 'create', ); if (claimError) return { success: false, message: claimError }; return await dnsManager.createRecord({ domainId: dataArg.domainId, name: dataArg.name, type: dataArg.type, value: dataArg.value, ttl: dataArg.ttl, proxied: dataArg.proxied, createdBy: userId, }); }); if (result.success) { this.opsServerRef.invalidateRealtime?.(['dns', 'emailDomains'], { changedIds: result.id ? [result.id] : undefined, reason: 'dns-record-created', }); this.opsServerRef.dcRouterRef.mailDnsSync.requestSync('operator DNS record created'); } return result; }, ), ); // Update record this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'updateDnsRecord', async (dataArg) => { const userId = await this.requireAuth(dataArg, 'dns-records:write'); const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; const result = await dnsManager.runManagedMailDnsMutationExclusive(async () => { const existing = await dnsManager.getRecord(dataArg.id); if (existing) { const currentClaimError = await this.rejectClaimedHostnameMutation( existing.domainId, existing.name, existing.type, userId, 'update', ); if (currentClaimError) return { success: false, message: currentClaimError }; if (dataArg.name !== undefined) { const nextClaimError = await this.rejectClaimedHostnameMutation( existing.domainId, dataArg.name, existing.type, userId, 'update', ); if (nextClaimError) return { success: false, message: nextClaimError }; } } return await dnsManager.updateRecord({ id: dataArg.id, name: dataArg.name, value: dataArg.value, ttl: dataArg.ttl, proxied: dataArg.proxied, }); }); if (result.success) { this.opsServerRef.invalidateRealtime?.(['dns', 'emailDomains'], { changedIds: [dataArg.id], reason: 'dns-record-updated', }); this.opsServerRef.dcRouterRef.mailDnsSync.requestSync('operator DNS record updated'); } return result; }, ), ); // Delete record this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'deleteDnsRecord', async (dataArg) => { const userId = await this.requireAuth(dataArg, 'dns-records:write'); const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; const result = await dnsManager.runManagedMailDnsMutationExclusive(async () => { const existing = await dnsManager.getRecord(dataArg.id); if (existing) { const claimError = await this.rejectClaimedHostnameMutation( existing.domainId, existing.name, existing.type, userId, 'delete', ); if (claimError) return { success: false, message: claimError }; } return await dnsManager.deleteRecord(dataArg.id); }); if (result.success) { this.opsServerRef.invalidateRealtime?.(['dns', 'emailDomains'], { changedIds: [dataArg.id], reason: 'dns-record-deleted', }); this.opsServerRef.dcRouterRef.mailDnsSync.requestSync('operator DNS record deleted'); } return result; }, ), ); } private async rejectClaimedHostnameMutation( domainIdArg: string, hostnameArg: string, recordTypeArg: interfaces.data.TDnsRecordType, actorArg: string, actionArg: 'create' | 'update' | 'delete', ): Promise { const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; if (!dnsManager) return undefined; if (recordTypeArg !== 'A' && recordTypeArg !== 'AAAA' && recordTypeArg !== 'CNAME') return undefined; let hostname: string; try { hostname = normalizeGatewayHostname(hostnameArg); } catch { return undefined; } const claims = getActiveGatewayDnsClaims(this.opsServerRef.dcRouterRef, hostname); if (!claims.length) return undefined; const ownerSummary = claims .map((claimArg) => `${claimArg.gatewayClientId} (${claimArg.routeExternalKeys.join(', ')})`) .join('; '); const message = `DNS hostname ${hostname} is claimed by gateway client route(s) ${ownerSummary}; actor ${actorArg} cannot ${actionArg} ${recordTypeArg} records at this hostname`; let domainName: string | undefined; try { domainName = (await dnsManager.getDomain(domainIdArg))?.name; } catch (error) { logger.log('warn', `Failed to load DNS domain context for rejected mutation on ${hostname}: ${(error as Error).message}`); } if (this.opsServerRef.dcRouterRef.opsEventManager) { try { await this.opsServerRef.dcRouterRef.opsEventManager.recordEvent({ severity: 'error', category: 'dns-conflict', title: 'Rejected manual DNS mutation on gateway-managed hostname', detail: message, context: { domain: domainName, recordName: hostname, routeRef: claims.flatMap((claimArg) => claimArg.routeExternalKeys).join(', '), actor: actorArg, }, }); } catch (error) { logger.log('warn', `Failed to record rejected gateway DNS mutation for ${hostname}: ${(error as Error).message}`); } } try { this.opsServerRef.invalidateRealtime?.('configEvents', { reason: 'gateway-dns-manual-mutation-rejected', }); } catch (error) { logger.log('warn', `Failed to invalidate config events after rejected DNS mutation for ${hostname}: ${(error as Error).message}`); } return message; } }