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:
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.git/
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
local.settings.json
|
||||||
|
*.pem
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
+27
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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 ACME account information as secrets in Azure KeyVault for secure management.
|
||||||
- Stores obtained SSL/TLS certificates in Azure KeyVault for easy access and 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).
|
- 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.
|
||||||
|
|||||||
Generated
+1553
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"sourceMap": false,
|
||||||
|
"declarationMap": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user