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