feat: initialize azure-acme-provisioner project with core functionality

- Add package.json for project metadata and dependencies
- Implement CLI in src/cli.ts for managing SSL/TLS certificates
- Create Azure Functions host configuration in src/function/host.json
- Set up timer function in src/function/index.ts for scheduled certificate management
- Define configuration loading and error handling in src/lib/config.ts
- Implement DNS zone scanning and challenge management in src/lib/dns.ts
- Develop ACME client for certificate issuance in src/lib/acme.ts
- Create KeyVault store for managing secrets and certificates in src/lib/keyvault.ts
- Implement provisioning logic in src/lib/provisioner.ts for issuing and renewing certificates
- Add TypeScript configuration files for building the project
This commit is contained in:
2026-05-21 13:40:40 +02:00
parent c2af853df6
commit e7098015de
18 changed files with 2560 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { loadConfig } from './lib/config.js';
import { domainToCertName, Provisioner } from './lib/provisioner.js';
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']);
}
const sharedOptions = (cmd: Command): Command =>
cmd
.option('--keyvault-url <url>', 'Azure KeyVault URL')
.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('--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')
).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);
});
program.parseAsync(process.argv).catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});