diff --git a/changelog.md b/changelog.md index bbce954..64f3e6c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-13 - 13.13.0 - feat(dns) +add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling + +- adds domain migration support in DnsManager, API handlers, request interfaces, app state, and domains UI +- routes ACME DNS-01 challenges through managed domains using createRecord/deleteRecord for both dcrouter-hosted and provider-managed zones +- enables immediate unregister of deleted dcrouter-hosted DNS records from the embedded DNS server + ## 2026-04-12 - 13.12.0 - feat(email-domains) support creating email domains on optional subdomains diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index a264abd..cc0c19c 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.12.0', + version: '13.13.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index db4146a..e34a43c 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -930,15 +930,16 @@ export class DcRouter { } // Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND - // ACME is enabled. The DnsManager dispatches each challenge to the right - // provider client based on the FQDN being certificated. + // ACME is enabled. The DnsManager dispatches each challenge through the + // unified createRecord()/deleteRecord() path — works for both dcrouter-hosted + // zones and provider-managed zones. Only domains under management get certs. let challengeHandlers: any[] = []; if ( acmeConfig && this.dnsManager && - (await this.dnsManager.hasAcmeCapableProvider()) + (await this.dnsManager.hasAnyManagedDomain()) ) { - logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)'); + logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (managed domains)'); const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider(); const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider); challengeHandlers.push(dns01Handler); diff --git a/ts/dns/manager.dns.ts b/ts/dns/manager.dns.ts index 10d3b4d..f0ab562 100644 --- a/ts/dns/manager.dns.ts +++ b/ts/dns/manager.dns.ts @@ -296,70 +296,99 @@ export class DnsManager { } /** - * True if any cloudflare provider exists in the DB. Used by setupSmartProxy() - * to decide whether to wire SmartAcme with a DNS-01 handler. + * Find the DomainDoc that covers a given FQDN, regardless of source + * (dcrouter-hosted or provider-managed). Uses longest-suffix match. */ - public async hasAcmeCapableProvider(): Promise { - const providers = await DnsProviderDoc.findAll(); - return providers.length > 0; + 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; } /** - * Build an IConvenientDnsProvider that dispatches each ACME challenge to - * the right provider client (whichever provider type owns the parent zone), - * based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient - * interface, so any registered provider implementation works. - * Returned object plugs directly into smartacme's Dns01Handler. + * 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 client = await self.getProviderClientForDomain(dnsChallenge.hostName); - if (!client) { + const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName); + if (!domainDoc) { throw new Error( - `DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` + - 'Add one in the Domains > Providers UI before issuing certificates.', + `DnsManager: no managed domain found for ${dnsChallenge.hostName}. ` + + 'Add the domain in Domains before issuing certificates.', ); } - // Clean any leftover challenge records first to avoid duplicates. + // Clean leftover challenge records first to avoid duplicates. try { - const existing = await client.listRecords(dnsChallenge.hostName); - for (const r of existing) { - if (r.type === 'TXT' && r.name === dnsChallenge.hostName) { - await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {}); - } - } + 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}`); } - await client.createRecord(dnsChallenge.hostName, { + // 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 client = await self.getProviderClientForDomain(dnsChallenge.hostName); - if (!client) { + const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName); + if (!domainDoc) { // The domain may have been removed; nothing to clean up. return; } try { - const existing = await client.listRecords(dnsChallenge.hostName); - for (const r of existing) { - if (r.type === 'TXT' && r.name === dnsChallenge.hostName) { - await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId); - } - } + 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 client = await self.getProviderClientForDomain(domain); - return !!client; + const domainDoc = await self.findDomainForFqdn(domain); + return !!domainDoc; }, }; return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider; @@ -642,6 +671,151 @@ export class DnsManager { 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; @@ -759,14 +933,24 @@ export class DnsManager { } } } - // For local records: smartdns has no unregister API in the pinned version, - // so the record stays served until the next restart. The DB delete still - // takes effect — on restart, the record will not be re-registered. + // 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 // ========================================================================== diff --git a/ts/opsserver/handlers/config.handler.ts b/ts/opsserver/handlers/config.handler.ts index 363c38f..0623170 100644 --- a/ts/opsserver/handlers/config.handler.ts +++ b/ts/opsserver/handlers/config.handler.ts @@ -127,7 +127,7 @@ export class ConfigHandler { // (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field). let dnsChallengeEnabled = false; try { - dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAcmeCapableProvider()) ?? false; + dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false; } catch { dnsChallengeEnabled = false; } diff --git a/ts/opsserver/handlers/domain.handler.ts b/ts/opsserver/handlers/domain.handler.ts index fa1956d..cd8717e 100644 --- a/ts/opsserver/handlers/domain.handler.ts +++ b/ts/opsserver/handlers/domain.handler.ts @@ -157,5 +157,23 @@ export class DomainHandler { }, ), ); + + // Migrate domain between dcrouter-hosted and provider-managed + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'migrateDomain', + async (dataArg) => { + await this.requireAuth(dataArg, 'domains:write'); + const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; + if (!dnsManager) return { success: false, message: 'DnsManager not initialized' }; + return await dnsManager.migrateDomain({ + id: dataArg.id, + targetSource: dataArg.targetSource, + targetProviderId: dataArg.targetProviderId, + deleteExistingProviderRecords: dataArg.deleteExistingProviderRecords, + }); + }, + ), + ); } } diff --git a/ts_interfaces/requests/domains.ts b/ts_interfaces/requests/domains.ts index b39f429..ae35c2e 100644 --- a/ts_interfaces/requests/domains.ts +++ b/ts_interfaces/requests/domains.ts @@ -148,3 +148,31 @@ export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implemen message?: string; }; } + +/** + * Migrate a domain between dcrouter-hosted and provider-managed (or between providers). + * Records are transferred to the target and the domain source/providerId are updated. + */ +export interface IReq_MigrateDomain extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_MigrateDomain +> { + method: 'migrateDomain'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + id: string; + /** Target source type. */ + targetSource: import('../data/domain.js').TDomainSource; + /** Required when targetSource is 'provider'. */ + targetProviderId?: string; + /** When migrating to a provider: delete all existing records at the provider first. */ + deleteExistingProviderRecords?: boolean; + }; + response: { + success: boolean; + /** Number of records migrated. */ + recordsMigrated?: number; + message?: string; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index a264abd..cc0c19c 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.12.0', + version: '13.13.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index f571b80..3a359be 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -1887,6 +1887,32 @@ export const syncDomainAction = domainsStatePart.createAction<{ id: string }>( }, ); +export const migrateDomainAction = domainsStatePart.createAction<{ + id: string; + targetSource: interfaces.data.TDomainSource; + targetProviderId?: string; + deleteExistingProviderRecords?: boolean; +}>( + async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_MigrateDomain + >('/typedrequest', 'migrateDomain'); + const response = await request.fire({ identity: context.identity!, ...dataArg }); + if (!response.success) { + return { ...statePartArg.getState()!, error: response.message || 'Migration failed' }; + } + return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Migration failed', + }; + } + }, +); + export const createDnsRecordAction = domainsStatePart.createAction<{ domainId: string; name: string; diff --git a/ts_web/elements/domains/ops-view-domains.ts b/ts_web/elements/domains/ops-view-domains.ts index c262455..8644fb2 100644 --- a/ts_web/elements/domains/ops-view-domains.ts +++ b/ts_web/elements/domains/ops-view-domains.ts @@ -149,6 +149,15 @@ export class OpsViewDomains extends DeesElement { }); }, }, + { + name: 'Migrate', + iconName: 'lucide:arrow-right-left', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + const domain = actionData.item as interfaces.data.IDomain; + await this.showMigrateDialog(domain); + }, + }, { name: 'Delete', iconName: 'lucide:trash2', @@ -308,6 +317,96 @@ export class OpsViewDomains extends DeesElement { }); } + private async showMigrateDialog(domain: interfaces.data.IDomain) { + const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); + const providers = this.domainsState.providers; + + // Build target options based on current source + const targetOptions: { option: string; key: string }[] = []; + if (domain.source === 'provider') { + targetOptions.push({ option: 'DcRouter (authoritative)', key: 'dcrouter' }); + } + // Add all providers (except the current one if already provider-managed) + for (const p of providers) { + if (domain.source === 'provider' && domain.providerId === p.id) continue; + targetOptions.push({ option: `${p.name} (${p.type})`, key: `provider:${p.id}` }); + } + if (domain.source === 'dcrouter') { + targetOptions.unshift({ option: 'DcRouter (authoritative)', key: 'dcrouter' }); + } + + if (targetOptions.length === 0) { + DeesToast.show({ + message: 'No migration targets available. Add a DNS provider first.', + type: 'warning', + duration: 3000, + }); + return; + } + + const currentLabel = domain.source === 'dcrouter' + ? 'DcRouter (authoritative)' + : providers.find((p) => p.id === domain.providerId)?.name || 'Provider'; + + DeesModal.createAndShow({ + heading: `Migrate: ${domain.name}`, + content: html` + + + + + + `, + menuOptions: [ + { name: 'Cancel', action: async (m: any) => m.destroy() }, + { + name: 'Migrate', + action: async (m: any) => { + const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + const targetKey = typeof data.target === 'object' ? data.target.key : data.target; + if (!targetKey) return; + + let targetSource: interfaces.data.TDomainSource; + let targetProviderId: string | undefined; + if (targetKey === 'dcrouter') { + targetSource = 'dcrouter'; + } else { + targetSource = 'provider'; + targetProviderId = targetKey.replace('provider:', ''); + } + + await appstate.domainsStatePart.dispatchAction(appstate.migrateDomainAction, { + id: domain.id, + targetSource, + targetProviderId, + deleteExistingProviderRecords: targetSource === 'provider' ? Boolean(data.deleteExisting) : false, + }); + DeesToast.show({ message: `Domain ${domain.name} migrated successfully`, type: 'success', duration: 3000 }); + m.destroy(); + }, + }, + ], + }); + } + private async deleteDomain(domain: interfaces.data.IDomain) { const { DeesModal } = await import('@design.estate/dees-catalog'); DeesModal.createAndShow({