import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { DnsProviderDoc, DomainDoc, DnsRecordDoc, EmailDomainDoc, } from '../db/documents/index.js'; import { DcRouterDb } from '../db/classes.dcrouter-db.js'; import type { IDcRouterOptions } from '../classes.dcrouter.js'; import type { IDnsProviderClient, IProviderRecord } from './providers/interfaces.js'; import { createDnsProvider } from './providers/factory.js'; import type { TDnsRecordType, TDnsRecordSource, } from '../../ts_interfaces/data/dns-record.js'; import type { TDnsProviderType, TDnsProviderCredentials, IDnsProviderPublic, IProviderDomainListing, } from '../../ts_interfaces/data/dns-provider.js'; import { resolveDomainOwnership, type IDomainOwnershipZone, type TDomainOwnership, } from './domain-ownership.js'; /** * Where a runtime DnsServer handler came from. * * - 'persisted' → rebuilt from a DnsRecordDoc row. * - 'generated-default' → synthesised by dcrouter (apex NS today; SOA and other * zone defaults would join this origin), so it has no DB row to enumerate and * must be torn down from the registry. */ export type TDnsRuntimeRegistrationOrigin = 'persisted' | 'generated-default'; /** One runtime DnsServer handler key owned by a DomainDoc. */ export interface IDnsRuntimeRegistration { name: string; type: TDnsRecordType; origin: TDnsRuntimeRegistrationOrigin; } interface IDnsRuntimeRegistrationState extends IDnsRuntimeRegistration { registrations: plugins.smartdns.dnsServerMod.IDnsHandlerRegistration[]; } export interface IDnsRecordDeleteResult { id: string; success: boolean; message?: string; } /** * DnsManager — owns runtime DNS state on top of the embedded DnsServer. * * Responsibilities: * - Load Domain/DnsRecord docs from the DB on start * - Register dcrouter-hosted domain records with smartdns.DnsServer at startup * - Provide CRUD methods used by OpsServer handlers (dcrouter-hosted domains hit * smartdns, provider domains hit the provider API) * - Expose a provider lookup used by the ACME DNS-01 wiring in setupSmartProxy() * * Provider-managed domains are NEVER served from the embedded DnsServer — the * provider stays authoritative. We only mirror their records locally for the UI * and to track providerRecordIds for updates / deletes. */ export class DnsManager { /** * Reference to the active smartdns DnsServer (set by DcRouter once it exists). * May be undefined if dnsNsDomains isn't configured. */ public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer; /** * Cached provider clients, keyed by DnsProviderDoc.id. * Created lazily when a provider is first needed. */ private providerClients = new Map(); /** * Per-domain registry of the runtime DnsServer handlers this manager owns, * keyed by DomainDoc.id then by `name|type`. * * Teardown must remove exactly what registration added — no more, no less. * Every key therefore retains all SmartDNS registration handles for the RRset; * the coarse `unregisterHandler(pattern, types)` API is never used. Recording * the actual registrations, with their origin, prevents deletion or authority * reconciliation from removing another owner's identical handler. */ private runtimeRegistrations = new Map>(); /** Serializes managed-mail reconciliation with destructive domain/provider deletion. */ private managedMailDnsMutationChain: Promise = Promise.resolve(); private acceptsManagedMailDnsMutations = true; /** Supplies the delegation-verified authority set. */ private authorityZonesResolver?: () => string[]; constructor(private options: IDcRouterOptions) {} public async runManagedMailDnsMutationExclusive(task: () => Promise): Promise { if (!this.acceptsManagedMailDnsMutations) { throw new Error('DnsManager is stopping; managed mail DNS mutations are closed'); } const run = this.managedMailDnsMutationChain.then(async () => { if (!this.acceptsManagedMailDnsMutations) { throw new Error('DnsManager stopped before the managed mail DNS mutation could run'); } return await task(); }); this.managedMailDnsMutationChain = run.then(() => undefined, () => undefined); return await run; } // ========================================================================== // Runtime registration registry // ========================================================================== private trackRuntimeRegistration( domainId: string, name: string, type: TDnsRecordType, origin: TDnsRuntimeRegistrationOrigin, registration: plugins.smartdns.dnsServerMod.IDnsHandlerRegistration, ): void { let owned = this.runtimeRegistrations.get(domainId); if (!owned) { owned = new Map(); this.runtimeRegistrations.set(domainId, owned); } const key = this.rrsetKey(name, type); const existing = owned.get(key); if (existing) { if (existing.origin !== origin) { registration.unregister(); throw new Error( `DnsManager: cannot mix ${existing.origin} and ${origin} runtime registrations for ${name} ${type}`, ); } existing.registrations.push(registration); return; } owned.set(key, { name: name.toLowerCase(), type, origin, registrations: [registration], }); } private unregisterRuntimeRegistration( domainId: string, name: string, type: TDnsRecordType, ): number { const owned = this.runtimeRegistrations.get(domainId); if (!owned) return 0; const registration = owned.get(this.rrsetKey(name, type)); if (!registration) return 0; owned.delete(this.rrsetKey(name, type)); if (owned.size === 0) { this.runtimeRegistrations.delete(domainId); } let removed = 0; for (const handlerRegistration of registration.registrations) { if (handlerRegistration.unregister()) removed++; } return removed; } /** Registrations currently owned by a domain. Exposed for teardown assertions. */ public listRuntimeRegistrations(domainId: string): IDnsRuntimeRegistration[] { return [...(this.runtimeRegistrations.get(domainId)?.values() || [])].map((registration) => ({ name: registration.name, type: registration.type, origin: registration.origin, })); } /** * Unregister every runtime handler owned by a domain and forget them. * * Called from every path that ends dcrouter's authority over a zone. Without * it, deleting a DomainDoc removed the DB source of truth (so record queries * started answering REFUSED) while the generated apex NS handler kept answering * `aa` with our nameservers until the process was restarted — stale authority * live in memory, with nothing telling the next operator to restart. A restart * is not an acceptable delete procedure: it costs 30–60 s of total public * outage. */ private tearDownDomainRuntimeRegistrations(domainId: string): void { const owned = this.runtimeRegistrations.get(domainId); this.runtimeRegistrations.delete(domainId); if (!owned) { return; } const generatedZones: string[] = []; let removedHandlers = 0; for (const registration of owned.values()) { for (const handlerRegistration of registration.registrations) { if (handlerRegistration.unregister()) removedHandlers++; } if (registration.origin === 'generated-default') { generatedZones.push(`${registration.name} ${registration.type}`); } } logger.log( 'info', `DnsManager: unregistered ${removedHandlers} runtime DNS handler(s) across ${owned.size} key(s) for domain ${domainId}` + (generatedZones.length ? `, including generated default(s) ${generatedZones.join(', ')}` : ''), { zone: 'dns', domainId }, ); } // ========================================================================== // Lifecycle // ========================================================================== public async start(): Promise { this.acceptsManagedMailDnsMutations = true; logger.log('info', 'DnsManager: starting'); } public async stop(): Promise { this.acceptsManagedMailDnsMutations = false; await this.managedMailDnsMutationChain; this.providerClients.clear(); this.detachDnsServer(); } /** * Wire the embedded DnsServer instance after it has been created by * DcRouter.setupDnsWithSocketHandler(). After this, local records on * dcrouter-hosted domains loaded from the DB are registered with the server. */ public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise { if (this.dnsServer === dnsServer) return; this.detachDnsServer(); this.dnsServer = dnsServer; try { await this.applyDcrouterDomainsToDnsServer(); } catch (error) { this.detachDnsServer(dnsServer); throw error; } } /** Remove manager-owned handlers from the current server before replacement or shutdown. */ public detachDnsServer(expectedServerArg?: plugins.smartdns.dnsServerMod.DnsServer): boolean { if (expectedServerArg && this.dnsServer !== expectedServerArg) return false; for (const domainId of [...this.runtimeRegistrations.keys()]) { this.tearDownDomainRuntimeRegistrations(domainId); } this.runtimeRegistrations.clear(); this.dnsServer = undefined; return true; } // ========================================================================== // DcRouter-hosted domain DnsServer wiring // ========================================================================== /** * Register all records from dcrouter-hosted domains in the DB with the * embedded DnsServer. Called once after attachDnsServer(). */ private async applyDcrouterDomainsToDnsServer(): Promise { if (!this.dnsServer) { return; } const allDomains = await DomainDoc.findAll(); // One ownership snapshot for the whole startup pass, not one scan per zone. const ownershipZones: IDomainOwnershipZone[] = allDomains.map((domainArg) => ({ name: domainArg.name, source: domainArg.source, providerId: domainArg.providerId, })); const dcrouterDomains = allDomains.filter((d) => d.source === 'dcrouter'); let registered = 0; for (const domain of dcrouterDomains) { const records = await DnsRecordDoc.findByDomainId(domain.id); const rrsets = new Set(records.map((record) => this.rrsetKey(record.name, record.type))); for (const rrset of rrsets) { const [name, type] = rrset.split('|') as [string, TDnsRecordType]; await this.refreshLocalRrset(domain.id, name, type); } registered += records.length; await this.registerAuthoritativeZoneDefaults(domain, ownershipZones); } logger.log( 'info', `DnsManager: registered ${registered} dcrouter-hosted DNS record(s) from DB`, ); } /** * Authoritative zones must answer apex NS queries or their delegation is * lame — Let's Encrypt's DNS-01 resolver SERVFAILs on such zones. This is the * only place generated apex NS records come from, for every authoritative * zone: they are served from options.dnsNsDomains unless explicit apex NS * records exist in the DB. DnsServerRuntime used to emit a static set for * bootstrap `dnsScopes` zones, which could neither appear for a zone verified * after startup nor disappear for one whose authority was revoked. * * Ownership is verified first. SmartDNS suppresses default-authority handlers * outside the live authority set, and a zone only enters that set after its * delegation is proven, so registration and authority use the same evidence. */ private async registerAuthoritativeZoneDefaults( domain: DomainDoc, ownershipZonesArg?: IDomainOwnershipZone[], ): Promise { if (!this.dnsServer) { return; } const nsDomains = this.options.dnsNsDomains || []; if (!nsDomains.length) { return; } const zoneName = domain.name.toLowerCase(); // Already serving this zone's generated apex NS — do not stack duplicate handlers. if (this.runtimeRegistrations.get(domain.id)?.get(this.rrsetKey(zoneName, 'NS'))?.origin === 'generated-default') { return; } const ownership = ownershipZonesArg ? resolveDomainOwnership({ fqdn: zoneName, zones: ownershipZonesArg, authorityZones: this.getAuthorityZones(), }) : await this.resolveDomainOwnership(zoneName); if (!ownership.verified) { logger.log( 'error', `DnsManager: refusing to serve authoritative apex NS records for ${zoneName} — ${ownership.detail}. ` + 'SmartDNS suppresses authoritative handlers outside the verified authority set, so this zone stays unserved (REFUSED) until ownership is verified.', { zone: 'dns', domainId: domain.id, ownershipReason: ownership.reason }, ); return; } const existingRecords = await DnsRecordDoc.findByDomainId(domain.id); const hasExplicitApexNs = existingRecords.some( (rec) => rec.type === 'NS' && rec.name.toLowerCase() === zoneName, ); if (hasExplicitApexNs) { return; } for (const nsDomain of nsDomains) { const registration = this.dnsServer.registerHandler(zoneName, ['NS'], (question) => { if (question.name.toLowerCase() === zoneName && question.type.toUpperCase() === 'NS') { return { name: question.name, type: 'NS', class: 'IN', ttl: 3600, data: nsDomain, }; } return null; }, { owner: `dcrouter:dns-manager:${domain.id}` }); this.trackRuntimeRegistration( domain.id, zoneName, 'NS', 'generated-default', registration, ); } logger.log( 'info', `DnsManager: serving default apex NS records for authoritative zone ${zoneName} ` + `(${nsDomains.join(', ')}); ownership verified via ${ownership.method} (${ownership.evidence})`, ); } // ========================================================================== // Domain ownership // ========================================================================== /** Ownership inputs for every managed zone, fetched once per evaluation pass. */ public async listOwnershipZones(): Promise { const domains = await DomainDoc.findAll(); return domains.map((domainArg) => ({ name: domainArg.name, source: domainArg.source, providerId: domainArg.providerId, })); } /** * The authority set used as an ownership proof: the delegation-verified zones * held in the database, and nothing else. Empty until the resolver is wired, * which fails closed — an unwired manager proves ownership of nothing rather * than falling back to a declared list that no longer exists. */ public getAuthorityZones(): string[] { return this.authorityZonesResolver?.() || []; } /** * Supply the effective authority set. Injected rather than imported so * DnsManager keeps a single direction of dependency and stays unit-testable. */ public setAuthorityZonesResolver(resolver?: () => string[]): void { this.authorityZonesResolver = resolver; } /** * Re-derive every generated apex NS registration against the current authority * set. Zones that gained proof start being served; zones that lost it are torn * down — both in-process, with no restart. * * Throws on the first failure so the caller can roll the change back rather * than leave the router half-converted. */ public async reconcileAuthoritativeZones(): Promise<{ registered: string[]; unregistered: string[] }> { const registered: string[] = []; const unregistered: string[] = []; if (!this.dnsServer) { return { registered, unregistered }; } const allDomains = await DomainDoc.findAll(); const ownershipZones: IDomainOwnershipZone[] = allDomains.map((domainArg) => ({ name: domainArg.name, source: domainArg.source, providerId: domainArg.providerId, })); for (const domain of allDomains) { if (domain.source !== 'dcrouter') continue; const zoneName = domain.name.toLowerCase(); const ownership = resolveDomainOwnership({ fqdn: zoneName, zones: ownershipZones, authorityZones: this.getAuthorityZones(), }); const hasGenerated = this.runtimeRegistrations .get(domain.id)?.get(this.rrsetKey(zoneName, 'NS'))?.origin === 'generated-default'; if (!ownership.verified && hasGenerated) { // Authority lost: stop answering for it immediately. this.unregisterRuntimeRegistration(domain.id, zoneName, 'NS'); unregistered.push(zoneName); continue; } if (ownership.verified && !hasGenerated) { await this.registerAuthoritativeZoneDefaults(domain, ownershipZones); if (this.runtimeRegistrations .get(domain.id)?.get(this.rrsetKey(zoneName, 'NS'))?.origin === 'generated-default') { registered.push(zoneName); } } } // Persisted records of newly-authoritative zones are already registered by // applyDcrouterDomainsToDnsServer(); only the generated defaults are gated on // ownership, so nothing else needs rebuilding here. if (registered.length || unregistered.length) { logger.log( 'info', `DnsManager: authority reconciliation registered ${registered.length} and unregistered ${unregistered.length} generated apex NS zone(s)`, { zone: 'dns' }, ); } return { registered, unregistered }; } /** Keep `DomainDoc.authoritative` honest after an authority change. */ public async syncAuthoritativeFlags(): Promise { const allDomains = await DomainDoc.findAll(); const ownershipZones: IDomainOwnershipZone[] = allDomains.map((domainArg) => ({ name: domainArg.name, source: domainArg.source, providerId: domainArg.providerId, })); const authorityZones = this.getAuthorityZones(); let changed = 0; for (const domain of allDomains) { if (domain.source !== 'dcrouter') continue; const ownership = resolveDomainOwnership({ fqdn: domain.name, zones: ownershipZones, authorityZones, }); if (domain.authoritative === ownership.verified) continue; domain.authoritative = ownership.verified; domain.updatedAt = Date.now(); await domain.save(); changed++; } return changed; } /** * Can we prove the zone containing `fqdn` is ours? This gates certificate * requirements and authoritative DNS. See ts/dns/domain-ownership.ts. */ public async resolveDomainOwnership(fqdn: string): Promise { return resolveDomainOwnership({ fqdn, zones: await this.listOwnershipZones(), authorityZones: this.getAuthorityZones(), }); } /** * Ownership of a zone that is about to become dcrouter-hosted, evaluated * against its post-write shape. Create and provider→dcrouter migration must * not read their own pre-write row: the provider link they are removing would * otherwise still count as the proof. */ private async resolveOwnershipForPendingDcrouterZone( zoneName: string, ): Promise { const name = zoneName.trim().toLowerCase(); const zones: IDomainOwnershipZone[] = (await this.listOwnershipZones()) .filter((zoneArg) => zoneArg.name.trim().toLowerCase() !== name); zones.push({ name, source: 'dcrouter' }); return resolveDomainOwnership({ fqdn: name, zones, authorityZones: this.getAuthorityZones(), }); } /** * Register a single record with the embedded DnsServer. The handler closure * captures the record fields, so updates require a re-register cycle. */ private registerRecordWithDnsServer(rec: DnsRecordDoc): void { if (!this.dnsServer) return; const registration = this.dnsServer.registerHandler(rec.name, [rec.type], (question) => { if (question.name.toLowerCase() === rec.name.toLowerCase() && question.type.toUpperCase() === rec.type) { return { name: question.name, type: rec.type, class: 'IN', ttl: rec.ttl, data: this.parseRecordData(rec.type, rec.value), }; } return null; }, { owner: `dcrouter:dns-manager:${rec.domainId}` }); // Track beside registration so every call site retains the exact handle. this.trackRuntimeRegistration(rec.domainId, rec.name, rec.type, 'persisted', registration); } private rrsetKey(name: string, type: TDnsRecordType): string { return `${name.toLowerCase()}|${type}`; } /** * Rebuild one authoritative RRset atomically from persisted rows. * * The unregister-then-rebuild cycle also replaces a generated default on the * same key (an explicit apex NS row supersedes the generated one), so the * registry is updated to match what is actually registered afterwards. */ private async refreshLocalRrset( domainId: string, name: string, type: TDnsRecordType, ): Promise { if (!this.dnsServer) return; this.unregisterRuntimeRegistration(domainId, name, type); const records = await DnsRecordDoc.findByDomainId(domainId); for (const record of records) { if (record.name.toLowerCase() === name.toLowerCase() && record.type === type) { this.registerRecordWithDnsServer(record); } } } private parseRecordData(type: TDnsRecordType, value: string): any { switch (type) { case 'A': case 'AAAA': case 'CNAME': case 'TXT': case 'NS': case 'CAA': return value; case 'MX': { const [priorityStr, exchange] = value.split(' '); return { preference: parseInt(priorityStr, 10), exchange }; } case 'SOA': { const parts = value.split(' '); return { mname: parts[0], rname: parts[1], serial: parseInt(parts[2], 10), refresh: parseInt(parts[3], 10), retry: parseInt(parts[4], 10), expire: parseInt(parts[5], 10), minimum: parseInt(parts[6], 10), }; } default: return value; } } // ========================================================================== // Provider lookup (used by ACME DNS-01 + record CRUD) // ========================================================================== /** * Get the provider client for a given DnsProviderDoc id, instantiating * (and caching) it on first use. */ public async getProviderClientById(providerId: string): Promise { const cached = this.providerClients.get(providerId); if (cached) return cached; const doc = await DnsProviderDoc.findById(providerId); if (!doc) return null; const client = createDnsProvider(doc.type, doc.credentials); this.providerClients.set(providerId, client); return client; } /** * Find the IDnsProviderClient that owns the given FQDN (by walking up its * labels to find a matching DomainDoc with `source === 'provider'`). * Returns null if no provider claims this FQDN. * * Used by: * - SmartAcme DNS-01 wiring in setupSmartProxy() * - DnsRecordHandler when creating provider records */ public async getProviderClientForDomain(fqdn: string): Promise { const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, ''); const allDomains = await DomainDoc.findAll(); const providerDomains = allDomains .filter((d) => d.source === 'provider' && d.providerId) // longest-match wins .sort((a, b) => b.name.length - a.name.length); for (const domain of providerDomains) { if (lower === domain.name || lower.endsWith(`.${domain.name}`)) { return this.getProviderClientById(domain.providerId!); } } return null; } /** * Find the DomainDoc that covers a given FQDN, regardless of source * (dcrouter-hosted or provider-managed). Uses longest-suffix match. */ public async findDomainForFqdn(fqdn: string): Promise { const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, ''); const allDomains = await DomainDoc.findAll(); // Sort by name length descending for longest-match-wins allDomains.sort((a, b) => b.name.length - a.name.length); for (const domain of allDomains) { if (lower === domain.name || lower.endsWith(`.${domain.name}`)) { return domain; } } return null; } /** * Delete DNS records matching a name and type under a domain. * When value is provided, only that exact record is removed so parallel ACME * challenges for the same host can coexist. */ public async deleteRecordsByNameAndType( domainId: string, name: string, type: TDnsRecordType, value?: string, ): Promise { const records = await DnsRecordDoc.findByDomainId(domainId); const failedDeletes: string[] = []; for (const rec of records) { if ( rec.name.toLowerCase() === name.toLowerCase() && rec.type === type && (value === undefined || rec.value === value) ) { const deleteResult = await this.deleteRecord(rec.id); if (!deleteResult.success) { failedDeletes.push(deleteResult.message || `failed to delete DNS record ${rec.id}`); } } } if (failedDeletes.length > 0) { throw new Error( `DnsManager: failed to delete ${type} record(s) for ${name}: ${failedDeletes.join('; ')}`, ); } } /** * True if any domain is under management (dcrouter-hosted or provider-managed). * Used by setupSmartProxy() to decide whether to wire SmartAcme with a DNS-01 handler. */ public async hasAnyManagedDomain(): Promise { const domains = await DomainDoc.findAll(); return domains.length > 0; } /** * Build an IConvenientDnsProvider that routes ACME DNS-01 challenges through * the DnsManager abstraction. Challenges are dispatched via createRecord() / * deleteRecord(), which transparently handle both dcrouter-hosted zones * (embedded DnsServer) and provider-managed zones (e.g. Cloudflare API). * * Only domains under management (with a DomainDoc in DB) are supported — * this acts as the management gate for certificate issuance. */ public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider { const self = this; const adapter = { async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) { const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName); if (!domainDoc) { throw new Error( `DnsManager: no managed domain found for ${dnsChallenge.hostName}. ` + 'Add the domain in Domains before issuing certificates.', ); } // Clean only the same challenge value. Exact + wildcard SAN orders can // legitimately need multiple TXT records at the same name. try { await self.deleteRecordsByNameAndType( domainDoc.id, dnsChallenge.hostName, 'TXT', dnsChallenge.challenge, ); } catch (err: unknown) { logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`); } // Create the challenge TXT record via the unified path const createResult = await self.createRecord({ domainId: domainDoc.id, name: dnsChallenge.hostName, type: 'TXT', value: dnsChallenge.challenge, ttl: 120, createdBy: 'acme-dns01', }); if (!createResult.success) { throw new Error( createResult.message || `DnsManager: failed to create TXT challenge for ${dnsChallenge.hostName}`, ); } }, async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) { const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName); if (!domainDoc) { // The domain may have been removed; nothing to clean up. return; } try { await self.deleteRecordsByNameAndType( domainDoc.id, dnsChallenge.hostName, 'TXT', dnsChallenge.challenge, ); } catch (err: unknown) { logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`); } }, async isDomainSupported(domain: string): Promise { const domainDoc = await self.findDomainForFqdn(domain); return !!domainDoc; }, }; return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider; } // ========================================================================== // Provider CRUD (used by DnsProviderHandler) // ========================================================================== public async listProviders(): Promise { const docs = await DnsProviderDoc.findAll(); return docs.map((d) => this.toPublicProvider(d)); } public async getProvider(id: string): Promise { const doc = await DnsProviderDoc.findById(id); return doc ? this.toPublicProvider(doc) : null; } public async createProvider(args: { name: string; type: TDnsProviderType; credentials: TDnsProviderCredentials; createdBy: string; }): Promise { if (args.type === 'dcrouter') { throw new Error( 'createProvider: cannot create a DnsProviderDoc with type "dcrouter" — ' + 'that type is reserved for the built-in pseudo-provider surfaced at read time.', ); } const now = Date.now(); const doc = new DnsProviderDoc(); doc.id = plugins.uuid.v4(); doc.name = args.name; doc.type = args.type; doc.credentials = args.credentials; doc.status = 'untested'; doc.createdAt = now; doc.updatedAt = now; doc.createdBy = args.createdBy; await doc.save(); return doc.id; } public async updateProvider( id: string, args: { name?: string; credentials?: TDnsProviderCredentials }, ): Promise { const doc = await DnsProviderDoc.findById(id); if (!doc) return false; if (args.name !== undefined) doc.name = args.name; if (args.credentials !== undefined) { doc.credentials = args.credentials; doc.status = 'untested'; doc.lastError = undefined; // Invalidate cached client so the next use re-instantiates with the new credentials. this.providerClients.delete(id); } doc.updatedAt = Date.now(); await doc.save(); return true; } public async deleteProvider(id: string, force: boolean): Promise<{ success: boolean; message?: string }> { return await this.runManagedMailDnsMutationExclusive( async () => await this.deleteProviderUnlocked(id, force), ); } private async deleteProviderUnlocked( id: string, force: boolean, ): Promise<{ success: boolean; message?: string }> { const doc = await DnsProviderDoc.findById(id); if (!doc) return { success: false, message: 'Provider not found' }; const linkedDomains = await DomainDoc.findByProviderId(id); if (linkedDomains.length > 0 && !force) { return { success: false, message: `Provider is referenced by ${linkedDomains.length} domain(s). Pass force: true to delete anyway.`, }; } // If forcing, also delete the linked domains and their records. if (force) { for (const domain of linkedDomains) { const references = await this.getManagedMailDnsReferences(domain.id); if (references.emailDomains.length > 0 || references.managedRecordCount > 0) { return { success: false, message: this.managedMailDnsReferenceMessage(domain.name, references), }; } } for (const domain of linkedDomains) { await this.deleteDomainUnlocked(domain.id); } } await doc.delete(); this.providerClients.delete(id); return { success: true }; } public async testProvider(id: string): Promise<{ ok: boolean; error?: string; testedAt: number }> { const doc = await DnsProviderDoc.findById(id); if (!doc) { return { ok: false, error: 'Provider not found', testedAt: Date.now() }; } const client = createDnsProvider(doc.type, doc.credentials); const result = await client.testConnection(); doc.status = result.ok ? 'ok' : 'error'; doc.lastTestedAt = Date.now(); doc.lastError = result.ok ? undefined : result.error; await doc.save(); if (result.ok) { this.providerClients.set(id, client); // cache the working client } return { ok: result.ok, error: result.error, testedAt: doc.lastTestedAt }; } public async listProviderDomains(providerId: string): Promise { const client = await this.getProviderClientById(providerId); if (!client) { throw new Error('Provider not found'); } return await client.listDomains(); } // ========================================================================== // Domain CRUD (used by DomainHandler) // ========================================================================== public async listDomains(): Promise { return await DomainDoc.findAll(); } public async getDomain(id: string): Promise { return await DomainDoc.findById(id); } /** * Create a dcrouter-hosted domain. dcrouter serves DNS records for it via the * embedded smartdns.DnsServer. * * `authoritative` reflects whether ownership can actually be proven — it used * to be hard-coded to `true`, which let an ops-API caller self-assert authority * over any zone on the internet. Creating the record still succeeds (it is the * container records and provider migration need), but an unverified zone is * recorded as non-authoritative and gets no generated apex NS handler. */ public async createDcrouterDomain(args: { name: string; description?: string; createdBy: string; }): Promise { const name = args.name.trim().toLowerCase(); if (!name) { throw new Error('domain name is required'); } const existing = await DomainDoc.findByName(name); if (existing) { throw new Error(`domain ${name} already exists`); } const ownership = await this.resolveOwnershipForPendingDcrouterZone(name); const now = Date.now(); const doc = new DomainDoc(); doc.id = plugins.uuid.v4(); doc.name = name; doc.source = 'dcrouter'; doc.authoritative = ownership.verified; doc.description = args.description; doc.createdAt = now; doc.updatedAt = now; doc.createdBy = args.createdBy; await doc.save(); if (!ownership.verified) { logger.log( 'error', `DnsManager: created dcrouter-hosted domain ${name} as NON-authoritative — ${ownership.detail}`, { zone: 'dns', domainId: doc.id, ownershipReason: ownership.reason }, ); } await this.registerAuthoritativeZoneDefaults(doc); return doc.id; } /** * Import one or more domains from a provider, pulling all of their DNS * records into local DnsRecordDocs. */ public async importDomainsFromProvider(args: { providerId: string; domainNames: string[]; createdBy: string; }): Promise { const provider = await DnsProviderDoc.findById(args.providerId); if (!provider) { throw new Error('Provider not found'); } const client = await this.getProviderClientById(args.providerId); if (!client) { throw new Error('Failed to instantiate provider client'); } const allProviderDomains = await client.listDomains(); const importedIds: string[] = []; const now = Date.now(); for (const wantedName of args.domainNames) { const lower = wantedName.toLowerCase(); const listing = allProviderDomains.find((d) => d.name.toLowerCase() === lower); if (!listing) { logger.log('warn', `DnsManager: import skipped — provider does not list domain ${wantedName}`); continue; } // Skip if already imported const existing = await DomainDoc.findByName(lower); if (existing) { logger.log('warn', `DnsManager: domain ${wantedName} already imported — skipping`); continue; } const domain = new DomainDoc(); domain.id = plugins.uuid.v4(); domain.name = lower; domain.source = 'provider'; domain.providerId = args.providerId; domain.authoritative = false; domain.nameservers = listing.nameservers; domain.externalZoneId = listing.externalId; domain.lastSyncedAt = now; domain.createdAt = now; domain.updatedAt = now; domain.createdBy = args.createdBy; await domain.save(); importedIds.push(domain.id); // Pull records for the imported domain try { const providerRecords = await client.listRecords(lower); for (const pr of providerRecords) { await this.createSyncedRecord(domain.id, pr, args.createdBy); } logger.log('info', `DnsManager: imported ${providerRecords.length} record(s) for ${lower}`); } catch (err: unknown) { logger.log('warn', `DnsManager: failed to import records for ${lower}: ${(err as Error).message}`); } } return importedIds; } public async updateDomain(id: string, args: { description?: string }): Promise { const doc = await DomainDoc.findById(id); if (!doc) return false; if (args.description !== undefined) doc.description = args.description; doc.updatedAt = Date.now(); await doc.save(); return true; } /** * Delete a domain and all of its DNS records. For provider domains, only * removes the local mirror — does NOT touch the provider. * For dcrouter-hosted domains, also unregisters records from the embedded * DnsServer. * * The unregister path is complete in-process: every runtime handler this * manager registered for the domain — persisted RRsets and generated defaults * alike — is removed from the registry, so deletion never leaves stale * authority answering `aa` until an unrelated restart. Nameserver glue * handlers and other domains' handlers are never touched, because only the * domain's own recorded registrations are removed. */ public async deleteDomain(id: string): Promise { return await this.runManagedMailDnsMutationExclusive( async () => await this.deleteDomainUnlocked(id), ); } private async deleteDomainUnlocked(id: string): Promise { const doc = await DomainDoc.findById(id); if (!doc) return false; const references = await this.getManagedMailDnsReferences(id); if (references.emailDomains.length > 0 || references.managedRecordCount > 0) { throw new Error(this.managedMailDnsReferenceMessage(doc.name, references)); } // Tear runtime state down before the durable rows go away: the registry is // the authority on what to remove, so this does not depend on the records // still being readable. this.tearDownDomainRuntimeRegistrations(id); const records = await DnsRecordDoc.findByDomainId(id); for (const r of records) { await r.delete(); } await doc.delete(); const leftover = this.listRuntimeRegistrations(id); if (leftover.length > 0) { logger.log( 'error', `DnsManager: ${leftover.length} runtime DNS handler key(s) are still registered for deleted domain ${doc.name}`, { zone: 'dns', domainId: id }, ); } return true; } private async getManagedMailDnsReferences(domainId: string): Promise<{ emailDomains: EmailDomainDoc[]; managedRecordCount: number; }> { const emailDomains = (await EmailDomainDoc.findAll()).filter((emailDomain) => ( emailDomain.linkedDomainId === domainId || emailDomain.reconciliation?.providerZoneIds?.includes(domainId) || emailDomain.reconciliation?.intents.some((intent) => intent.domainId === domainId) )); const managedRecordCount = (await DnsRecordDoc.findByDomainId(domainId)).filter((record) => ( record.managedBy === 'mail-dns-reconciler' )).length; return { emailDomains, managedRecordCount }; } private managedMailDnsReferenceMessage( domainName: string, references: { emailDomains: EmailDomainDoc[]; managedRecordCount: number }, ): string { return `DNS domain ${domainName} is referenced by ${references.emailDomains.length} managed email domain(s) and ${references.managedRecordCount} managed mail DNS record(s); delete those email domains first`; } /** * Force-resync a provider-managed domain: re-pull all records from the * provider API, replacing the cached DnsRecordDocs. */ public async syncDomain(id: string): Promise<{ success: boolean; recordCount?: number; listedProviderRecordIds?: string[]; message?: string; }> { const doc = await DomainDoc.findById(id); if (!doc) return { success: false, message: 'Domain not found' }; if (doc.source !== 'provider' || !doc.providerId) { return { success: false, message: 'Domain is not provider-managed' }; } const client = await this.getProviderClientById(doc.providerId); if (!client) { return { success: false, message: 'Provider client unavailable' }; } let providerRecords: IProviderRecord[]; let allProviderIds: string[]; try { [providerRecords, allProviderIds] = await Promise.all([ client.listRecords(doc.name), client.listRecordIds(doc.name), ]); } catch (error: unknown) { return { success: false, message: `Provider list failed: ${(error as Error).message}` }; } const providerIds = providerRecords.map((record) => record.providerRecordId.trim()); if (providerIds.some((providerId) => !providerId)) { return { success: false, message: 'Provider returned a record without a non-empty provider id' }; } if (new Set(providerIds).size !== providerIds.length) { return { success: false, message: 'Provider returned duplicate provider record ids' }; } allProviderIds = allProviderIds.map((providerId) => providerId.trim()); if (allProviderIds.some((providerId) => !providerId)) { return { success: false, message: 'Provider returned a record without a non-empty provider id' }; } if (new Set(allProviderIds).size !== allProviderIds.length) { return { success: false, message: 'Provider returned duplicate provider record ids' }; } const allProviderIdSet = new Set(allProviderIds); if (providerIds.some((providerId) => !allProviderIdSet.has(providerId))) { return { success: false, message: 'Provider record listings returned inconsistent record ids' }; } // Provider ids are the only identity proof. Tuple matching could adopt an // operator replacement and transfer reconciler ownership to the wrong row. const existing = await DnsRecordDoc.findByDomainId(id); const localByProviderId = new Map(); for (const record of existing) { const providerId = record.providerRecordId?.trim(); if (!providerId) continue; if (localByProviderId.has(providerId)) { return { success: false, message: `Local mirror has duplicate provider record id ${providerId}` }; } localByProviderId.set(providerId, record); } const remaining = new Set(existing); let mirrorChanged = false; for (let index = 0; index < providerRecords.length; index++) { const pr = providerRecords[index]; const providerId = providerIds[index]; const match = localByProviderId.get(providerId); if (!match) { await this.createSyncedRecord(id, { ...pr, providerRecordId: providerId }, doc.createdBy); mirrorChanged = true; continue; } remaining.delete(match); const providerName = pr.name.toLowerCase(); const providerProxied = pr.proxied; const changed = match.name !== providerName || match.type !== pr.type || match.value !== pr.value || match.ttl !== pr.ttl || (match.proxied ?? false) !== (providerProxied ?? false) || match.source !== 'synced' || match.providerRecordId !== providerId; if (!changed) continue; match.name = providerName; match.type = pr.type; match.value = pr.value; match.ttl = pr.ttl; match.proxied = providerProxied; match.source = 'synced'; match.providerRecordId = providerId; match.updatedAt = Date.now(); await match.save(); mirrorChanged = true; } for (const staleMirror of remaining) { if (staleMirror.source !== 'synced' || !staleMirror.providerRecordId?.trim()) continue; await staleMirror.delete(); mirrorChanged = true; } if (mirrorChanged) { doc.lastSyncedAt = Date.now(); doc.updatedAt = doc.lastSyncedAt; await doc.save(); } return { success: true, recordCount: providerRecords.length, listedProviderRecordIds: allProviderIds, }; } // ========================================================================== // Record CRUD (used by DnsRecordHandler) // ========================================================================== public async listRecordsForDomain(domainId: string): Promise { return await DnsRecordDoc.findByDomainId(domainId); } public async getRecord(id: string): Promise { return await DnsRecordDoc.findById(id); } // ========================================================================== // Domain migration // ========================================================================== /** * Migrate a domain between dcrouter-hosted and provider-managed. * Transfers all records to the target and updates domain metadata. */ public async migrateDomain(args: { id: string; targetSource: 'dcrouter' | 'provider'; targetProviderId?: string; deleteExistingProviderRecords?: boolean; }): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> { return await this.runManagedMailDnsMutationExclusive(async () => { const domain = await DomainDoc.findById(args.id); if (!domain) return { success: false, message: 'Domain not found' }; const references = await this.getManagedMailDnsReferences(domain.id); if (references.emailDomains.length > 0 || references.managedRecordCount > 0) { return { success: false, message: this.managedMailDnsReferenceMessage(domain.name, references) }; } if (domain.source === args.targetSource && domain.providerId === args.targetProviderId) { return { success: false, message: 'Domain is already in the target configuration' }; } if (args.targetSource === 'provider') { const records = await DnsRecordDoc.findByDomainId(domain.id); return await this.migrateToDnsProvider( domain, records, args.targetProviderId!, args.deleteExistingProviderRecords ?? false, ); } return await this.migrateToDcrouter(domain.id); }); } /** * Migrate domain from dcrouter-hosted (or another provider) to an external DNS provider. */ private async migrateToDnsProvider( domain: DomainDoc, records: DnsRecordDoc[], targetProviderId: string, deleteExistingProviderRecords: boolean, ): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> { // Validate the target provider exists const client = await this.getProviderClientById(targetProviderId); if (!client) { return { success: false, message: 'Target DNS provider not found' }; } // Find the zone at the provider const providerDomains = await client.listDomains(); const zone = providerDomains.find( (z) => z.name.toLowerCase() === domain.name.toLowerCase(), ); if (!zone) { return { success: false, message: `Zone "${domain.name}" not found at the target provider` }; } // Optionally delete existing records at the provider if (deleteExistingProviderRecords) { try { const existingProviderRecords = await client.listRecords(domain.name); for (const pr of existingProviderRecords) { await client.deleteRecord(domain.name, pr.providerRecordId).catch(() => {}); } logger.log('info', `Deleted ${existingProviderRecords.length} existing records at provider for ${domain.name}`); } catch (err: unknown) { logger.log('warn', `Failed to clean existing provider records for ${domain.name}: ${(err as Error).message}`); } } // Push each local record to the provider let migrated = 0; for (const rec of records) { try { const providerRecord = await client.createRecord(domain.name, { name: rec.name, type: rec.type as any, value: rec.value, ttl: rec.ttl, }); // Unregister from embedded DnsServer if it was dcrouter-hosted if (domain.source === 'dcrouter') { this.unregisterRecordFromDnsServer(rec); } // Update the record doc to synced rec.source = 'synced' as TDnsRecordSource; rec.providerRecordId = providerRecord.providerRecordId; await rec.save(); migrated++; } catch (err: unknown) { logger.log('warn', `Failed to migrate record ${rec.name} ${rec.type} to provider: ${(err as Error).message}`); } } // The provider is authoritative from here on, so every runtime handler we // still own for this zone goes away — including the generated apex NS and any // record whose individual migration above threw. Leaving them registered // would keep answering `aa` for a zone we no longer serve: the same // stale-authority leak that deletion had. this.tearDownDomainRuntimeRegistrations(domain.id); // Update domain metadata domain.source = 'provider'; domain.authoritative = false; domain.providerId = targetProviderId; domain.externalZoneId = zone.externalId; domain.nameservers = zone.nameservers; domain.lastSyncedAt = Date.now(); domain.updatedAt = Date.now(); await domain.save(); logger.log('info', `Domain ${domain.name} migrated to provider (${migrated} records)`); return { success: true, recordsMigrated: migrated }; } /** * Migrate domain from provider-managed to dcrouter-hosted (authoritative). */ private async migrateToDcrouter( domainId: string, ): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> { const db = DcRouterDb.getInstance().getDb(); const session = db.startSession(); let migration: { domain: DomainDoc; records: DnsRecordDoc[]; ownership: TDomainOwnership; } | undefined; try { migration = await session.withTransaction(async () => { const domain = await DomainDoc.getInstance({ id: domainId }, { session }); if (!domain) { throw new Error(`Domain ${domainId} disappeared during provider-to-dcrouter migration`); } const records = await DnsRecordDoc.getInstances({ domainId }, { session }); const ownership = await this.resolveOwnershipForPendingDcrouterZone(domain.name); const now = Date.now(); for (const rec of records) { rec.source = 'local' as TDnsRecordSource; rec.providerRecordId = undefined; rec.updatedAt = now; await rec.save({ session }); await db.mongoDb.collection('DnsRecordDoc').updateOne( { id: rec.id }, { $unset: { providerRecordId: '' } }, { session }, ); delete (rec as any).providerRecordId; } // Losing the provider link means the credentialed zone listing no // longer proves ownership, so authority is re-derived rather than // assumed. domain.source = 'dcrouter'; domain.providerId = undefined; domain.externalZoneId = undefined; domain.nameservers = undefined; domain.lastSyncedAt = undefined; domain.updatedAt = now; domain.authoritative = ownership.verified; await domain.save({ session }); await db.mongoDb.collection('DomainDoc').updateOne( { id: domain.id }, { $unset: { providerId: '', externalZoneId: '', nameservers: '', lastSyncedAt: '', }, }, { session }, ); delete (domain as any).providerId; delete (domain as any).externalZoneId; delete (domain as any).nameservers; delete (domain as any).lastSyncedAt; return { domain, records, ownership }; }); } finally { await session.endSession(); } if (!migration) { throw new Error(`Provider-to-dcrouter migration for ${domainId} committed without a result`); } const { domain, records, ownership } = migration; if (!ownership.verified) { logger.log( 'error', `DnsManager: domain ${domain.name} migrated to dcrouter as NON-authoritative — ${ownership.detail}`, { zone: 'dns', domainId: domain.id, ownershipReason: ownership.reason }, ); } // Runtime state is rebuilt only after every durable row has committed. A // failed transaction therefore leaves the provider-backed runtime untouched. this.tearDownDomainRuntimeRegistrations(domain.id); try { const rrsets = new Set(records.map((recordArg) => ( this.rrsetKey(recordArg.name, recordArg.type) ))); for (const rrset of rrsets) { const [name, type] = rrset.split('|') as [string, TDnsRecordType]; await this.refreshLocalRrset(domain.id, name, type); } await this.registerAuthoritativeZoneDefaults(domain); } catch (error) { this.tearDownDomainRuntimeRegistrations(domain.id); throw error; } logger.log('info', `Domain ${domain.name} migrated to dcrouter (${records.length} records)`); return { success: true, recordsMigrated: records.length }; } // ========================================================================== // Record CRUD // ========================================================================== public async createRecord(args: { domainId: string; name: string; type: TDnsRecordType; value: string; ttl?: number; proxied?: boolean; createdBy: string; managedBy?: string; managedOwnerId?: string; managedRecordKey?: string; }): Promise<{ success: boolean; id?: string; message?: string }> { const domain = await DomainDoc.findById(args.domainId); if (!domain) return { success: false, message: 'Domain not found' }; const now = Date.now(); const doc = new DnsRecordDoc(); doc.id = plugins.uuid.v4(); doc.domainId = args.domainId; doc.name = args.name.toLowerCase(); doc.type = args.type; doc.value = args.value; doc.ttl = args.ttl ?? 300; if (args.proxied !== undefined) doc.proxied = args.proxied; doc.source = 'local'; doc.createdAt = now; doc.updatedAt = now; doc.createdBy = args.createdBy; doc.managedBy = args.managedBy; doc.managedOwnerId = args.managedOwnerId; doc.managedRecordKey = args.managedRecordKey; if (domain.source === 'provider') { // Push to provider first; only persist locally on success if (!domain.providerId) { return { success: false, message: 'Provider domain has no providerId' }; } const client = await this.getProviderClientById(domain.providerId); if (!client) return { success: false, message: 'Provider client unavailable' }; try { const created = await client.createRecord(domain.name, { name: doc.name, type: doc.type, value: doc.value, ttl: doc.ttl, proxied: doc.proxied, }); const providerRecordId = created.providerRecordId.trim(); if (!providerRecordId) throw new Error('Provider returned an empty record id'); doc.providerRecordId = providerRecordId; doc.source = 'synced'; } catch (err: unknown) { return { success: false, message: `Provider rejected record: ${(err as Error).message}` }; } } await doc.save(); if (domain.source === 'dcrouter') { await this.refreshLocalRrset(doc.domainId, doc.name, doc.type); } return { success: true, id: doc.id }; } /** * Adopt an already-correct record into an automation lifecycle without * mutating provider DNS. The expected value fields make adoption fail closed * if the provider mirror changed between inspection and persistence. */ public async adoptRecordManagement(args: { id: string; name: string; type: TDnsRecordType; value: string; ttl: number; proxied: boolean; managedBy: string; managedOwnerId: string; managedRecordKey: string; }): Promise<{ success: boolean; message?: string }> { const doc = await DnsRecordDoc.findById(args.id); if (!doc) return { success: false, message: 'Record not found' }; if ( doc.name.toLowerCase().replace(/\.+$/, '') !== args.name.toLowerCase().replace(/\.+$/, '') || doc.type !== args.type || doc.value !== args.value || doc.ttl !== args.ttl || doc.proxied !== args.proxied || doc.managedBy ) { return { success: false, message: 'DNS record changed before provenance adoption' }; } doc.managedBy = args.managedBy; doc.managedOwnerId = args.managedOwnerId; doc.managedRecordKey = args.managedRecordKey; doc.updatedAt = Date.now(); await doc.save(); return { success: true }; } public async updateRecord(args: { id: string; name?: string; type?: TDnsRecordType; value?: string; ttl?: number; proxied?: boolean; }): Promise<{ success: boolean; message?: string }> { const doc = await DnsRecordDoc.findById(args.id); if (!doc) return { success: false, message: 'Record not found' }; const domain = await DomainDoc.findById(doc.domainId); if (!domain) return { success: false, message: 'Parent domain not found' }; const previousName = doc.name; const previousType = doc.type; if (args.name !== undefined) doc.name = args.name.toLowerCase(); if (args.type !== undefined) doc.type = args.type; if (args.value !== undefined) doc.value = args.value; if (args.ttl !== undefined) doc.ttl = args.ttl; if (args.proxied !== undefined) doc.proxied = args.proxied; doc.updatedAt = Date.now(); if (domain.source === 'provider') { if (!domain.providerId || !doc.providerRecordId) { return { success: false, message: 'Provider record metadata missing' }; } const client = await this.getProviderClientById(domain.providerId); if (!client) return { success: false, message: 'Provider client unavailable' }; try { await client.updateRecord(domain.name, doc.providerRecordId, { name: doc.name, type: doc.type, value: doc.value, ttl: doc.ttl, proxied: doc.proxied, }); } catch (err: unknown) { return { success: false, message: `Provider rejected update: ${(err as Error).message}` }; } } await doc.save(); if (domain.source === 'dcrouter') { await this.refreshLocalRrset(doc.domainId, previousName, previousType); if (previousName.toLowerCase() !== doc.name.toLowerCase() || previousType !== doc.type) { await this.refreshLocalRrset(doc.domainId, doc.name, doc.type); } } return { success: true }; } public async deleteRecord(id: string): Promise<{ success: boolean; message?: string }> { const [result] = await this.deleteRecords([id]); return result.message ? { success: result.success, message: result.message } : { success: result.success }; } public async deleteRecords(ids: string[]): Promise { const results = new Map(); const providerGroups = new Map(); for (const id of [...new Set(ids)]) { const doc = await DnsRecordDoc.findById(id); if (!doc) { results.set(id, { id, success: false, message: 'Record not found' }); continue; } const domain = await DomainDoc.findById(doc.domainId); if (!domain) { results.set(id, { id, success: false, message: 'Parent domain not found' }); continue; } if (domain.source !== 'provider') { await doc.delete(); await this.refreshLocalRrset(doc.domainId, doc.name, doc.type); results.set(id, { id, success: true }); continue; } if (!domain.providerId || !doc.providerRecordId?.trim()) { results.set(id, { id, success: false, message: 'Provider record metadata missing' }); continue; } let group = providerGroups.get(domain.id); if (!group) { const client = await this.getProviderClientById(domain.providerId); if (!client) { results.set(id, { id, success: false, message: 'Provider client unavailable' }); continue; } group = { domain, client, records: [] }; providerGroups.set(domain.id, group); } group.records.push(doc); } for (const { domain, client, records } of providerGroups.values()) { const deleteErrors = new Map(); for (const record of records) { try { await client.deleteRecord(domain.name, record.providerRecordId!); } catch (error: unknown) { deleteErrors.set(record.id, error instanceof Error ? error : new Error(String(error))); } } let providerRecordIds: string[] | undefined; let providerListError: Error | undefined; try { providerRecordIds = (await client.listRecordIds(domain.name)).map((providerRecordId) => ( providerRecordId.trim() )); } catch (error: unknown) { providerListError = error instanceof Error ? error : new Error(String(error)); } const invalidListingMessage = providerRecordIds?.some((providerRecordId) => !providerRecordId) ? 'Provider deletion could not be confirmed: provider returned an empty record id' : providerRecordIds && new Set(providerRecordIds).size !== providerRecordIds.length ? 'Provider deletion could not be confirmed: provider returned duplicate record ids' : undefined; const listedIds = providerRecordIds && !invalidListingMessage ? new Set(providerRecordIds) : undefined; for (const record of records) { const providerRecordId = record.providerRecordId!.trim(); const deleteError = deleteErrors.get(record.id); if (deleteError && this.isProviderRecordNotFoundError(deleteError)) { await record.delete(); results.set(record.id, { id: record.id, success: true }); continue; } if (providerListError) { results.set(record.id, { id: record.id, success: false, message: `Provider deletion could not be confirmed: ${providerListError.message}`, }); continue; } if (invalidListingMessage || !listedIds) { results.set(record.id, { id: record.id, success: false, message: invalidListingMessage || 'Provider deletion could not be confirmed', }); continue; } if (listedIds.has(providerRecordId)) { results.set(record.id, { id: record.id, success: false, message: deleteError ? `Provider rejected delete and still lists the record: ${deleteError.message}` : 'Provider accepted delete but still lists the record', }); continue; } await record.delete(); results.set(record.id, { id: record.id, success: true }); } } return ids.map((id) => results.get(id) || { id, success: false, message: 'Record deletion was not evaluated' }); } private isProviderRecordNotFoundError(error: Error): boolean { const providerError = error as Error & { status?: unknown; statusCode?: unknown }; return providerError.status === 404 || providerError.statusCode === 404; } /** * Unregister a record's handler from the embedded DnsServer. */ public unregisterRecordFromDnsServer(rec: DnsRecordDoc): void { this.unregisterRuntimeRegistration(rec.domainId, rec.name, rec.type); } // ========================================================================== // Internal helpers // ========================================================================== private async createSyncedRecord( domainId: string, pr: IProviderRecord, createdBy: string, ): Promise { const providerRecordId = pr.providerRecordId.trim(); if (!providerRecordId) { throw new Error('Cannot mirror a provider record without a non-empty provider id'); } const now = Date.now(); const doc = new DnsRecordDoc(); doc.id = plugins.uuid.v4(); doc.domainId = domainId; doc.name = pr.name.toLowerCase(); doc.type = pr.type; doc.value = pr.value; doc.ttl = pr.ttl; if (pr.proxied !== undefined) doc.proxied = pr.proxied; doc.source = 'synced'; doc.providerRecordId = providerRecordId; doc.createdAt = now; doc.updatedAt = now; doc.createdBy = createdBy; await doc.save(); } /** * Convert a DnsProviderDoc to its public, secret-stripped representation * for the OpsServer API. */ public toPublicProvider(doc: DnsProviderDoc): IDnsProviderPublic { return { id: doc.id, name: doc.name, type: doc.type, status: doc.status, lastTestedAt: doc.lastTestedAt, lastError: doc.lastError, createdAt: doc.createdAt, updatedAt: doc.updatedAt, createdBy: doc.createdBy, hasCredentials: !!doc.credentials, }; } /** * Convert a DomainDoc to its plain interface representation. */ public toPublicDomain(doc: DomainDoc): { id: string; name: string; source: 'dcrouter' | 'provider'; providerId?: string; authoritative: boolean; nameservers?: string[]; externalZoneId?: string; lastSyncedAt?: number; description?: string; createdAt: number; updatedAt: number; createdBy: string; } { return { id: doc.id, name: doc.name, source: doc.source, providerId: doc.providerId, authoritative: doc.authoritative, nameservers: doc.nameservers, externalZoneId: doc.externalZoneId, lastSyncedAt: doc.lastSyncedAt, description: doc.description, createdAt: doc.createdAt, updatedAt: doc.updatedAt, createdBy: doc.createdBy, }; } /** * Convert a DnsRecordDoc to its plain interface representation. */ public toPublicRecord(doc: DnsRecordDoc): { id: string; domainId: string; name: string; type: TDnsRecordType; value: string; ttl: number; proxied?: boolean; source: TDnsRecordSource; providerRecordId?: string; createdAt: number; updatedAt: number; createdBy: string; } { return { id: doc.id, domainId: doc.domainId, name: doc.name, type: doc.type, value: doc.value, ttl: doc.ttl, proxied: doc.proxied, source: doc.source, providerRecordId: doc.providerRecordId, createdAt: doc.createdAt, updatedAt: doc.updatedAt, createdBy: doc.createdBy, }; } }