import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { DnsProviderDoc, DomainDoc, DnsRecordDoc, } from '../db/documents/index.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'; /** * DnsManager — owns runtime DNS state on top of the embedded DnsServer. * * Responsibilities: * - Load Domain/DnsRecord docs from the DB on start * - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains) * - 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 dnsScopes/dnsNsDomains aren'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(); constructor(private options: IDcRouterOptions) {} // ========================================================================== // Lifecycle // ========================================================================== /** * Called from DcRouter after DcRouterDb is up. Performs first-boot seeding * from legacy constructor config if (and only if) the DB is empty. */ public async start(): Promise { logger.log('info', 'DnsManager: starting'); await this.seedFromConstructorConfigIfEmpty(); } public async stop(): Promise { this.providerClients.clear(); this.dnsServer = undefined; } /** * 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 { this.dnsServer = dnsServer; await this.applyDcrouterDomainsToDnsServer(); } // ========================================================================== // First-boot seeding // ========================================================================== /** * If no DomainDocs exist yet but the constructor has legacy DNS fields, * seed them as dcrouter-hosted (`domain.source: 'dcrouter'`) zones with * local (`record.source: 'local'`) records. On subsequent boots (DB has * entries), constructor config is ignored with a warning. */ private async seedFromConstructorConfigIfEmpty(): Promise { const existingDomains = await DomainDoc.findAll(); const hasLegacyConfig = (this.options.dnsScopes && this.options.dnsScopes.length > 0) || (this.options.dnsRecords && this.options.dnsRecords.length > 0); if (existingDomains.length > 0) { if (hasLegacyConfig) { logger.log( 'warn', 'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' + 'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.', ); } return; } if (!hasLegacyConfig) { return; } logger.log('info', 'DnsManager: seeding DB from legacy constructor DNS config'); const now = Date.now(); const seededDomains = new Map(); // Create one DomainDoc per dnsScope (these are the authoritative zones) for (const scope of this.options.dnsScopes ?? []) { const domain = new DomainDoc(); domain.id = plugins.uuid.v4(); domain.name = scope.toLowerCase(); domain.source = 'dcrouter'; domain.authoritative = true; domain.createdAt = now; domain.updatedAt = now; domain.createdBy = 'seed'; await domain.save(); seededDomains.set(domain.name, domain); logger.log('info', `DnsManager: seeded DomainDoc for ${domain.name}`); } // Map each legacy dnsRecord to its parent DomainDoc for (const rec of this.options.dnsRecords ?? []) { const parent = this.findParentDomain(rec.name, seededDomains); if (!parent) { logger.log( 'warn', `DnsManager: legacy dnsRecord '${rec.name}' has no matching dnsScope — skipping seed`, ); continue; } const record = new DnsRecordDoc(); record.id = plugins.uuid.v4(); record.domainId = parent.id; record.name = rec.name.toLowerCase(); record.type = rec.type as TDnsRecordType; record.value = rec.value; record.ttl = rec.ttl ?? 300; record.source = 'local'; record.createdAt = now; record.updatedAt = now; record.createdBy = 'seed'; await record.save(); } logger.log( 'info', `DnsManager: seeded ${seededDomains.size} domain(s) and ${this.options.dnsRecords?.length ?? 0} record(s) from legacy config`, ); } private findParentDomain( recordName: string, domains: Map, ): DomainDoc | null { const lower = recordName.toLowerCase().replace(/^\*\./, ''); let candidate: DomainDoc | null = null; for (const [name, doc] of domains) { if (lower === name || lower.endsWith(`.${name}`)) { if (!candidate || name.length > candidate.name.length) { candidate = doc; } } } return candidate; } // ========================================================================== // 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(); const dcrouterDomains = allDomains.filter((d) => d.source === 'dcrouter'); let registered = 0; for (const domain of dcrouterDomains) { const records = await DnsRecordDoc.findByDomainId(domain.id); for (const rec of records) { this.registerRecordWithDnsServer(rec); registered++; } } logger.log( 'info', `DnsManager: registered ${registered} dcrouter-hosted DNS record(s) from DB`, ); } /** * 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; this.dnsServer.registerHandler(rec.name, [rec.type], (question) => { if (question.name === rec.name && question.type === rec.type) { return { name: rec.name, type: rec.type, class: 'IN', ttl: rec.ttl, data: this.parseRecordData(rec.type, rec.value), }; } return null; }); } 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 { priority: 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 all DNS records matching a name and type under a domain. * Used for ACME challenge cleanup (may have multiple TXT records at the same name). */ public async deleteRecordsByNameAndType( domainId: string, name: string, type: TDnsRecordType, ): Promise { const records = await DnsRecordDoc.findByDomainId(domainId); for (const rec of records) { if (rec.name.toLowerCase() === name.toLowerCase() && rec.type === type) { await this.deleteRecord(rec.id); } } } /** * 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 leftover challenge records first to avoid duplicates. try { await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT'); } 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 await self.createRecord({ domainId: domainDoc.id, name: dnsChallenge.hostName, type: 'TXT', value: dnsChallenge.challenge, ttl: 120, createdBy: 'acme-dns01', }); }, 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'); } 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 }> { 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) { await this.deleteDomain(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 (authoritative) domain. dcrouter will serve * DNS records for this domain via the embedded smartdns.DnsServer. */ public async createDcrouterDomain(args: { name: string; description?: string; createdBy: string; }): Promise { const now = Date.now(); const doc = new DomainDoc(); doc.id = plugins.uuid.v4(); doc.name = args.name.toLowerCase(); doc.source = 'dcrouter'; doc.authoritative = true; doc.description = args.description; doc.createdAt = now; doc.updatedAt = now; doc.createdBy = args.createdBy; await doc.save(); 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. * * Note: smartdns has no public unregister-by-name API in the version pinned * here, so local record deletes only take effect after a restart. The DB * is the source of truth and the next start will not register the deleted * record. */ public async deleteDomain(id: string): Promise { const doc = await DomainDoc.findById(id); if (!doc) return false; const records = await DnsRecordDoc.findByDomainId(id); for (const r of records) { await r.delete(); } await doc.delete(); return true; } /** * 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; 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' }; } const providerRecords = await client.listRecords(doc.name); // Drop existing records and replace const existing = await DnsRecordDoc.findByDomainId(id); for (const r of existing) { await r.delete(); } for (const pr of providerRecords) { await this.createSyncedRecord(id, pr, doc.createdBy); } doc.lastSyncedAt = Date.now(); doc.updatedAt = doc.lastSyncedAt; await doc.save(); return { success: true, recordCount: providerRecords.length }; } // ========================================================================== // 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 }> { const domain = await DomainDoc.findById(args.id); if (!domain) return { success: false, message: 'Domain not found' }; if (domain.source === args.targetSource && domain.providerId === args.targetProviderId) { return { success: false, message: 'Domain is already in the target configuration' }; } const records = await DnsRecordDoc.findByDomainId(domain.id); if (args.targetSource === 'provider') { return this.migrateToDnsProvider(domain, records, args.targetProviderId!, args.deleteExistingProviderRecords ?? false); } else { return this.migrateToDcrouter(domain, records); } } /** * 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}`); } } // 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( domain: DomainDoc, records: DnsRecordDoc[], ): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> { // Register each record with the embedded DnsServer let migrated = 0; for (const rec of records) { try { this.registerRecordWithDnsServer(rec); // Update the record doc to local rec.source = 'local' as TDnsRecordSource; rec.providerRecordId = undefined; await rec.save(); migrated++; } catch (err: unknown) { logger.log('warn', `Failed to register record ${rec.name} ${rec.type} with DnsServer: ${(err as Error).message}`); } } // Update domain metadata domain.source = 'dcrouter'; domain.authoritative = true; domain.providerId = undefined; domain.externalZoneId = undefined; domain.nameservers = undefined; domain.lastSyncedAt = undefined; domain.updatedAt = Date.now(); await domain.save(); logger.log('info', `Domain ${domain.name} migrated to dcrouter (${migrated} records)`); return { success: true, recordsMigrated: migrated }; } // ========================================================================== // Record CRUD // ========================================================================== public async createRecord(args: { domainId: string; name: string; type: TDnsRecordType; value: string; ttl?: number; proxied?: boolean; createdBy: 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; 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, }); doc.providerRecordId = created.providerRecordId; doc.source = 'synced'; } catch (err: unknown) { return { success: false, message: `Provider rejected record: ${(err as Error).message}` }; } } else { // dcrouter-hosted / authoritative — register with embedded DnsServer immediately this.registerRecordWithDnsServer(doc); } await doc.save(); return { success: true, id: doc.id }; } public async updateRecord(args: { id: string; name?: string; 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' }; if (args.name !== undefined) doc.name = args.name.toLowerCase(); 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}` }; } } else { // Re-register the local record so the new closure picks up the updated fields this.registerRecordWithDnsServer(doc); } await doc.save(); return { success: true }; } public async deleteRecord(id: string): Promise<{ success: boolean; message?: string }> { const doc = await DnsRecordDoc.findById(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' }; if (domain.source === 'provider') { if (domain.providerId && doc.providerRecordId) { const client = await this.getProviderClientById(domain.providerId); if (client) { try { await client.deleteRecord(domain.name, doc.providerRecordId); } catch (err: unknown) { return { success: false, message: `Provider rejected delete: ${(err as Error).message}` }; } } } } // For dcrouter-hosted records: unregister the handler from the embedded DnsServer // so the record stops being served immediately (not just after restart). if (domain.source === 'dcrouter' && this.dnsServer) { this.unregisterRecordFromDnsServer(doc); } await doc.delete(); return { success: true }; } /** * Unregister a record's handler from the embedded DnsServer. */ public unregisterRecordFromDnsServer(rec: DnsRecordDoc): void { if (!this.dnsServer) return; this.dnsServer.unregisterHandler(rec.name, [rec.type]); } // ========================================================================== // Internal helpers // ========================================================================== private async createSyncedRecord( domainId: string, pr: IProviderRecord, createdBy: string, ): Promise { 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 = pr.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, }; } }