Files
azure-acme-provisioner/src/lib/dns.ts
T

174 lines
5.7 KiB
TypeScript

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<DomainRecord[]> {
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<string>();
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<string>,
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<string, string> | 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<string, string> | 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<Map<string, string>> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}