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:
+122
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"applicationInsights": {
|
||||
"samplingSettings": {
|
||||
"isEnabled": true,
|
||||
"excludedTypes": "Request"
|
||||
}
|
||||
},
|
||||
"logLevel": {
|
||||
"default": "Information",
|
||||
"Host.Results": "Error",
|
||||
"Function": "Information",
|
||||
"Host.Aggregator": "Trace"
|
||||
}
|
||||
},
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { app, InvocationContext, Timer } from '@azure/functions';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { Provisioner } from '../lib/provisioner.js';
|
||||
|
||||
app.timer('acmeProvisioner', {
|
||||
schedule: '0 0 2 * * *',
|
||||
useMonitor: true,
|
||||
handler: async (_timer: Timer, context: InvocationContext): Promise<void> => {
|
||||
context.log('Azure ACME Provisioner starting');
|
||||
const config = loadConfig();
|
||||
const provisioner = new Provisioner(config, context.log.bind(context));
|
||||
const result = await provisioner.run();
|
||||
context.log('Provisioning complete', result);
|
||||
if (result.errors.length > 0) {
|
||||
throw new Error(`${result.errors.length} domain(s) failed — see logs for details`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export { loadConfig, ConfigError } from './lib/config.js';
|
||||
export type { Config } from './lib/config.js';
|
||||
|
||||
export { KeyVaultStore } from './lib/keyvault.js';
|
||||
|
||||
export { scanDnsZones, DnsChallengeManager } from './lib/dns.js';
|
||||
export type { DomainRecord } from './lib/dns.js';
|
||||
|
||||
export { AcmeClient } from './lib/acme.js';
|
||||
export type { IssuedCertificate } from './lib/acme.js';
|
||||
|
||||
export { Provisioner, domainToCertName } from './lib/provisioner.js';
|
||||
export type { ProvisioningResult } from './lib/provisioner.js';
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import * as acme from 'acme-client';
|
||||
import { promises as dns } from 'node:dns';
|
||||
import { Config } from './config.js';
|
||||
import { DnsChallengeManager } from './dns.js';
|
||||
import { KeyVaultStore } from './keyvault.js';
|
||||
|
||||
const ACCOUNT_KEY_SECRET = 'acme-account-private-key';
|
||||
const ACCOUNT_URL_SECRET = 'acme-account-url';
|
||||
|
||||
export interface IssuedCertificate {
|
||||
privateKeyPem: string;
|
||||
certificatePem: string;
|
||||
chainPem: string;
|
||||
}
|
||||
|
||||
export class AcmeClient {
|
||||
private client: acme.Client | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly store: KeyVaultStore,
|
||||
private readonly config: Config,
|
||||
private readonly log: (msg: string) => void
|
||||
) {}
|
||||
|
||||
async ensureAccount(): Promise<void> {
|
||||
const storedKey = await this.store.getSecret(ACCOUNT_KEY_SECRET);
|
||||
const storedUrl = await this.store.getSecret(ACCOUNT_URL_SECRET);
|
||||
|
||||
let accountKey: Buffer;
|
||||
if (storedKey) {
|
||||
this.log('Loading existing ACME account from KeyVault');
|
||||
accountKey = Buffer.from(storedKey);
|
||||
} else {
|
||||
this.log('Creating new ACME account');
|
||||
accountKey = await acme.crypto.createPrivateKey();
|
||||
await this.store.setSecret(ACCOUNT_KEY_SECRET, accountKey.toString());
|
||||
}
|
||||
|
||||
this.client = new acme.Client({
|
||||
directoryUrl: this.config.acmeDirectoryUrl,
|
||||
accountKey,
|
||||
accountUrl: storedUrl ?? undefined,
|
||||
});
|
||||
|
||||
const account = await this.client.createAccount({
|
||||
termsOfServiceAgreed: true,
|
||||
contact: [`mailto:${this.config.acmeContactEmail}`],
|
||||
});
|
||||
|
||||
if (!storedUrl) {
|
||||
const accountUrl = this.client.getAccountUrl();
|
||||
await this.store.setSecret(ACCOUNT_URL_SECRET, accountUrl);
|
||||
this.log(`ACME account registered: ${accountUrl}`);
|
||||
}
|
||||
|
||||
return void account;
|
||||
}
|
||||
|
||||
async orderCertificate(
|
||||
domains: string[],
|
||||
challengeManager: DnsChallengeManager
|
||||
): Promise<IssuedCertificate> {
|
||||
if (!this.client) throw new Error('Call ensureAccount() before ordering certificates');
|
||||
|
||||
const [privateKey, csr] = await acme.crypto.createCsr({
|
||||
altNames: domains,
|
||||
});
|
||||
|
||||
const certificatePem = await this.client.auto({
|
||||
csr,
|
||||
challengePriority: ['dns-01'],
|
||||
challengeCreateFn: async (_authz, _challenge, keyAuthorization) => {
|
||||
const domain = _authz.identifier.value;
|
||||
const txtFqdn = `_acme-challenge.${domain}`;
|
||||
this.log(`Creating DNS TXT record: ${txtFqdn}`);
|
||||
await challengeManager.createTxtRecord(txtFqdn, keyAuthorization);
|
||||
await this.waitForDnsPropagation(txtFqdn, keyAuthorization);
|
||||
},
|
||||
challengeRemoveFn: async (_authz, _challenge, _keyAuthorization) => {
|
||||
const domain = _authz.identifier.value;
|
||||
const txtFqdn = `_acme-challenge.${domain}`;
|
||||
this.log(`Removing DNS TXT record: ${txtFqdn}`);
|
||||
await challengeManager.deleteTxtRecord(txtFqdn);
|
||||
},
|
||||
});
|
||||
|
||||
const [cert, ...chainCerts] = certificatePem
|
||||
.split(/(?=-----BEGIN CERTIFICATE-----)/)
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
privateKeyPem: privateKey.toString(),
|
||||
certificatePem: cert,
|
||||
chainPem: chainCerts.join(''),
|
||||
};
|
||||
}
|
||||
|
||||
private async waitForDnsPropagation(fqdn: string, expectedValue: string): Promise<void> {
|
||||
const deadline = Date.now() + this.config.dnsPropagationWaitSeconds * 1000;
|
||||
const pollInterval = 5000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const records = await dns.resolveTxt(fqdn);
|
||||
const found = records.flat().includes(expectedValue);
|
||||
if (found) {
|
||||
this.log(`DNS propagation confirmed for ${fqdn}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// record not yet visible
|
||||
}
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
|
||||
this.log(`DNS propagation wait timed out for ${fqdn}, proceeding anyway`);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface Config {
|
||||
keyVaultUrl: string;
|
||||
acmeDirectoryUrl: string;
|
||||
acmeContactEmail: string;
|
||||
subscriptionId: string;
|
||||
resourceGroups: string[];
|
||||
dnsZones?: string[];
|
||||
renewalThresholdDays: number;
|
||||
dnsPropagationWaitSeconds: number;
|
||||
dnsChallengeTtl: number;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
}
|
||||
|
||||
export class ConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new ConfigError(`Missing required environment variable: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalEnv(name: string, defaultValue: string): string {
|
||||
return process.env[name] ?? defaultValue;
|
||||
}
|
||||
|
||||
function optionalEnvInt(name: string, defaultValue: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return defaultValue;
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (isNaN(parsed)) throw new ConfigError(`Environment variable ${name} must be an integer, got: ${raw}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const resourceGroupsRaw = requireEnv('ACME_RESOURCE_GROUPS');
|
||||
const resourceGroups = resourceGroupsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (resourceGroups.length === 0) {
|
||||
throw new ConfigError('ACME_RESOURCE_GROUPS must contain at least one resource group');
|
||||
}
|
||||
|
||||
const dnsZonesRaw = process.env['ACME_DNS_ZONES'];
|
||||
const dnsZones = dnsZonesRaw
|
||||
? dnsZonesRaw.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const logLevel = optionalEnv('ACME_LOG_LEVEL', 'info');
|
||||
if (!['debug', 'info', 'warn', 'error'].includes(logLevel)) {
|
||||
throw new ConfigError(`ACME_LOG_LEVEL must be one of: debug, info, warn, error. Got: ${logLevel}`);
|
||||
}
|
||||
|
||||
return {
|
||||
keyVaultUrl: requireEnv('ACME_KEYVAULT_URL'),
|
||||
acmeDirectoryUrl: optionalEnv(
|
||||
'ACME_DIRECTORY_URL',
|
||||
'https://acme-v02.api.letsencrypt.org/directory'
|
||||
),
|
||||
acmeContactEmail: requireEnv('ACME_CONTACT_EMAIL'),
|
||||
subscriptionId: requireEnv('ACME_SUBSCRIPTION_ID'),
|
||||
resourceGroups,
|
||||
dnsZones,
|
||||
renewalThresholdDays: optionalEnvInt('ACME_RENEWAL_THRESHOLD_DAYS', 30),
|
||||
dnsPropagationWaitSeconds: optionalEnvInt('ACME_DNS_PROPAGATION_WAIT', 60),
|
||||
dnsChallengeTtl: optionalEnvInt('ACME_DNS_CHALLENGE_TTL', 60),
|
||||
logLevel: logLevel as Config['logLevel'],
|
||||
};
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { DnsManagementClient } from '@azure/arm-dns';
|
||||
import { TokenCredential } from '@azure/identity';
|
||||
import { Config } from './config.js';
|
||||
|
||||
export interface DomainRecord {
|
||||
fqdn: string;
|
||||
zoneName: string;
|
||||
resourceGroup: string;
|
||||
isWildcard: boolean;
|
||||
}
|
||||
|
||||
export async function scanDnsZones(
|
||||
credential: TokenCredential,
|
||||
config: Config
|
||||
): Promise<DomainRecord[]> {
|
||||
const client = new DnsManagementClient(credential, config.subscriptionId);
|
||||
const results: DomainRecord[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const rg of config.resourceGroups) {
|
||||
for await (const zone of client.zones.listByResourceGroup(rg)) {
|
||||
if (!zone.name) continue;
|
||||
if (config.dnsZones && !config.dnsZones.includes(zone.name)) continue;
|
||||
|
||||
if (isAcmeTagged(zone.tags)) {
|
||||
addDomain(results, seen, zone.name, rg, false);
|
||||
addDomain(results, seen, `*.${zone.name}`, rg, true);
|
||||
}
|
||||
|
||||
for await (const record of client.recordSets.listAllByDnsZone(rg, zone.name)) {
|
||||
if (!record.name) continue;
|
||||
if (!isAcmeTagged(record.metadata)) continue;
|
||||
if (record.type !== 'Microsoft.Network/dnszones/A' &&
|
||||
record.type !== 'Microsoft.Network/dnszones/CNAME') continue;
|
||||
|
||||
const fqdn = record.name === '@' ? zone.name : `${record.name}.${zone.name}`;
|
||||
addDomain(results, seen, fqdn, rg, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function addDomain(
|
||||
results: DomainRecord[],
|
||||
seen: Set<string>,
|
||||
fqdn: string,
|
||||
resourceGroup: string,
|
||||
isWildcard: boolean
|
||||
): void {
|
||||
if (seen.has(fqdn)) return;
|
||||
seen.add(fqdn);
|
||||
const zoneName = fqdn.replace(/^\*\./, '');
|
||||
results.push({ fqdn, zoneName, resourceGroup, isWildcard });
|
||||
}
|
||||
|
||||
function isAcmeTagged(tags: Record<string, string> | undefined): boolean {
|
||||
if (!tags) return false;
|
||||
const val = tags['acme'];
|
||||
return val === 'true' || val === 'enabled';
|
||||
}
|
||||
|
||||
export class DnsChallengeManager {
|
||||
private readonly client: DnsManagementClient;
|
||||
|
||||
constructor(credential: TokenCredential, private readonly config: Config) {
|
||||
this.client = new DnsManagementClient(credential, config.subscriptionId);
|
||||
}
|
||||
|
||||
async createTxtRecord(fqdn: string, value: string): Promise<void> {
|
||||
const { resourceGroup, zone, name } = this.parseFqdn(fqdn);
|
||||
await this.client.recordSets.createOrUpdate(resourceGroup, zone, name, 'TXT', {
|
||||
ttl: this.config.dnsChallengeTtl,
|
||||
txtRecords: [{ value: [value] }],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTxtRecord(fqdn: string): Promise<void> {
|
||||
const { resourceGroup, zone, name } = this.parseFqdn(fqdn);
|
||||
try {
|
||||
await this.client.recordSets.delete(resourceGroup, zone, name, 'TXT');
|
||||
} catch {
|
||||
// best-effort cleanup; ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
private parseFqdn(fqdn: string): { resourceGroup: string; zone: string; name: string } {
|
||||
for (const rg of this.config.resourceGroups) {
|
||||
for (const zone of this.config.dnsZones ?? []) {
|
||||
if (fqdn.endsWith(`.${zone}`) || fqdn === zone) {
|
||||
const name = fqdn === zone ? '@' : fqdn.slice(0, -(zone.length + 1));
|
||||
return { resourceGroup: rg, zone, name };
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback: derive zone from fqdn by stripping first label
|
||||
const parts = fqdn.split('.');
|
||||
const zone = parts.slice(1).join('.');
|
||||
const name = parts[0];
|
||||
const rg = this.config.resourceGroups[0];
|
||||
return { resourceGroup: rg, zone, name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { TokenCredential } from '@azure/identity';
|
||||
import {
|
||||
CertificateClient,
|
||||
ImportCertificateOptions,
|
||||
KeyVaultCertificateWithPolicy,
|
||||
} from '@azure/keyvault-certificates';
|
||||
import { SecretClient } from '@azure/keyvault-secrets';
|
||||
|
||||
export class KeyVaultStore {
|
||||
private readonly secretClient: SecretClient;
|
||||
private readonly certClient: CertificateClient;
|
||||
|
||||
constructor(credential: TokenCredential, keyVaultUrl: string) {
|
||||
this.secretClient = new SecretClient(keyVaultUrl, credential);
|
||||
this.certClient = new CertificateClient(keyVaultUrl, credential);
|
||||
}
|
||||
|
||||
async getSecret(name: string): Promise<string | undefined> {
|
||||
try {
|
||||
const secret = await this.secretClient.getSecret(name);
|
||||
return secret.value;
|
||||
} catch (err: unknown) {
|
||||
if (isNotFound(err)) return undefined;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setSecret(name: string, value: string): Promise<void> {
|
||||
await this.secretClient.setSecret(name, value);
|
||||
}
|
||||
|
||||
async getCertificate(name: string): Promise<KeyVaultCertificateWithPolicy | undefined> {
|
||||
try {
|
||||
return await this.certClient.getCertificate(name);
|
||||
} catch (err: unknown) {
|
||||
if (isNotFound(err)) return undefined;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async certificateExpiresWithin(name: string, days: number): Promise<boolean | 'missing'> {
|
||||
const cert = await this.getCertificate(name);
|
||||
if (!cert) return 'missing';
|
||||
const expiresOn = cert.properties.expiresOn;
|
||||
if (!expiresOn) return false;
|
||||
const thresholdMs = days * 24 * 60 * 60 * 1000;
|
||||
return expiresOn.getTime() - Date.now() <= thresholdMs;
|
||||
}
|
||||
|
||||
async importCertificate(name: string, pemBundle: string): Promise<void> {
|
||||
const options: ImportCertificateOptions = {
|
||||
policy: {
|
||||
contentType: 'application/x-pem-file',
|
||||
issuerName: 'Unknown',
|
||||
subject: 'CN=unknown',
|
||||
},
|
||||
};
|
||||
await this.certClient.importCertificate(name, Buffer.from(pemBundle), options);
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFound(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'statusCode' in err &&
|
||||
(err as { statusCode: number }).statusCode === 404
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { DefaultAzureCredential } from '@azure/identity';
|
||||
import { AcmeClient } from './acme.js';
|
||||
import { Config } from './config.js';
|
||||
import { DnsChallengeManager, DomainRecord, scanDnsZones } from './dns.js';
|
||||
import { KeyVaultStore } from './keyvault.js';
|
||||
|
||||
export interface ProvisioningResult {
|
||||
domainsScanned: number;
|
||||
certificatesIssued: string[];
|
||||
certificatesRenewed: string[];
|
||||
certificatesSkipped: string[];
|
||||
errors: Array<{ domain: string; error: string }>;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export class Provisioner {
|
||||
private readonly credential: DefaultAzureCredential;
|
||||
private readonly store: KeyVaultStore;
|
||||
private readonly acme: AcmeClient;
|
||||
private readonly challengeManager: DnsChallengeManager;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly log: (msg: string, ...args: unknown[]) => void = console.log
|
||||
) {
|
||||
this.credential = new DefaultAzureCredential();
|
||||
this.store = new KeyVaultStore(this.credential, config.keyVaultUrl);
|
||||
this.acme = new AcmeClient(this.store, config, (msg) => this.log(msg));
|
||||
this.challengeManager = new DnsChallengeManager(this.credential, config);
|
||||
}
|
||||
|
||||
async run(dryRun = false): Promise<ProvisioningResult> {
|
||||
const start = Date.now();
|
||||
const result: ProvisioningResult = {
|
||||
domainsScanned: 0,
|
||||
certificatesIssued: [],
|
||||
certificatesRenewed: [],
|
||||
certificatesSkipped: [],
|
||||
errors: [],
|
||||
durationMs: 0,
|
||||
};
|
||||
|
||||
this.log('Initializing ACME account');
|
||||
await this.acme.ensureAccount();
|
||||
|
||||
this.log('Scanning DNS zones');
|
||||
const domains = await scanDnsZones(this.credential, this.config);
|
||||
result.domainsScanned = domains.length;
|
||||
this.log(`Found ${domains.length} domain(s) tagged for ACME management`);
|
||||
|
||||
const groups = groupWildcardWithApex(domains);
|
||||
|
||||
for (const group of groups) {
|
||||
const primary = group[0];
|
||||
const certName = domainToCertName(primary.fqdn);
|
||||
|
||||
try {
|
||||
const status = await this.store.certificateExpiresWithin(
|
||||
certName,
|
||||
this.config.renewalThresholdDays
|
||||
);
|
||||
|
||||
if (status === false) {
|
||||
this.log(`Skipping ${primary.fqdn} — certificate is current`);
|
||||
result.certificatesSkipped.push(primary.fqdn);
|
||||
continue;
|
||||
}
|
||||
|
||||
const action = status === 'missing' ? 'Issuing' : 'Renewing';
|
||||
this.log(`${action} certificate for ${group.map(d => d.fqdn).join(', ')}`);
|
||||
|
||||
if (dryRun) {
|
||||
this.log(`[dry-run] Would ${action.toLowerCase()} ${primary.fqdn}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fqdns = group.map(d => d.fqdn);
|
||||
const issued = await this.acme.orderCertificate(fqdns, this.challengeManager);
|
||||
const pemBundle = issued.privateKeyPem + issued.certificatePem + issued.chainPem;
|
||||
await this.store.importCertificate(certName, pemBundle);
|
||||
|
||||
if (status === 'missing') {
|
||||
result.certificatesIssued.push(primary.fqdn);
|
||||
} else {
|
||||
result.certificatesRenewed.push(primary.fqdn);
|
||||
}
|
||||
this.log(`Certificate stored in KeyVault as '${certName}'`);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.log(`Error processing ${primary.fqdn}: ${message}`);
|
||||
result.errors.push({ domain: primary.fqdn, error: message });
|
||||
}
|
||||
}
|
||||
|
||||
result.durationMs = Date.now() - start;
|
||||
this.log(
|
||||
`Done. Issued: ${result.certificatesIssued.length}, ` +
|
||||
`Renewed: ${result.certificatesRenewed.length}, ` +
|
||||
`Skipped: ${result.certificatesSkipped.length}, ` +
|
||||
`Errors: ${result.errors.length}`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async scan(): Promise<DomainRecord[]> {
|
||||
return scanDnsZones(this.credential, this.config);
|
||||
}
|
||||
|
||||
async status(): Promise<Array<{ fqdn: string; certName: string; expiresOn: Date | undefined; daysRemaining: number | undefined }>> {
|
||||
const domains = await scanDnsZones(this.credential, this.config);
|
||||
const rows = [];
|
||||
|
||||
for (const domain of domains) {
|
||||
const certName = domainToCertName(domain.fqdn);
|
||||
const cert = await this.store.getCertificate(certName);
|
||||
const expiresOn = cert?.properties.expiresOn;
|
||||
const daysRemaining = expiresOn
|
||||
? Math.floor((expiresOn.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
: undefined;
|
||||
rows.push({ fqdn: domain.fqdn, certName, expiresOn, daysRemaining });
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
export function domainToCertName(fqdn: string): string {
|
||||
return 'cert-' + fqdn.replace(/^\*\./, 'wildcard-').replace(/\./g, '-');
|
||||
}
|
||||
|
||||
function groupWildcardWithApex(domains: DomainRecord[]): DomainRecord[][] {
|
||||
const byZone = new Map<string, DomainRecord[]>();
|
||||
|
||||
for (const d of domains) {
|
||||
const key = d.zoneName;
|
||||
const group = byZone.get(key) ?? [];
|
||||
group.push(d);
|
||||
byZone.set(key, group);
|
||||
}
|
||||
|
||||
const groups: DomainRecord[][] = [];
|
||||
for (const group of byZone.values()) {
|
||||
const hasWildcard = group.some(d => d.isWildcard);
|
||||
const hasApex = group.some(d => !d.isWildcard && d.fqdn === d.zoneName);
|
||||
|
||||
if (hasWildcard && hasApex) {
|
||||
// combine into a single SAN order: wildcard first, then apex
|
||||
const wildcard = group.filter(d => d.isWildcard);
|
||||
const apex = group.filter(d => !d.isWildcard && d.fqdn === d.zoneName);
|
||||
const rest = group.filter(d => !d.isWildcard && d.fqdn !== d.zoneName);
|
||||
groups.push([...wildcard, ...apex]);
|
||||
for (const d of rest) groups.push([d]);
|
||||
} else {
|
||||
for (const d of group) groups.push([d]);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
Reference in New Issue
Block a user