update: version 0.6.0 and a refactor of parameters.
This commit is contained in:
@@ -38,59 +38,219 @@ The provisioner discovers domains by scanning Azure DNS zones. Tag a **zone** or
|
|||||||
|
|
||||||
## CLI Usage
|
## CLI Usage
|
||||||
|
|
||||||
```
|
All commands share a common set of options. Required options vary per command and are listed in each section below.
|
||||||
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
|
|
||||||
download Download the PEM bundle for a domain from Key Vault
|
|
||||||
assign-role Assign Key Vault roles to a principal for a domain certificate
|
|
||||||
|
|
||||||
Common options:
|
### Common options
|
||||||
--keyvault-url <url> Azure KeyVault URL
|
|
||||||
--subscription-id <id> Azure subscription ID
|
| Option | Description |
|
||||||
--resource-group <rg> Resource group to scan (repeatable)
|
|---|---|
|
||||||
--dns-zone <zone> Restrict to specific DNS zone (repeatable)
|
| `--keyvault-name <name>` | Azure Key Vault name (constructs `https://<name>.vault.azure.net`) |
|
||||||
--email <email> ACME contact email
|
| `--keyvault-url <url>` | Azure Key Vault URL — use for sovereign clouds or non-standard URLs |
|
||||||
--renewal-threshold <days> Days before expiry to renew (default: 30)
|
| `--subscription-id <id>` | Azure subscription ID |
|
||||||
--dry-run Show what would be done without making changes
|
| `--resource-group <rg>` | Resource group to scan |
|
||||||
--log-level <level> debug | info | warn | error (default: info)
|
| `--email <email>` | ACME contact email |
|
||||||
--output <format> table | json (scan and status commands)
|
| `--renewal-threshold <days>` | Days before expiry to trigger renewal (default: 30) |
|
||||||
--http <port> Use HTTP-01 challenge on the given port (run and renew only)
|
| `--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
|
### Challenge methods
|
||||||
|
|
||||||
By default `run` and `renew` use **DNS-01** via Azure DNS (requires DNS Zone Contributor role).
|
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 server must be reachable from the internet on that port for the ACME CA to validate ownership.
|
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
|
```sh
|
||||||
# DNS-01 (default)
|
# DNS-01 (default)
|
||||||
azure-acme-provisioner run
|
azure-acme-provisioner run --keyvault-name myvault --subscription-id <id> --email admin@example.com
|
||||||
|
|
||||||
# HTTP-01 on port 80
|
# HTTP-01 on port 80
|
||||||
azure-acme-provisioner run --http 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)
|
# HTTP-01 on a non-privileged port (useful behind a reverse proxy or NAT rule)
|
||||||
azure-acme-provisioner run --http 8080
|
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`.
|
> **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`.
|
||||||
|
|
||||||
### Downloading certificates
|
|
||||||
|
|
||||||
The `download` command fetches the PEM bundle (private key + certificate + chain) from Key Vault and writes it to stdout or a file:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Print to stdout
|
|
||||||
azure-acme-provisioner download api.example.com
|
|
||||||
|
|
||||||
# Write to a file
|
|
||||||
azure-acme-provisioner download api.example.com --output api.example.com.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All configuration is via environment variables. CLI flags override env vars when both are provided.
|
All configuration is via environment variables. CLI flags override env vars when both are provided.
|
||||||
@@ -101,19 +261,21 @@ All configuration is via environment variables. CLI flags override env vars when
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `ACME_KEYVAULT_URL` | Azure Key Vault URL, e.g. `https://myvault.vault.azure.net` |
|
| `ACME_KEYVAULT_URL` | Azure Key Vault URL, e.g. `https://myvault.vault.azure.net` |
|
||||||
| `ACME_SUBSCRIPTION_ID` | Azure subscription ID |
|
| `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 |
|
| `ACME_CONTACT_EMAIL` | Contact email registered with the ACME CA |
|
||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ACME_DNS_ZONES` | all zones in resource groups | Comma-separated list of DNS zone names to restrict scanning |
|
| `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_DIRECTORY_URL` | Let's Encrypt production | ACME directory URL |
|
||||||
| `ACME_RENEWAL_THRESHOLD_DAYS` | `30` | Renew certificates this many days before expiry |
|
| `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_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_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_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_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. |
|
| `ACME_SCHEDULE` | `0 0 2 * * *` | Azure Function timer schedule (cron expression, 6-field format). Only used when deployed as an Azure Function. |
|
||||||
|
|
||||||
@@ -225,7 +387,7 @@ az functionapp config appsettings set \
|
|||||||
--settings \
|
--settings \
|
||||||
"ACME_KEYVAULT_URL=https://<keyvault-name>.vault.azure.net" \
|
"ACME_KEYVAULT_URL=https://<keyvault-name>.vault.azure.net" \
|
||||||
"ACME_SUBSCRIPTION_ID=<subscription-id>" \
|
"ACME_SUBSCRIPTION_ID=<subscription-id>" \
|
||||||
"ACME_RESOURCE_GROUPS=<dns-resource-group>" \
|
"ACME_RESOURCE_GROUP=<dns-resource-group>" \
|
||||||
"ACME_CONTACT_EMAIL=<email>" \
|
"ACME_CONTACT_EMAIL=<email>" \
|
||||||
"ACME_SCHEDULE=0 0 2 * * *"
|
"ACME_SCHEDULE=0 0 2 * * *"
|
||||||
```
|
```
|
||||||
@@ -251,7 +413,7 @@ Create `local.settings.json` at the project root (gitignored) and fill in your v
|
|||||||
"FUNCTIONS_WORKER_RUNTIME": "node",
|
"FUNCTIONS_WORKER_RUNTIME": "node",
|
||||||
"ACME_KEYVAULT_URL": "https://<keyvault-name>.vault.azure.net",
|
"ACME_KEYVAULT_URL": "https://<keyvault-name>.vault.azure.net",
|
||||||
"ACME_SUBSCRIPTION_ID": "<subscription-id>",
|
"ACME_SUBSCRIPTION_ID": "<subscription-id>",
|
||||||
"ACME_RESOURCE_GROUPS": "<dns-resource-group>",
|
"ACME_RESOURCE_GROUP": "<dns-resource-group>",
|
||||||
"ACME_CONTACT_EMAIL": "<email>",
|
"ACME_CONTACT_EMAIL": "<email>",
|
||||||
"ACME_SCHEDULE": "0 0 2 * * *"
|
"ACME_SCHEDULE": "0 0 2 * * *"
|
||||||
}
|
}
|
||||||
@@ -291,7 +453,7 @@ console.log(result);
|
|||||||
|
|
||||||
## Certificate Storage
|
## 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.
|
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.
|
ACME account credentials (private key and account URL) are stored as Key Vault Secrets and reused across runs.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "azure-acme-provisioner",
|
"name": "azure-acme-provisioner",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "azure-acme-provisioner",
|
"name": "azure-acme-provisioner",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/arm-authorization": "^9.0.0",
|
"@azure/arm-authorization": "^9.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "azure-acme-provisioner",
|
"name": "azure-acme-provisioner",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Sławomir Koszewski",
|
"name": "Sławomir Koszewski",
|
||||||
"url": "https://github.com/skoszewski"
|
"url": "https://github.com/skoszewski"
|
||||||
|
|||||||
+33
-40
@@ -22,14 +22,7 @@ program
|
|||||||
function applyOverrides(options: Record<string, unknown>): void {
|
function applyOverrides(options: Record<string, unknown>): void {
|
||||||
if (options['keyvaultUrl']) process.env['ACME_KEYVAULT_URL'] = String(options['keyvaultUrl']);
|
if (options['keyvaultUrl']) process.env['ACME_KEYVAULT_URL'] = String(options['keyvaultUrl']);
|
||||||
if (options['subscriptionId']) process.env['ACME_SUBSCRIPTION_ID'] = String(options['subscriptionId']);
|
if (options['subscriptionId']) process.env['ACME_SUBSCRIPTION_ID'] = String(options['subscriptionId']);
|
||||||
if (options['resourceGroup']) {
|
if (options['resourceGroup']) process.env['ACME_RESOURCE_GROUP'] = String(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['email']) process.env['ACME_CONTACT_EMAIL'] = String(options['email']);
|
||||||
if (options['renewalThreshold']) process.env['ACME_RENEWAL_THRESHOLD_DAYS'] = String(options['renewalThreshold']);
|
if (options['renewalThreshold']) process.env['ACME_RENEWAL_THRESHOLD_DAYS'] = String(options['renewalThreshold']);
|
||||||
if (options['logLevel']) process.env['ACME_LOG_LEVEL'] = String(options['logLevel']);
|
if (options['logLevel']) process.env['ACME_LOG_LEVEL'] = String(options['logLevel']);
|
||||||
@@ -43,27 +36,23 @@ const sharedOptions = (cmd: Command): Command =>
|
|||||||
cmd
|
cmd
|
||||||
.option('--keyvault-name <name>', 'Azure KeyVault name (constructs https://<name>.vault.azure.net)')
|
.option('--keyvault-name <name>', 'Azure KeyVault name (constructs https://<name>.vault.azure.net)')
|
||||||
.option('--keyvault-url <url>', 'Azure KeyVault URL (overrides --keyvault-name; use for sovereign clouds)')
|
.option('--keyvault-url <url>', 'Azure KeyVault URL (overrides --keyvault-name; use for sovereign clouds)')
|
||||||
.option('--keyvault-resource-group <rg>', 'Resource group containing the Key Vault')
|
|
||||||
.option('--subscription-id <id>', 'Azure subscription ID')
|
.option('--subscription-id <id>', 'Azure subscription ID')
|
||||||
.option('--resource-group <rg>', 'Resource group to scan (repeatable)', collect, [])
|
.option('--resource-group <rg>', 'Resource group to scan')
|
||||||
.option('--dns-zone <zone>', 'Restrict to specific DNS zone (repeatable)', collect, [])
|
|
||||||
.option('--email <email>', 'ACME contact email')
|
.option('--email <email>', 'ACME contact email')
|
||||||
.option('--renewal-threshold <days>', 'Days before expiry to renew')
|
.option('--renewal-threshold <days>', 'Days before expiry to renew')
|
||||||
.option('--log-level <level>', 'Log level: debug|info|warn|error');
|
.option('--log-level <level>', 'Log level: debug|info|warn|error');
|
||||||
|
|
||||||
function collect(value: string, previous: string[]): string[] {
|
|
||||||
return [...previous, value];
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedOptions(
|
sharedOptions(
|
||||||
program
|
program
|
||||||
.command('run', { isDefault: true })
|
.command('run [zone] [names...]', { isDefault: true })
|
||||||
.description('Scan DNS zones and issue or renew certificates')
|
.description('Issue or renew certificates. Optionally scope to a zone or specific record names within a zone.')
|
||||||
.option('--http <port>', 'Use HTTP-01 challenge on the given port instead of DNS-01')
|
.option('--http <port>', 'Use HTTP-01 challenge on the given port instead of DNS-01')
|
||||||
.option('--pem', 'Store certificate as PEM bundle instead of PFX (PKCS#12)')
|
.option('--pem', 'Store certificates as PEM bundle instead of PFX (PKCS#12)')
|
||||||
.option('--dry-run', 'Show what would be done without making changes')
|
.option('--dry-run', 'Show what would be done without making changes')
|
||||||
).action(async (options: Record<string, unknown>) => {
|
).action(async (zone: string | undefined, names: string[], options: Record<string, unknown>) => {
|
||||||
applyOverrides(options);
|
applyOverrides(options);
|
||||||
|
if (zone) process.env['ACME_DNS_ZONE'] = zone;
|
||||||
|
if (names.length > 0) process.env['ACME_CERT_NAMES'] = names.join(',');
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const provisioner = new Provisioner(config);
|
const provisioner = new Provisioner(config);
|
||||||
const result = await provisioner.run(Boolean(options['dryRun']));
|
const result = await provisioner.run(Boolean(options['dryRun']));
|
||||||
@@ -72,11 +61,13 @@ sharedOptions(
|
|||||||
|
|
||||||
sharedOptions(
|
sharedOptions(
|
||||||
program
|
program
|
||||||
.command('scan')
|
.command('scan [zone] [names...]')
|
||||||
.description('List all domains tagged for ACME management')
|
.description('List domains in scope. Optionally scope to a zone or specific record names within a zone.')
|
||||||
.option('--output <format>', 'Output format: table|json', 'table')
|
.option('--output <format>', 'Output format: table|json', 'table')
|
||||||
).action(async (options: Record<string, unknown>) => {
|
).action(async (zone: string | undefined, names: string[], options: Record<string, unknown>) => {
|
||||||
applyOverrides(options);
|
applyOverrides(options);
|
||||||
|
if (zone) process.env['ACME_DNS_ZONE'] = zone;
|
||||||
|
if (names.length > 0) process.env['ACME_CERT_NAMES'] = names.join(',');
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const provisioner = new Provisioner(config);
|
const provisioner = new Provisioner(config);
|
||||||
const domains = await provisioner.scan();
|
const domains = await provisioner.scan();
|
||||||
@@ -94,11 +85,13 @@ sharedOptions(
|
|||||||
|
|
||||||
sharedOptions(
|
sharedOptions(
|
||||||
program
|
program
|
||||||
.command('status')
|
.command('status [zone] [names...]')
|
||||||
.description('Show certificate expiry status for all managed domains')
|
.description('Show certificate expiry status. Optionally scope to a zone or specific record names within a zone.')
|
||||||
.option('--output <format>', 'Output format: table|json', 'table')
|
.option('--output <format>', 'Output format: table|json', 'table')
|
||||||
).action(async (options: Record<string, unknown>) => {
|
).action(async (zone: string | undefined, names: string[], options: Record<string, unknown>) => {
|
||||||
applyOverrides(options);
|
applyOverrides(options);
|
||||||
|
if (zone) process.env['ACME_DNS_ZONE'] = zone;
|
||||||
|
if (names.length > 0) process.env['ACME_CERT_NAMES'] = names.join(',');
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const provisioner = new Provisioner(config);
|
const provisioner = new Provisioner(config);
|
||||||
const rows = await provisioner.status();
|
const rows = await provisioner.status();
|
||||||
@@ -119,18 +112,17 @@ sharedOptions(
|
|||||||
|
|
||||||
sharedOptions(
|
sharedOptions(
|
||||||
program
|
program
|
||||||
.command('renew <domain>')
|
.command('renew [zone] [names...]')
|
||||||
.description('Force-renew a certificate for a specific domain, bypassing the renewal threshold')
|
.description('Force-renew certificates, bypassing the renewal threshold. Optionally scope to a zone or specific names.')
|
||||||
.option('--http <port>', 'Use HTTP-01 challenge on the given port instead of DNS-01')
|
.option('--http <port>', 'Use HTTP-01 challenge on the given port instead of DNS-01')
|
||||||
.option('--pem', 'Store certificate as PEM bundle instead of PFX (PKCS#12)')
|
.option('--pem', 'Store certificates as PEM bundle instead of PFX (PKCS#12)')
|
||||||
).action(async (domain: string, options: Record<string, unknown>) => {
|
).action(async (zone: string | undefined, names: string[], options: Record<string, unknown>) => {
|
||||||
applyOverrides(options);
|
applyOverrides(options);
|
||||||
|
if (zone) process.env['ACME_DNS_ZONE'] = zone;
|
||||||
|
if (names.length > 0) process.env['ACME_CERT_NAMES'] = names.join(',');
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
config.renewalThresholdDays = 36500; // effectively "always renew"
|
config.renewalThresholdDays = 36500;
|
||||||
const provisioner = new Provisioner(config);
|
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);
|
const result = await provisioner.run(false);
|
||||||
if (result.errors.length > 0) process.exit(1);
|
if (result.errors.length > 0) process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -141,6 +133,7 @@ sharedOptions(
|
|||||||
.description('Assign Key Vault Certificate User and Secrets User roles to a principal for a domain certificate')
|
.description('Assign Key Vault Certificate User and Secrets User roles to a principal for a domain certificate')
|
||||||
.requiredOption('--principal-id <id>', 'Azure principal ID to assign roles to')
|
.requiredOption('--principal-id <id>', 'Azure principal ID to assign roles to')
|
||||||
.requiredOption('--principal-type <type>', 'Principal type: User | Group | ServicePrincipal (use ServicePrincipal for managed identities)')
|
.requiredOption('--principal-type <type>', 'Principal type: User | Group | ServicePrincipal (use ServicePrincipal for managed identities)')
|
||||||
|
.option('--keyvault-resource-group <rg>', 'Resource group containing the Key Vault')
|
||||||
.option('--dry-run', 'Show what would be assigned without making changes')
|
.option('--dry-run', 'Show what would be assigned without making changes')
|
||||||
).action(async (fqdn: string, options: Record<string, unknown>) => {
|
).action(async (fqdn: string, options: Record<string, unknown>) => {
|
||||||
applyOverrides(options);
|
applyOverrides(options);
|
||||||
@@ -178,14 +171,14 @@ sharedOptions(
|
|||||||
|
|
||||||
sharedOptions(
|
sharedOptions(
|
||||||
program
|
program
|
||||||
.command('download <domain>')
|
.command('download <fqdn>')
|
||||||
.description('Download the PEM bundle (private key + certificate + chain) for a domain')
|
.description('Download the certificate bundle (private key + certificate + chain) for a domain')
|
||||||
.option('--output <file>', 'Write to file instead of stdout')
|
.option('--output <file>', 'Write to file instead of stdout')
|
||||||
).action(async (domain: string, options: Record<string, unknown>) => {
|
).action(async (fqdn: string, options: Record<string, unknown>) => {
|
||||||
applyOverrides(options);
|
applyOverrides(options);
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const provisioner = new Provisioner(config);
|
const provisioner = new Provisioner(config);
|
||||||
const pem = await provisioner.download(domain);
|
const pem = await provisioner.download(fqdn);
|
||||||
if (options['output']) {
|
if (options['output']) {
|
||||||
writeFileSync(String(options['output']), pem, 'utf8');
|
writeFileSync(String(options['output']), pem, 'utf8');
|
||||||
console.log(`Certificate written to ${options['output']}`);
|
console.log(`Certificate written to ${options['output']}`);
|
||||||
@@ -196,14 +189,14 @@ sharedOptions(
|
|||||||
|
|
||||||
sharedOptions(
|
sharedOptions(
|
||||||
program
|
program
|
||||||
.command('convert <domain>')
|
.command('convert <fqdn>')
|
||||||
.description('Convert a stored certificate between PFX (PKCS#12) and PEM format')
|
.description('Convert a stored certificate between PFX (PKCS#12) and PEM format')
|
||||||
.option('--pem', 'Convert to PEM bundle instead of PFX (PKCS#12)')
|
.option('--pem', 'Convert to PEM bundle instead of PFX (PKCS#12)')
|
||||||
).action(async (domain: string, options: Record<string, unknown>) => {
|
).action(async (fqdn: string, options: Record<string, unknown>) => {
|
||||||
applyOverrides(options);
|
applyOverrides(options);
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const provisioner = new Provisioner(config);
|
const provisioner = new Provisioner(config);
|
||||||
await provisioner.convert(domain, config.pfx ? 'pfx' : 'pem');
|
await provisioner.convert(fqdn, config.pfx ? 'pfx' : 'pem');
|
||||||
});
|
});
|
||||||
|
|
||||||
program.parseAsync(process.argv).catch((err: unknown) => {
|
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||||
|
|||||||
+9
-12
@@ -3,8 +3,9 @@ export interface Config {
|
|||||||
acmeDirectoryUrl: string;
|
acmeDirectoryUrl: string;
|
||||||
acmeContactEmail?: string;
|
acmeContactEmail?: string;
|
||||||
subscriptionId?: string;
|
subscriptionId?: string;
|
||||||
resourceGroups: string[];
|
resourceGroup?: string;
|
||||||
dnsZones?: string[];
|
dnsZone?: string;
|
||||||
|
certNames?: string[];
|
||||||
renewalThresholdDays: number;
|
renewalThresholdDays: number;
|
||||||
dnsPropagationWaitSeconds: number;
|
dnsPropagationWaitSeconds: number;
|
||||||
dnsChallengeTtl: number;
|
dnsChallengeTtl: number;
|
||||||
@@ -33,14 +34,9 @@ function optionalEnvInt(name: string, defaultValue: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(): Config {
|
export function loadConfig(): Config {
|
||||||
const resourceGroupsRaw = process.env['ACME_RESOURCE_GROUPS'];
|
const certNamesRaw = process.env['ACME_CERT_NAMES'];
|
||||||
const resourceGroups = resourceGroupsRaw
|
const certNames = certNamesRaw
|
||||||
? resourceGroupsRaw.split(',').map(s => s.trim()).filter(Boolean)
|
? certNamesRaw.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
: [];
|
|
||||||
|
|
||||||
const dnsZonesRaw = process.env['ACME_DNS_ZONES'];
|
|
||||||
const dnsZones = dnsZonesRaw
|
|
||||||
? dnsZonesRaw.split(',').map(s => s.trim()).filter(Boolean)
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const logLevel = optionalEnv('ACME_LOG_LEVEL', 'info');
|
const logLevel = optionalEnv('ACME_LOG_LEVEL', 'info');
|
||||||
@@ -59,8 +55,9 @@ export function loadConfig(): Config {
|
|||||||
),
|
),
|
||||||
acmeContactEmail: process.env['ACME_CONTACT_EMAIL'],
|
acmeContactEmail: process.env['ACME_CONTACT_EMAIL'],
|
||||||
subscriptionId: process.env['ACME_SUBSCRIPTION_ID'],
|
subscriptionId: process.env['ACME_SUBSCRIPTION_ID'],
|
||||||
resourceGroups,
|
resourceGroup: process.env['ACME_RESOURCE_GROUP'],
|
||||||
dnsZones,
|
dnsZone: process.env['ACME_DNS_ZONE'],
|
||||||
|
certNames,
|
||||||
renewalThresholdDays: optionalEnvInt('ACME_RENEWAL_THRESHOLD_DAYS', 30),
|
renewalThresholdDays: optionalEnvInt('ACME_RENEWAL_THRESHOLD_DAYS', 30),
|
||||||
dnsPropagationWaitSeconds: optionalEnvInt('ACME_DNS_PROPAGATION_WAIT', 60),
|
dnsPropagationWaitSeconds: optionalEnvInt('ACME_DNS_PROPAGATION_WAIT', 60),
|
||||||
dnsChallengeTtl: optionalEnvInt('ACME_DNS_CHALLENGE_TTL', 60),
|
dnsChallengeTtl: optionalEnvInt('ACME_DNS_CHALLENGE_TTL', 60),
|
||||||
|
|||||||
+53
-16
@@ -1,4 +1,4 @@
|
|||||||
import { DnsManagementClient } from '@azure/arm-dns';
|
import { Zone, DnsManagementClient } from '@azure/arm-dns';
|
||||||
import { TokenCredential } from '@azure/identity';
|
import { TokenCredential } from '@azure/identity';
|
||||||
import { promises as dnsPromises } from 'node:dns';
|
import { promises as dnsPromises } from 'node:dns';
|
||||||
import { AcmeAuthz, AcmeChallenge, ChallengeHandler } from './challenge.js';
|
import { AcmeAuthz, AcmeChallenge, ChallengeHandler } from './challenge.js';
|
||||||
@@ -20,12 +20,43 @@ export async function scanDnsZones(
|
|||||||
const results: DomainRecord[] = [];
|
const results: DomainRecord[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const rg of config.resourceGroups) {
|
if (config.resourceGroup && config.dnsZone) {
|
||||||
for await (const zone of client.zones.listByResourceGroup(rg)) {
|
const zone = await client.zones.get(config.resourceGroup, config.dnsZone);
|
||||||
|
await processZone(client, config.resourceGroup, zone, config, results, seen);
|
||||||
|
} else if (config.resourceGroup) {
|
||||||
|
for await (const zone of client.zones.listByResourceGroup(config.resourceGroup)) {
|
||||||
if (!zone.name) continue;
|
if (!zone.name) continue;
|
||||||
if (config.dnsZones && !config.dnsZones.includes(zone.name)) continue;
|
await processZone(client, config.resourceGroup, zone, config, results, seen);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for await (const zone of client.zones.list()) {
|
||||||
|
if (!zone.name || !zone.id) continue;
|
||||||
|
const rg = extractResourceGroup(zone.id);
|
||||||
|
if (!rg) continue;
|
||||||
|
await processZone(client, rg, zone, config, results, seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const zoneName = zone.name;
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processZone(
|
||||||
|
client: DnsManagementClient,
|
||||||
|
rg: string,
|
||||||
|
zone: Zone,
|
||||||
|
config: Config,
|
||||||
|
results: DomainRecord[],
|
||||||
|
seen: Set<string>
|
||||||
|
): Promise<void> {
|
||||||
|
const zoneName = zone.name!;
|
||||||
|
|
||||||
|
if (config.certNames && config.certNames.length > 0) {
|
||||||
|
for (const name of config.certNames) {
|
||||||
|
const fqdn = name === '@' ? zoneName : `${name}.${zoneName}`;
|
||||||
|
addDomain(results, seen, fqdn, rg, fqdn.startsWith('*.'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isAcmeTagged(zone.tags)) {
|
if (isAcmeTagged(zone.tags)) {
|
||||||
addDomain(results, seen, zoneName, rg, false);
|
addDomain(results, seen, zoneName, rg, false);
|
||||||
@@ -33,17 +64,16 @@ export async function scanDnsZones(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for await (const record of client.recordSets.listByDnsZone(rg, zoneName)) {
|
for await (const record of client.recordSets.listByDnsZone(rg, zoneName)) {
|
||||||
if (!record.name) continue;
|
if (!record.name || !isAcmeTagged(record.metadata)) continue;
|
||||||
if (!isAcmeTagged(record.metadata)) continue;
|
|
||||||
const recordType = record.type?.split('/').pop();
|
const recordType = record.type?.split('/').pop();
|
||||||
if (recordType !== 'A' && recordType !== 'AAAA' && recordType !== 'CNAME') continue;
|
if (recordType !== 'A' && recordType !== 'AAAA' && recordType !== 'CNAME') continue;
|
||||||
const fqdn = record.name === '@' ? zoneName : `${record.name}.${zoneName}`;
|
const fqdn = record.name === '@' ? zoneName : `${record.name}.${zoneName}`;
|
||||||
addDomain(results, seen, fqdn, rg, false);
|
addDomain(results, seen, fqdn, rg, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
function extractResourceGroup(id: string): string {
|
||||||
|
return id.match(/\/resourceGroups\/([^/]+)\//i)?.[1] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDomain(
|
function addDomain(
|
||||||
@@ -133,15 +163,22 @@ export class DnsChallengeManager implements ChallengeHandler {
|
|||||||
|
|
||||||
private async loadZoneMap(): Promise<Map<string, string>> {
|
private async loadZoneMap(): Promise<Map<string, string>> {
|
||||||
if (this.zoneMap) return this.zoneMap;
|
if (this.zoneMap) return this.zoneMap;
|
||||||
|
|
||||||
this.zoneMap = new Map();
|
this.zoneMap = new Map();
|
||||||
for (const rg of this.config.resourceGroups) {
|
|
||||||
for await (const zone of this.client.zones.listByResourceGroup(rg)) {
|
if (this.config.resourceGroup && this.config.dnsZone) {
|
||||||
if (!zone.name) continue;
|
this.zoneMap.set(this.config.dnsZone, this.config.resourceGroup);
|
||||||
if (this.config.dnsZones && !this.config.dnsZones.includes(zone.name)) continue;
|
} else if (this.config.resourceGroup) {
|
||||||
this.zoneMap.set(zone.name, rg);
|
for await (const zone of this.client.zones.listByResourceGroup(this.config.resourceGroup)) {
|
||||||
|
if (zone.name) this.zoneMap.set(zone.name, this.config.resourceGroup);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for await (const zone of this.client.zones.list()) {
|
||||||
|
if (!zone.name || !zone.id) continue;
|
||||||
|
const rg = extractResourceGroup(zone.id);
|
||||||
|
if (rg) this.zoneMap.set(zone.name, rg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.zoneMap;
|
return this.zoneMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user