feat(dns): add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling

This commit is contained in:
2026-04-13 09:47:19 +00:00
parent 1fdff79dd0
commit 1c4f7dbb11
10 changed files with 405 additions and 42 deletions

View File

@@ -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

View File

@@ -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.'
}

View File

@@ -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);

View File

@@ -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<boolean> {
const providers = await DnsProviderDoc.findAll();
return providers.length > 0;
public async findDomainForFqdn(fqdn: string): Promise<DomainDoc | null> {
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<void> {
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<boolean> {
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<boolean> {
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
// ==========================================================================

View File

@@ -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;
}

View File

@@ -157,5 +157,23 @@ export class DomainHandler {
},
),
);
// Migrate domain between dcrouter-hosted and provider-managed
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_MigrateDomain>(
'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,
});
},
),
);
}
}

View File

@@ -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;
};
}

View File

@@ -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.'
}

View File

@@ -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<IDomainsState> => {
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;

View File

@@ -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`
<dees-form>
<dees-input-text
.key=${'currentSource'}
.label=${'Current source'}
.value=${currentLabel}
.disabled=${true}
></dees-input-text>
<dees-input-dropdown
.key=${'target'}
.label=${'Migrate to'}
.description=${'Select the target DNS management'}
.options=${targetOptions}
.required=${true}
></dees-input-dropdown>
<dees-input-checkbox
.key=${'deleteExisting'}
.label=${'Delete existing records at provider first'}
.description=${'Removes all records at the provider before pushing migrated records'}
.value=${true}
></dees-input-checkbox>
</dees-form>
`,
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({