Files
shankar0123 5d5bd02f3e refactor(config): extract ACME family to its own file (Phase 9, 2 of N)
Continuing Phase 9 ARCH-M2 closure. Sprint 1 (commit 45ddcb75)
extracted NotifierConfig as the smallest-possible pattern
demonstration. This sprint extracts a larger, equally clean family:
the three ACME-related config types.

What moved
==========
  internal/config/acme.go (new, 262 lines including BSL header +
                           Phase 9 doc-comment + `import "time"` +
                           the three structs verbatim)

  - ACMEConfig                 (68 lines, the consumer/issuer side:
                                we talk UP to Let's Encrypt / pebble)
  - ACMEServerConfig           (119 lines, the server side: we ARE
                                the ACME server, RFC 8555 + RFC 9773)
  - ACMEServerDirectoryMeta    (20 lines, the directory `meta` block)

These types form a single logical concern (everything ACME) and
were already adjacent in config.go (lines 587-812 pre-split). The
internal cross-reference is local: ACMEServerConfig.DirectoryMeta is
typed as ACMEServerDirectoryMeta. Both still live in package
`config`, so the field type continues to resolve without an import.

Why this sprint specifically
============================
- Clean boundary: zero helper-function dependencies on Load(). Each
  field is read directly in Load() via getEnv*() helpers; those
  helpers stay in config.go. The struct definitions are pure
  data-shape and move cleanly.
- High-LOC win: 227 lines deleted from config.go in one cut. After
  Sprint 1 (-68) + Sprint 2 (-227 from this commit) the file dropped
  from 3403 to 3108 LOC — already ~9% smaller than its pre-Phase-9
  size with two clean PRs.
- Mirrors the Phase 4 + Phase 6 prior art: ACME-related code already
  has its own subpackages (internal/api/handler/acme.go,
  internal/connector/issuer/acme/, internal/api/acme/) so a config
  sibling keeps the convention consistent.

What stayed in config.go
=========================
- `ErrACMEInsecureWithoutAck` sentinel (lines 35-46) — still needed by
  Load()'s validation pass, lives in the config.go top-of-file
  sentinel block alongside `ErrAgentBootstrapTokenRequired` and
  `ErrDemoModeAckExpired`. These three sentinels are tied to
  Validate()'s behavior, not to the ACME config struct itself.
- All the `getEnv*()` helpers that ACME fields use to load — they're
  shared across every config struct.
- The Config{}.ACME and Config{}.ACMEServer field declarations on
  the master Config type — those are part of the Config struct
  surface and stay until the Config split (Sprint 6 or later).

Public-surface invariant
========================
Every type, field, and doc-comment is byte-identical to pre-split.
Package stays `config`. Every caller's `config.ACMEConfig` /
`config.ACMEServerConfig` / `config.ACMEServerDirectoryMeta` import
path is preserved without modification.

Verification:
  gofmt -l internal/config/                 → clean
  go build ./internal/config/...            → clean
  go test ./internal/config/... -count=1    → ok (0.68s)
  staticcheck ./internal/config/...         → clean
  git diff --stat HEAD                      → -227 lines from config.go
  grep -nE '^type ACME[A-Za-z]+ struct' internal/config/config.go
    → empty (none in config.go anymore)
  grep -nE '^type ACME[A-Za-z]+ struct' internal/config/acme.go
    → 3 (ACMEConfig, ACMEServerConfig, ACMEServerDirectoryMeta)

LOC delta:
  config.go:  3335 → 3108  (-227 lines)
  acme.go:    new, 262 lines (incl. 32-line Phase 9 doc-comment +
                              BSL header + package decl + import)

Phase 9 progress: 2 of 12 sub-splits shipped.
Next queued (Sprint 3): SCEP family from config.go →
internal/config/scep.go (~330 LOC including helpers — SCEP has
several scattered helpers like loadSCEPProfilesFromEnv,
mergeSCEPLegacyIntoProfiles, validSCEPPathID that need to come
along; this is meaningfully more complex than the pure-data ACME
cut).

Pre-commit verification gate respected:
  gofmt -l                            → clean
  go vet (implicit via go test)       → clean
  go test ./internal/config/...       → ok
  staticcheck ./internal/config/...   → clean

Closes: cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M2
        (partial — 2 of 12 — full ARCH-M2 closure is the aggregate)
2026-05-14 03:53:17 +00:00

263 lines
12 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package config
import "time"
// Phase 9 ARCH-M2 closure Sprint 2 (2026-05-14): extracted from
// config.go to reduce its change-risk hotspot footprint. Three
// related types live here:
//
// ACMEConfig — the issuer-connector (consumer) side:
// we are a CLIENT talking UP to an ACME
// CA (Let's Encrypt, pebble, step-ca).
// CERTCTL_ACME_* prefix.
// ACMEServerConfig — the server-side ACME (RFC 8555 + RFC
// 9773) configuration: we ARE the ACME
// server, exposing /acme/profile/<id>/*
// to cert-manager / lego / acme.sh
// clients. CERTCTL_ACME_SERVER_* prefix
// (deliberately distinct from the
// consumer namespace).
// ACMEServerDirectoryMeta — the optional `meta` block of the ACME
// directory document, populated from
// CERTCTL_ACME_SERVER_TOS_URL / WEBSITE
// / CAA_IDENTITIES / EAB_REQUIRED.
//
// Every field, doc-comment, and exported name is byte-identical to
// the pre-split form. The structs live in the same `config` package
// so every caller's `config.ACMEConfig` etc. import path is
// preserved without modification.
//
// Public-surface invariant: `go doc internal/config ACMEConfig` and
// `go doc internal/config ACMEServerConfig` produce identical output
// before and after this split.
// ACMEConfig contains ACME issuer connector configuration.
type ACMEConfig struct {
// DirectoryURL is the ACME directory URL for certificate issuance.
// Examples: "https://acme-v02.api.letsencrypt.org/directory" (Let's Encrypt),
// "https://acme.zerossl.com/v2/DV90" (ZeroSSL), or custom CA directory.
DirectoryURL string
// Email is the email address for ACME account registration.
// Used for certificate expiration notices and account recovery by ACME CA.
Email string
// ChallengeType selects the ACME challenge mechanism for domain validation.
// Valid values: "http-01" (default, requires public HTTP endpoint),
// "dns-01" (DNS TXT record per renewal), or "dns-persist-01" (standing DNS record).
// Default: "http-01".
ChallengeType string
// DNSPresentScript is the path to a shell script that creates DNS TXT records.
// Required for dns-01 and dns-persist-01 challenge types.
// Script receives these environment variables:
// - CERTCTL_DNS_DOMAIN: domain being validated (e.g., "example.com")
// - CERTCTL_DNS_FQDN: full record name (e.g., "_acme-challenge.example.com" or "_validation-persist.example.com")
// - CERTCTL_DNS_VALUE: TXT record value (key authorization digest for dns-01, or issuer domain info for dns-persist-01)
// - CERTCTL_DNS_TOKEN: ACME challenge token
// Example: /opt/dns-scripts/add-record.sh
DNSPresentScript string
// DNSCleanUpScript is the path to a shell script that removes DNS TXT records.
// Used only for dns-01 challenges to clean up temporary validation records.
// Script receives the same environment variables as DNSPresentScript.
// Leave empty if cleanup is not needed (e.g., dns-persist-01).
DNSCleanUpScript string
// DNSPersistIssuerDomain is the issuer domain for dns-persist-01 standing records.
// Example: "letsencrypt.org" or "zerossl.com". Only used if ChallengeType is "dns-persist-01".
// The record value becomes: "<issuer_domain>; accounturi=<acme_account_uri>"
DNSPersistIssuerDomain string
// Profile selects the ACME certificate profile for newOrder requests.
// Let's Encrypt supports "tlsserver" (standard TLS) and "shortlived" (6-day certs).
// Leave empty for the CA's default profile (backward-compatible).
// Setting: CERTCTL_ACME_PROFILE environment variable.
Profile string
// ARIEnabled enables ACME Renewal Information (RFC 9773) support.
// When enabled, the renewal scheduler queries the CA for suggested renewal windows
// instead of relying solely on static expiration thresholds.
// Default: false. Requires a CA that supports ARI (e.g., Let's Encrypt).
// Setting: CERTCTL_ACME_ARI_ENABLED environment variable.
ARIEnabled bool
// Insecure skips TLS certificate verification when connecting to the ACME directory.
// Only use for testing with self-signed ACME servers like Pebble. Never in production.
// Setting: CERTCTL_ACME_INSECURE environment variable.
Insecure bool
// InsecureAck is the Phase 2 SEC-M4 closure (2026-05-13): when
// Insecure=true, Validate() refuses to start unless InsecureAck is
// also true. Pre-Phase-2 the Insecure flag only emitted a boot-time
// WARN log; this guard converts that to a hard fail-closed gate so
// the dev-only escape hatch cannot be flipped accidentally in
// production via a copy-pasted Pebble runbook.
//
// Acknowledged (Insecure=true + InsecureAck=true): boot proceeds + WARN logs.
// Unack'd (Insecure=true + InsecureAck=false): ErrACMEInsecureWithoutAck.
// Off (Insecure=false): InsecureAck is ignored entirely.
//
// Setting: CERTCTL_ACME_INSECURE_ACK environment variable.
InsecureAck bool
}
// ACMEServerConfig is the SERVER-side ACME (RFC 8555 + RFC 9773 ARI)
// configuration. Distinct from ACMEConfig (the consumer-side issuer
// connector that talks UP to Let's Encrypt / pebble). Server uses
// CERTCTL_ACME_SERVER_* prefix throughout to avoid colliding with
// the existing CERTCTL_ACME_* consumer namespace (DIRECTORY_URL /
// PROFILE / CHALLENGE_TYPE / etc.).
//
// Phase 1a wires Enabled / DefaultAuthMode / DefaultProfileID /
// NonceTTL / DirectoryMeta. Order/Authz TTLs + the per-challenge-type
// concurrency caps + DNS01 resolver are reserved fields populated for
// Phases 2/3 — exposing them now keeps the env-var surface stable
// from day one (operators can set CERTCTL_ACME_SERVER_HTTP01_CONCURRENCY
// today; it's a no-op until Phase 3 reads it).
type ACMEServerConfig struct {
// Enabled is the master toggle. When false, the ACME handler is
// constructed (so the registry-shape stays stable) but no routes
// are registered. Operators flip this on after configuring the
// per-profile auth_mode column on certificate_profiles.
// Setting: CERTCTL_ACME_SERVER_ENABLED.
Enabled bool
// DefaultAuthMode sets the default value of certificate_profiles.acme_auth_mode
// for NEWLY-created profiles (e.g. via API). Existing profile rows
// retain whatever value they were created with — per-profile
// values, once set, override this default. Architecture decision:
// auth mode is per-profile, not server-wide.
// Valid: "trust_authenticated" (default) or "challenge".
// Setting: CERTCTL_ACME_SERVER_DEFAULT_AUTH_MODE.
DefaultAuthMode string
// DefaultProfileID, when set, activates the /acme/* shorthand
// path family — /acme/directory mirrors
// /acme/profile/<DefaultProfileID>/directory etc. When empty,
// requests to the shorthand return RFC 7807
// userActionRequired with a hint pointing at the per-profile
// path. Single-profile deployments can set this for ergonomic
// client config; multi-profile deployments leave it empty.
// Setting: CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID.
DefaultProfileID string
// NonceTTL is how long an issued ACME nonce remains valid before
// the server rejects it as expired. RFC 8555 §6.5.1 allows the
// server to set any TTL; 5 minutes is the operator-friendly
// default (clock-skew tolerant without enabling long-replay
// attacks). Setting: CERTCTL_ACME_SERVER_NONCE_TTL.
NonceTTL time.Duration
// OrderTTL is the lifetime of an unfulfilled ACME order. Phase 2
// reads; Phase 1a reserves the field. Default: 24h.
// Setting: CERTCTL_ACME_SERVER_ORDER_TTL.
OrderTTL time.Duration
// AuthzTTL is the lifetime of an unfulfilled authorization. Phase 2
// reads; Phase 1a reserves. Default: 24h.
// Setting: CERTCTL_ACME_SERVER_AUTHZ_TTL.
AuthzTTL time.Duration
// HTTP01ConcurrencyMax is the bound on concurrent HTTP-01 validators
// (semaphore weight). Phase 3 reads; Phase 1a reserves. Default: 10.
// Setting: CERTCTL_ACME_SERVER_HTTP01_CONCURRENCY.
HTTP01ConcurrencyMax int
// DNS01Resolver is the resolver address used by the DNS-01 validator.
// Phase 3 reads; Phase 1a reserves. Default: "8.8.8.8:53".
// Setting: CERTCTL_ACME_SERVER_DNS01_RESOLVER.
DNS01Resolver string
// DNS01ConcurrencyMax bounds concurrent DNS-01 validators. Default: 10.
// Setting: CERTCTL_ACME_SERVER_DNS01_CONCURRENCY.
DNS01ConcurrencyMax int
// TLSALPN01ConcurrencyMax bounds concurrent TLS-ALPN-01 validators.
// Default: 10. Setting: CERTCTL_ACME_SERVER_TLSALPN01_CONCURRENCY.
TLSALPN01ConcurrencyMax int
// ARIEnabled toggles RFC 9773 ACME Renewal Information surface
// (the `renewalInfo` directory entry + GET
// /acme/profile/<id>/renewal-info/<cert-id>). Default: true.
// Operators wanting Phase-1a-style "directory + nonce + accounts +
// orders + finalize + challenges only" can flip this off; doing so
// drops the renewalInfo URL from the directory document so ACME
// clients fall back to their static renewal scheduler. Phase 4 wires.
// Setting: CERTCTL_ACME_SERVER_ARI_ENABLED.
ARIEnabled bool
// ARIPollInterval is the value the server returns in the Retry-After
// response header on a 200 ARI response — i.e., the suggested gap
// between successive ARI polls a client should respect. RFC 9773 §4.2
// leaves this server-policy. Default: 6h. Tighter intervals (e.g. 1h)
// suit short-lived certs; looser intervals (24h) suit standard 90-day
// certs. Setting: CERTCTL_ACME_SERVER_ARI_POLL_INTERVAL.
ARIPollInterval time.Duration
// RateLimitOrdersPerHour caps new-order requests per ACME account per
// rolling hour. 0 disables (no limit). Default: 100. Hits return RFC
// 7807 + RFC 8555 §6.7 `urn:ietf:params:acme:error:rateLimited` with
// a Retry-After header. In-memory token-bucket — restart wipes the
// counter, which is acceptable for orders/hour caps (eventual-
// consistency anyway). Setting:
// CERTCTL_ACME_SERVER_RATE_LIMIT_ORDERS_PER_HOUR.
RateLimitOrdersPerHour int
// RateLimitConcurrentOrders caps the number of orders an ACME account
// can have in pending/ready/processing state simultaneously. 0
// disables. Default: 5. Same Problem shape as the per-hour limit.
// Setting: CERTCTL_ACME_SERVER_RATE_LIMIT_CONCURRENT_ORDERS.
RateLimitConcurrentOrders int
// RateLimitKeyChangePerHour caps account-key rollovers per account
// per rolling hour. 0 disables. Default: 5 (rollovers should be rare;
// a flood is an attack signal). Setting:
// CERTCTL_ACME_SERVER_RATE_LIMIT_KEY_CHANGE_PER_HOUR.
RateLimitKeyChangePerHour int
// RateLimitChallengeRespondsPerHour caps challenge-respond requests
// per challenge per rolling hour. 0 disables. Default: 60 (defends
// against retry storms from a misbehaving client). Setting:
// CERTCTL_ACME_SERVER_RATE_LIMIT_CHALLENGE_RESPONDS_PER_HOUR.
RateLimitChallengeRespondsPerHour int
// GCInterval is the tick interval for the ACME GC scheduler loop.
// On each tick the loop sweeps expired nonces, transitions expired
// pending authzs to `expired`, transitions expired
// pending/ready/processing orders to `invalid`, and reaps Phase-2
// atomicity-window orphans (orders without a linked cert when one
// should exist). 0 disables the loop entirely. Default: 1m. Setting:
// CERTCTL_ACME_SERVER_GC_INTERVAL.
GCInterval time.Duration
// DirectoryMeta is the optional metadata advertised in the directory
// document per RFC 8555 §7.1.1.
DirectoryMeta ACMEServerDirectoryMeta
}
// ACMEServerDirectoryMeta holds the optional fields of the directory
// `meta` block. Each is populated from a CERTCTL_ACME_SERVER_*
// env var; an all-empty struct produces an omitempty-suppressed JSON
// `meta` field on the directory.
type ACMEServerDirectoryMeta struct {
// TermsOfService is a URL pointing to the operator's ToS document.
// Setting: CERTCTL_ACME_SERVER_TOS_URL.
TermsOfService string
// Website is a URL pointing to the operator's homepage.
// Setting: CERTCTL_ACME_SERVER_WEBSITE.
Website string
// CAAIdentities is the list of CAA-record domain values clients
// should authorize for this server. Setting:
// CERTCTL_ACME_SERVER_CAA_IDENTITIES (comma-separated).
CAAIdentities []string
// ExternalAccountRequired, when true, signals to clients that
// new-account requires an EAB token (RFC 8555 §7.3.4). Phase 1a
// advertises but does not enforce; EAB enforcement is a follow-up.
// Setting: CERTCTL_ACME_SERVER_EAB_REQUIRED.
ExternalAccountRequired bool
}