213 lines
9.3 KiB
JavaScript
213 lines
9.3 KiB
JavaScript
#!/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<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['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']);
|
|
if (options['pem']) process.env['ACME_CERT_FORMAT'] = 'pem';
|
|
if (options['keyvaultName'] && !options['keyvaultUrl'])
|
|
process.env['ACME_KEYVAULT_URL'] = `https://${options['keyvaultName']}.vault.azure.net`;
|
|
}
|
|
|
|
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('--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')
|
|
.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('--dry-run', 'Show what would be done without making changes')
|
|
).action(async (options: Record<string, unknown>) => {
|
|
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 <format>', 'Output format: table|json', 'table')
|
|
).action(async (options: Record<string, unknown>) => {
|
|
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 <format>', 'Output format: table|json', 'table')
|
|
).action(async (options: Record<string, unknown>) => {
|
|
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 <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')
|
|
.option('--pem', 'Store certificate as PEM bundle instead of PFX (PKCS#12)')
|
|
).action(async (domain: string, options: Record<string, unknown>) => {
|
|
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 <fqdn>')
|
|
.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('--dry-run', 'Show what would be assigned without making changes')
|
|
).action(async (fqdn: string, options: Record<string, unknown>) => {
|
|
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 principalType = String(options['principalType']) as 'User' | 'Group' | 'ServicePrincipal';
|
|
const vaultName = new URL(config.keyVaultUrl).hostname.split('.')[0];
|
|
const certName = domainToCertName(fqdn);
|
|
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}` },
|
|
];
|
|
|
|
const dryRun = Boolean(options['dryRun']);
|
|
for (const { role, scope } of assignments) {
|
|
if (dryRun) {
|
|
console.log(`[dry-run] Would assign '${role}' to ${principalId} on ${scope}`);
|
|
} else {
|
|
const roleDefinitionId = `/subscriptions/${sub}/providers/Microsoft.Authorization/roleDefinitions/${ROLE_IDS[role]}`;
|
|
await authClient.roleAssignments.create(scope, randomUUID(), { roleDefinitionId, principalId, principalType });
|
|
console.log(`Assigned '${role}' to ${principalId} on ${scope}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
sharedOptions(
|
|
program
|
|
.command('download <domain>')
|
|
.description('Download the PEM 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>) => {
|
|
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);
|
|
}
|
|
});
|
|
|
|
sharedOptions(
|
|
program
|
|
.command('convert <domain>')
|
|
.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>) => {
|
|
applyOverrides(options);
|
|
const config = loadConfig();
|
|
const provisioner = new Provisioner(config);
|
|
await provisioner.convert(domain, config.pfx ? 'pfx' : 'pem');
|
|
});
|
|
|
|
program.parseAsync(process.argv).catch((err: unknown) => {
|
|
console.error(err instanceof Error ? err.message : String(err));
|
|
process.exit(1);
|
|
});
|