#!/usr/bin/env node import { randomUUID } from 'node:crypto'; import { writeFileSync } from 'node:fs'; import { AuthorizationManagementClient } from '@azure/arm-authorization'; import { DefaultAzureCredential } from '@azure/identity'; import { Command } from 'commander'; import { loadConfig } from './lib/config.js'; import { domainToCertName, Provisioner } from './lib/provisioner.js'; const ROLE_IDS = { 'Key Vault Certificate User': 'db79e9a7-68ee-4b58-9aeb-b90e7c24fcba', 'Key Vault Secrets User': '4633458b-17de-408a-b874-0445c86b69e6', } as const; const program = new Command(); program .name('azure-acme-provisioner') .description('Automated SSL/TLS certificate management using ACME protocol with Azure KeyVault and Azure DNS') .version(require('../package.json').version as string); function applyOverrides(options: Record): 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['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 => cmd .option('--keyvault-url ', 'Azure KeyVault URL') .option('--keyvault-resource-group ', 'Resource group containing the Key Vault') .option('--subscription-id ', 'Azure subscription ID') .option('--resource-group ', 'Resource group to scan (repeatable)', collect, []) .option('--dns-zone ', 'Restrict to specific DNS zone (repeatable)', collect, []) .option('--email ', 'ACME contact email') .option('--renewal-threshold ', 'Days before expiry to renew') .option('--log-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') .option('--http ', '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) => { applyOverrides(options); const config = loadConfig(); const provisioner = new Provisioner(config); const result = await provisioner.run(Boolean(options['dryRun'])); if (result.errors.length > 0) process.exit(1); }); sharedOptions( program .command('scan') .description('List all domains tagged for ACME management') .option('--output ', 'Output format: table|json', 'table') ).action(async (options: Record) => { applyOverrides(options); const config = loadConfig(); const provisioner = new Provisioner(config); const domains = await provisioner.scan(); if (options['output'] === 'json') { console.log(JSON.stringify(domains, null, 2)); } else { console.log(`\nFound ${domains.length} managed domain(s):\n`); for (const d of domains) { console.log(` ${d.fqdn.padEnd(50)} zone: ${d.zoneName} rg: ${d.resourceGroup}`); } console.log(); } }); sharedOptions( program .command('status') .description('Show certificate expiry status for all managed domains') .option('--output ', 'Output format: table|json', 'table') ).action(async (options: Record) => { applyOverrides(options); const config = loadConfig(); const provisioner = new Provisioner(config); const rows = await provisioner.status(); if (options['output'] === 'json') { console.log(JSON.stringify(rows, null, 2)); } else { console.log(`\n${'Domain'.padEnd(50)} ${'Cert Name'.padEnd(40)} ${'Expires'.padEnd(12)} Days`); console.log('-'.repeat(110)); for (const r of rows) { const expires = r.expiresOn ? r.expiresOn.toISOString().slice(0, 10) : 'MISSING'; const days = r.daysRemaining !== undefined ? String(r.daysRemaining) : '—'; console.log(`${r.fqdn.padEnd(50)} ${r.certName.padEnd(40)} ${expires.padEnd(12)} ${days}`); } console.log(); } }); sharedOptions( program .command('renew ') .description('Force-renew a certificate for a specific domain, bypassing the renewal threshold') .option('--http ', 'Use HTTP-01 challenge on the given port instead of DNS-01') ).action(async (domain: string, options: Record) => { applyOverrides(options); const config = loadConfig(); config.renewalThresholdDays = 36500; // effectively "always renew" 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); }); sharedOptions( program .command('assign-role ') .description('Assign Key Vault Certificate User and Secrets User roles to a principal for a domain certificate') .requiredOption('--principal-id ', 'Azure principal ID to assign roles to') ).action(async (domain: string, options: Record) => { applyOverrides(options); const config = loadConfig(); if (!config.subscriptionId) throw new Error('--subscription-id is required'); if (!config.keyVaultUrl) throw new Error('--keyvault-url is required'); const kvRg = options['keyvaultResourceGroup']; if (!kvRg) throw new Error('--keyvault-resource-group is required'); const sub = config.subscriptionId; const principalId = String(options['principalId']); const vaultName = new URL(config.keyVaultUrl).hostname.split('.')[0]; const certName = domainToCertName(domain); const vaultBase = `/subscriptions/${sub}/resourceGroups/${kvRg}/providers/Microsoft.KeyVault/vaults/${vaultName}`; const credential = new DefaultAzureCredential(); const authClient = new AuthorizationManagementClient(credential, sub); const assignments = [ { role: 'Key Vault Certificate User' as const, scope: `${vaultBase}/certificates/${certName}` }, { role: 'Key Vault Secrets User' as const, scope: `${vaultBase}/secrets/${certName}` }, ]; for (const { role, scope } of assignments) { const roleDefinitionId = `/subscriptions/${sub}/providers/Microsoft.Authorization/roleDefinitions/${ROLE_IDS[role]}`; await authClient.roleAssignments.create(scope, randomUUID(), { roleDefinitionId, principalId }); console.log(`Assigned '${role}' to ${principalId} on ${scope}`); } }); sharedOptions( program .command('download ') .description('Download the PEM bundle (private key + certificate + chain) for a domain') .option('--output ', 'Write to file instead of stdout') ).action(async (domain: string, options: Record) => { applyOverrides(options); const config = loadConfig(); const provisioner = new Provisioner(config); const pem = await provisioner.download(domain); if (options['output']) { writeFileSync(String(options['output']), pem, 'utf8'); console.log(`Certificate written to ${options['output']}`); } else { process.stdout.write(pem); } }); program.parseAsync(process.argv).catch((err: unknown) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); });