import { DnsManagementClient } from '@azure/arm-dns'; import { TokenCredential } from '@azure/identity'; import { promises as dnsPromises } from 'node:dns'; import { AcmeAuthz, AcmeChallenge, ChallengeHandler } from './challenge.js'; import { Config } from './config.js'; export interface DomainRecord { fqdn: string; zoneName: string; resourceGroup: string; isWildcard: boolean; } export async function scanDnsZones( credential: TokenCredential, config: Config ): Promise { if (!config.subscriptionId) throw new Error('ACME_SUBSCRIPTION_ID is required for DNS zone scanning'); const client = new DnsManagementClient(credential, config.subscriptionId); const results: DomainRecord[] = []; const seen = new Set(); for (const rg of config.resourceGroups) { for await (const zone of client.zones.listByResourceGroup(rg)) { if (!zone.name) continue; if (config.dnsZones && !config.dnsZones.includes(zone.name)) continue; const zoneName = zone.name; if (isAcmeTagged(zone.tags)) { addDomain(results, seen, zoneName, rg, false); addDomain(results, seen, `*.${zoneName}`, rg, true); } for await (const record of client.recordSets.listByDnsZone(rg, zoneName)) { if (!record.name) continue; if (!isAcmeTagged(record.metadata)) continue; const recordType = record.type?.split('/').pop(); if (recordType !== 'A' && recordType !== 'AAAA' && recordType !== 'CNAME') continue; const fqdn = record.name === '@' ? zoneName : `${record.name}.${zoneName}`; addDomain(results, seen, fqdn, rg, false); } } } return results; } function addDomain( results: DomainRecord[], seen: Set, fqdn: string, resourceGroup: string, isWildcard: boolean ): void { if (seen.has(fqdn)) return; seen.add(fqdn); const zoneName = fqdn.replace(/^\*\./, ''); results.push({ fqdn, zoneName, resourceGroup, isWildcard }); } function isAcmeTagged(tags: Record | undefined): boolean { if (!tags) return false; const val = tags['acme']; return val === 'true' || val === 'enabled'; } export class DnsChallengeManager implements ChallengeHandler { readonly challengeType = 'dns-01' as const; private readonly client: DnsManagementClient; private zoneMap: Map | undefined; constructor( credential: TokenCredential, private readonly config: Config, private readonly log: (msg: string) => void ) { if (!config.subscriptionId) throw new Error('ACME_SUBSCRIPTION_ID is required for DNS challenges'); this.client = new DnsManagementClient(credential, config.subscriptionId); } async create(authz: AcmeAuthz, _challenge: AcmeChallenge, keyAuthorization: string): Promise { const domain = authz.identifier.value; const txtFqdn = `_acme-challenge.${domain}`; this.log(`Creating DNS TXT record: ${txtFqdn}`); await this.createTxtRecord(txtFqdn, keyAuthorization); await this.waitForPropagation(txtFqdn, keyAuthorization); } async remove(authz: AcmeAuthz, _challenge: AcmeChallenge, _keyAuthorization: string): Promise { const domain = authz.identifier.value; const txtFqdn = `_acme-challenge.${domain}`; this.log(`Removing DNS TXT record: ${txtFqdn}`); await this.deleteTxtRecord(txtFqdn); } async createTxtRecord(fqdn: string, value: string): Promise { const { resourceGroup, zone, name } = await this.resolveFqdn(fqdn); await this.client.recordSets.createOrUpdate(resourceGroup, zone, name, 'TXT', { ttl: this.config.dnsChallengeTtl, txtRecords: [{ value: [value] }], }); } async deleteTxtRecord(fqdn: string): Promise { const { resourceGroup, zone, name } = await this.resolveFqdn(fqdn); try { await this.client.recordSets.delete(resourceGroup, zone, name, 'TXT'); } catch { // best-effort cleanup; ignore errors } } private async waitForPropagation(fqdn: string, expectedValue: string): Promise { const deadline = Date.now() + this.config.dnsPropagationWaitSeconds * 1000; const pollInterval = 5000; while (Date.now() < deadline) { try { const records = await dnsPromises.resolveTxt(fqdn); if (records.flat().includes(expectedValue)) { this.log(`DNS propagation confirmed for ${fqdn}`); return; } } catch { // record not yet visible } await sleep(pollInterval); } this.log(`DNS propagation wait timed out for ${fqdn}, proceeding anyway`); } private async loadZoneMap(): Promise> { if (this.zoneMap) return this.zoneMap; this.zoneMap = new Map(); for (const rg of this.config.resourceGroups) { for await (const zone of this.client.zones.listByResourceGroup(rg)) { if (!zone.name) continue; if (this.config.dnsZones && !this.config.dnsZones.includes(zone.name)) continue; this.zoneMap.set(zone.name, rg); } } return this.zoneMap; } private async resolveFqdn(fqdn: string): Promise<{ resourceGroup: string; zone: string; name: string }> { const zones = await this.loadZoneMap(); let bestZone = ''; for (const zoneName of zones.keys()) { if ( (fqdn === zoneName || fqdn.endsWith(`.${zoneName}`)) && zoneName.length > bestZone.length ) { bestZone = zoneName; } } if (!bestZone) { throw new Error(`No Azure DNS zone found for FQDN: ${fqdn}`); } const resourceGroup = zones.get(bestZone)!; const name = fqdn === bestZone ? '@' : fqdn.slice(0, -(bestZone.length + 1)); return { resourceGroup, zone: bestZone, name }; } } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); }