feat: add HTTP-01 challenge support
This commit is contained in:
+49
-4
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user