174 lines
5.7 KiB
TypeScript
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));
|
|
}
|