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