Activates the previously-templated `letsencrypt-dns01-prod` ClusterIssuer
in bp-cert-manager by shipping the missing piece — a Go binary that
satisfies cert-manager's external webhook contract
(`webhook.acme.cert-manager.io/v1alpha1`) against the Dynadot api3.json.
Architecture
============
* `core/pkg/dynadot-client/` — canonical Dynadot HTTP client (shared with
pool-domain-manager and catalyst-dns). Encapsulates the api3.json
transport, command builders, response decoding, and the safe
read-modify-write semantics required to never accidentally wipe a
zone (memory: feedback_dynadot_dns.md). Destructive `set_dns2`
variant is unexported.
* `core/cmd/cert-manager-dynadot-webhook/` — the cert-manager webhook
binary. Implements `Solver.Present` via the client's append-only
`AddRecord` path and `Solver.CleanUp` via the read-modify-write
`RemoveSubRecord` path. Domain allowlist (`DYNADOT_MANAGED_DOMAINS`)
rejects challenges for unmanaged apexes BEFORE any Dynadot call.
* `platform/cert-manager-dynadot-webhook/` — Catalyst-authored Helm
wrapper. Templates Deployment + Service + APIService + serving
Certificate (CA chain via cert-manager Issuer self-signing) +
RBAC + ServiceAccount. Mirrors the standard cert-manager external-
webhook deployment shape.
* `platform/cert-manager/chart/` — flips `dns01.enabled: true` so the
paired ClusterIssuer activates. The interim http01 issuer remains
templated as the rollback path.
Test results
============
core/pkg/dynadot-client — 7 tests PASS (race-clean)
core/cmd/cert-manager-dynadot-... — 9 tests PASS (race-clean)
Test coverage includes a Present/CleanUp round-trip against an
httptest fixture that models Dynadot's zone state, an explicit
unmanaged-domain rejection, a regression preserving a pre-existing
CNAME across the DNS-01 round-trip (the zone-wipe defence), and a
typed-error propagation test that surfaces `ErrInvalidToken` to
cert-manager so the controller will retry.
Helm template smoke render
==========================
`helm template` against the new chart with default values yields 12
resources / 424 lines (APIService, Certificate, ClusterRoleBinding,
Deployment, Issuer, Role, RoleBinding, Service, ServiceAccount). The
modified bp-cert-manager chart still renders both ClusterIssuers
(`letsencrypt-dns01-prod` + `letsencrypt-http01-prod`) with default
values; flipping `certManager.issuers.dns01.enabled=false` is the
clean rollback.
Smoke command (post-deploy)
===========================
kubectl get apiservices.apiregistration.k8s.io \
v1alpha1.acme.dynadot.openova.io
# Issue a *.<sovereign>.<pool> wildcard cert and watch the
# Order/Challenge progress through cert-manager.
CI
==
`.github/workflows/build-cert-manager-dynadot-webhook.yaml` mirrors the
pool-domain-manager-build pattern (cosign keyless signing, SBOM
attestation, GHCR push at `ghcr.io/openova-io/openova/cert-manager-
dynadot-webhook:<sha>`). Triggered by changes to either the binary or
the shared dynadot-client package.
Closes #159
Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
86 lines
2.4 KiB
Go
86 lines
2.4 KiB
Go
package dynadot
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// ManagedDomains is a thread-safe allowlist of pool domains the calling
|
|
// process is permitted to mutate via the Dynadot API. The cert-manager
|
|
// webhook consults it to refuse DNS-01 challenges for domains the
|
|
// operator hasn't enrolled — same defense as the catalyst-dns binary,
|
|
// just exposed at the package boundary.
|
|
//
|
|
// Population is up to the caller — typically the webhook reads a
|
|
// comma- or whitespace-separated `DYNADOT_MANAGED_DOMAINS` env var
|
|
// (mounted from the dynadot-api-credentials K8s secret's `domains`
|
|
// key) at startup. Per docs/INVIOLABLE-PRINCIPLES.md #4 the list is
|
|
// runtime-configurable; adding a fourth pool domain is a secret
|
|
// update, not a rebuild.
|
|
type ManagedDomains struct {
|
|
mu sync.RWMutex
|
|
set map[string]struct{}
|
|
}
|
|
|
|
// NewManagedDomains parses a comma- or whitespace-separated list and
|
|
// returns a populated allowlist. Empty strings are dropped, entries
|
|
// are lower-cased, duplicates collapsed.
|
|
func NewManagedDomains(raw string) *ManagedDomains {
|
|
m := &ManagedDomains{set: make(map[string]struct{})}
|
|
for _, tok := range splitDomainsList(raw) {
|
|
m.set[tok] = struct{}{}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Has reports whether the given domain is in the allowlist (case-
|
|
// insensitive, whitespace-trimmed).
|
|
func (m *ManagedDomains) Has(domain string) bool {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if m.set == nil {
|
|
return false
|
|
}
|
|
_, ok := m.set[strings.ToLower(strings.TrimSpace(domain))]
|
|
return ok
|
|
}
|
|
|
|
// List returns a sorted, deduplicated copy of the configured domains.
|
|
// Useful for /healthz exposure and operator logs.
|
|
func (m *ManagedDomains) List() []string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
out := make([]string, 0, len(m.set))
|
|
for d := range m.set {
|
|
out = append(out, d)
|
|
}
|
|
for i := 1; i < len(out); i++ {
|
|
for j := i; j > 0 && out[j-1] > out[j]; j-- {
|
|
out[j-1], out[j] = out[j], out[j-1]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// splitDomainsList parses a `DYNADOT_MANAGED_DOMAINS`-style string —
|
|
// comma- or whitespace-separated, lower-cased, trimmed, deduped.
|
|
func splitDomainsList(raw string) []string {
|
|
raw = strings.ToLower(raw)
|
|
raw = strings.ReplaceAll(raw, ",", " ")
|
|
parts := strings.Fields(raw)
|
|
seen := make(map[string]struct{}, len(parts))
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[p]; ok {
|
|
continue
|
|
}
|
|
seen[p] = struct{}{}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|