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>
5.6 KiB
bp-cert-manager-dynadot-webhook
Catalyst Blueprint for the cert-manager DNS-01 external webhook for Dynadot. Closes openova#159.
What it is
A Go binary that satisfies cert-manager's external webhook contract
(webhook.acme.cert-manager.io/v1alpha1 — Present / CleanUp on a
ChallengeRequest) and writes ACME challenge TXT records to a
Dynadot-managed pool domain via the api3.json endpoint.
The binary lives at core/cmd/cert-manager-dynadot-webhook/. The
HTTP transport, command builders, and zone-safety contract live in
core/pkg/dynadot-client/ and are shared with the other Catalyst
services that talk to Dynadot (pool-domain-manager, catalyst-dns).
Why this exists separately from external-dns-dynadot-webhook
cert-manager's webhook contract and external-dns's webhook contract are
DIFFERENT protocols. external-dns expects a sidecar that implements
records.list / records.add / records.delete over an HTTP RPC schema;
cert-manager expects an aggregated Kubernetes apiserver that responds to
ChallengeRequest CRs. The two binaries cannot share code at the
transport layer. They DO share the underlying Dynadot HTTP client at
core/pkg/dynadot-client/.
What this chart deploys
| Resource | Purpose |
|---|---|
| Deployment | Runs the webhook binary as a non-root pod in the chart's release namespace. |
| Service | ClusterIP fronting the Deployment on port 443. |
| APIService | Registers v1alpha1.acme.dynadot.openova.io so the kube-apiserver routes ChallengeRequest calls to the Service. |
| Issuer (selfsigned) | Bootstraps the CA chain that issues the webhook's serving cert. |
| Issuer (CA) | Signs the leaf serving cert from the CA Secret. |
| Certificate (CA) | Root CA cert used by the APIService's cert-manager.io/inject-ca-from annotation. |
| Certificate (serving) | Leaf cert mounted into the Deployment at /tls. |
| ServiceAccount | Identity for the Deployment. |
| ClusterRoleBinding (auth-delegator) | Lets the aggregated apiserver delegate auth back to kube-apiserver. |
| RoleBinding (auth-reader) | Reads extension-apiserver-authentication ConfigMap from kube-system. |
| Role + RoleBinding (dynadot secret) | Grants the SA read access to the Dynadot credentials Secret in the configured namespace. |
Pairing with bp-cert-manager
bp-cert-manager's letsencrypt-dns01-prod ClusterIssuer points at this
webhook via solvers[].dns01.webhook.groupName + solverName. The two
charts MUST be deployed on the same Sovereign and bp-cert-manager-dynadot-
webhook MUST be Ready before any wildcard Certificate is requested.
The bp-cert-manager chart now ships with dns01.enabled: true by
default (changed in this PR — was false while the webhook was being
built). The interim letsencrypt-http01-prod issuer remains templated
as the rollback path; flip certManager.issuers.dns01.enabled=false in
the umbrella values to disable wildcard issuance and continue with
per-host certs.
Credentials
The webhook reads three values from a Kubernetes Secret in its release namespace:
| Env var | Default secret key |
|---|---|
DYNADOT_API_KEY |
api-key |
DYNADOT_API_SECRET |
api-secret |
DYNADOT_MANAGED_DOMAINS |
domains (legacy fallback: domain) |
The canonical secret (dynadot-api-credentials in openova-system) is
shared with pool-domain-manager and catalyst-dns. Because Pod
secretKeyRef cannot cross namespaces, the cluster overlay MUST
replicate the secret into the webhook's release namespace via
ExternalSecret (preferred) or reflector annotations. See
clusters/_template/dynadot-credentials-replication.yaml.
Domain allowlist
DYNADOT_MANAGED_DOMAINS is a comma- or whitespace-separated allowlist
of pool domains the webhook is permitted to mutate. ChallengeRequests
for domains NOT under any allowlisted apex are rejected before any
Dynadot API call is made. This is the same defence pattern
pool-domain-manager and catalyst-dns use; it prevents a misconfigured
ClusterIssuer from causing the webhook to write to a third-party domain.
Zone safety
The shared core/pkg/dynadot-client/ enforces the safety contract
documented in memory/feedback_dynadot_dns.md: every mutation either
uses the append path (add_dns_to_current_setting=yes) or performs a
read-modify-write via domain_info → set_dns2. The destructive
zone-wipe variant of set_dns2 is unexported. The webhook's Present
path uses AddRecord (append); CleanUp uses RemoveSubRecord
(read-modify-write that match-deletes a single record).
Smoke test
Once both charts are reconciled on a Sovereign:
# Verify the webhook is running and the APIService is healthy
kubectl get -n cert-manager deploy/release-name-bp-cert-manager-dynadot-webhook
kubectl get apiservices.apiregistration.k8s.io v1alpha1.acme.dynadot.openova.io
# Issue a wildcard cert against the Sovereign apex
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-omantel-omani-works
namespace: cilium-gateway
spec:
secretName: wildcard-omantel-omani-works-tls
issuerRef:
name: letsencrypt-dns01-prod
kind: ClusterIssuer
dnsNames:
- "*.omantel.omani.works"
EOF
# Watch the Order + Challenge progress
kubectl get certificate,order,challenge -A -w
See also
core/cmd/cert-manager-dynadot-webhook/— binary sourcecore/pkg/dynadot-client/— shared Dynadot HTTP clientplatform/cert-manager/chart/templates/clusterissuer-letsencrypt-dns01.yaml— paired ClusterIssuer- openova#159 — closing issue
- cert-manager DNS-01 webhook docs