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 @@
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));
}
+71
View File
@@ -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
View File
@@ -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 };
}
}
+69
View File
@@ -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
);
}
+160
View File
@@ -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;
}