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
+11
View File
@@ -0,0 +1,11 @@
node_modules/
dist/
.git/
*.log
tmp/
temp/
local.settings.json
*.pem
*.pfx
*.p12
*.key
+27
View File
@@ -0,0 +1,27 @@
node_modules/
dist/
*.tsbuildinfo
*.log
npm-debug.log*
coverage/
.nyc_output/
.vscode/
.idea/
*.swp
*.swo
.DS_Store
local.settings.json
*.pem
*.pfx
*.p12
*.crt
*.key
*.cer
tmp/
temp/
+27
View File
@@ -0,0 +1,27 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig*.json ./
RUN npm ci --ignore-scripts
COPY src/ ./src/
RUN npm run build
RUN npm ci --omit=dev --ignore-scripts
FROM node:24-alpine AS runtime
WORKDIR /app
RUN addgroup -S acme && adduser -S acme -G acme
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
USER acme
ENV NODE_ENV=production
ENTRYPOINT ["node", "dist/cli.js"]
CMD ["run"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sławomir Koszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+137
View File
@@ -8,3 +8,140 @@ Azure ACME Provisioner is a NodeJS package that provides necessary tools to auto
- Stores ACME account information as secrets in Azure KeyVault for secure management.
- Stores obtained SSL/TLS certificates in Azure KeyVault for easy access and management.
- Automatically scans configured Azure DNS zones to identify records that require certificates (uses the `acme` tag to identify relevant recordsets).
## Requirements
- Node.js 24 or later
- Azure subscription with:
- Azure DNS zone(s) with records tagged `acme: true` or `acme: enabled`
- Azure Key Vault instance
- Managed Identity (or service principal) with permissions to read/write Key Vault secrets and certificates, and to manage DNS record sets
## Installation
```sh
npm install azure-acme-provisioner
```
Or use the CLI directly via `npx`:
```sh
npx azure-acme-provisioner --help
```
## DNS Zone Tagging
The provisioner discovers domains by scanning Azure DNS zones. Tag a **zone** or individual **A/CNAME recordsets** with `acme: true` to include them:
- **Zone-level tag** — issues certificates for both the zone apex (`example.com`) and a wildcard (`*.example.com`) as a single SAN order.
- **Recordset-level tag** — issues a certificate for that specific FQDN.
## CLI Usage
```
Commands:
run Scan DNS zones and issue or renew certificates (default)
scan List all domains tagged for ACME management
status Show certificate expiry status for all managed domains
renew Force-renew a certificate for a specific domain
Common options:
--keyvault-url <url> Azure KeyVault URL
--subscription-id <id> Azure subscription ID
--resource-group <rg> Resource group to scan (repeatable)
--dns-zone <zone> Restrict to specific DNS zone (repeatable)
--email <email> ACME contact email
--renewal-threshold <days> Days before expiry to renew (default: 30)
--dry-run Show what would be done without making changes
--log-level <level> debug | info | warn | error (default: info)
--output <format> table | json (scan and status commands)
```
## Configuration
All configuration is via environment variables. CLI flags override env vars when both are provided.
### Required
| Variable | Description |
|---|---|
| `ACME_KEYVAULT_URL` | Azure Key Vault URL, e.g. `https://myvault.vault.azure.net` |
| `ACME_SUBSCRIPTION_ID` | Azure subscription ID |
| `ACME_RESOURCE_GROUPS` | Comma-separated list of resource groups to scan |
| `ACME_CONTACT_EMAIL` | Contact email registered with the ACME CA |
### Optional
| Variable | Default | Description |
|---|---|---|
| `ACME_DNS_ZONES` | all zones in resource groups | Comma-separated list of DNS zone names to restrict scanning |
| `ACME_DIRECTORY_URL` | Let's Encrypt production | ACME directory URL |
| `ACME_RENEWAL_THRESHOLD_DAYS` | `30` | Renew certificates this many days before expiry |
| `ACME_DNS_PROPAGATION_WAIT` | `60` | Maximum seconds to wait for DNS TXT record propagation |
| `ACME_DNS_CHALLENGE_TTL` | `60` | TTL (seconds) for DNS-01 challenge TXT records |
| `ACME_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### Azure Authentication
The provisioner uses [`DefaultAzureCredential`](https://learn.microsoft.com/en-us/javascript/api/@azure/identity/defaultazurecredential) from `@azure/identity`, which tries authentication methods in this order:
1. **Managed Identity** — recommended for Azure-hosted deployments (Functions, ACI, AKS). Assign a system or user-assigned managed identity with the required RBAC roles. No credential configuration needed.
2. **Workload Identity Federation** — for Kubernetes or CI/CD (GitHub Actions). Set `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_FEDERATED_TOKEN_FILE`. No secrets required.
3. **Certificate-based service principal** — set `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_CLIENT_CERTIFICATE_PATH` (optionally `AZURE_CLIENT_CERTIFICATE_PASSWORD`, `AZURE_CLIENT_SEND_CERTIFICATE_CHAIN`).
4. **Client secret service principal** — set `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_CLIENT_SECRET`. Least secure; use only as a last resort.
5. **Azure CLI / Developer CLI** — used automatically in local development when logged in via `az login` or `azd auth login`.
For sovereign clouds (Azure Government, Azure China), set `AZURE_AUTHORITY_HOST` to the appropriate authority endpoint.
## Azure Function
The package includes an Azure Functions v4 timer trigger that runs the provisioner daily at 02:00 UTC. To deploy, point the function app at this package's entry point and configure the environment variables above as application settings.
The function app requires a Managed Identity with the following RBAC assignments:
| Scope | Role |
|---|---|
| Key Vault | Key Vault Certificates Officer |
| Key Vault | Key Vault Secrets Officer |
| DNS Zone(s) | DNS Zone Contributor |
## Docker
```sh
docker run --rm \
-e ACME_KEYVAULT_URL=https://myvault.vault.azure.net \
-e ACME_SUBSCRIPTION_ID=<subscription-id> \
-e ACME_RESOURCE_GROUPS=my-rg \
-e ACME_CONTACT_EMAIL=admin@example.com \
ghcr.io/your-org/azure-acme-provisioner
```
When running in Azure Container Instances with a user-assigned Managed Identity, set `AZURE_CLIENT_ID` to the identity's client ID. No other credential variables are needed.
## Library Usage
```typescript
import { Provisioner, loadConfig } from 'azure-acme-provisioner';
const config = loadConfig(); // reads from environment variables
const provisioner = new Provisioner(config);
const result = await provisioner.run();
console.log(result);
```
## Certificate Storage
Certificates are stored as native Azure Key Vault Certificates (PEM format, `application/x-pem-file`), making them available to Azure App Service, API Management, and other Azure services that integrate with Key Vault.
ACME account credentials (private key and account URL) are stored as Key Vault Secrets and reused across runs.
Certificate names are derived from the domain: dots are replaced with hyphens, wildcards become `wildcard-`, and a `cert-` prefix is added. For example:
| Domain | Key Vault certificate name |
|---|---|
| `api.example.com` | `cert-api-example-com` |
| `*.example.com` | `cert-wildcard-example-com` |
## AI Disclaimer
The files in this repository may contain code generated by AI tools. While we strive to ensure the quality and security of all code, we recommend reviewing any AI-generated code before using it in production environments.
+1553
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "azure-acme-provisioner",
"version": "0.1.0",
"author": {
"name": "Sławomir Koszewski",
"url": "https://github.com/skoszewski"
},
"licenses": [
{
"type":"MIT",
"url": "https://opensource.org/licenses/MIT"
}
],
"description": "Automated SSL/TLS certificate management using ACME protocol with Azure KeyVault and Azure DNS",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"azure-acme-provisioner": "./dist/cli.js"
},
"files": [
"dist/",
"!dist/**/*.test.*"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"build:watch": "tsc -p tsconfig.build.json --watch",
"clean": "rimraf dist",
"lint": "tsc --noEmit",
"prebuild": "npm run clean",
"start": "node dist/cli.js run",
"start:function": "func start"
},
"dependencies": {
"@azure/arm-dns": "^5.1.0",
"@azure/functions": "^4.14.0",
"@azure/identity": "^4.13.1",
"@azure/keyvault-certificates": "^4.10.3",
"@azure/keyvault-secrets": "^4.11.2",
"@peculiar/x509": "^2.0.0",
"acme-client": "^5.4.0",
"commander": "^14.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"rimraf": "^6.1.3",
"typescript": "^6.0.0"
},
"engines": {
"node": ">=24.0.0"
},
"license": "MIT"
}
+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);
});
+21
View File
@@ -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)"
}
}
+18
View File
@@ -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`);
}
},
});
+13
View File
@@ -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
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;
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declarationMap": false
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "CommonJS",
"moduleResolution": "bundler",
"lib": ["ES2024"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}