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:
shankar0123
2026-03-26 14:23:46 -04:00
parent 5c38bc3bfe
commit e19c240a79
12 changed files with 325 additions and 50 deletions
+18
View File
@@ -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