update: version 0.6.0 and a refactor of parameters.

This commit is contained in:
2026-05-22 14:04:56 +02:00
parent e0f8b1b402
commit 2c481baf39
6 changed files with 310 additions and 121 deletions
+33 -40
View File
@@ -22,14 +22,7 @@ program
function applyOverrides(options: Record<string, unknown>): void {
if (options['keyvaultUrl']) process.env['ACME_KEYVAULT_URL'] = String(options['keyvaultUrl']);
if (options['subscriptionId']) process.env['ACME_SUBSCRIPTION_ID'] = String(options['subscriptionId']);
if (options['resourceGroup']) {
const rgs = options['resourceGroup'] as string[];
process.env['ACME_RESOURCE_GROUPS'] = rgs.join(',');
}
if (options['dnsZone']) {
const zones = options['dnsZone'] as string[];
process.env['ACME_DNS_ZONES'] = zones.join(',');
}
if (options['resourceGroup']) process.env['ACME_RESOURCE_GROUP'] = String(options['resourceGroup']);
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']);
@@ -43,27 +36,23 @@ const sharedOptions = (cmd: Command): Command =>
cmd
.option('--keyvault-name <name>', 'Azure KeyVault name (constructs https://<name>.vault.azure.net)')
.option('--keyvault-url <url>', 'Azure KeyVault URL (overrides --keyvault-name; use for sovereign clouds)')
.option('--keyvault-resource-group <rg>', 'Resource group containing the Key Vault')
.option('--subscription-id <id>', 'Azure subscription ID')
.option('--resource-group <rg>', 'Resource group to scan (repeatable)', collect, [])
.option('--dns-zone <zone>', 'Restrict to specific DNS zone (repeatable)', collect, [])
.option('--resource-group <rg>', 'Resource group to scan')
.option('--email <email>', 'ACME contact email')
.option('--renewal-threshold <days>', 'Days before expiry to renew')
.option('--log-level <level>', 'Log level: debug|info|warn|error');
function collect(value: string, previous: string[]): string[] {
return [...previous, value];
}
sharedOptions(
program
.command('run', { isDefault: true })
.description('Scan DNS zones and issue or renew certificates')
.command('run [zone] [names...]', { isDefault: true })
.description('Issue or renew certificates. Optionally scope to a zone or specific record names within a zone.')
.option('--http <port>', 'Use HTTP-01 challenge on the given port instead of DNS-01')
.option('--pem', 'Store certificate as PEM bundle instead of PFX (PKCS#12)')
.option('--pem', 'Store certificates as PEM bundle instead of PFX (PKCS#12)')
.option('--dry-run', 'Show what would be done without making changes')
).action(async (options: Record<string, unknown>) => {
).action(async (zone: string | undefined, names: string[], options: Record<string, unknown>) => {
applyOverrides(options);
if (zone) process.env['ACME_DNS_ZONE'] = zone;
if (names.length > 0) process.env['ACME_CERT_NAMES'] = names.join(',');
const config = loadConfig();
const provisioner = new Provisioner(config);
const result = await provisioner.run(Boolean(options['dryRun']));
@@ -72,11 +61,13 @@ sharedOptions(
sharedOptions(
program
.command('scan')
.description('List all domains tagged for ACME management')
.command('scan [zone] [names...]')
.description('List domains in scope. Optionally scope to a zone or specific record names within a zone.')
.option('--output <format>', 'Output format: table|json', 'table')
).action(async (options: Record<string, unknown>) => {
).action(async (zone: string | undefined, names: string[], options: Record<string, unknown>) => {
applyOverrides(options);
if (zone) process.env['ACME_DNS_ZONE'] = zone;
if (names.length > 0) process.env['ACME_CERT_NAMES'] = names.join(',');
const config = loadConfig();
const provisioner = new Provisioner(config);
const domains = await provisioner.scan();
@@ -94,11 +85,13 @@ sharedOptions(
sharedOptions(
program
.command('status')
.description('Show certificate expiry status for all managed domains')
.command('status [zone] [names...]')
.description('Show certificate expiry status. Optionally scope to a zone or specific record names within a zone.')
.option('--output <format>', 'Output format: table|json', 'table')
).action(async (options: Record<string, unknown>) => {
).action(async (zone: string | undefined, names: string[], options: Record<string, unknown>) => {
applyOverrides(options);
if (zone) process.env['ACME_DNS_ZONE'] = zone;
if (names.length > 0) process.env['ACME_CERT_NAMES'] = names.join(',');
const config = loadConfig();
const provisioner = new Provisioner(config);
const rows = await provisioner.status();
@@ -119,18 +112,17 @@ sharedOptions(
sharedOptions(
program
.command('renew <domain>')
.description('Force-renew a certificate for a specific domain, bypassing the renewal threshold')
.command('renew [zone] [names...]')
.description('Force-renew certificates, bypassing the renewal threshold. Optionally scope to a zone or specific names.')
.option('--http <port>', 'Use HTTP-01 challenge on the given port instead of DNS-01')
.option('--pem', 'Store certificate as PEM bundle instead of PFX (PKCS#12)')
).action(async (domain: string, options: Record<string, unknown>) => {
.option('--pem', 'Store certificates as PEM bundle instead of PFX (PKCS#12)')
).action(async (zone: string | undefined, names: string[], options: Record<string, unknown>) => {
applyOverrides(options);
if (zone) process.env['ACME_DNS_ZONE'] = zone;
if (names.length > 0) process.env['ACME_CERT_NAMES'] = names.join(',');
const config = loadConfig();
config.renewalThresholdDays = 36500; // effectively "always renew"
config.renewalThresholdDays = 36500;
const provisioner = new Provisioner(config);
// override: mark cert as expiring so it gets processed
const certName = domainToCertName(domain);
console.log(`Force-renewing ${domain} (cert name: ${certName})`);
const result = await provisioner.run(false);
if (result.errors.length > 0) process.exit(1);
});
@@ -141,6 +133,7 @@ sharedOptions(
.description('Assign Key Vault Certificate User and Secrets User roles to a principal for a domain certificate')
.requiredOption('--principal-id <id>', 'Azure principal ID to assign roles to')
.requiredOption('--principal-type <type>', 'Principal type: User | Group | ServicePrincipal (use ServicePrincipal for managed identities)')
.option('--keyvault-resource-group <rg>', 'Resource group containing the Key Vault')
.option('--dry-run', 'Show what would be assigned without making changes')
).action(async (fqdn: string, options: Record<string, unknown>) => {
applyOverrides(options);
@@ -178,14 +171,14 @@ sharedOptions(
sharedOptions(
program
.command('download <domain>')
.description('Download the PEM bundle (private key + certificate + chain) for a domain')
.command('download <fqdn>')
.description('Download the certificate bundle (private key + certificate + chain) for a domain')
.option('--output <file>', 'Write to file instead of stdout')
).action(async (domain: string, options: Record<string, unknown>) => {
).action(async (fqdn: string, options: Record<string, unknown>) => {
applyOverrides(options);
const config = loadConfig();
const provisioner = new Provisioner(config);
const pem = await provisioner.download(domain);
const pem = await provisioner.download(fqdn);
if (options['output']) {
writeFileSync(String(options['output']), pem, 'utf8');
console.log(`Certificate written to ${options['output']}`);
@@ -196,14 +189,14 @@ sharedOptions(
sharedOptions(
program
.command('convert <domain>')
.command('convert <fqdn>')
.description('Convert a stored certificate between PFX (PKCS#12) and PEM format')
.option('--pem', 'Convert to PEM bundle instead of PFX (PKCS#12)')
).action(async (domain: string, options: Record<string, unknown>) => {
).action(async (fqdn: string, options: Record<string, unknown>) => {
applyOverrides(options);
const config = loadConfig();
const provisioner = new Provisioner(config);
await provisioner.convert(domain, config.pfx ? 'pfx' : 'pem');
await provisioner.convert(fqdn, config.pfx ? 'pfx' : 'pem');
});
program.parseAsync(process.argv).catch((err: unknown) => {
+9 -12
View File
@@ -3,8 +3,9 @@ export interface Config {
acmeDirectoryUrl: string;
acmeContactEmail?: string;
subscriptionId?: string;
resourceGroups: string[];
dnsZones?: string[];
resourceGroup?: string;
dnsZone?: string;
certNames?: string[];
renewalThresholdDays: number;
dnsPropagationWaitSeconds: number;
dnsChallengeTtl: number;
@@ -33,14 +34,9 @@ function optionalEnvInt(name: string, defaultValue: number): number {
}
export function loadConfig(): Config {
const resourceGroupsRaw = process.env['ACME_RESOURCE_GROUPS'];
const resourceGroups = resourceGroupsRaw
? resourceGroupsRaw.split(',').map(s => s.trim()).filter(Boolean)
: [];
const dnsZonesRaw = process.env['ACME_DNS_ZONES'];
const dnsZones = dnsZonesRaw
? dnsZonesRaw.split(',').map(s => s.trim()).filter(Boolean)
const certNamesRaw = process.env['ACME_CERT_NAMES'];
const certNames = certNamesRaw
? certNamesRaw.split(',').map(s => s.trim()).filter(Boolean)
: undefined;
const logLevel = optionalEnv('ACME_LOG_LEVEL', 'info');
@@ -59,8 +55,9 @@ export function loadConfig(): Config {
),
acmeContactEmail: process.env['ACME_CONTACT_EMAIL'],
subscriptionId: process.env['ACME_SUBSCRIPTION_ID'],
resourceGroups,
dnsZones,
resourceGroup: process.env['ACME_RESOURCE_GROUP'],
dnsZone: process.env['ACME_DNS_ZONE'],
certNames,
renewalThresholdDays: optionalEnvInt('ACME_RENEWAL_THRESHOLD_DAYS', 30),
dnsPropagationWaitSeconds: optionalEnvInt('ACME_DNS_PROPAGATION_WAIT', 60),
dnsChallengeTtl: optionalEnvInt('ACME_DNS_CHALLENGE_TTL', 60),
+63 -26
View File
@@ -1,4 +1,4 @@
import { DnsManagementClient } from '@azure/arm-dns';
import { Zone, DnsManagementClient } from '@azure/arm-dns';
import { TokenCredential } from '@azure/identity';
import { promises as dnsPromises } from 'node:dns';
import { AcmeAuthz, AcmeChallenge, ChallengeHandler } from './challenge.js';
@@ -20,32 +20,62 @@ export async function scanDnsZones(
const results: DomainRecord[] = [];
const seen = new Set<string>();
for (const rg of config.resourceGroups) {
for await (const zone of client.zones.listByResourceGroup(rg)) {
if (config.resourceGroup && config.dnsZone) {
const zone = await client.zones.get(config.resourceGroup, config.dnsZone);
await processZone(client, config.resourceGroup, zone, config, results, seen);
} else if (config.resourceGroup) {
for await (const zone of client.zones.listByResourceGroup(config.resourceGroup)) {
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);
}
await processZone(client, config.resourceGroup, zone, config, results, seen);
}
} else {
for await (const zone of client.zones.list()) {
if (!zone.name || !zone.id) continue;
const rg = extractResourceGroup(zone.id);
if (!rg) continue;
await processZone(client, rg, zone, config, results, seen);
}
}
return results;
}
async function processZone(
client: DnsManagementClient,
rg: string,
zone: Zone,
config: Config,
results: DomainRecord[],
seen: Set<string>
): Promise<void> {
const zoneName = zone.name!;
if (config.certNames && config.certNames.length > 0) {
for (const name of config.certNames) {
const fqdn = name === '@' ? zoneName : `${name}.${zoneName}`;
addDomain(results, seen, fqdn, rg, fqdn.startsWith('*.'));
}
return;
}
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 || !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);
}
}
function extractResourceGroup(id: string): string {
return id.match(/\/resourceGroups\/([^/]+)\//i)?.[1] ?? '';
}
function addDomain(
results: DomainRecord[],
seen: Set<string>,
@@ -133,15 +163,22 @@ export class DnsChallengeManager implements ChallengeHandler {
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);
if (this.config.resourceGroup && this.config.dnsZone) {
this.zoneMap.set(this.config.dnsZone, this.config.resourceGroup);
} else if (this.config.resourceGroup) {
for await (const zone of this.client.zones.listByResourceGroup(this.config.resourceGroup)) {
if (zone.name) this.zoneMap.set(zone.name, this.config.resourceGroup);
}
} else {
for await (const zone of this.client.zones.list()) {
if (!zone.name || !zone.id) continue;
const rg = extractResourceGroup(zone.id);
if (rg) this.zoneMap.set(zone.name, rg);
}
}
return this.zoneMap;
}