feat(dns): add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling
This commit is contained in:
@@ -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
|
||||
// ==========================================================================
|
||||
|
||||
Reference in New Issue
Block a user