feat: add HTTP-01 challenge support

This commit is contained in:
2026-05-21 20:18:32 +02:00
parent fcf412b13b
commit a92bdabac3
11 changed files with 983 additions and 56 deletions
+49 -4
View File
@@ -1,5 +1,7 @@
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 {
@@ -61,14 +63,34 @@ function isAcmeTagged(tags: Record<string, string> | undefined): boolean {
return val === 'true' || val === 'enabled';
}
export class DnsChallengeManager {
export class DnsChallengeManager implements ChallengeHandler {
readonly challengeType = 'dns-01' as const;
private readonly client: DnsManagementClient;
private zoneMap: Map<string, string> | undefined; // zone name → resource group
private zoneMap: Map<string, string> | undefined;
constructor(credential: TokenCredential, private readonly config: Config) {
constructor(
credential: TokenCredential,
private readonly config: Config,
private readonly log: (msg: string) => void
) {
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', {
@@ -86,6 +108,26 @@ export class DnsChallengeManager {
}
}
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;
@@ -103,7 +145,6 @@ export class DnsChallengeManager {
private async resolveFqdn(fqdn: string): Promise<{ resourceGroup: string; zone: string; name: string }> {
const zones = await this.loadZoneMap();
// longest-suffix match: find the most specific zone that is a suffix of fqdn
let bestZone = '';
for (const zoneName of zones.keys()) {
if (
@@ -123,3 +164,7 @@ export class DnsChallengeManager {
return { resourceGroup, zone: bestZone, name };
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}