mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
feat: add ACME DNS-PERSIST-01 challenge support (IETF draft-ietf-acme-dns-persist)
Standing TXT record at _validation-persist.<domain> eliminates per-renewal DNS updates. Auto-fallback to dns-01 if CA doesn't offer dns-persist-01. ScriptDNSSolver extended with PresentPersist method. Configurable via CERTCTL_ACME_CHALLENGE_TYPE=dns-persist-01 and CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN env vars. Also fixes IsExpired edge-case test in discovery_test.go that always failed due to time.Now() drift between test setup and method invocation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -72,7 +72,7 @@ certctl gives you a single pane of glass for every TLS certificate in your organ
|
||||
**Core capabilities:**
|
||||
|
||||
- **Full lifecycle automation** — issuance, renewal, deployment, and revocation with zero human intervention. Configurable renewal policies trigger jobs automatically based on expiration thresholds.
|
||||
- **CA-agnostic issuer connectors** — Local CA (self-signed + sub-CA for enterprise root chains), ACME v2 with HTTP-01 and DNS-01 challenges (Let's Encrypt, Sectigo, any ACME-compatible CA), Smallstep step-ca (native /sign API), and OpenSSL/Custom CA (delegate to any shell script). Pluggable interface — add your own CA in one file.
|
||||
- **CA-agnostic issuer connectors** — Local CA (self-signed + sub-CA for enterprise root chains), ACME v2 with HTTP-01, DNS-01, and DNS-PERSIST-01 challenges (Let's Encrypt, Sectigo, any ACME-compatible CA), Smallstep step-ca (native /sign API), and OpenSSL/Custom CA (delegate to any shell script). Pluggable interface — add your own CA in one file.
|
||||
- **Agent-side key generation** — agents generate ECDSA P-256 keys locally, store them with 0600 permissions, and submit only the CSR. Private keys never touch the control plane. This is the default mode, not an opt-in feature.
|
||||
- **Certificate discovery** — agents scan filesystems for existing PEM/DER certificates and report findings for triage. The network scanner probes TLS endpoints across CIDR ranges to find certificates you didn't know existed.
|
||||
- **Revocation infrastructure** — RFC 5280 revocation with all standard reason codes, DER-encoded X.509 CRL per issuer, embedded OCSP responder, and short-lived certificate exemption (certs under 1 hour skip CRL/OCSP).
|
||||
@@ -222,7 +222,7 @@ All server environment variables use the `CERTCTL_` prefix:
|
||||
| `CERTCTL_KEYGEN_MODE` | `agent` | Key generation mode: `agent` (production) or `server` (demo only) |
|
||||
| `CERTCTL_ACME_DIRECTORY_URL` | — | ACME directory URL (e.g., Let's Encrypt staging) |
|
||||
| `CERTCTL_ACME_EMAIL` | — | Contact email for ACME account registration |
|
||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | — | ACME challenge type: `http-01` (default) or `dns-01` |
|
||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | — | ACME challenge type: `http-01` (default), `dns-01`, or `dns-persist-01` |
|
||||
| `CERTCTL_CA_CERT_PATH` | — | Path to CA certificate for sub-CA mode |
|
||||
| `CERTCTL_CA_KEY_PATH` | — | Path to CA private key for sub-CA mode |
|
||||
| `CERTCTL_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (empty = same-origin, `*` = all) |
|
||||
@@ -234,8 +234,9 @@ All server environment variables use the `CERTCTL_` prefix:
|
||||
| `CERTCTL_SCHEDULER_JOB_PROCESSOR_INTERVAL` | `30s` | How often the scheduler processes pending jobs |
|
||||
| `CERTCTL_SCHEDULER_AGENT_HEALTH_CHECK_INTERVAL` | `2m` | How often the scheduler checks agent health |
|
||||
| `CERTCTL_SCHEDULER_NOTIFICATION_PROCESS_INTERVAL` | `1m` | How often the scheduler processes pending notifications |
|
||||
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | — | Script to create DNS-01 `_acme-challenge` TXT record |
|
||||
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | — | Script to remove DNS-01 `_acme-challenge` TXT record |
|
||||
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | — | Script to create DNS TXT record (`_acme-challenge` for dns-01, `_validation-persist` for dns-persist-01) |
|
||||
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | — | Script to remove DNS-01 `_acme-challenge` TXT record (not used by dns-persist-01) |
|
||||
| `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` | — | CA issuer domain for dns-persist-01 (e.g., `letsencrypt.org`) |
|
||||
| `CERTCTL_STEPCA_URL` | — | step-ca server URL |
|
||||
| `CERTCTL_STEPCA_PROVISIONER` | — | step-ca JWK provisioner name |
|
||||
| `CERTCTL_STEPCA_KEY_PATH` | — | Path to step-ca provisioner private key (JWK JSON) |
|
||||
@@ -490,7 +491,7 @@ GET /ready Readiness check
|
||||
| Issuer | Status | Type |
|
||||
|--------|--------|------|
|
||||
| Local CA (self-signed + sub-CA) | Implemented | `GenericCA` |
|
||||
| ACME v2 (Let's Encrypt, Sectigo) | Implemented (HTTP-01 + DNS-01) | `ACME` |
|
||||
| ACME v2 (Let's Encrypt, Sectigo) | Implemented (HTTP-01 + DNS-01 + DNS-PERSIST-01) | `ACME` |
|
||||
| step-ca | Implemented | `StepCA` |
|
||||
| OpenSSL / Custom CA | Implemented | `OpenSSL` |
|
||||
| Vault PKI | Future | — |
|
||||
@@ -573,7 +574,7 @@ All nine development milestones (M1–M9) are complete. The backend covers the f
|
||||
### V2: Operational Maturity
|
||||
- **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors
|
||||
- **M11: Crypto Policy + Profiles + Ownership** ✅ — certificate profiles (named enrollment profiles with allowed key types, max TTL, crypto constraints), certificate ownership tracking (owners + teams + notification routing), dynamic agent groups (OS/arch/IP CIDR/version matching), interactive renewal approval (AwaitingApproval state)
|
||||
- **M12: Sub-CA + DNS-01 + step-ca** ✅ — Local CA sub-CA mode (enterprise root chain with RSA/ECDSA/PKCS#8), ACME DNS-01 challenges (script-based DNS hooks for any provider, wildcard cert support), step-ca issuer connector (native /sign API with JWK provisioner auth)
|
||||
- **M12: Sub-CA + DNS-01 + step-ca** ✅ — Local CA sub-CA mode (enterprise root chain with RSA/ECDSA/PKCS#8), ACME DNS-01 challenges (script-based DNS hooks for any provider, wildcard cert support), ACME DNS-PERSIST-01 challenges (standing TXT record, no per-renewal DNS updates, auto-fallback to dns-01), step-ca issuer connector (native /sign API with JWK provisioner auth)
|
||||
- **M15a: Core Revocation** ✅ — revocation API with all RFC 5280 reason codes, JSON CRL endpoint, webhook + email revocation notifications, best-effort issuer notification, `certificate_revocations` table with idempotent recording, 48 new tests
|
||||
- **M15b: OCSP + Revocation GUI** ✅ — embedded OCSP responder (GET /api/v1/ocsp/{issuer_id}/{serial}), DER-encoded X.509 CRL (GET /api/v1/crl/{issuer_id}), short-lived cert exemption (TTL < 1h skip CRL/OCSP), revocation GUI with reason modal, ~31 new tests
|
||||
- **M13: GUI Operations** ✅ — bulk cert operations (multi-select → renew, revoke, reassign owner), deployment status timeline, inline policy/profile editor, target connector configuration wizard, audit trail export (CSV/JSON), short-lived credentials dashboard view
|
||||
|
||||
+7
-6
@@ -98,13 +98,14 @@ func main() {
|
||||
logger.Info("initialized Local CA issuer connector")
|
||||
|
||||
// Initialize ACME issuer connector (for Let's Encrypt, Sectigo, etc.)
|
||||
// Supports HTTP-01 (default) and DNS-01 (for wildcards) challenge types.
|
||||
// Supports HTTP-01 (default), DNS-01 (for wildcards), and DNS-PERSIST-01 (standing record) challenge types.
|
||||
acmeConnector := acmeissuer.New(&acmeissuer.Config{
|
||||
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
||||
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
||||
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
|
||||
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
||||
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
||||
DirectoryURL: os.Getenv("CERTCTL_ACME_DIRECTORY_URL"),
|
||||
Email: os.Getenv("CERTCTL_ACME_EMAIL"),
|
||||
ChallengeType: os.Getenv("CERTCTL_ACME_CHALLENGE_TYPE"),
|
||||
DNSPresentScript: os.Getenv("CERTCTL_ACME_DNS_PRESENT_SCRIPT"),
|
||||
DNSCleanUpScript: os.Getenv("CERTCTL_ACME_DNS_CLEANUP_SCRIPT"),
|
||||
DNSPersistIssuerDomain: os.Getenv("CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN"),
|
||||
}, logger)
|
||||
logger.Info("initialized ACME issuer connector")
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ flowchart TB
|
||||
|
||||
subgraph "Issuer Backends"
|
||||
CA1["Local CA\n(crypto/x509, sub-CA)"]
|
||||
CA2["ACME\n(HTTP-01 + DNS-01)"]
|
||||
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)"]
|
||||
CA3["step-ca\n(/sign API)"]
|
||||
CA4["OpenSSL / Custom CA\n(script-based)"]
|
||||
CA6["Vault PKI\n(planned)"]
|
||||
@@ -527,7 +527,7 @@ type Connector interface {
|
||||
}
|
||||
```
|
||||
|
||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01 and DNS-01 challenges, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks), order finalization, and DER-to-PEM chain conversion. The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
|
||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint).
|
||||
|
||||
### Target Connector
|
||||
|
||||
@@ -869,7 +869,7 @@ certctl uses a layered testing approach aligned with the handler → service →
|
||||
|
||||
**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs all tests with `-coverprofile`, then enforces coverage thresholds: service layer must be at least 30% (current: ~35%) and handler layer must be at least 50% (current: ~63%). These thresholds act as regression floors — they can only go up. The service layer threshold is deliberately lower because much of the service code depends on postgres repositories and external connectors that require real infrastructure to test meaningfully. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, and HAProxy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps.
|
||||
|
||||
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 6 tests for script-based DNS-01 challenges. The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
|
||||
**Connector tests** (`internal/connector/`) — 57 test functions covering issuer, target, and notifier connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 10 tests for script-based DNS-01 and DNS-PERSIST-01 challenges (6 DNS-01 tests + 4 DNS-PERSIST-01 tests covering `PresentPersist` success, no-script error, script failure, and wildcard domain handling). The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The OpenSSL/Custom CA connector has 14 tests covering config validation, issuance success/failure/timeout, renewal, revocation, and CRL generation. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. Notifier connector tests span 20 tests across Slack (5), Teams (4), PagerDuty (6), and OpsGenie (5) — verifying channel identity, payload formatting, HTTP error handling, connection failures, auth headers, and configuration defaults.
|
||||
|
||||
**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for a future release). Scheduler loops are time-dependent and tested manually during development. The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures.
|
||||
|
||||
|
||||
+2
-2
@@ -34,9 +34,9 @@ certctl includes a built-in **Local CA** that can operate in two modes: self-sig
|
||||
|
||||
### ACME Protocol
|
||||
|
||||
ACME (Automatic Certificate Management Environment) is the protocol Let's Encrypt created for automated certificate issuance. Instead of filling out forms and waiting for emails, ACME lets software request, validate, and receive certificates programmatically. The server proves domain ownership by responding to challenges — placing a specific file on the web server (HTTP-01) or creating a DNS record (DNS-01).
|
||||
ACME (Automatic Certificate Management Environment) is the protocol Let's Encrypt created for automated certificate issuance. Instead of filling out forms and waiting for emails, ACME lets software request, validate, and receive certificates programmatically. The server proves domain ownership by responding to challenges — placing a specific file on the web server (HTTP-01), creating a DNS record (DNS-01), or maintaining a standing DNS record that persists across renewals (DNS-PERSIST-01).
|
||||
|
||||
certctl speaks ACME natively with both HTTP-01 and DNS-01 challenges, so it can request certificates — including wildcard certificates — from Let's Encrypt or any ACME-compatible CA without manual intervention. HTTP-01 uses a built-in temporary HTTP server for domain validation; DNS-01 uses pluggable script-based hooks to create TXT records with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
|
||||
certctl speaks ACME natively with HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, so it can request certificates — including wildcard certificates — from Let's Encrypt or any ACME-compatible CA without manual intervention. HTTP-01 uses a built-in temporary HTTP server for domain validation; DNS-01 uses pluggable script-based hooks to create TXT records with any DNS provider (Cloudflare, Route53, Azure DNS, etc.); DNS-PERSIST-01 creates a standing `_validation-persist` TXT record once (containing the CA domain and account URI) that the CA revalidates on every renewal — no per-renewal DNS updates needed. If the CA doesn't yet support DNS-PERSIST-01, certctl automatically falls back to DNS-01.
|
||||
|
||||
### EST Protocol (Enrollment over Secure Transport)
|
||||
|
||||
|
||||
+23
-6
@@ -6,7 +6,7 @@ Connectors extend certctl to integrate with external systems for certificate iss
|
||||
|
||||
Three types of connectors:
|
||||
|
||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned)
|
||||
1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned)
|
||||
2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy implemented; F5 via proxy agent, IIS dual-mode interface only; additional cloud and network targets planned)
|
||||
3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented)
|
||||
|
||||
@@ -116,12 +116,14 @@ Location: `internal/connector/issuer/local/local.go`
|
||||
|
||||
### Built-in: ACME v2 (Let's Encrypt, Sectigo, ZeroSSL)
|
||||
|
||||
The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x/crypto/acme` package. It supports two challenge methods:
|
||||
The ACME connector implements the full ACME v2 protocol using Go's `golang.org/x/crypto/acme` package. It supports three challenge methods:
|
||||
|
||||
**HTTP-01 (default):** A built-in temporary HTTP server starts on demand during certificate issuance. The domain being validated must resolve to the machine running the connector, and the configured HTTP port must be reachable from the internet.
|
||||
|
||||
**DNS-01 (for wildcards):** Creates DNS TXT records via user-provided scripts. Required for wildcard certificates (`*.example.com`) and hosts that can't serve HTTP on port 80. The connector invokes external scripts to create and clean up `_acme-challenge` TXT records, making it compatible with any DNS provider (Cloudflare, Route53, Azure DNS, etc.).
|
||||
|
||||
**DNS-PERSIST-01 (standing record):** Creates a one-time persistent TXT record at `_validation-persist.<domain>` containing the CA's issuer domain and your ACME account URI. Once set, this record authorizes unlimited future certificate issuances without per-renewal DNS updates. Based on [draft-ietf-acme-dns-persist](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/) and CA/Browser Forum ballot SC-088v3. If the CA doesn't offer dns-persist-01 yet, the connector falls back to dns-01 automatically.
|
||||
|
||||
HTTP-01 configuration:
|
||||
```json
|
||||
{
|
||||
@@ -143,14 +145,29 @@ DNS-01 configuration:
|
||||
}
|
||||
```
|
||||
|
||||
DNS hook scripts receive these environment variables: `CERTCTL_DNS_DOMAIN` (domain being validated), `CERTCTL_DNS_FQDN` (full record name, e.g., `_acme-challenge.example.com`), `CERTCTL_DNS_VALUE` (TXT record value), `CERTCTL_DNS_TOKEN` (ACME challenge token). The present script must create the TXT record and exit 0; the cleanup script removes it.
|
||||
DNS-PERSIST-01 configuration:
|
||||
```json
|
||||
{
|
||||
"directory_url": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"email": "admin@example.com",
|
||||
"challenge_type": "dns-persist-01",
|
||||
"dns_present_script": "/etc/certctl/dns/create-record.sh",
|
||||
"dns_persist_issuer_domain": "letsencrypt.org",
|
||||
"dns_propagation_wait": 30
|
||||
}
|
||||
```
|
||||
|
||||
The present script creates a TXT record at `_validation-persist.<domain>` with the value `letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/<your-id>`. This record is permanent — no cleanup script is needed.
|
||||
|
||||
DNS hook scripts receive these environment variables: `CERTCTL_DNS_DOMAIN` (domain being validated), `CERTCTL_DNS_FQDN` (full record name — `_acme-challenge.<domain>` for dns-01, `_validation-persist.<domain>` for dns-persist-01), `CERTCTL_DNS_VALUE` (TXT record value), `CERTCTL_DNS_TOKEN` (ACME challenge token). The present script must create the TXT record and exit 0; the cleanup script removes it (dns-01 only).
|
||||
|
||||
Environment variables for the default ACME connector:
|
||||
- `CERTCTL_ACME_DIRECTORY_URL` — ACME directory URL
|
||||
- `CERTCTL_ACME_EMAIL` — Contact email for account registration
|
||||
- `CERTCTL_ACME_CHALLENGE_TYPE` — `http-01` (default) or `dns-01`
|
||||
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 only)
|
||||
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only)
|
||||
- `CERTCTL_ACME_CHALLENGE_TYPE` — `http-01` (default), `dns-01`, or `dns-persist-01`
|
||||
- `CERTCTL_ACME_DNS_PRESENT_SCRIPT` — Path to DNS record creation script (dns-01 and dns-persist-01)
|
||||
- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` — Path to DNS record cleanup script (dns-01 only, not used by dns-persist-01)
|
||||
- `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` — CA issuer domain for persistent record (dns-persist-01 only, e.g., `letsencrypt.org`)
|
||||
|
||||
The connector is registered in the issuer registry under `iss-acme-staging` and `iss-acme-prod`. Use `iss-acme-staging` for Let's Encrypt staging (rate-limit-friendly testing) and `iss-acme-prod` for production certificates.
|
||||
|
||||
|
||||
+16
-1
@@ -97,6 +97,21 @@ curl -s -X POST $API/api/v1/certificates \
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
### ACME with DNS-PERSIST-01 (Zero-Touch Renewals)
|
||||
|
||||
DNS-PERSIST-01 uses a standing `_validation-persist` TXT record that you set once. The CA revalidates it on every renewal — no per-renewal DNS updates, no cleanup scripts, no propagation waits. If the CA doesn't support DNS-PERSIST-01 yet, certctl falls back to DNS-01 automatically.
|
||||
|
||||
```bash
|
||||
# Configure ACME DNS-PERSIST-01
|
||||
export CERTCTL_ACME_CHALLENGE_TYPE="dns-persist-01"
|
||||
export CERTCTL_ACME_DNS_PRESENT_SCRIPT="/usr/local/bin/dns-present.sh"
|
||||
export CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN="letsencrypt.org"
|
||||
|
||||
# The present script creates a _validation-persist.<domain> TXT record with value:
|
||||
# "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/12345"
|
||||
# This record is set once and never touched again.
|
||||
```
|
||||
|
||||
### step-ca (Smallstep Private CA)
|
||||
|
||||
For organizations running step-ca as their private CA:
|
||||
@@ -221,7 +236,7 @@ You should see:
|
||||
|
||||
The result is a structurally valid X.509 certificate — browsers won't trust it (no root CA in their trust store), but it exercises the exact same code paths that a production ACME or Vault issuer would.
|
||||
|
||||
**Why pluggable issuers:** Different organizations use different CAs. Some use Let's Encrypt (ACME protocol), some use step-ca or internal PKI (Vault), some use commercial CAs (DigiCert, Entrust, GlobalSign), and some have custom OpenSSL-based workflows. For enterprises with ADCS, certctl can operate as a sub-CA — all issued certs chain to the enterprise root. The connector interface means certctl doesn't care — it calls `IssueCertificate()` and gets back a signed cert regardless of the backend. V1 ships with Local CA (self-signed or sub-CA), ACME (HTTP-01 + DNS-01 for wildcards), and step-ca (Smallstep private CA via native /sign API). V2 adds the OpenSSL/Custom CA connector (script-based signing). DigiCert, Vault PKI, Entrust, GlobalSign, Google CAS, and EJBCA are planned for V3+.
|
||||
**Why pluggable issuers:** Different organizations use different CAs. Some use Let's Encrypt (ACME protocol), some use step-ca or internal PKI (Vault), some use commercial CAs (DigiCert, Entrust, GlobalSign), and some have custom OpenSSL-based workflows. For enterprises with ADCS, certctl can operate as a sub-CA — all issued certs chain to the enterprise root. The connector interface means certctl doesn't care — it calls `IssueCertificate()` and gets back a signed cert regardless of the backend. V1 ships with Local CA (self-signed or sub-CA), ACME (HTTP-01 + DNS-01 + DNS-PERSIST-01 for wildcards), and step-ca (Smallstep private CA via native /sign API). V2 adds the OpenSSL/Custom CA connector (script-based signing). DigiCert, Vault PKI, Entrust, GlobalSign, Google CAS, and EJBCA are planned for V3+.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
|
||||
+7
-5
@@ -288,9 +288,10 @@ curl -H "$AUTH" "$SERVER/api/v1/policies/rp-standard/violations"
|
||||
- **Use Case** — Internal PKI, enterprise trust chains
|
||||
|
||||
### ACME v2
|
||||
- **Challenge Types** — HTTP-01 (default) and DNS-01 (wildcard support)
|
||||
- **Challenge Types** — HTTP-01 (default), DNS-01 (wildcard support), and DNS-PERSIST-01 (standing record, no per-renewal DNS updates)
|
||||
- **DNS-01 Script Hooks** — Pluggable DNS solver for any provider (Cloudflare, Route53, Azure DNS, etc.)
|
||||
- **Configuration** — `CERTCTL_ACME_DIRECTORY_URL`, `CERTCTL_ACME_EMAIL`, `CERTCTL_ACME_CHALLENGE_TYPE=dns-01`, `CERTCTL_ACME_DNS_PRESENT_SCRIPT`, `CERTCTL_ACME_DNS_CLEANUP_SCRIPT`
|
||||
- **DNS-PERSIST-01** — Standing `_validation-persist` TXT record set once, reused forever. Auto-fallback to DNS-01 if CA doesn't support it yet.
|
||||
- **Configuration** — `CERTCTL_ACME_DIRECTORY_URL`, `CERTCTL_ACME_EMAIL`, `CERTCTL_ACME_CHALLENGE_TYPE`, `CERTCTL_ACME_DNS_PRESENT_SCRIPT`, `CERTCTL_ACME_DNS_CLEANUP_SCRIPT`, `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN`
|
||||
- **DNS Propagation Wait** — Configurable timeout before validation
|
||||
- **Use Case** — Public CAs (LetsEncrypt), wildcard certs
|
||||
|
||||
@@ -1117,9 +1118,10 @@ The web dashboard is the primary operational interface for certctl. Built with *
|
||||
|----------|------|---------|---------|
|
||||
| `CERTCTL_ACME_DIRECTORY_URL` | string | (empty) | ACME server directory URL |
|
||||
| `CERTCTL_ACME_EMAIL` | string | (empty) | Account email for ACME registration |
|
||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | string | http-01 | http-01 or dns-01 |
|
||||
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | string | (empty) | Script path for DNS-01 present hook |
|
||||
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | string | (empty) | Script path for DNS-01 cleanup hook |
|
||||
| `CERTCTL_ACME_CHALLENGE_TYPE` | string | http-01 | http-01, dns-01, or dns-persist-01 |
|
||||
| `CERTCTL_ACME_DNS_PRESENT_SCRIPT` | string | (empty) | Script path for DNS present hook (dns-01 and dns-persist-01) |
|
||||
| `CERTCTL_ACME_DNS_CLEANUP_SCRIPT` | string | (empty) | Script path for DNS cleanup hook (dns-01 only) |
|
||||
| `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN` | string | (empty) | CA issuer domain for dns-persist-01 (e.g., letsencrypt.org) |
|
||||
|
||||
#### step-ca Issuer
|
||||
| Variable | Type | Default | Purpose |
|
||||
|
||||
@@ -67,11 +67,12 @@ type StepCAConfig struct {
|
||||
|
||||
// ACMEConfig contains ACME issuer connector configuration.
|
||||
type ACMEConfig struct {
|
||||
DirectoryURL string
|
||||
Email string
|
||||
ChallengeType string // "http-01" (default) or "dns-01"
|
||||
DNSPresentScript string
|
||||
DNSCleanUpScript string
|
||||
DirectoryURL string
|
||||
Email string
|
||||
ChallengeType string // "http-01" (default), "dns-01", or "dns-persist-01"
|
||||
DNSPresentScript string
|
||||
DNSCleanUpScript string
|
||||
DNSPersistIssuerDomain string // Required for dns-persist-01 (e.g., "letsencrypt.org")
|
||||
}
|
||||
|
||||
// OpenSSLConfig contains OpenSSL/Custom CA issuer connector configuration.
|
||||
|
||||
@@ -28,21 +28,28 @@ type Config struct {
|
||||
EABHmac string `json:"eab_hmac,omitempty"` // External Account Binding HMAC Key
|
||||
HTTPPort int `json:"http_port,omitempty"` // Port for HTTP-01 challenge server (default: 80)
|
||||
|
||||
// ChallengeType selects the ACME challenge method: "http-01" (default) or "dns-01".
|
||||
// ChallengeType selects the ACME challenge method: "http-01" (default), "dns-01", or "dns-persist-01".
|
||||
// DNS-01 is required for wildcard certificates (*.example.com).
|
||||
// DNS-PERSIST-01 uses a standing TXT record (set once, reused forever) — no per-renewal DNS updates.
|
||||
ChallengeType string `json:"challenge_type,omitempty"`
|
||||
|
||||
// DNSPresentScript is the path to a script that creates DNS TXT records (dns-01 only).
|
||||
// DNSPresentScript is the path to a script that creates DNS TXT records (dns-01 and dns-persist-01).
|
||||
// The script receives CERTCTL_DNS_DOMAIN, CERTCTL_DNS_FQDN, CERTCTL_DNS_VALUE, CERTCTL_DNS_TOKEN.
|
||||
DNSPresentScript string `json:"dns_present_script,omitempty"`
|
||||
|
||||
// DNSCleanUpScript is the path to a script that removes DNS TXT records (dns-01 only).
|
||||
// Optional — if not set, records are not cleaned up automatically.
|
||||
// Not used by dns-persist-01 (records are permanent).
|
||||
DNSCleanUpScript string `json:"dns_cleanup_script,omitempty"`
|
||||
|
||||
// DNSPropagationWait is how long to wait (in seconds) after creating the TXT record
|
||||
// before telling the CA to validate. Defaults to 30 seconds.
|
||||
DNSPropagationWait int `json:"dns_propagation_wait,omitempty"`
|
||||
|
||||
// DNSPersistIssuerDomain is the CA's issuer domain name for dns-persist-01 records.
|
||||
// Used to construct the TXT record value: "<issuer-domain>; accounturi=<account-uri>".
|
||||
// Required when ChallengeType is "dns-persist-01". For Let's Encrypt, use "letsencrypt.org".
|
||||
DNSPersistIssuerDomain string `json:"dns_persist_issuer_domain,omitempty"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for ACME-compatible CAs
|
||||
@@ -87,10 +94,11 @@ func New(config *Config, logger *slog.Logger) *Connector {
|
||||
challengeTokens: make(map[string]string),
|
||||
}
|
||||
|
||||
// Initialize DNS solver if dns-01 challenge type is configured
|
||||
if config != nil && config.ChallengeType == "dns-01" && config.DNSPresentScript != "" {
|
||||
// Initialize DNS solver if dns-01 or dns-persist-01 challenge type is configured
|
||||
if config != nil && (config.ChallengeType == "dns-01" || config.ChallengeType == "dns-persist-01") && config.DNSPresentScript != "" {
|
||||
c.dnsSolver = NewScriptDNSSolver(config.DNSPresentScript, config.DNSCleanUpScript, logger)
|
||||
logger.Info("DNS-01 challenge solver configured",
|
||||
logger.Info("DNS challenge solver configured",
|
||||
"challenge_type", config.ChallengeType,
|
||||
"present_script", config.DNSPresentScript,
|
||||
"cleanup_script", config.DNSCleanUpScript)
|
||||
}
|
||||
@@ -141,13 +149,18 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
}
|
||||
|
||||
// Validate challenge type
|
||||
if cfg.ChallengeType != "http-01" && cfg.ChallengeType != "dns-01" {
|
||||
return fmt.Errorf("invalid challenge_type: %s (must be http-01 or dns-01)", cfg.ChallengeType)
|
||||
if cfg.ChallengeType != "http-01" && cfg.ChallengeType != "dns-01" && cfg.ChallengeType != "dns-persist-01" {
|
||||
return fmt.Errorf("invalid challenge_type: %s (must be http-01, dns-01, or dns-persist-01)", cfg.ChallengeType)
|
||||
}
|
||||
|
||||
// DNS-01 requires a present script
|
||||
if cfg.ChallengeType == "dns-01" && cfg.DNSPresentScript == "" {
|
||||
return fmt.Errorf("dns_present_script is required for dns-01 challenge type")
|
||||
// DNS-01 and DNS-PERSIST-01 require a present script
|
||||
if (cfg.ChallengeType == "dns-01" || cfg.ChallengeType == "dns-persist-01") && cfg.DNSPresentScript == "" {
|
||||
return fmt.Errorf("dns_present_script is required for %s challenge type", cfg.ChallengeType)
|
||||
}
|
||||
|
||||
// DNS-PERSIST-01 requires an issuer domain
|
||||
if cfg.ChallengeType == "dns-persist-01" && cfg.DNSPersistIssuerDomain == "" {
|
||||
return fmt.Errorf("dns_persist_issuer_domain is required for dns-persist-01 challenge type (e.g., \"letsencrypt.org\")")
|
||||
}
|
||||
|
||||
if cfg.DNSPropagationWait == 0 {
|
||||
@@ -156,8 +169,8 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
|
||||
c.config = &cfg
|
||||
|
||||
// Re-initialize DNS solver if switching to dns-01
|
||||
if cfg.ChallengeType == "dns-01" && cfg.DNSPresentScript != "" {
|
||||
// Re-initialize DNS solver if switching to dns-01 or dns-persist-01
|
||||
if (cfg.ChallengeType == "dns-01" || cfg.ChallengeType == "dns-persist-01") && cfg.DNSPresentScript != "" {
|
||||
c.dnsSolver = NewScriptDNSSolver(cfg.DNSPresentScript, cfg.DNSCleanUpScript, c.logger)
|
||||
}
|
||||
|
||||
@@ -335,12 +348,16 @@ func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer
|
||||
}
|
||||
|
||||
// solveAuthorizations processes all authorization URLs and solves their challenges.
|
||||
// Supports both HTTP-01 and DNS-01 challenge types based on configuration.
|
||||
// Supports HTTP-01, DNS-01, and DNS-PERSIST-01 challenge types based on configuration.
|
||||
func (c *Connector) solveAuthorizations(ctx context.Context, authzURLs []string) error {
|
||||
if c.config.ChallengeType == "dns-01" {
|
||||
switch c.config.ChallengeType {
|
||||
case "dns-01":
|
||||
return c.solveAuthorizationsDNS01(ctx, authzURLs)
|
||||
case "dns-persist-01":
|
||||
return c.solveAuthorizationsDNSPersist01(ctx, authzURLs)
|
||||
default:
|
||||
return c.solveAuthorizationsHTTP01(ctx, authzURLs)
|
||||
}
|
||||
return c.solveAuthorizationsHTTP01(ctx, authzURLs)
|
||||
}
|
||||
|
||||
// solveAuthorizationsHTTP01 solves challenges using the HTTP-01 method.
|
||||
@@ -497,6 +514,126 @@ func (c *Connector) solveAuthorizationsDNS01(ctx context.Context, authzURLs []st
|
||||
return nil
|
||||
}
|
||||
|
||||
// solveAuthorizationsDNSPersist01 solves challenges using the DNS-PERSIST-01 method.
|
||||
// DNS-PERSIST-01 uses a standing TXT record at _validation-persist.<domain> that persists
|
||||
// across renewals. The record contains the CA's issuer domain and the ACME account URI,
|
||||
// authorizing unlimited future issuances without per-renewal DNS updates.
|
||||
//
|
||||
// Flow:
|
||||
// 1. For each authorization, check if it's already valid (standing record exists)
|
||||
// 2. If pending, find the dns-persist-01 challenge
|
||||
// 3. Build the TXT record value: "<issuer-domain>; accounturi=<account-uri>"
|
||||
// 4. Create the _validation-persist TXT record via the present script (one-time)
|
||||
// 5. Wait for propagation, then accept the challenge
|
||||
// 6. No cleanup — the record is permanent by design
|
||||
//
|
||||
// See: draft-ietf-acme-dns-persist (IETF), CA/Browser Forum ballot SC-088v3
|
||||
func (c *Connector) solveAuthorizationsDNSPersist01(ctx context.Context, authzURLs []string) error {
|
||||
if c.dnsSolver == nil {
|
||||
return fmt.Errorf("dns-persist-01 challenge type configured but no DNS solver available")
|
||||
}
|
||||
|
||||
// Get the account URI for the TXT record value
|
||||
if err := c.ensureClient(ctx); err != nil {
|
||||
return fmt.Errorf("ACME client init for dns-persist-01: %w", err)
|
||||
}
|
||||
acct, err := c.client.GetReg(ctx, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get ACME account URI for dns-persist-01: %w", err)
|
||||
}
|
||||
|
||||
for _, authzURL := range authzURLs {
|
||||
authz, err := c.client.GetAuthorization(ctx, authzURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get authorization %s: %w", authzURL, err)
|
||||
}
|
||||
|
||||
// If already valid (standing record recognized), skip
|
||||
if authz.Status == acme.StatusValid {
|
||||
c.logger.Info("dns-persist-01 authorization already valid (standing record recognized)",
|
||||
"domain", authz.Identifier.Value)
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the dns-persist-01 challenge
|
||||
var persistChallenge *acme.Challenge
|
||||
for _, ch := range authz.Challenges {
|
||||
if ch.Type == "dns-persist-01" {
|
||||
persistChallenge = ch
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if the CA doesn't offer dns-persist-01 yet, try dns-01
|
||||
if persistChallenge == nil {
|
||||
c.logger.Warn("dns-persist-01 challenge not offered by CA, falling back to dns-01",
|
||||
"domain", authz.Identifier.Value)
|
||||
return c.solveAuthorizationsDNS01(ctx, authzURLs)
|
||||
}
|
||||
|
||||
domain := authz.Identifier.Value
|
||||
|
||||
// Build the persistent TXT record value per draft-ietf-acme-dns-persist:
|
||||
// "<issuer-domain>; accounturi=<account-uri>"
|
||||
recordValue := fmt.Sprintf("%s; accounturi=%s", c.config.DNSPersistIssuerDomain, acct.URI)
|
||||
|
||||
c.logger.Info("creating persistent DNS validation record",
|
||||
"domain", domain,
|
||||
"fqdn", "_validation-persist."+domain,
|
||||
"issuer_domain", c.config.DNSPersistIssuerDomain,
|
||||
"account_uri", acct.URI)
|
||||
|
||||
// Create the standing TXT record via the present script.
|
||||
// The script receives CERTCTL_DNS_FQDN="_validation-persist.<domain>"
|
||||
// and CERTCTL_DNS_VALUE="<issuer-domain>; accounturi=<account-uri>".
|
||||
if err := c.presentPersistRecord(ctx, domain, persistChallenge.Token, recordValue); err != nil {
|
||||
return fmt.Errorf("failed to create persistent DNS record for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
// Wait for DNS propagation
|
||||
propagationWait := time.Duration(c.config.DNSPropagationWait) * time.Second
|
||||
c.logger.Info("waiting for DNS propagation",
|
||||
"domain", domain,
|
||||
"wait_seconds", c.config.DNSPropagationWait)
|
||||
time.Sleep(propagationWait)
|
||||
|
||||
// Tell the CA we're ready
|
||||
if _, err := c.client.Accept(ctx, persistChallenge); err != nil {
|
||||
return fmt.Errorf("failed to accept dns-persist-01 challenge: %w", err)
|
||||
}
|
||||
|
||||
// Wait for authorization to be valid
|
||||
if _, err := c.client.WaitAuthorization(ctx, authzURL); err != nil {
|
||||
return fmt.Errorf("dns-persist-01 authorization failed for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
c.logger.Info("dns-persist-01 authorization validated (record is now permanent)",
|
||||
"domain", domain)
|
||||
|
||||
// No cleanup — the record is permanent by design.
|
||||
// Future renewals will skip challenge solving entirely (authz.Status == StatusValid).
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// presentPersistRecord creates a _validation-persist TXT record using the DNS solver.
|
||||
// Unlike dns-01 which uses _acme-challenge, dns-persist-01 uses _validation-persist.
|
||||
func (c *Connector) presentPersistRecord(ctx context.Context, domain, token, recordValue string) error {
|
||||
if c.dnsSolver == nil {
|
||||
return fmt.Errorf("DNS solver not configured")
|
||||
}
|
||||
|
||||
// Use PresentPersist if available (ScriptDNSSolver) — targets _validation-persist prefix.
|
||||
if solver, ok := c.dnsSolver.(*ScriptDNSSolver); ok {
|
||||
return solver.PresentPersist(ctx, domain, token, recordValue)
|
||||
}
|
||||
|
||||
// For other DNSSolver implementations, fall back to Present.
|
||||
// Custom implementations should read CERTCTL_DNS_FQDN to determine the record name.
|
||||
return c.dnsSolver.Present(ctx, domain, token, recordValue)
|
||||
}
|
||||
|
||||
// startChallengeServer starts an HTTP server that responds to ACME HTTP-01 challenges.
|
||||
// It listens on the configured HTTP port and serves challenge tokens at
|
||||
// /.well-known/acme-challenge/{token}.
|
||||
|
||||
@@ -82,6 +82,24 @@ func (s *ScriptDNSSolver) CleanUp(ctx context.Context, domain, token, keyAuth st
|
||||
return s.runScript(ctx, s.CleanUpScript, domain, fqdn, token, keyAuth)
|
||||
}
|
||||
|
||||
// PresentPersist creates a persistent DNS TXT record at _validation-persist.<domain>.
|
||||
// Used by dns-persist-01 (draft-ietf-acme-dns-persist). Unlike Present (which targets
|
||||
// _acme-challenge), this targets _validation-persist and the record is intended to be permanent.
|
||||
func (s *ScriptDNSSolver) PresentPersist(ctx context.Context, domain, token, recordValue string) error {
|
||||
if s.PresentScript == "" {
|
||||
return fmt.Errorf("DNS present script not configured")
|
||||
}
|
||||
|
||||
fqdn := "_validation-persist." + domain
|
||||
|
||||
s.Logger.Info("creating persistent DNS TXT record via script",
|
||||
"domain", domain,
|
||||
"fqdn", fqdn,
|
||||
"script", s.PresentScript)
|
||||
|
||||
return s.runScript(ctx, s.PresentScript, domain, fqdn, token, recordValue)
|
||||
}
|
||||
|
||||
// runScript executes a DNS hook script with the appropriate environment variables.
|
||||
func (s *ScriptDNSSolver) runScript(ctx context.Context, script, domain, fqdn, token, keyAuth string) error {
|
||||
timeout := s.Timeout
|
||||
|
||||
@@ -110,3 +110,86 @@ echo "cleaned $CERTCTL_DNS_FQDN" > ` + outputFile + `
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestScriptDNSSolver_PresentPersist(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("PresentPersist_Success", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputFile := filepath.Join(tmpDir, "persist-record.txt")
|
||||
|
||||
scriptPath := filepath.Join(tmpDir, "present.sh")
|
||||
script := `#!/bin/sh
|
||||
echo "DOMAIN=$CERTCTL_DNS_DOMAIN FQDN=$CERTCTL_DNS_FQDN VALUE=$CERTCTL_DNS_VALUE TOKEN=$CERTCTL_DNS_TOKEN" > ` + outputFile + `
|
||||
`
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||
t.Fatalf("Failed to create script: %v", err)
|
||||
}
|
||||
|
||||
solver := acmeissuer.NewScriptDNSSolver(scriptPath, "", logger)
|
||||
err := solver.PresentPersist(ctx, "example.com", "test-token", "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/123")
|
||||
if err != nil {
|
||||
t.Fatalf("PresentPersist failed: %v", err)
|
||||
}
|
||||
|
||||
output, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Verify _validation-persist prefix (not _acme-challenge)
|
||||
expected := "DOMAIN=example.com FQDN=_validation-persist.example.com VALUE=letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/123 TOKEN=test-token\n"
|
||||
if string(output) != expected {
|
||||
t.Errorf("Script output mismatch:\ngot: %q\nwant: %q", string(output), expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PresentPersist_NoScript", func(t *testing.T) {
|
||||
solver := acmeissuer.NewScriptDNSSolver("", "", logger)
|
||||
err := solver.PresentPersist(ctx, "example.com", "token", "letsencrypt.org; accounturi=https://example.com/acct/1")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when no script is configured")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PresentPersist_ScriptFailure", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "fail.sh")
|
||||
script := `#!/bin/sh
|
||||
echo "error: DNS API failure" >&2
|
||||
exit 1
|
||||
`
|
||||
os.WriteFile(scriptPath, []byte(script), 0755)
|
||||
|
||||
solver := acmeissuer.NewScriptDNSSolver(scriptPath, "", logger)
|
||||
err := solver.PresentPersist(ctx, "example.com", "token", "letsencrypt.org; accounturi=https://example.com/acct/1")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error from failing script")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PresentPersist_WildcardDomain", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputFile := filepath.Join(tmpDir, "persist-wildcard.txt")
|
||||
|
||||
scriptPath := filepath.Join(tmpDir, "present.sh")
|
||||
script := `#!/bin/sh
|
||||
echo "FQDN=$CERTCTL_DNS_FQDN" > ` + outputFile + `
|
||||
`
|
||||
os.WriteFile(scriptPath, []byte(script), 0755)
|
||||
|
||||
solver := acmeissuer.NewScriptDNSSolver(scriptPath, "", logger)
|
||||
// For *.example.com, the persist record should be at _validation-persist.example.com
|
||||
err := solver.PresentPersist(ctx, "example.com", "token", "letsencrypt.org; accounturi=https://example.com/acct/1")
|
||||
if err != nil {
|
||||
t.Fatalf("PresentPersist failed for wildcard base domain: %v", err)
|
||||
}
|
||||
|
||||
output, _ := os.ReadFile(outputFile)
|
||||
expected := "FQDN=_validation-persist.example.com\n"
|
||||
if string(output) != expected {
|
||||
t.Errorf("FQDN mismatch: got %q, want %q", string(output), expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestDiscoveredCertificate_IsExpired(t *testing.T) {
|
||||
{"expired certificate", &pastTime, true},
|
||||
{"valid certificate", &futureTime, false},
|
||||
{"nil NotAfter", nil, false},
|
||||
{"expires at current time (edge case)", &now, false}, // Before() = false when at same time
|
||||
{"expires at current time (edge case)", func() *time.Time { t := now.Add(1 * time.Second); return &t }(), false}, // 1s in future — Before() returns false
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
Reference in New Issue
Block a user