openova/core/pkg/dynadot-client/managed.go
e3mrah 5502d9aa48
feat(dns): cert-manager-dynadot-webhook for DNS-01 wildcard TLS (closes #159) (#291)
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>
2026-04-30 19:37:47 +04:00

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
}