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
+3
View File
@@ -24,6 +24,7 @@ function applyOverrides(options: Record<string, unknown>): void {
if (options['email']) process.env['ACME_CONTACT_EMAIL'] = String(options['email']);
if (options['renewalThreshold']) process.env['ACME_RENEWAL_THRESHOLD_DAYS'] = String(options['renewalThreshold']);
if (options['logLevel']) process.env['ACME_LOG_LEVEL'] = String(options['logLevel']);
if (options['http']) process.env['ACME_HTTP_PORT'] = String(options['http']);
}
const sharedOptions = (cmd: Command): Command =>
@@ -44,6 +45,7 @@ sharedOptions(
program
.command('run', { isDefault: true })
.description('Scan DNS zones and issue or renew certificates')
.option('--http <port>', 'Use HTTP-01 challenge on the given port instead of DNS-01')
.option('--dry-run', 'Show what would be done without making changes')
).action(async (options: Record<string, unknown>) => {
applyOverrides(options);
@@ -104,6 +106,7 @@ sharedOptions(
program
.command('renew <domain>')
.description('Force-renew a certificate for a specific domain, bypassing the renewal threshold')
.option('--http <port>', 'Use HTTP-01 challenge on the given port instead of DNS-01')
).action(async (domain: string, options: Record<string, unknown>) => {
applyOverrides(options);
const config = loadConfig();
+4
View File
@@ -3,9 +3,13 @@ export type { Config } from './lib/config.js';
export { KeyVaultStore } from './lib/keyvault.js';
export type { ChallengeHandler, AcmeAuthz, AcmeChallenge } from './lib/challenge.js';
export { scanDnsZones, DnsChallengeManager } from './lib/dns.js';
export type { DomainRecord } from './lib/dns.js';
export { HttpChallengeServer } from './lib/http-challenge.js';
export { AcmeClient } from './lib/acme.js';
export type { IssuedCertificate } from './lib/acme.js';
+7 -40
View File
@@ -1,7 +1,6 @@
import * as acme from 'acme-client';
import { promises as dns } from 'node:dns';
import { ChallengeHandler } from './challenge.js';
import { Config } from './config.js';
import { DnsChallengeManager } from './dns.js';
import { KeyVaultStore } from './keyvault.js';
const ACCOUNT_KEY_SECRET = 'acme-account-private-key';
@@ -58,7 +57,7 @@ export class AcmeClient {
async orderCertificate(
domains: string[],
challengeManager: DnsChallengeManager
handler: ChallengeHandler
): Promise<IssuedCertificate> {
if (!this.client) throw new Error('Call ensureAccount() before ordering certificates');
@@ -68,19 +67,12 @@ export class AcmeClient {
const certificatePem = await this.client.auto({
csr,
challengePriority: ['dns-01'],
challengeCreateFn: async (_authz, _challenge, keyAuthorization) => {
const domain = _authz.identifier.value;
const txtFqdn = `_acme-challenge.${domain}`;
this.log(`Creating DNS TXT record: ${txtFqdn}`);
await challengeManager.createTxtRecord(txtFqdn, keyAuthorization);
await this.waitForDnsPropagation(txtFqdn, keyAuthorization);
challengePriority: [handler.challengeType],
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
await handler.create(authz, challenge, keyAuthorization);
},
challengeRemoveFn: async (_authz, _challenge, _keyAuthorization) => {
const domain = _authz.identifier.value;
const txtFqdn = `_acme-challenge.${domain}`;
this.log(`Removing DNS TXT record: ${txtFqdn}`);
await challengeManager.deleteTxtRecord(txtFqdn);
challengeRemoveFn: async (authz, challenge, keyAuthorization) => {
await handler.remove(authz, challenge, keyAuthorization);
},
});
@@ -94,29 +86,4 @@ export class AcmeClient {
chainPem: chainCerts.join(''),
};
}
private async waitForDnsPropagation(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 dns.resolveTxt(fqdn);
const found = records.flat().includes(expectedValue);
if (found) {
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`);
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
+13
View File
@@ -0,0 +1,13 @@
export interface AcmeAuthz {
identifier: { value: string };
}
export interface AcmeChallenge {
token: string;
}
export interface ChallengeHandler {
readonly challengeType: 'dns-01' | 'http-01';
create(authz: AcmeAuthz, challenge: AcmeChallenge, keyAuthorization: string): Promise<void>;
remove(authz: AcmeAuthz, challenge: AcmeChallenge, keyAuthorization: string): Promise<void>;
}
+2
View File
@@ -9,6 +9,7 @@ export interface Config {
dnsPropagationWaitSeconds: number;
dnsChallengeTtl: number;
logLevel: 'debug' | 'info' | 'warn' | 'error';
httpChallengePort?: number;
}
export class ConfigError extends Error {
@@ -67,5 +68,6 @@ export function loadConfig(): Config {
dnsPropagationWaitSeconds: optionalEnvInt('ACME_DNS_PROPAGATION_WAIT', 60),
dnsChallengeTtl: optionalEnvInt('ACME_DNS_CHALLENGE_TTL', 60),
logLevel: logLevel as Config['logLevel'],
httpChallengePort: optionalEnvInt('ACME_HTTP_PORT', 0) || undefined,
};
}
+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));
}
+53
View File
@@ -0,0 +1,53 @@
import * as http from 'node:http';
import express from 'express';
import { AcmeAuthz, AcmeChallenge, ChallengeHandler } from './challenge.js';
export class HttpChallengeServer implements ChallengeHandler {
readonly challengeType = 'http-01' as const;
private readonly tokens = new Map<string, string>();
private server: http.Server | undefined;
constructor(
private readonly port: number,
private readonly log: (msg: string) => void
) {}
async create(_authz: AcmeAuthz, challenge: AcmeChallenge, keyAuthorization: string): Promise<void> {
this.tokens.set(challenge.token, keyAuthorization);
if (!this.server) await this.start();
}
async remove(_authz: AcmeAuthz, challenge: AcmeChallenge, _keyAuthorization: string): Promise<void> {
this.tokens.delete(challenge.token);
if (this.tokens.size === 0) await this.stop();
}
private start(): Promise<void> {
return new Promise((resolve, reject) => {
const app = express();
app.get('/.well-known/acme-challenge/:token', (req, res) => {
const keyAuth = this.tokens.get(req.params['token']);
if (keyAuth) {
res.type('text/plain').send(keyAuth);
} else {
res.status(404).send('Not found');
}
});
this.server = app.listen(this.port, () => {
this.log(`HTTP-01 challenge server listening on port ${this.port}`);
resolve();
});
this.server.once('error', reject);
});
}
private stop(): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.server) { resolve(); return; }
this.server.close(err => {
this.server = undefined;
if (err) reject(err); else resolve();
});
});
}
}
+10 -6
View File
@@ -1,7 +1,9 @@
import { DefaultAzureCredential } from '@azure/identity';
import { AcmeClient } from './acme.js';
import { ChallengeHandler } from './challenge.js';
import { Config } from './config.js';
import { DnsChallengeManager, DomainRecord, scanDnsZones } from './dns.js';
import { HttpChallengeServer } from './http-challenge.js';
import { KeyVaultStore } from './keyvault.js';
export interface ProvisioningResult {
@@ -17,7 +19,7 @@ export class Provisioner {
private readonly credential: DefaultAzureCredential;
private _store: KeyVaultStore | undefined;
private _acme: AcmeClient | undefined;
private _challengeManager: DnsChallengeManager | undefined;
private _challengeHandler: ChallengeHandler | undefined;
constructor(
private readonly config: Config,
@@ -46,11 +48,13 @@ export class Provisioner {
return this._acme;
}
private get challengeManager(): DnsChallengeManager {
if (!this._challengeManager) {
this._challengeManager = new DnsChallengeManager(this.credential, this.config);
private get challengeHandler(): ChallengeHandler {
if (!this._challengeHandler) {
this._challengeHandler = this.config.httpChallengePort
? new HttpChallengeServer(this.config.httpChallengePort, (msg) => this.log(msg))
: new DnsChallengeManager(this.credential, this.config, (msg) => this.log(msg));
}
return this._challengeManager;
return this._challengeHandler;
}
async run(dryRun = false): Promise<ProvisioningResult> {
@@ -99,7 +103,7 @@ export class Provisioner {
}
const fqdns = group.map(d => d.fqdn);
const issued = await this.acme.orderCertificate(fqdns, this.challengeManager);
const issued = await this.acme.orderCertificate(fqdns, this.challengeHandler);
const pemBundle = issued.privateKeyPem + issued.certificatePem + issued.chainPem;
await this.store.importCertificate(certName, pemBundle);