470 lines
18 KiB
Markdown
470 lines
18 KiB
Markdown
# Azure ACME Provisioner
|
|
|
|
Azure ACME Provisioner is a NodeJS package that provides necessary tools to automate the process of obtaining SSL/TLS certificates from ACME (Automatic Certificate Management Environment) compliant certificate authorities, such as Let's Encrypt, for applications hosted on Microsoft Azure. It uses Azure KeyVault to securely store and manage the obtained certificates and ACME account credentials. The package may function as a standalone tool, a docker image, as a library or as an Azure Function, making it versatile for various deployment scenarios.
|
|
|
|
## Features
|
|
|
|
- Uses ACME protocol to automate certificate issuance and renewal.
|
|
- 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
|
|
|
|
All commands share a common set of options. Required options vary per command and are listed in each section below.
|
|
|
|
### Common options
|
|
|
|
| Option | Description |
|
|
|---|---|
|
|
| `--keyvault-name <name>` | Azure Key Vault name (constructs `https://<name>.vault.azure.net`) |
|
|
| `--keyvault-url <url>` | Azure Key Vault URL — use for sovereign clouds or non-standard URLs |
|
|
| `--subscription-id <id>` | Azure subscription ID |
|
|
| `--resource-group <rg>` | Resource group to scan |
|
|
| `--email <email>` | ACME contact email |
|
|
| `--renewal-threshold <days>` | Days before expiry to trigger renewal (default: 30) |
|
|
| `--log-level <level>` | `debug` \| `info` \| `warn` \| `error` (default: `info`) |
|
|
|
|
---
|
|
|
|
### Scanning scope
|
|
|
|
The `run`, `scan`, `status`, and `renew` commands accept optional positional arguments to narrow the scope:
|
|
|
|
| Arguments | Scope |
|
|
|---|---|
|
|
| *(none)* | All tagged records in all DNS zones in the subscription |
|
|
| `--resource-group <rg>` | All tagged records in all DNS zones in the resource group |
|
|
| `--resource-group <rg> <zone>` | All tagged records in the specified DNS zone |
|
|
| `--resource-group <rg> <zone> <name> [name...]` | Specific record names in the zone — **bypasses tag filtering** |
|
|
|
|
Record names are the DNS label within the zone (e.g. `api`, `www`, `@` for apex, `*` for wildcard). FQDNs are constructed by appending the zone name.
|
|
|
|
---
|
|
|
|
### `run [zone] [names...]` — issue or renew certificates
|
|
|
|
Issues or renews certificates for all domains in scope.
|
|
|
|
**Required:** `--keyvault-name` or `--keyvault-url`, `--subscription-id`, `--email`
|
|
|
|
```sh
|
|
# All tagged records in the subscription
|
|
azure-acme-provisioner run \
|
|
--keyvault-name myvault \
|
|
--subscription-id <subscription-id> \
|
|
--email admin@example.com
|
|
|
|
# All tagged records in a resource group
|
|
azure-acme-provisioner run \
|
|
--keyvault-name myvault \
|
|
--subscription-id <subscription-id> \
|
|
--resource-group my-dns-rg \
|
|
--email admin@example.com
|
|
|
|
# All tagged records in a specific zone
|
|
azure-acme-provisioner run \
|
|
--keyvault-name myvault \
|
|
--subscription-id <subscription-id> \
|
|
--resource-group my-dns-rg \
|
|
--email admin@example.com \
|
|
example.com
|
|
|
|
# Specific records (bypasses tags)
|
|
azure-acme-provisioner run \
|
|
--keyvault-name myvault \
|
|
--subscription-id <subscription-id> \
|
|
--resource-group my-dns-rg \
|
|
--email admin@example.com \
|
|
example.com api www
|
|
```
|
|
|
|
| Option | Description |
|
|
|---|---|
|
|
| `--http <port>` | Use HTTP-01 challenge on the given port instead of DNS-01 |
|
|
| `--pem` | Store certificates as PEM bundle instead of PFX (PKCS#12) |
|
|
| `--dry-run` | Show what would be issued or renewed without making changes |
|
|
|
|
### `scan [zone] [names...]` — list FQDNs in scope
|
|
|
|
Lists all FQDNs for which certificates will be issued, along with their zone and resource group.
|
|
|
|
**Required:** `--subscription-id`
|
|
|
|
```sh
|
|
azure-acme-provisioner scan \
|
|
--subscription-id <subscription-id> \
|
|
--resource-group my-dns-rg
|
|
```
|
|
|
|
| Option | Description |
|
|
|---|---|
|
|
| `--output table\|json` | Output format (default: `table`) |
|
|
|
|
### `status [zone] [names...]` — show certificate expiry
|
|
|
|
Shows the expiry date and days remaining for each domain's certificate.
|
|
|
|
**Required:** `--keyvault-name` or `--keyvault-url`, `--subscription-id`
|
|
|
|
```sh
|
|
azure-acme-provisioner status \
|
|
--keyvault-name myvault \
|
|
--subscription-id <subscription-id> \
|
|
--resource-group my-dns-rg
|
|
```
|
|
|
|
| Option | Description |
|
|
|---|---|
|
|
| `--output table\|json` | Output format (default: `table`) |
|
|
|
|
### `renew [zone] [names...]` — force-renew certificates
|
|
|
|
Force-renews certificates in scope, bypassing the renewal threshold. Accepts the same scope arguments as `run`.
|
|
|
|
**Required:** `--keyvault-name` or `--keyvault-url`, `--subscription-id`, `--email`
|
|
|
|
```sh
|
|
# Renew all managed certificates
|
|
azure-acme-provisioner renew \
|
|
--keyvault-name myvault \
|
|
--subscription-id <subscription-id> \
|
|
--email admin@example.com
|
|
|
|
# Renew specific records in a zone (bypasses tags)
|
|
azure-acme-provisioner renew \
|
|
--keyvault-name myvault \
|
|
--subscription-id <subscription-id> \
|
|
--resource-group my-dns-rg \
|
|
--email admin@example.com \
|
|
example.com api www
|
|
```
|
|
|
|
| Option | Description |
|
|
|---|---|
|
|
| `--http <port>` | Use HTTP-01 challenge on the given port instead of DNS-01 |
|
|
| `--pem` | Store certificates as PEM bundle instead of PFX (PKCS#12) |
|
|
|
|
### `download <fqdn>` — download a certificate
|
|
|
|
Fetches the certificate bundle from Key Vault and writes it to stdout or a file.
|
|
|
|
**Required:** `--keyvault-name` or `--keyvault-url`
|
|
|
|
```sh
|
|
# Print to stdout
|
|
azure-acme-provisioner download api.example.com --keyvault-name myvault
|
|
|
|
# Write to a file
|
|
azure-acme-provisioner download api.example.com --keyvault-name myvault --output api.example.com.pfx
|
|
```
|
|
|
|
| Option | Description |
|
|
|---|---|
|
|
| `--output <file>` | Write to file instead of stdout |
|
|
|
|
### `convert <fqdn>` — convert certificate format
|
|
|
|
Converts a stored certificate between PFX (PKCS#12) and PEM format in-place.
|
|
|
|
**Required:** `--keyvault-name` or `--keyvault-url`
|
|
|
|
```sh
|
|
# Convert to PFX (default)
|
|
azure-acme-provisioner convert api.example.com --keyvault-name myvault
|
|
|
|
# Convert to PEM bundle
|
|
azure-acme-provisioner convert api.example.com --keyvault-name myvault --pem
|
|
```
|
|
|
|
| Option | Description |
|
|
|---|---|
|
|
| `--pem` | Convert to PEM bundle instead of PFX (PKCS#12) |
|
|
|
|
### `assign-role <fqdn>` — assign Key Vault RBAC roles
|
|
|
|
Assigns **Key Vault Certificate User** and **Key Vault Secrets User** roles to a principal for the certificate and secret objects corresponding to the given FQDN.
|
|
|
|
**Required:** `--principal-id`, `--principal-type`, `--keyvault-resource-group`, `--keyvault-name` or `--keyvault-url`, `--subscription-id`
|
|
|
|
```sh
|
|
azure-acme-provisioner assign-role api.example.com \
|
|
--principal-id <object-id> \
|
|
--principal-type ServicePrincipal \
|
|
--keyvault-resource-group my-kv-rg \
|
|
--keyvault-name myvault \
|
|
--subscription-id <subscription-id>
|
|
```
|
|
|
|
| Option | Description |
|
|
|---|---|
|
|
| `--principal-id <id>` | Object ID of the Azure principal to assign roles to |
|
|
| `--principal-type <type>` | `User` \| `Group` \| `ServicePrincipal` (use `ServicePrincipal` for managed identities) |
|
|
| `--keyvault-resource-group <rg>` | Resource group that contains the Key Vault |
|
|
| `--dry-run` | Show what would be assigned without making changes |
|
|
|
|
---
|
|
|
|
### Challenge methods
|
|
|
|
By default `run` and `renew` use **DNS-01** via Azure DNS (requires DNS Zone Contributor role).
|
|
|
|
Pass `--http <port>` to use **HTTP-01** instead. The provisioner starts a temporary Express HTTP server on the given port and shuts it down after each certificate is issued. The ACME CA always validates against **port 80**, so either pass `--http 80` directly, or run the listener on a non-privileged port and forward port 80 to it externally (reverse proxy, NAT rule, or Docker port mapping).
|
|
|
|
```sh
|
|
# DNS-01 (default)
|
|
azure-acme-provisioner run --keyvault-name myvault --subscription-id <id> --email admin@example.com
|
|
|
|
# HTTP-01 on port 80
|
|
azure-acme-provisioner run --keyvault-name myvault --subscription-id <id> --email admin@example.com --http 80
|
|
|
|
# HTTP-01 on a non-privileged port (useful behind a reverse proxy or NAT rule)
|
|
azure-acme-provisioner run --keyvault-name myvault --subscription-id <id> --email admin@example.com --http 8080
|
|
```
|
|
|
|
> **Note:** Binding port 80 requires root privileges or `CAP_NET_BIND_SERVICE`. When running in Docker, map the host port to the container: `-p 80:8080` and pass `--http 8080`.
|
|
|
|
## 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_CONTACT_EMAIL` | Contact email registered with the ACME CA |
|
|
|
|
### Optional
|
|
|
|
| Variable | Default | Description |
|
|
|---|---|---|
|
|
| `ACME_RESOURCE_GROUP` | all resource groups in subscription | Resource group to scan |
|
|
| `ACME_DNS_ZONE` | all zones in resource group | DNS zone name to restrict scanning |
|
|
| `ACME_CERT_NAMES` | tag-based discovery | Comma-separated record names within the zone — bypasses tag filtering |
|
|
| `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_HTTP_PORT` | unset | If set to a positive integer, use HTTP-01 challenge on that port instead of DNS-01 |
|
|
| `ACME_CERT_FORMAT` | `pfx` | Certificate storage format: `pfx` (PKCS#12) or `pem` |
|
|
| `ACME_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
|
| `ACME_SCHEDULE` | `0 0 2 * * *` | Azure Function timer schedule (cron expression, 6-field format). Only used when deployed as an Azure Function. |
|
|
|
|
### 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.
|
|
|
|
The function app requires a Managed Identity with the following RBAC assignments:
|
|
|
|
| Scope | Role |
|
|
|---|---|
|
|
| Key Vault instance | Key Vault Certificates Officer |
|
|
| Key Vault instance | Key Vault Secrets Officer |
|
|
| Each DNS zone | DNS Zone Contributor |
|
|
|
|
> **Note:** The only DNS changes made are temporary `_acme-challenge.<domain>` TXT records created during the DNS-01 challenge and deleted immediately after validation. No A, CNAME, or other records are modified. If you require tighter permissions than `DNS Zone Contributor`, create a custom role limited to `Microsoft.Network/dnszones/TXT/write` and `Microsoft.Network/dnszones/TXT/delete`.
|
|
|
|
### Deploying with Azure Functions Core Tools
|
|
|
|
**Prerequisites:** [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli), [Azure Functions Core Tools v4](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local#install-the-azure-functions-core-tools), Node.js 24.
|
|
|
|
**1. Log in to Azure**
|
|
|
|
```sh
|
|
az login
|
|
```
|
|
|
|
**2. Create a resource group and storage account** (skip if they already exist)
|
|
|
|
```sh
|
|
az group create --name <resource-group> --location <location>
|
|
|
|
az storage account create \
|
|
--name <storage-account-name> \
|
|
--resource-group <resource-group> \
|
|
--location <location> \
|
|
--sku Standard_LRS
|
|
```
|
|
|
|
**3. Create the Function App**
|
|
|
|
```sh
|
|
az functionapp create \
|
|
--name <function-app-name> \
|
|
--resource-group <resource-group> \
|
|
--storage-account <storage-account-name> \
|
|
--consumption-plan-location <location> \
|
|
--runtime node \
|
|
--runtime-version 24 \
|
|
--functions-version 4
|
|
```
|
|
|
|
**4. Assign a system-assigned Managed Identity**
|
|
|
|
```sh
|
|
az functionapp identity assign \
|
|
--name <function-app-name> \
|
|
--resource-group <resource-group>
|
|
```
|
|
|
|
Note the `principalId` from the output — you will need it in the next step.
|
|
|
|
**5. Grant RBAC roles to the Managed Identity**
|
|
|
|
```sh
|
|
# Key Vault Certificates Officer
|
|
az role assignment create \
|
|
--assignee <principalId> \
|
|
--role "Key Vault Certificates Officer" \
|
|
--scope /subscriptions/<subscription-id>/resourceGroups/<kv-resource-group>/providers/Microsoft.KeyVault/vaults/<keyvault-name>
|
|
|
|
# Key Vault Secrets Officer
|
|
az role assignment create \
|
|
--assignee <principalId> \
|
|
--role "Key Vault Secrets Officer" \
|
|
--scope /subscriptions/<subscription-id>/resourceGroups/<kv-resource-group>/providers/Microsoft.KeyVault/vaults/<keyvault-name>
|
|
|
|
# Option A — per zone (minimum permission, repeat for each managed DNS zone)
|
|
az role assignment create \
|
|
--assignee <principalId> \
|
|
--role "DNS Zone Contributor" \
|
|
--scope /subscriptions/<subscription-id>/resourceGroups/<dns-resource-group>/providers/Microsoft.Network/dnszones/<zone-name>
|
|
|
|
# Option B — per resource group (convenient when all DNS zones are in one group)
|
|
az role assignment create \
|
|
--assignee <principalId> \
|
|
--role "DNS Zone Contributor" \
|
|
--scope /subscriptions/<subscription-id>/resourceGroups/<dns-resource-group>
|
|
```
|
|
|
|
**6. Configure production application settings**
|
|
|
|
```sh
|
|
az functionapp config appsettings set \
|
|
--name <function-app-name> \
|
|
--resource-group <resource-group> \
|
|
--settings \
|
|
"ACME_KEYVAULT_URL=https://<keyvault-name>.vault.azure.net" \
|
|
"ACME_SUBSCRIPTION_ID=<subscription-id>" \
|
|
"ACME_RESOURCE_GROUP=<dns-resource-group>" \
|
|
"ACME_CONTACT_EMAIL=<email>" \
|
|
"ACME_SCHEDULE=0 0 2 * * *"
|
|
```
|
|
|
|
**7. Build and deploy**
|
|
|
|
```sh
|
|
npm run build
|
|
func azure functionapp publish <function-app-name> --no-build
|
|
```
|
|
|
|
`--no-build` tells `func` to skip its own build step since we already compiled the TypeScript output. To also push application settings from `local.settings.json` in the same step, append `--publish-local-settings --overwrite-settings`. This is useful for the initial deployment or when settings and code change together.
|
|
|
|
### Local testing
|
|
|
|
Create `local.settings.json` at the project root (gitignored) and fill in your values:
|
|
|
|
```json
|
|
{
|
|
"IsEncrypted": false,
|
|
"Values": {
|
|
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
|
|
"FUNCTIONS_WORKER_RUNTIME": "node",
|
|
"ACME_KEYVAULT_URL": "https://<keyvault-name>.vault.azure.net",
|
|
"ACME_SUBSCRIPTION_ID": "<subscription-id>",
|
|
"ACME_RESOURCE_GROUP": "<dns-resource-group>",
|
|
"ACME_CONTACT_EMAIL": "<email>",
|
|
"ACME_SCHEDULE": "0 0 2 * * *"
|
|
}
|
|
}
|
|
```
|
|
|
|
Then run:
|
|
|
|
```sh
|
|
npm run build
|
|
func start
|
|
```
|
|
|
|
## 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 in **PFX (PKCS#12) format** (`application/x-pkcs12`) by default, making them directly consumable by Azure App Service, API Management, and other Azure services that integrate with Key Vault. Pass `--pem` (or set `ACME_CERT_FORMAT=pem`) to store as a PEM bundle (`application/x-pem-file`) instead. Use the `convert` command to change the format of an already-stored certificate.
|
|
|
|
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.
|