openova/core/pkg/dynadot-client/doc.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

43 lines
2.2 KiB
Go

// Package dynadot is the shared OpenOva client for the Dynadot api3.json
// REST endpoint. It is the single ground-truth implementation of the
// Dynadot HTTP transport, command builders, response decoding, and the
// safe read-modify-write semantics required for record management.
//
// The client is consumed by every Catalyst service that has to talk to
// api.dynadot.com:
//
// - core/cmd/cert-manager-dynadot-webhook (DNS-01 wildcard TLS)
// - core/pool-domain-manager (NS-flip during BYO-domain provisioning)
// - products/catalyst/bootstrap/api/cmd/catalyst-dns (Sovereign A-record set)
//
// Why a separate Go module under core/pkg/:
//
// - The legacy clients live under each consumer's `internal/` tree, so
// they cannot be imported across module boundaries (Go's internal-
// package rule). Hosting the canonical client at core/pkg/ makes it
// visible to every service module without breaking the convention
// that service-private code stays in `internal/`.
// - A standalone module keeps the dependency surface tiny: this package
// uses only the standard library, so the consumers don't transitively
// pick up Postgres / chi / etc. when all they need is a single API
// call.
// - Per docs/INVIOLABLE-PRINCIPLES.md #3 (one canonical implementation
// per concern), all future Dynadot work should land here. The legacy
// copies under products/catalyst/bootstrap/api/internal/dynadot and
// core/pool-domain-manager/internal/registrar/dynadot remain in
// place at the time of writing — they will be migrated to this
// package in a follow-up. Do not extend them; extend this package.
//
// Safety contract — the package enforces the operator-memory rule that
// `set_dns2` calls without `add_dns_to_current_setting=yes` wipe the
// entire zone. Every record-mutating helper in this package either:
//
// 1. Uses `add_dns_to_current_setting=yes` (append-only path), or
// 2. Performs a read-modify-write against `domain_info` and writes the
// reconstructed full record set so no record is ever silently
// dropped.
//
// The caller cannot accidentally invoke the destructive variant — that
// command builder is unexported.
package dynadot