update: version 0.6.0 and a refactor of parameters.
This commit is contained in:
+33
-40
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user