diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7e4cfec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +.git/ +*.log +tmp/ +temp/ +local.settings.json +*.pem +*.pfx +*.p12 +*.key diff --git a/.gitignore b/.gitignore index e69de29..1032714 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..754c22d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b37d7bf --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index a7bad8c..6b451d0 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,140 @@ Azure ACME Provisioner is a NodeJS package that provides necessary tools to auto - Stores ACME account information as secrets in Azure KeyVault for secure management. - Stores obtained SSL/TLS certificates in Azure KeyVault for easy access and management. - Automatically scans configured Azure DNS zones to identify records that require certificates (uses the `acme` tag to identify relevant recordsets). + +## Requirements + +- Node.js 24 or later +- Azure subscription with: + - Azure DNS zone(s) with records tagged `acme: true` or `acme: enabled` + - Azure Key Vault instance + - Managed Identity (or service principal) with permissions to read/write Key Vault secrets and certificates, and to manage DNS record sets + +## Installation + +```sh +npm install azure-acme-provisioner +``` + +Or use the CLI directly via `npx`: + +```sh +npx azure-acme-provisioner --help +``` + +## DNS Zone Tagging + +The provisioner discovers domains by scanning Azure DNS zones. Tag a **zone** or individual **A/CNAME recordsets** with `acme: true` to include them: + +- **Zone-level tag** — issues certificates for both the zone apex (`example.com`) and a wildcard (`*.example.com`) as a single SAN order. +- **Recordset-level tag** — issues a certificate for that specific FQDN. + +## CLI Usage + +``` +Commands: + run Scan DNS zones and issue or renew certificates (default) + scan List all domains tagged for ACME management + status Show certificate expiry status for all managed domains + renew Force-renew a certificate for a specific domain + +Common options: + --keyvault-url Azure KeyVault URL + --subscription-id Azure subscription ID + --resource-group Resource group to scan (repeatable) + --dns-zone Restrict to specific DNS zone (repeatable) + --email ACME contact email + --renewal-threshold Days before expiry to renew (default: 30) + --dry-run Show what would be done without making changes + --log-level debug | info | warn | error (default: info) + --output 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= \ + -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. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bb3a1a6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1553 @@ +{ + "name": "azure-acme-provisioner", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "azure-acme-provisioner", + "version": "0.1.0", + "license": "MIT", + "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" + }, + "bin": { + "azure-acme-provisioner": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "rimraf": "^6.1.3", + "typescript": "^6.0.0" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@azure-rest/core-client": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.6.0.tgz", + "integrity": "sha512-iuFKDm8XPzNxPfRjhyU5/xKZmcRDzSuEghXDHHk4MjBV/wFL34GmYVBZnn9wmuoLBeS1qAw9ceMdaeJBPcB1QQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure-rest/core-client/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", + "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@azure/arm-dns": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@azure/arm-dns/-/arm-dns-5.1.0.tgz", + "integrity": "sha512-zEh1FZ6sctbtll11FuxyPl2CBo1PT9iGUuZGXQVSkg+9b3CcLatKQN6ZOZZWugFi86y3EZERIE4IjY5r7vD7Bg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.6.1", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.2.0", + "@azure/core-rest-pipeline": "^1.8.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-auth/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-lro/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", + "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/functions": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.15.0.tgz", + "integrity": "sha512-N5qVZutVvAOFzAtOlxxFgyW7T7TbHLTO8RENUIy8GTfZxyvHllDDyQpZD1RF1CIFS9FLmBqVyFabYMMLmK++fA==", + "license": "MIT", + "dependencies": { + "@azure/functions-extensions-base": "0.2.0", + "cookie": "^0.7.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@azure/functions-extensions-base": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@azure/functions-extensions-base/-/functions-extensions-base-0.2.0.tgz", + "integrity": "sha512-ncCkHBNQYJa93dBIh+toH0v1iSgCzSo9tr94s6SMBe7DPWREkaWh8cq33A5P4rPSFX1g5W+3SPvIzDr/6/VOWQ==", + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/keyvault-certificates": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/@azure/keyvault-certificates/-/keyvault-certificates-4.10.3.tgz", + "integrity": "sha512-PgQLfcgsqVPEN8+HYs9L/9sUxd1qkUQa8csuKPFRq3twiIE5Sm1me+1tmoNp5rPCdYoBJSNzi/FYpldHlDeHDw==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.1", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.20.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.12.0", + "@azure/keyvault-common": "^2.1.0", + "@azure/logger": "^1.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-certificates/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/keyvault-common": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.1.0.tgz", + "integrity": "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-common/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/keyvault-secrets": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@azure/keyvault-secrets/-/keyvault-secrets-4.11.2.tgz", + "integrity": "sha512-ECj/kwZbZlQXj2kfWivSICbKwj6W3chmFhv8qUdauqYnjvZ0hWZBFSsZWux7W2nX3MP49PLUCusXk+hAg3pipg==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.1.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-secrets/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.11.0.tgz", + "integrity": "sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.6.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.2.tgz", + "integrity": "sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.2.tgz", + "integrity": "sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.2", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz", + "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz", + "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz", + "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-rsa": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz", + "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz", + "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pfx": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.0.0.tgz", + "integrity": "sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", + "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/acme-client": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz", + "integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.11.0", + "asn1js": "^3.0.5", + "axios": "^1.7.2", + "debug": "^4.3.5", + "node-forge": "^1.3.1" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/acme-client/node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2bc7c01 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..bbed00b --- /dev/null +++ b/src/cli.ts @@ -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): 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 ', 'Azure KeyVault URL') + .option('--subscription-id ', 'Azure subscription ID') + .option('--resource-group ', 'Resource group to scan (repeatable)', collect, []) + .option('--dns-zone ', 'Restrict to specific DNS zone (repeatable)', collect, []) + .option('--email ', 'ACME contact email') + .option('--renewal-threshold ', 'Days before expiry to renew') + .option('--log-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) => { + 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 ', 'Output format: table|json', 'table') +).action(async (options: Record) => { + 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 ', 'Output format: table|json', 'table') +).action(async (options: Record) => { + 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 ') + .description('Force-renew a certificate for a specific domain, bypassing the renewal threshold') +).action(async (domain: string, options: Record) => { + 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); +}); diff --git a/src/function/host.json b/src/function/host.json new file mode 100644 index 0000000..54df865 --- /dev/null +++ b/src/function/host.json @@ -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)" + } +} diff --git a/src/function/index.ts b/src/function/index.ts new file mode 100644 index 0000000..9efcaf4 --- /dev/null +++ b/src/function/index.ts @@ -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 => { + 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`); + } + }, +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ffb3c7f --- /dev/null +++ b/src/index.ts @@ -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'; diff --git a/src/lib/acme.ts b/src/lib/acme.ts new file mode 100644 index 0000000..927a3d5 --- /dev/null +++ b/src/lib/acme.ts @@ -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 { + 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 { + 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 { + 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 { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..c1841a1 --- /dev/null +++ b/src/lib/config.ts @@ -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'], + }; +} diff --git a/src/lib/dns.ts b/src/lib/dns.ts new file mode 100644 index 0000000..cc85637 --- /dev/null +++ b/src/lib/dns.ts @@ -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 { + const client = new DnsManagementClient(credential, config.subscriptionId); + const results: DomainRecord[] = []; + const seen = new Set(); + + 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, + 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 | 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 { + 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 { + 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 }; + } +} diff --git a/src/lib/keyvault.ts b/src/lib/keyvault.ts new file mode 100644 index 0000000..cb5f484 --- /dev/null +++ b/src/lib/keyvault.ts @@ -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 { + 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 { + await this.secretClient.setSecret(name, value); + } + + async getCertificate(name: string): Promise { + try { + return await this.certClient.getCertificate(name); + } catch (err: unknown) { + if (isNotFound(err)) return undefined; + throw err; + } + } + + async certificateExpiresWithin(name: string, days: number): Promise { + 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 { + 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 + ); +} diff --git a/src/lib/provisioner.ts b/src/lib/provisioner.ts new file mode 100644 index 0000000..544ad48 --- /dev/null +++ b/src/lib/provisioner.ts @@ -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 { + 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 { + return scanDnsZones(this.credential, this.config); + } + + async status(): Promise> { + 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(); + + 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; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..7973b10 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": false, + "declarationMap": false + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..806b487 --- /dev/null +++ b/tsconfig.json @@ -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"] +}