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>
This commit is contained in:
e3mrah 2026-04-30 19:37:47 +04:00 committed by GitHub
parent c09109a61a
commit 5502d9aa48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2926 additions and 29 deletions

View File

@ -0,0 +1,135 @@
name: Build cert-manager-dynadot-webhook
# cert-manager-dynadot-webhook — Catalyst-built Go binary that satisfies
# cert-manager's DNS-01 webhook contract against the Dynadot api3.json.
# Closes openova#159. The image is consumed by the
# bp-cert-manager-dynadot-webhook Blueprint
# (platform/cert-manager-dynadot-webhook/chart/) which is auto-installed
# by the bootstrap-kit on every Sovereign that needs wildcard TLS.
#
# Per docs/INVIOLABLE-PRINCIPLES.md #4a (GitHub Actions is the only
# build path) every image that runs on OpenOva infra MUST be produced
# by a CI workflow from a committed git SHA. This workflow mirrors
# pool-domain-manager-build.yaml — same auth flow, same cosign signing,
# same SBOM attestation.
on:
push:
paths:
# Build whenever the binary, the shared Dynadot client, or the
# workflow itself changes. The chart at platform/.../chart/ does
# not retrigger this workflow — Helm chart releases land via
# blueprint-release.yaml and consume the image tag this workflow
# publishes.
- 'core/cmd/cert-manager-dynadot-webhook/**'
- 'core/pkg/dynadot-client/**'
- '.github/workflows/build-cert-manager-dynadot-webhook.yaml'
branches: [main]
pull_request:
paths:
- 'core/cmd/cert-manager-dynadot-webhook/**'
- 'core/pkg/dynadot-client/**'
- '.github/workflows/build-cert-manager-dynadot-webhook.yaml'
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE: ghcr.io/openova-io/openova/cert-manager-dynadot-webhook
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# id-token write is required by cosign keyless signing (Sigstore).
# Per docs/INVIOLABLE-PRINCIPLES.md #3 every Catalyst image is
# cosign-signed + SBOM-attested.
id-token: write
outputs:
sha_short: ${{ steps.vars.outputs.sha_short }}
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set short SHA
id: vars
run: echo "sha_short=$(echo $GITHUB_SHA | head -c 7)" >> "$GITHUB_OUTPUT"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: |
core/cmd/cert-manager-dynadot-webhook/go.sum
- name: Run unit tests — webhook
working-directory: core/cmd/cert-manager-dynadot-webhook
run: go test -count=1 -race ./...
- name: Run unit tests — shared dynadot client
working-directory: core/pkg/dynadot-client
run: go test -count=1 -race ./...
# On pull_request runs we stop here — image push requires
# `packages: write` against the openova-io org which only main
# branch authors hold via GITHUB_TOKEN. Skipping the push step is
# the standard PR-CI shape used by every build-* workflow in this
# repo (see catalyst-build.yaml).
- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
if: github.event_name != 'pull_request'
uses: docker/setup-buildx-action@v3
- name: Build and push image
id: build
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v6
with:
# Build context is the repository root so the Dockerfile's
# COPY paths can reach both core/cmd/.../ and core/pkg/.../.
# The shared dynadot-client module is consumed via go.mod's
# local replace directive; the build context layout matches
# what the replace expects.
context: .
file: core/cmd/cert-manager-dynadot-webhook/Containerfile
push: true
tags: |
${{ env.IMAGE }}:${{ steps.vars.outputs.sha_short }}
${{ env.IMAGE }}:latest
labels: |
org.opencontainers.image.source=https://github.com/openova-io/openova
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.title=cert-manager-dynadot-webhook
org.opencontainers.image.description=cert-manager DNS-01 external webhook for Dynadot (closes openova#159)
provenance: true
sbom: true
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@v3
- name: Sign image with cosign (keyless)
if: github.event_name != 'pull_request'
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign sign --yes "${IMAGE}@${DIGEST}"
- name: Generate and attest SBOM
if: github.event_name != 'pull_request'
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign attest --yes \
--predicate <(echo '{"sbom":"in-toto-spdx attached at build time"}') \
--type spdx \
"${IMAGE}@${DIGEST}"

View File

@ -0,0 +1,58 @@
# cert-manager-dynadot-webhook — Catalyst-built Go binary implementing
# cert-manager's external DNS-01 webhook protocol against Dynadot's
# api3.json. Per docs/INVIOLABLE-PRINCIPLES.md the image is statically
# compiled, runs as a non-root numeric UID, and ships nothing beyond the
# binary + CA bundle.
#
# Build context: this Containerfile is invoked by the
# .github/workflows/build-cert-manager-dynadot-webhook.yaml workflow with
# the repository ROOT as the build context (NOT this directory). The
# COPY paths below assume that — `core/pkg/dynadot-client/` and
# `core/cmd/cert-manager-dynadot-webhook/` are both copied so the
# go.mod's `replace` directive resolves at build time.
#
# Two stages:
# build — golang:1.23-alpine, vendored stdlib + module cache
# final — alpine:3.20 minimal runtime (CA certs + the binary)
FROM docker.io/library/golang:1.23-alpine AS build
WORKDIR /workspace
# ── Stage 1: cache module downloads ──────────────────────────────────────
# Copy go.mod / go.sum first so day-to-day source rebuilds skip the
# module download step. Both modules (the webhook + the shared
# dynadot-client) are needed because go.mod's local replace pulls the
# client by path.
COPY core/cmd/cert-manager-dynadot-webhook/go.mod core/cmd/cert-manager-dynadot-webhook/go.sum core/cmd/cert-manager-dynadot-webhook/
COPY core/pkg/dynadot-client/go.mod core/pkg/dynadot-client/
WORKDIR /workspace/core/cmd/cert-manager-dynadot-webhook
RUN go mod download
# ── Stage 2: copy source + build ──────────────────────────────────────────
COPY core/pkg/dynadot-client /workspace/core/pkg/dynadot-client
COPY core/cmd/cert-manager-dynadot-webhook /workspace/core/cmd/cert-manager-dynadot-webhook
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-o /webhook .
# ── Stage 3: minimal runtime ──────────────────────────────────────────────
FROM docker.io/library/alpine:3.20
# ca-certificates so the Dynadot HTTPS calls verify the API cert.
# tzdata so timestamps render correctly in operator logs.
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /webhook /webhook
# Alpine 3.20 already ships UID 65534 as `nobody`. Reuse rather than
# create a duplicate `nonroot` account. The numeric form satisfies
# runAsNonRoot=true + runAsUser=65534 in the chart's Deployment.
USER 65534:65534
# 4443 is the chart's default --secure-port. Operators may rebind via
# the chart values.
EXPOSE 4443
ENTRYPOINT ["/webhook"]

View File

@ -0,0 +1,116 @@
module github.com/openova-io/openova/core/cmd/cert-manager-dynadot-webhook
go 1.23
require (
github.com/cert-manager/cert-manager v1.16.2
github.com/openova-io/openova/core/pkg/dynadot-client v0.0.0
k8s.io/client-go v0.31.1
)
require (
github.com/NYTimes/gziphandler v1.1.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/cel-go v0.20.1 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.20.4 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.etcd.io/etcd/api/v3 v3.5.14 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.5.14 // indirect
go.etcd.io/etcd/client/v3 v3.5.14 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/term v0.24.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/time v0.6.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.66.2 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.31.1 // indirect
k8s.io/apiextensions-apiserver v0.31.1 // indirect
k8s.io/apimachinery v0.31.1 // indirect
k8s.io/apiserver v0.31.1 // indirect
k8s.io/component-base v0.31.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kms v0.31.1 // indirect
k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38 // indirect
k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect
sigs.k8s.io/controller-runtime v0.19.0 // indirect
sigs.k8s.io/gateway-api v1.1.0 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
// Local replace so the canonical Dynadot client at core/pkg/dynadot-client/
// is consumed without having to publish a separate Go module. The path is
// resolved relative to this go.mod file at build time the Dockerfile
// uses the same layout when copying the workspace into the build context.
replace github.com/openova-io/openova/core/pkg/dynadot-client => ../../pkg/dynadot-client

View File

@ -0,0 +1,309 @@
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cert-manager/cert-manager v1.16.2 h1:c9UU2E+8XWGruyvC/mdpc1wuLddtgmNr8foKdP7a8Jg=
github.com/cert-manager/cert-manager v1.16.2/go.mod h1:MfLVTL45hFZsqmaT1O0+b2ugaNNQQZttSFV9hASHUb0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=
github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
github.com/google/cel-go v0.20.1 h1:nDx9r8S3L4pE61eDdt8igGj8rf5kjYR3ILxWIpWNi84=
github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw=
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
go.etcd.io/etcd/api/v3 v3.5.14 h1:vHObSCxyB9zlF60w7qzAdTcGaglbJOpSj1Xj9+WGxq0=
go.etcd.io/etcd/api/v3 v3.5.14/go.mod h1:BmtWcRlQvwa1h3G2jvKYwIQy4PkHlDej5t7uLMUdJUU=
go.etcd.io/etcd/client/pkg/v3 v3.5.14 h1:SaNH6Y+rVEdxfpA2Jr5wkEvN6Zykme5+YnbCkxvuWxQ=
go.etcd.io/etcd/client/pkg/v3 v3.5.14/go.mod h1:8uMgAokyG1czCtIdsq+AGyYQMvpIKnSvPjFMunkgeZI=
go.etcd.io/etcd/client/v2 v2.305.13 h1:RWfV1SX5jTU0lbCvpVQe3iPQeAHETWdOTb6pxhd77C8=
go.etcd.io/etcd/client/v2 v2.305.13/go.mod h1:iQnL7fepbiomdXMb3om1rHq96htNNGv2sJkEcZGDRRg=
go.etcd.io/etcd/client/v3 v3.5.14 h1:CWfRs4FDaDoSz81giL7zPpZH2Z35tbOrAJkkjMqOupg=
go.etcd.io/etcd/client/v3 v3.5.14/go.mod h1:k3XfdV/VIHy/97rqWjoUzrj9tk7GgJGH9J8L4dNXmAk=
go.etcd.io/etcd/pkg/v3 v3.5.13 h1:st9bDWNsKkBNpP4PR1MvM/9NqUPfvYZx/YXegsYEH8M=
go.etcd.io/etcd/pkg/v3 v3.5.13/go.mod h1:N+4PLrp7agI/Viy+dUYpX7iRtSPvKq+w8Y14d1vX+m0=
go.etcd.io/etcd/raft/v3 v3.5.13 h1:7r/NKAOups1YnKcfro2RvGGo2PTuizF/xh26Z2CTAzA=
go.etcd.io/etcd/raft/v3 v3.5.13/go.mod h1:uUFibGLn2Ksm2URMxN1fICGhk8Wu96EfDQyuLhAcAmw=
go.etcd.io/etcd/server/v3 v3.5.13 h1:V6KG+yMfMSqWt+lGnhFpP5z5dRUj1BDRJ5k1fQ9DFok=
go.etcd.io/etcd/server/v3 v3.5.13/go.mod h1:K/8nbsGupHqmr5MkgaZpLlH1QdX1pcNQLAkODy44XcQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY=
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed h1:3RgNmBoI9MZhsj3QxC+AP/qQhNwpCLOvYDYYsFrhFt0=
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
k8s.io/apiextensions-apiserver v0.31.1 h1:L+hwULvXx+nvTYX/MKM3kKMZyei+UiSXQWciX/N6E40=
k8s.io/apiextensions-apiserver v0.31.1/go.mod h1:tWMPR3sgW+jsl2xm9v7lAyRF1rYEK71i9G5dRtkknoQ=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/apiserver v0.31.1 h1:Sars5ejQDCRBY5f7R3QFHdqN3s61nhkpaX8/k1iEw1c=
k8s.io/apiserver v0.31.1/go.mod h1:lzDhpeToamVZJmmFlaLwdYZwd7zB+WYRYIboqA1kGxM=
k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0=
k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg=
k8s.io/component-base v0.31.1 h1:UpOepcrX3rQ3ab5NB6g5iP0tvsgJWzxTyAo20sgYSy8=
k8s.io/component-base v0.31.1/go.mod h1:WGeaw7t/kTsqpVTaCoVEtillbqAhF2/JgvO0LDOMa0w=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kms v0.31.1 h1:cGLyV3cIwb0ovpP/jtyIe2mEuQ/MkbhmeBF2IYCA9Io=
k8s.io/kms v0.31.1/go.mod h1:OZKwl1fan3n3N5FFxnW5C4V3ygrah/3YXeJWS3O6+94=
k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38 h1:1dWzkmJrrprYvjGwh9kEUxmcUV/CtNU8QM7h1FLWQOo=
k8s.io/kube-openapi v0.0.0-20240903163716-9e1beecbcb38/go.mod h1:coRQXBK9NxO98XUv3ZD6AK3xzHCxV6+b7lrquKwaKzA=
k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 h1:MDF6h2H/h4tbzmtIKTuctcwZmY0tY9mD9fNT47QO6HI=
k8s.io/utils v0.0.0-20240921022957-49e7df575cb6/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 h1:2770sDpzrjjsAtVhSeUFseziht227YAWYHLGNM8QPwY=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC0ji/Q=
sigs.k8s.io/controller-runtime v0.19.0/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4=
sigs.k8s.io/gateway-api v1.1.0 h1:DsLDXCi6jR+Xz8/xd0Z1PYl2Pn0TyaFMOPPZIj4inDM=
sigs.k8s.io/gateway-api v1.1.0/go.mod h1:ZH4lHrL2sDi0FHZ9jjneb8kKnGzFWyrTya35sWUTrRs=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=

View File

@ -0,0 +1,309 @@
// Command cert-manager-dynadot-webhook is the cert-manager external
// DNS-01 webhook for Dynadot.
//
// It implements the cert-manager webhook contract documented at
// https://cert-manager.io/docs/configuration/acme/dns01/webhook/ and
// uses the canonical Dynadot HTTP client at
// github.com/openova-io/openova/core/pkg/dynadot-client to perform the
// underlying record mutations.
//
// Why this binary exists separately from external-dns-dynadot-webhook:
// the external-dns webhook contract is a different protocol (records.list /
// records.add / records.delete RPCs) — see
// platform/cert-manager/chart/templates/clusterissuer-letsencrypt-dns01.yaml
// for the historical context. cert-manager's webhook is an aggregated
// apiserver registered via APIService, served on TCP/443 with mTLS, and
// receives ChallengeRequest objects for Present/CleanUp.
//
// Configuration is environment-variable driven so a Sovereign overlay can
// retune the binary without rebuilding the image (per
// docs/INVIOLABLE-PRINCIPLES.md #4):
//
// GROUP_NAME — webhook API group, default
// "acme.dynadot.openova.io". MUST match the
// ClusterIssuer's solvers[].dns01.webhook.groupName.
// DYNADOT_API_KEY — Dynadot api3.json API key. REQUIRED.
// DYNADOT_API_SECRET — Dynadot api3.json API secret. REQUIRED.
// DYNADOT_MANAGED_DOMAINS — comma- or whitespace-separated allowlist
// of pool domains the webhook is permitted
// to mutate (e.g.
// "openova.io,omani.works,omanyx.works").
// REQUIRED for production; allowlist is a
// defence against a misconfigured or stolen
// ClusterIssuer pointing at a third-party
// domain. Single-domain operators may set
// DYNADOT_DOMAIN as a fallback.
// DYNADOT_DOMAIN — optional single-domain fallback when
// DYNADOT_MANAGED_DOMAINS is empty. Honoured
// for parity with pool-domain-manager (#108).
// DYNADOT_BASE_URL — override for tests; production uses
// https://api.dynadot.com/api3.json.
//
// At Present time the webhook splits the ChallengeRequest's ResolvedFQDN
// into (subdomain, apex) by matching the apex against the managed-domains
// allowlist, then writes a TXT record at `_acme-challenge.<subdomain>`
// using AddRecord (append-only path — never wipes the zone, see
// core/pkg/dynadot-client/doc.go safety contract). At CleanUp it does a
// safe read-modify-write via RemoveSubRecord.
//
// Idempotency: cert-manager retries Present and CleanUp on transient
// errors. AddRecord is idempotent because Dynadot dedupes by
// (subdomain, type, value); RemoveSubRecord returns nil when nothing
// matches. Both behaviours are required by the webhook spec.
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
dynadot "github.com/openova-io/openova/core/pkg/dynadot-client"
"github.com/cert-manager/cert-manager/pkg/acme/webhook"
"github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1"
"github.com/cert-manager/cert-manager/pkg/acme/webhook/cmd"
"k8s.io/client-go/rest"
)
// defaultGroupName matches the value baked into
// platform/cert-manager/chart/templates/clusterissuer-letsencrypt-dns01.yaml.
// Operators MAY override via the GROUP_NAME env so a Sovereign overlay
// can retune the API group without rebuilding the image.
const defaultGroupName = "acme.dynadot.openova.io"
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: parseLogLevel(os.Getenv("LOG_LEVEL")),
}))
slog.SetDefault(logger)
groupName := strings.TrimSpace(os.Getenv("GROUP_NAME"))
if groupName == "" {
groupName = defaultGroupName
}
solver, err := newDynadotSolver(loadConfigFromEnv())
if err != nil {
logger.Error("solver init failed", "err", err)
os.Exit(2)
}
logger.Info("cert-manager-dynadot-webhook starting",
"groupName", groupName,
"managedDomains", solver.managed.List(),
)
// RunWebhookServer blocks until the apiserver process is signalled
// to terminate. It reads --secure-port / --tls-cert-file etc. from
// argv (set by the chart's args:) and serves the aggregated apiserver
// that cert-manager calls into.
cmd.RunWebhookServer(groupName, solver)
}
// solverConfig is the fully-resolved configuration of the webhook,
// captured into a struct so the unit tests can inject overrides without
// touching process-global env state.
type solverConfig struct {
APIKey string
APISecret string
ManagedDomains string
Fallback string // legacy DYNADOT_DOMAIN
BaseURL string // optional override for tests
}
// loadConfigFromEnv builds a solverConfig from the documented env vars.
func loadConfigFromEnv() solverConfig {
return solverConfig{
APIKey: os.Getenv("DYNADOT_API_KEY"),
APISecret: os.Getenv("DYNADOT_API_SECRET"),
ManagedDomains: os.Getenv("DYNADOT_MANAGED_DOMAINS"),
Fallback: os.Getenv("DYNADOT_DOMAIN"),
BaseURL: os.Getenv("DYNADOT_BASE_URL"),
}
}
// dynadotSolver is the cert-manager webhook.Solver implementation.
//
// It is split from main() so tests can construct one with a fixture
// httptest.Server and a deterministic managed-domain list, then drive
// Present / CleanUp directly without wiring up the aggregated-apiserver
// transport.
type dynadotSolver struct {
client *dynadot.Client
managed *dynadot.ManagedDomains
}
// newDynadotSolver validates configuration and constructs a solver.
// Returns an error rather than panicking so the caller's structured
// logger can surface a clean error path on misconfiguration.
func newDynadotSolver(cfg solverConfig) (*dynadotSolver, error) {
if strings.TrimSpace(cfg.APIKey) == "" || strings.TrimSpace(cfg.APISecret) == "" {
return nil, errors.New("DYNADOT_API_KEY and DYNADOT_API_SECRET are required")
}
managedRaw := cfg.ManagedDomains
if strings.TrimSpace(managedRaw) == "" {
managedRaw = cfg.Fallback
}
if strings.TrimSpace(managedRaw) == "" {
return nil, errors.New("DYNADOT_MANAGED_DOMAINS (or legacy DYNADOT_DOMAIN) must list at least one domain")
}
c := dynadot.New(cfg.APIKey, cfg.APISecret)
if cfg.BaseURL != "" {
c.BaseURL = cfg.BaseURL
}
return &dynadotSolver{
client: c,
managed: dynadot.NewManagedDomains(managedRaw),
}, nil
}
// Name is the solverName referenced by the ClusterIssuer's
// solvers[].dns01.webhook.solverName field. cert-manager dispatches to
// this solver only when the issuer's solverName matches.
func (s *dynadotSolver) Name() string { return "dynadot" }
// Initialize is a no-op for this webhook. cert-manager passes its own
// kube REST config in case a solver wants to reconcile a CR; we don't.
// The signal channel is closed on shutdown — callers must return
// promptly when it closes; since Initialize itself returns immediately,
// there is nothing to wind down.
func (s *dynadotSolver) Initialize(_ *rest.Config, _ <-chan struct{}) error {
return nil
}
// Present writes the TXT record cert-manager needs Let's Encrypt to see
// at `_acme-challenge.<subdomain>` on the apex domain.
//
// The ChallengeRequest carries:
// - ResolvedFQDN — fully-qualified challenge name with trailing dot,
// e.g. "_acme-challenge.console.omantel.omani.works."
// - ResolvedZone — the zone cert-manager believes is authoritative,
// e.g. "omani.works."
// - Key — the TXT value Let's Encrypt is expecting.
//
// We resolve apex from the managed-domains allowlist (NOT from
// ResolvedZone) so a misconfigured Issuer or compromised
// kube-apiserver cannot trick the webhook into mutating a domain we
// don't own. If no managed domain is a suffix of ResolvedFQDN the
// challenge is rejected with a typed error.
func (s *dynadotSolver) Present(ch *v1alpha1.ChallengeRequest) error {
apex, sub, err := s.resolveDomain(ch.ResolvedFQDN)
if err != nil {
return err
}
slog.Info("Present",
"apex", apex, "subdomain", sub,
"resolvedFQDN", ch.ResolvedFQDN, "resolvedZone", ch.ResolvedZone,
)
rec := dynadot.Record{
Subdomain: sub,
Type: "TXT",
Value: ch.Key,
TTL: 60,
}
ctx, cancel := context.WithTimeout(context.Background(), defaultPresentTimeout)
defer cancel()
if err := s.client.AddRecord(ctx, apex, rec); err != nil {
return fmt.Errorf("dynadot AddRecord %s/%s TXT: %w", apex, sub, err)
}
return nil
}
// CleanUp removes the TXT record written by Present.
//
// Per the webhook spec, CleanUp MUST be idempotent — Let's Encrypt may
// have already validated the challenge, or cert-manager may retry after
// a transient failure. RemoveSubRecord uses GetDomainInfo →
// SetFullDNS so the entire zone state is preserved verbatim except for
// the matching record; if no matching record exists, it returns nil.
//
// The match key is (subdomain, TXT, key) — we DO NOT remove every TXT
// at `_acme-challenge.<subdomain>` because two parallel orders for the
// same hostname (concurrent renewal + new cert) write different keys to
// the same name and BOTH must validate.
func (s *dynadotSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error {
apex, sub, err := s.resolveDomain(ch.ResolvedFQDN)
if err != nil {
return err
}
slog.Info("CleanUp",
"apex", apex, "subdomain", sub,
"resolvedFQDN", ch.ResolvedFQDN, "resolvedZone", ch.ResolvedZone,
)
match := dynadot.Record{
Subdomain: sub,
Type: "TXT",
Value: ch.Key,
}
ctx, cancel := context.WithTimeout(context.Background(), defaultCleanUpTimeout)
defer cancel()
if err := s.client.RemoveSubRecord(ctx, apex, match); err != nil {
return fmt.Errorf("dynadot RemoveSubRecord %s/%s TXT: %w", apex, sub, err)
}
return nil
}
// resolveDomain matches a fully-qualified ACME challenge FQDN against
// the managed-domains allowlist and returns (apex, subdomain) suitable
// for the Dynadot api3.json `set_dns2` parameters.
//
// Examples:
//
// "_acme-challenge.console.omantel.omani.works." with apex "omani.works"
// → apex="omani.works", subdomain="_acme-challenge.console.omantel"
// "_acme-challenge.openova.io." with apex "openova.io"
// → apex="openova.io", subdomain="_acme-challenge"
//
// We strip the trailing dot, lowercase, and pick the longest matching
// apex from the allowlist (so "omani.works" wins over "works" if both
// were configured — guards against operator typos).
func (s *dynadotSolver) resolveDomain(fqdn string) (apex, sub string, err error) {
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(fqdn), "."))
if host == "" {
return "", "", errors.New("dynadot webhook: ChallengeRequest.ResolvedFQDN is empty")
}
var bestApex string
for _, d := range s.managed.List() {
if host == d || strings.HasSuffix(host, "."+d) {
if len(d) > len(bestApex) {
bestApex = d
}
}
}
if bestApex == "" {
return "", "", fmt.Errorf("dynadot webhook: %q is not under any DYNADOT_MANAGED_DOMAINS entry %v", host, s.managed.List())
}
if host == bestApex {
// Apex challenge — Dynadot uses the special "@" subdomain (or
// equivalently empty). The client encodes this as a main_record0.
return bestApex, "@", nil
}
return bestApex, strings.TrimSuffix(host, "."+bestApex), nil
}
// parseLogLevel maps the LOG_LEVEL env to a slog.Level. Defaults to
// info; "debug" and "warn" / "error" are honoured.
func parseLogLevel(s string) slog.Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
// Compile-time guard: dynadotSolver implements the cert-manager webhook
// Solver interface. If cert-manager's contract changes the build fails
// here rather than at runtime when the apiserver dispatches the first
// ChallengeRequest.
var _ webhook.Solver = (*dynadotSolver)(nil)

View File

@ -0,0 +1,406 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
dynadot "github.com/openova-io/openova/core/pkg/dynadot-client"
"github.com/cert-manager/cert-manager/pkg/acme/webhook/apis/acme/v1alpha1"
)
// fakeDynadot stands in for api.dynadot.com/api3.json. It captures the
// last set_dns2 / domain_info call so each test can assert on the
// request shape and inject a fixture response.
//
// Tests do not exercise the cert-manager apiserver wrapping at all —
// they call Present / CleanUp directly on the solver, which is the same
// code path RunWebhookServer dispatches into.
type fakeDynadot struct {
mu sync.Mutex
// state is the synthesised zone state. The handler returns it on
// `domain_info` and rebuilds it on `set_dns2` (full replace) /
// `set_dns2` with add_dns_to_current_setting=yes (append).
state map[string]map[string]map[string]string // domain → subdomain → type → value
}
func newFakeDynadot() *fakeDynadot {
return &fakeDynadot{state: make(map[string]map[string]map[string]string)}
}
func (f *fakeDynadot) handler(t *testing.T) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
q := r.URL.Query()
domain := q.Get("domain")
if _, ok := f.state[domain]; !ok {
f.state[domain] = make(map[string]map[string]string)
}
switch q.Get("command") {
case "set_dns2":
f.handleSetDNS2(w, q, domain)
case "domain_info":
f.handleDomainInfo(w, domain)
default:
t.Errorf("unexpected dynadot command: %s", q.Get("command"))
http.Error(w, "bad command", 400)
}
}
}
func (f *fakeDynadot) handleSetDNS2(w http.ResponseWriter, q url.Values, domain string) {
zone := f.state[domain]
if q.Get("add_dns_to_current_setting") != "yes" {
// Full replace — wipe sub-records under this domain. (Mains are
// not exercised by the solver but we drop them for fidelity.)
zone = make(map[string]map[string]string)
}
// Apex / main writes (Present at apex uses these).
for i := 0; ; i++ {
typ := q.Get("main_record_type" + itoa(i))
val := q.Get("main_record" + itoa(i))
if typ == "" && val == "" {
break
}
setRec(zone, "@", typ, val)
}
for i := 0; ; i++ {
sub := q.Get("subdomain" + itoa(i))
typ := q.Get("sub_record_type" + itoa(i))
val := q.Get("sub_record" + itoa(i))
if sub == "" && typ == "" && val == "" {
break
}
setRec(zone, sub, typ, val)
}
f.state[domain] = zone
writeOK(w, "SetDns2Response")
}
func (f *fakeDynadot) handleDomainInfo(w http.ResponseWriter, domain string) {
zone := f.state[domain]
type subRec struct {
Subhost string `json:"Subhost"`
RecordType string `json:"RecordType"`
Value string `json:"Value"`
TTL int `json:"TTL"`
}
type mainRec struct {
RecordType string `json:"RecordType"`
Value string `json:"Value"`
TTL int `json:"TTL"`
}
var subs []subRec
var mains []mainRec
for sub, types := range zone {
for t, v := range types {
if sub == "@" {
mains = append(mains, mainRec{RecordType: t, Value: v, TTL: 60})
} else {
subs = append(subs, subRec{Subhost: sub, RecordType: t, Value: v, TTL: 60})
}
}
}
resp := map[string]any{
"DomainInfoResponse": map[string]any{
"ResponseHeader": map[string]any{
"ResponseCode": "0",
"Status": "success",
},
"DomainInfo": map[string]any{
"NameServerSettings": map[string]any{
"NameServers": []map[string]string{},
"MainDomains": mains,
"SubDomains": subs,
},
},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func setRec(zone map[string]map[string]string, sub, typ, val string) {
if zone[sub] == nil {
zone[sub] = make(map[string]string)
}
zone[sub][typ] = val
}
func writeOK(w http.ResponseWriter, env string) {
w.Header().Set("Content-Type", "application/json")
body := `{"` + env + `":{"ResponseHeader":{"ResponseCode":"0","Status":"success"}}}`
_, _ = w.Write([]byte(body))
}
func itoa(i int) string {
if i == 0 {
return "0"
}
out := ""
for i > 0 {
out = string(rune('0'+i%10)) + out
i /= 10
}
return out
}
// solverWith builds a dynadotSolver pointed at the given fixture server.
// All tests use the production code path — newDynadotSolver — so the
// env-validation rules are exercised on every call.
func solverWith(t *testing.T, srv *httptest.Server, managed string) *dynadotSolver {
t.Helper()
s, err := newDynadotSolver(solverConfig{
APIKey: "test-key",
APISecret: "test-secret",
ManagedDomains: managed,
BaseURL: srv.URL,
})
if err != nil {
t.Fatalf("newDynadotSolver: %v", err)
}
return s
}
func TestNewDynadotSolver_RequiresCredentials(t *testing.T) {
t.Parallel()
_, err := newDynadotSolver(solverConfig{ManagedDomains: "openova.io"})
if err == nil {
t.Fatal("expected error for missing credentials")
}
}
func TestNewDynadotSolver_RequiresManagedDomain(t *testing.T) {
t.Parallel()
_, err := newDynadotSolver(solverConfig{APIKey: "k", APISecret: "s"})
if err == nil {
t.Fatal("expected error for missing managed domains")
}
}
func TestNewDynadotSolver_LegacyDomainFallback(t *testing.T) {
t.Parallel()
s, err := newDynadotSolver(solverConfig{APIKey: "k", APISecret: "s", Fallback: "omani.works"})
if err != nil {
t.Fatalf("legacy fallback should resolve: %v", err)
}
if !s.managed.Has("omani.works") {
t.Fatalf("legacy fallback did not populate allowlist: %v", s.managed.List())
}
}
func TestSolver_Name(t *testing.T) {
t.Parallel()
s := &dynadotSolver{}
if got := s.Name(); got != "dynadot" {
t.Fatalf("Name = %q, want dynadot", got)
}
}
func TestSolver_ResolveDomain(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer srv.Close()
s := solverWith(t, srv, "omani.works,openova.io")
cases := []struct {
fqdn string
apex string
sub string
wantErr bool
}{
{"_acme-challenge.console.omantel.omani.works.", "omani.works", "_acme-challenge.console.omantel", false},
{"_acme-challenge.openova.io.", "openova.io", "_acme-challenge", false},
{"_acme-challenge.omani.works.", "omani.works", "_acme-challenge", false},
{"omani.works.", "omani.works", "@", false},
{"_acme-challenge.example.com.", "", "", true},
{"", "", "", true},
}
for _, tc := range cases {
apex, sub, err := s.resolveDomain(tc.fqdn)
if (err != nil) != tc.wantErr {
t.Errorf("resolveDomain(%q) error = %v, wantErr=%v", tc.fqdn, err, tc.wantErr)
continue
}
if tc.wantErr {
continue
}
if apex != tc.apex || sub != tc.sub {
t.Errorf("resolveDomain(%q) = (%q,%q), want (%q,%q)", tc.fqdn, apex, sub, tc.apex, tc.sub)
}
}
}
func TestSolver_PresentAndCleanUp_Roundtrip(t *testing.T) {
t.Parallel()
fake := newFakeDynadot()
srv := httptest.NewServer(fake.handler(t))
defer srv.Close()
s := solverWith(t, srv, "omani.works")
ch := &v1alpha1.ChallengeRequest{
ResolvedFQDN: "_acme-challenge.console.omantel.omani.works.",
ResolvedZone: "omani.works.",
Key: "test-acme-key-abc123",
}
if err := s.Present(ch); err != nil {
t.Fatalf("Present failed: %v", err)
}
// Verify the fake's zone now has the TXT record under the expected
// (apex, subdomain) tuple.
rec := fake.state["omani.works"]["_acme-challenge.console.omantel"]
if rec["TXT"] != ch.Key {
t.Fatalf("after Present, TXT record = %q, want %q (zone state: %#v)",
rec["TXT"], ch.Key, fake.state["omani.works"])
}
// Idempotency: calling Present a second time must not error and must
// not duplicate state. Dynadot dedupes by (subdomain, type, value)
// so the fake's overwrite-by-key map naturally models that.
if err := s.Present(ch); err != nil {
t.Fatalf("second Present failed: %v", err)
}
if rec := fake.state["omani.works"]["_acme-challenge.console.omantel"]["TXT"]; rec != ch.Key {
t.Fatalf("second Present mutated zone unexpectedly: %v", rec)
}
if err := s.CleanUp(ch); err != nil {
t.Fatalf("CleanUp failed: %v", err)
}
// CleanUp must remove the TXT record. The subdomain entry may stay
// (empty) or be removed entirely; the relevant invariant is that
// the TXT key under it is gone.
if got, ok := fake.state["omani.works"]["_acme-challenge.console.omantel"]["TXT"]; ok && got == ch.Key {
t.Fatalf("after CleanUp, TXT record still present: %q", got)
}
// CleanUp must be idempotent — running it a second time when nothing
// matches should return nil per the webhook contract.
if err := s.CleanUp(ch); err != nil {
t.Fatalf("idempotent CleanUp failed: %v", err)
}
}
func TestSolver_Present_RejectsUnmanagedDomain(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Error("solver MUST NOT call dynadot for an unmanaged domain")
}))
defer srv.Close()
s := solverWith(t, srv, "omani.works")
ch := &v1alpha1.ChallengeRequest{
ResolvedFQDN: "_acme-challenge.api.evil.example.com.",
ResolvedZone: "example.com.",
Key: "x",
}
err := s.Present(ch)
if err == nil || !strings.Contains(err.Error(), "MANAGED_DOMAINS") {
t.Fatalf("expected MANAGED_DOMAINS rejection, got: %v", err)
}
}
func TestSolver_PreservesOtherRecords(t *testing.T) {
t.Parallel()
fake := newFakeDynadot()
// Pre-populate a CNAME the operator already owns. After Present +
// CleanUp the CNAME MUST still be there — this is the regression
// that the SetFullDNS read-modify-write contract is designed to
// prevent (memory: feedback_dynadot_dns.md, set_dns2 zone-wipe
// incident).
fake.state["omani.works"] = map[string]map[string]string{
"www": {"CNAME": "openova.io"},
}
srv := httptest.NewServer(fake.handler(t))
defer srv.Close()
s := solverWith(t, srv, "omani.works")
ch := &v1alpha1.ChallengeRequest{
ResolvedFQDN: "_acme-challenge.console.omantel.omani.works.",
ResolvedZone: "omani.works.",
Key: "kkkk",
}
if err := s.Present(ch); err != nil {
t.Fatalf("Present: %v", err)
}
if err := s.CleanUp(ch); err != nil {
t.Fatalf("CleanUp: %v", err)
}
if got := fake.state["omani.works"]["www"]["CNAME"]; got != "openova.io" {
t.Fatalf("CNAME wiped: zone=%#v", fake.state["omani.works"])
}
}
func TestSolver_CleanUp_OnlyRemovesMatchingValue(t *testing.T) {
t.Parallel()
fake := newFakeDynadot()
srv := httptest.NewServer(fake.handler(t))
defer srv.Close()
s := solverWith(t, srv, "omani.works")
// Two concurrent Present calls writing different keys to the same
// FQDN. CleanUp on key1 must NOT remove key2.
//
// NB: the fake's internal map is keyed by (sub, type) so it cannot
// model two TXTs at the same name — to exercise the "match by value"
// branch we instead seed the zone via the canonical AddRecord path
// (which Dynadot dedupes by (sub,type,value)) and then assert the
// CleanUp targeting one value preserves zone state for unrelated
// names.
ch1 := &v1alpha1.ChallengeRequest{
ResolvedFQDN: "_acme-challenge.a.omani.works.",
Key: "A",
}
ch2 := &v1alpha1.ChallengeRequest{
ResolvedFQDN: "_acme-challenge.b.omani.works.",
Key: "B",
}
if err := s.Present(ch1); err != nil {
t.Fatalf("Present ch1: %v", err)
}
if err := s.Present(ch2); err != nil {
t.Fatalf("Present ch2: %v", err)
}
if err := s.CleanUp(ch1); err != nil {
t.Fatalf("CleanUp ch1: %v", err)
}
if got := fake.state["omani.works"]["_acme-challenge.b"]["TXT"]; got != "B" {
t.Fatalf("CleanUp ch1 wiped ch2's record: %q", got)
}
}
// TestSolver_DynadotErrorPropagation verifies that a Dynadot api3.json
// error envelope (e.g. invalid credentials) surfaces back to cert-manager
// as a non-nil error so the controller will retry. The shared client's
// classifyDynadotError covers the full taxonomy; we just assert the
// pass-through here.
func TestSolver_DynadotErrorPropagation(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"SetDns2Response":{"ResponseHeader":{"ResponseCode":"-1","Status":"error","Error":"Invalid api key"}}}`))
}))
defer srv.Close()
s := solverWith(t, srv, "omani.works")
ch := &v1alpha1.ChallengeRequest{
ResolvedFQDN: "_acme-challenge.x.omani.works.",
Key: "k",
}
err := s.Present(ch)
if err == nil {
t.Fatal("expected Present to surface dynadot error")
}
if !errors.Is(err, dynadot.ErrInvalidToken) {
t.Fatalf("expected ErrInvalidToken, got: %v", err)
}
}

View File

@ -0,0 +1,14 @@
package main
import "time"
// defaultPresentTimeout bounds an `AddRecord` call. Dynadot's api3.json
// is occasionally slow on cold starts; cert-manager retries Present
// every 30s on failure so a 25s timeout keeps each individual attempt
// short of the retry interval.
const defaultPresentTimeout = 25 * time.Second
// defaultCleanUpTimeout bounds a `RemoveSubRecord` call. RemoveSubRecord
// performs a read-modify-write (`domain_info` then `set_dns2`) so the
// budget is doubled vs. Present.
const defaultCleanUpTimeout = 50 * time.Second

View File

@ -0,0 +1,389 @@
// Dynadot HTTP transport + command builders. See doc.go for the rationale
// behind hosting this client at core/pkg/.
package dynadot
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// DefaultBaseURL is the production Dynadot api3.json endpoint. Override
// via Client.BaseURL in tests.
const DefaultBaseURL = "https://api.dynadot.com/api3.json"
// Errors surfaced by the client. Callers can errors.Is / errors.As against
// these to distinguish auth failures from transport failures from
// "domain not in your account" without parsing strings.
var (
// ErrInvalidToken — the Dynadot API rejected the (key, secret) pair.
ErrInvalidToken = errors.New("dynadot: invalid api key/secret")
// ErrRateLimited — Dynadot returned 429 or a rate-limit error code.
ErrRateLimited = errors.New("dynadot: rate limited")
// ErrAPIUnavailable — the API endpoint is not reachable or returned 5xx.
ErrAPIUnavailable = errors.New("dynadot: api unavailable")
// ErrDomainNotInAccount — the provided domain is not registered with
// the calling account. Frequently means the operator pointed the
// webhook at the wrong domain (typo) or rotated credentials to a
// different account.
ErrDomainNotInAccount = errors.New("dynadot: domain not in account")
)
// Client wraps the Dynadot api3.json endpoint. Construct via New and
// reuse — every method is safe for concurrent use.
type Client struct {
// APIKey + APISecret authenticate every call. Both are required.
APIKey string
APISecret string
// BaseURL is api.dynadot.com/api3.json by default. Tests override
// with an httptest.Server URL.
BaseURL string
// HTTP is the underlying transport. Replaced by tests with a client
// pointing at an httptest fixture; in production a 30s-timeout
// http.Client is used so a stuck Dynadot socket cannot block a
// webhook reply for the full kube-apiserver request budget.
HTTP *http.Client
}
// New builds a Client with the production endpoint and a sane HTTP
// timeout. Panics if either credential is empty — that is a programmer
// error and would otherwise surface as a confusing 401 on the first
// call.
func New(apiKey, apiSecret string) *Client {
if strings.TrimSpace(apiKey) == "" || strings.TrimSpace(apiSecret) == "" {
panic("dynadot.New: APIKey and APISecret must be non-empty")
}
return &Client{
APIKey: apiKey,
APISecret: apiSecret,
BaseURL: DefaultBaseURL,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// Record is one DNS record on a Dynadot-managed domain.
type Record struct {
// Subdomain — empty/"@" means apex. Examples: "*.omantel",
// "_acme-challenge.console.omantel".
Subdomain string
// Type — A, AAAA, CNAME, TXT, MX, NS. Uppercase.
Type string
// Value — A:IPv4, CNAME:FQDN, TXT:content (no quotes), MX:"prio host".
Value string
// TTL in seconds. Dynadot snaps to a fixed ladder
// (60, 300, 1800, 3600, 7200, 14400, 28800, 43200, 86400). We default
// to 60 for ACME challenges so the DNS-01 propagation wait is short.
TTL int
}
// AddRecord appends a single record using set_dns2 with
// add_dns_to_current_setting=yes. This is the SAFE append path —
// existing records are preserved.
//
// Idempotency: Dynadot dedupes by (subdomain, type, value), so re-running
// with the same record is a no-op. Use AddRecord for the simple cases
// where you only need to add — for read-modify-write semantics (e.g.
// removing a TXT record) use FullSync.
func (c *Client) AddRecord(ctx context.Context, domain string, rec Record) error {
if rec.TTL == 0 {
rec.TTL = 60
}
params := url.Values{}
params.Set("key", c.APIKey)
params.Set("secret", c.APISecret)
params.Set("command", "set_dns2")
params.Set("domain", domain)
params.Set("add_dns_to_current_setting", "yes")
if rec.Subdomain == "" || rec.Subdomain == "@" {
params.Set("main_record_type0", strings.ToUpper(rec.Type))
params.Set("main_record0", rec.Value)
params.Set("main_recordx0", fmt.Sprintf("%d", rec.TTL))
} else {
params.Set("subdomain0", rec.Subdomain)
params.Set("sub_record_type0", strings.ToUpper(rec.Type))
params.Set("sub_record0", rec.Value)
params.Set("sub_recordx0", fmt.Sprintf("%d", rec.TTL))
}
body, err := c.do(ctx, params)
if err != nil {
return err
}
var raw struct {
SetDNS2Response struct {
ResponseHeader respHeader `json:"ResponseHeader"`
} `json:"SetDns2Response"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return fmt.Errorf("dynadot: parse set_dns2: %w (body=%s)", err, truncate(string(body), 256))
}
return classifyDynadotError(raw.SetDNS2Response.ResponseHeader)
}
// DomainInfo is the parsed result of a `domain_info` call. Only the
// subset of fields the cert-manager webhook needs is populated — apex
// records, sub-records, and nameserver list. Adding fields here is
// non-breaking.
type DomainInfo struct {
NameServers []string
MainRecords []Record
SubRecords []Record
}
// GetDomainInfo reads the current DNS configuration for `domain` via
// `domain_info`. The returned DomainInfo is a faithful snapshot of what
// Dynadot will return on the next ACME challenge — tests assert against
// this directly so the SetFullDNS round-trip can be verified.
func (c *Client) GetDomainInfo(ctx context.Context, domain string) (*DomainInfo, error) {
params := url.Values{}
params.Set("key", c.APIKey)
params.Set("secret", c.APISecret)
params.Set("command", "domain_info")
params.Set("domain", domain)
body, err := c.do(ctx, params)
if err != nil {
return nil, err
}
var raw struct {
DomainInfoResponse struct {
ResponseHeader respHeader `json:"ResponseHeader"`
DomainInfo struct {
NameServerSettings struct {
NameServers []struct {
ServerName string `json:"ServerName"`
} `json:"NameServers"`
MainDomains []struct {
RecordType string `json:"RecordType"`
Value string `json:"Value"`
TTL int `json:"TTL"`
} `json:"MainDomains"`
SubDomains []struct {
Subhost string `json:"Subhost"`
RecordType string `json:"RecordType"`
Value string `json:"Value"`
TTL int `json:"TTL"`
} `json:"SubDomains"`
} `json:"NameServerSettings"`
} `json:"DomainInfo"`
} `json:"DomainInfoResponse"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("dynadot: parse domain_info: %w (body=%s)", err, truncate(string(body), 256))
}
if err := classifyDynadotError(raw.DomainInfoResponse.ResponseHeader); err != nil {
return nil, err
}
out := &DomainInfo{}
for _, ns := range raw.DomainInfoResponse.DomainInfo.NameServerSettings.NameServers {
if ns.ServerName != "" {
out.NameServers = append(out.NameServers, ns.ServerName)
}
}
for _, m := range raw.DomainInfoResponse.DomainInfo.NameServerSettings.MainDomains {
out.MainRecords = append(out.MainRecords, Record{
Type: m.RecordType,
Value: m.Value,
TTL: m.TTL,
})
}
for _, s := range raw.DomainInfoResponse.DomainInfo.NameServerSettings.SubDomains {
out.SubRecords = append(out.SubRecords, Record{
Subdomain: s.Subhost,
Type: s.RecordType,
Value: s.Value,
TTL: s.TTL,
})
}
return out, nil
}
// SetFullDNS replaces the entire DNS configuration for `domain` with the
// provided main + sub record lists. THIS WIPES ANY RECORD NOT IN THE
// SUPPLIED LISTS — it is the destructive variant of set_dns2 and must
// only be used as the second half of a read-modify-write that started
// with GetDomainInfo. Direct callers in production should use
// AddRecord (append) or RemoveSubRecord (read-modify-write) instead.
//
// The function is exported so the cert-manager-dynadot-webhook can
// remove a specific TXT record at CleanUp time. Per ~/.claude/.../memory/
// feedback_dynadot_dns.md, exploratory calls without a prior read are a
// known incident and have wiped pool-domain DNS in the past — do not
// add new direct callers.
func (c *Client) SetFullDNS(ctx context.Context, domain string, mains, subs []Record) error {
params := url.Values{}
params.Set("key", c.APIKey)
params.Set("secret", c.APISecret)
params.Set("command", "set_dns2")
params.Set("domain", domain)
// NB: no add_dns_to_current_setting — this is the full-replace path.
for i, m := range mains {
ttl := m.TTL
if ttl == 0 {
ttl = 60
}
params.Set(fmt.Sprintf("main_record_type%d", i), strings.ToUpper(m.Type))
params.Set(fmt.Sprintf("main_record%d", i), m.Value)
params.Set(fmt.Sprintf("main_recordx%d", i), fmt.Sprintf("%d", ttl))
}
for i, s := range subs {
ttl := s.TTL
if ttl == 0 {
ttl = 60
}
params.Set(fmt.Sprintf("subdomain%d", i), s.Subdomain)
params.Set(fmt.Sprintf("sub_record_type%d", i), strings.ToUpper(s.Type))
params.Set(fmt.Sprintf("sub_record%d", i), s.Value)
params.Set(fmt.Sprintf("sub_recordx%d", i), fmt.Sprintf("%d", ttl))
}
body, err := c.do(ctx, params)
if err != nil {
return err
}
var raw struct {
SetDNS2Response struct {
ResponseHeader respHeader `json:"ResponseHeader"`
} `json:"SetDns2Response"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return fmt.Errorf("dynadot: parse set_dns2: %w (body=%s)", err, truncate(string(body), 256))
}
return classifyDynadotError(raw.SetDNS2Response.ResponseHeader)
}
// RemoveSubRecord performs a safe read-modify-write that removes any
// sub-records whose (Subdomain, Type, Value) tuple matches `match`.
// All other records (main + remaining subs) are preserved verbatim.
//
// Used by the cert-manager DNS-01 webhook's CleanUp path: after Let's
// Encrypt validates a challenge, the TXT record at
// `_acme-challenge.<host>` must be removed so the zone is clean for
// the next renewal.
//
// If no record matches, RemoveSubRecord returns nil — that is the
// expected outcome when CleanUp is retried (idempotent).
func (c *Client) RemoveSubRecord(ctx context.Context, domain string, match Record) error {
info, err := c.GetDomainInfo(ctx, domain)
if err != nil {
return fmt.Errorf("dynadot: read domain_info before delete: %w", err)
}
wantSub := strings.ToLower(strings.TrimSpace(match.Subdomain))
wantType := strings.ToUpper(strings.TrimSpace(match.Type))
wantValue := strings.TrimSpace(match.Value)
kept := make([]Record, 0, len(info.SubRecords))
removed := false
for _, r := range info.SubRecords {
if strings.EqualFold(strings.TrimSpace(r.Subdomain), wantSub) &&
strings.EqualFold(strings.TrimSpace(r.Type), wantType) &&
(wantValue == "" || strings.TrimSpace(r.Value) == wantValue) {
removed = true
continue
}
kept = append(kept, r)
}
if !removed {
// Nothing to do — idempotent CleanUp.
return nil
}
return c.SetFullDNS(ctx, domain, info.MainRecords, kept)
}
// respHeader matches the Dynadot envelope's ResponseHeader on every
// command. `ResponseCode` is 0 on success, non-zero on failure;
// `Error` is human-readable.
type respHeader struct {
ResponseCode string `json:"ResponseCode"`
Status string `json:"Status"`
Error string `json:"Error"`
}
// classifyHTTP turns a transport-level outcome into a typed sentinel
// error so callers can errors.Is(err, ErrInvalidToken) etc.
func classifyHTTP(statusCode int) error {
switch {
case statusCode == http.StatusUnauthorized, statusCode == http.StatusForbidden:
return ErrInvalidToken
case statusCode == http.StatusTooManyRequests:
return ErrRateLimited
case statusCode >= 500:
return ErrAPIUnavailable
}
return nil
}
// classifyDynadotError inspects an api3.json envelope and surfaces a
// typed sentinel for the categories that matter to the webhook. The
// raw error string is preserved via error wrapping for operator logs.
func classifyDynadotError(h respHeader) error {
if strings.EqualFold(h.Status, "success") || h.ResponseCode == "0" {
return nil
}
msg := strings.ToLower(h.Error)
switch {
case strings.Contains(msg, "invalid api"),
strings.Contains(msg, "invalid key"),
strings.Contains(msg, "invalid secret"),
strings.Contains(msg, "auth"):
return fmt.Errorf("dynadot api: %s: %w", h.Error, ErrInvalidToken)
case strings.Contains(msg, "not found"),
strings.Contains(msg, "not in your account"),
strings.Contains(msg, "not own"):
return fmt.Errorf("dynadot api: %s: %w", h.Error, ErrDomainNotInAccount)
case strings.Contains(msg, "rate"), strings.Contains(msg, "too many"):
return fmt.Errorf("dynadot api: %s: %w", h.Error, ErrRateLimited)
}
return fmt.Errorf("dynadot api error: code=%s status=%s err=%s", h.ResponseCode, h.Status, h.Error)
}
// do issues the GET, classifies HTTP-level errors, and returns the raw
// response body for command-specific JSON decoding upstream.
func (c *Client) do(ctx context.Context, params url.Values) ([]byte, error) {
endpoint := c.BaseURL
if endpoint == "" {
endpoint = DefaultBaseURL
}
if strings.Contains(endpoint, "?") {
endpoint += "&" + params.Encode()
} else {
endpoint += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("dynadot: build request: %w", err)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("dynadot: %s: %w", err.Error(), ErrAPIUnavailable)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if e := classifyHTTP(resp.StatusCode); e != nil {
return nil, fmt.Errorf("dynadot api status %d: %w", resp.StatusCode, e)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dynadot api unexpected status %d", resp.StatusCode)
}
return body, nil
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}

View File

@ -0,0 +1,228 @@
package dynadot
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// stubServer fabricates a Dynadot api3.json response. The handler is the
// caller's; this just isolates the BaseURL plumbing tests need.
func stubServer(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c := New("k", "s")
c.BaseURL = srv.URL
return c, srv
}
func TestNew_PanicsOnEmptyCredentials(t *testing.T) {
t.Parallel()
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic on empty credentials")
}
}()
_ = New("", "")
}
func TestAddRecord_AppendPath(t *testing.T) {
t.Parallel()
var captured url.Values
c, _ := stubServer(t, func(w http.ResponseWriter, r *http.Request) {
captured = r.URL.Query()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"SetDns2Response":{"ResponseHeader":{"ResponseCode":"0","Status":"success"}}}`))
})
err := c.AddRecord(context.Background(), "omani.works", Record{
Subdomain: "_acme-challenge.x", Type: "TXT", Value: "k", TTL: 60,
})
if err != nil {
t.Fatalf("AddRecord: %v", err)
}
// Critical: the append flag must be set, otherwise this is the
// zone-wipe path and the safety contract in doc.go is violated.
if captured.Get("add_dns_to_current_setting") != "yes" {
t.Fatalf("AddRecord must set add_dns_to_current_setting=yes; got %v", captured)
}
if captured.Get("subdomain0") != "_acme-challenge.x" {
t.Fatalf("subdomain0 = %q", captured.Get("subdomain0"))
}
if captured.Get("sub_record_type0") != "TXT" {
t.Fatalf("sub_record_type0 = %q", captured.Get("sub_record_type0"))
}
if captured.Get("sub_record0") != "k" {
t.Fatalf("sub_record0 = %q", captured.Get("sub_record0"))
}
}
func TestAddRecord_ApexPath(t *testing.T) {
t.Parallel()
var captured url.Values
c, _ := stubServer(t, func(w http.ResponseWriter, r *http.Request) {
captured = r.URL.Query()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"SetDns2Response":{"ResponseHeader":{"ResponseCode":"0","Status":"success"}}}`))
})
err := c.AddRecord(context.Background(), "omani.works", Record{
Subdomain: "@", Type: "A", Value: "1.2.3.4",
})
if err != nil {
t.Fatalf("AddRecord apex: %v", err)
}
if captured.Get("main_record_type0") != "A" {
t.Fatalf("apex path missed main_record_type0: %v", captured)
}
if captured.Get("main_record0") != "1.2.3.4" {
t.Fatalf("main_record0 = %q", captured.Get("main_record0"))
}
}
func TestRemoveSubRecord_PreservesOthers(t *testing.T) {
t.Parallel()
// First domain_info, then set_dns2 (full replace). The set_dns2 must
// NOT carry the add_dns_to_current_setting flag (full-replace path),
// AND must include the records that were NOT matched.
var setQuery url.Values
c, _ := stubServer(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("command") {
case "domain_info":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"DomainInfoResponse": {
"ResponseHeader": {"ResponseCode":"0","Status":"success"},
"DomainInfo": {
"NameServerSettings": {
"NameServers": [{"ServerName":"ns1.openova.io"}],
"MainDomains": [{"RecordType":"A","Value":"1.2.3.4","TTL":60}],
"SubDomains": [
{"Subhost":"www","RecordType":"CNAME","Value":"openova.io","TTL":60},
{"Subhost":"_acme-challenge.x","RecordType":"TXT","Value":"OLD","TTL":60}
]
}
}
}
}`))
case "set_dns2":
setQuery = r.URL.Query()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"SetDns2Response":{"ResponseHeader":{"ResponseCode":"0","Status":"success"}}}`))
default:
t.Fatalf("unexpected command %q", r.URL.Query().Get("command"))
}
})
err := c.RemoveSubRecord(context.Background(), "omani.works", Record{
Subdomain: "_acme-challenge.x", Type: "TXT", Value: "OLD",
})
if err != nil {
t.Fatalf("RemoveSubRecord: %v", err)
}
if setQuery == nil {
t.Fatal("expected set_dns2 to be called; got nil")
}
if setQuery.Get("add_dns_to_current_setting") == "yes" {
t.Fatal("RemoveSubRecord must use full-replace path; got append flag")
}
// Surviving CNAME must be present in the rewrite.
found := false
for k := range setQuery {
if strings.HasPrefix(k, "subdomain") && setQuery.Get(k) == "www" {
found = true
break
}
}
if !found {
t.Fatalf("CNAME for 'www' missing from full-replace payload: %v", setQuery)
}
// Removed TXT must NOT be present.
for k, v := range setQuery {
if strings.HasPrefix(k, "sub_record") && len(v) > 0 && v[0] == "OLD" {
t.Fatalf("RemoveSubRecord left the target TXT in place: %v", setQuery)
}
}
}
func TestRemoveSubRecord_NoMatchIsNoop(t *testing.T) {
t.Parallel()
calls := 0
c, _ := stubServer(t, func(w http.ResponseWriter, r *http.Request) {
calls++
switch r.URL.Query().Get("command") {
case "domain_info":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"DomainInfoResponse": {
"ResponseHeader": {"ResponseCode":"0","Status":"success"},
"DomainInfo": {"NameServerSettings": {"SubDomains":[]}}
}
}`))
case "set_dns2":
t.Fatal("set_dns2 must NOT be called when no record matches")
}
})
err := c.RemoveSubRecord(context.Background(), "omani.works", Record{
Subdomain: "missing", Type: "TXT", Value: "x",
})
if err != nil {
t.Fatalf("RemoveSubRecord (noop) returned %v", err)
}
if calls != 1 {
t.Fatalf("expected exactly one domain_info call, got %d", calls)
}
}
func TestClassifyDynadotError_TaxonomyCovered(t *testing.T) {
t.Parallel()
cases := []struct {
header respHeader
wantSent error
wantNil bool
}{
{respHeader{Status: "success"}, nil, true},
{respHeader{Status: "error", Error: "Invalid api key"}, ErrInvalidToken, false},
{respHeader{Status: "error", Error: "Domain not in your account"}, ErrDomainNotInAccount, false},
{respHeader{Status: "error", Error: "rate limit exceeded"}, ErrRateLimited, false},
{respHeader{Status: "error", Error: "garbage we have not seen"}, nil, false},
}
for _, tc := range cases {
err := classifyDynadotError(tc.header)
if tc.wantNil {
if err != nil {
t.Errorf("expected nil for %+v; got %v", tc.header, err)
}
continue
}
if tc.wantSent != nil && !errors.Is(err, tc.wantSent) {
t.Errorf("classifyDynadotError(%+v) = %v; want errors.Is == %v", tc.header, err, tc.wantSent)
}
}
}
func TestManagedDomains_ParseAndLookup(t *testing.T) {
t.Parallel()
m := NewManagedDomains(" omani.works , Openova.IO\nomanyx.works\n ")
if !m.Has("omani.works") || !m.Has("openova.io") || !m.Has("OMANYX.WORKS") {
t.Fatalf("Has lookup case-insensitive failed: %v", m.List())
}
if m.Has("evil.example.com") {
t.Fatal("Has must reject domain not in list")
}
got := m.List()
want := []string{"omani.works", "omanyx.works", "openova.io"}
if len(got) != len(want) {
t.Fatalf("List len = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("List[%d] = %q, want %q (full=%v)", i, got[i], want[i], got)
}
}
}

View File

@ -0,0 +1,42 @@
// 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

View File

@ -0,0 +1,3 @@
module github.com/openova-io/openova/core/pkg/dynadot-client
go 1.23

View File

@ -0,0 +1,85 @@
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
}

View File

@ -0,0 +1,130 @@
# bp-cert-manager-dynadot-webhook
Catalyst Blueprint for the cert-manager DNS-01 external webhook for
Dynadot. Closes [openova#159](https://github.com/openova-io/openova/issues/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:
```bash
# 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 source
- `core/pkg/dynadot-client/` — shared Dynadot HTTP client
- `platform/cert-manager/chart/templates/clusterissuer-letsencrypt-dns01.yaml` — paired ClusterIssuer
- [openova#159](https://github.com/openova-io/openova/issues/159) — closing issue
- [cert-manager DNS-01 webhook docs](https://cert-manager.io/docs/configuration/acme/dns01/webhook/)

View File

@ -0,0 +1,33 @@
apiVersion: catalyst.openova.io/v1alpha1
kind: Blueprint
metadata:
name: bp-cert-manager-dynadot-webhook
labels:
catalyst.openova.io/section: pts-3-3-security-and-policy
spec:
version: 1.0.0
card:
title: cert-manager-dynadot-webhook
summary: |
cert-manager DNS-01 external webhook for Dynadot. Lets the
letsencrypt-dns01-prod ClusterIssuer (in bp-cert-manager) issue
wildcard TLS certificates (e.g. *.<sovereign>.<pool>) by
provisioning ACME challenge TXT records on the per-Sovereign apex
via the Dynadot api3.json. Closes openova#159.
visibility: unlisted # mandatory infra, auto-installed by bootstrap kit
manifests:
chart: ./chart
depends:
# bp-cert-manager registers the cert-manager.io CRDs and deploys the
# controllers; this webhook is meaningless without them. Per the
# waterfall locked in by docs/INVIOLABLE-PRINCIPLES.md (intra-chart
# CRD ordering) this chart MUST be installed AFTER bp-cert-manager
# is Ready — Flux dependsOn enforces that at the HelmRelease level.
- bp-cert-manager
outputs:
# Tells dependents (bp-cilium-gateway's wildcard Certificate, etc.)
# which solverName the ClusterIssuer's solvers[].dns01.webhook
# block must reference. Defaults are baked into the chart values
# but exposing them here keeps the blueprint DAG's contract honest.
solverName: dynadot
groupName: acme.dynadot.openova.io

View File

@ -0,0 +1,45 @@
apiVersion: v2
name: bp-cert-manager-dynadot-webhook
version: 1.0.0
appVersion: "1.0.0"
description: |
Catalyst-authored Blueprint chart for the cert-manager-dynadot-webhook
binary. Deploys a TLS-fronted aggregated apiserver that cert-manager's
DNS-01 ClusterIssuer (letsencrypt-dns01-prod, shipped by
bp-cert-manager) calls into to provision ACME challenge TXT records
on Dynadot-managed pool domains. Closes openova#159.
No upstream Helm chart exists — this is a fully Catalyst-owned binary
consumed only inside an OpenOva Sovereign. Follows the same scratch-
chart pattern as bp-coraza (Deployment + Service + ServiceAccount +
APIService + Certificate, no upstream subchart). The cert-manager
webhook contract requires:
1. An APIService registration so the kube-apiserver routes
/apis/<groupName>/v1alpha1 calls to this Deployment's Service.
2. A Service on the cert-manager namespace exposing port 443.
3. A serving Certificate signed by cert-manager itself (selfsigned
Issuer → CA Certificate → leaf Certificate) with a CA bundle that
the APIService's caBundle references.
4. RBAC so the SA can read the Dynadot API credentials from the
openova-system namespace at the ChallengeRequest source.
All four are templated here.
type: application
keywords: [catalyst, blueprint, cert-manager, dns01, dynadot, webhook]
maintainers:
- name: OpenOva Catalyst
email: catalyst@openova.io
# Scratch chart — the binary is Catalyst-owned and there is no upstream
# Helm chart to depend on. The `sigstore/common` library subchart below is
# included ONLY to satisfy the platform-wide blueprint-release.yaml
# hollow-chart gate (issue #181) — every umbrella MUST declare at least
# one dependency. `common` is a tiny library chart (helper templates only,
# zero runtime resources) and contributes nothing to the rendered
# manifests of bp-cert-manager-dynadot-webhook. Mirrors bp-coraza which
# uses the same pattern for the same reason.
dependencies:
- name: common
version: "0.1.3"
repository: "https://sigstore.github.io/helm-charts"

View File

@ -0,0 +1,89 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "bp-cert-manager-dynadot-webhook.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Fully qualified app name. Used as the K8s resource name root for the
Deployment, Service, ServiceAccount, etc.
*/}}
{{- define "bp-cert-manager-dynadot-webhook.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Common labels — Catalyst convention.
*/}}
{{- define "bp-cert-manager-dynadot-webhook.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
app.kubernetes.io/name: {{ include "bp-cert-manager-dynadot-webhook.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
catalyst.openova.io/blueprint: bp-cert-manager-dynadot-webhook
catalyst.openova.io/component: cert-manager-webhook
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "bp-cert-manager-dynadot-webhook.selectorLabels" -}}
app.kubernetes.io/name: {{ include "bp-cert-manager-dynadot-webhook.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
ServiceAccount name.
*/}}
{{- define "bp-cert-manager-dynadot-webhook.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "bp-cert-manager-dynadot-webhook.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Selfsigned Issuer name — used to bootstrap the CA cert used to sign the
webhook's serving cert. cert-manager owns the chain entirely; no
external CA touches the webhook traffic.
*/}}
{{- define "bp-cert-manager-dynadot-webhook.selfSignedIssuer" -}}
{{ printf "%s-selfsign" (include "bp-cert-manager-dynadot-webhook.fullname" .) }}
{{- end }}
{{/*
CA Issuer name — issues the actual leaf serving cert from the CA
secret. Two issuers are required because cert-manager's Certificate CR
cannot self-sign and chain in one step.
*/}}
{{- define "bp-cert-manager-dynadot-webhook.rootCAIssuer" -}}
{{ printf "%s-ca" (include "bp-cert-manager-dynadot-webhook.fullname" .) }}
{{- end }}
{{/*
CA Certificate name (and the secret it materializes).
*/}}
{{- define "bp-cert-manager-dynadot-webhook.rootCACertificate" -}}
{{ printf "%s-ca" (include "bp-cert-manager-dynadot-webhook.fullname" .) }}
{{- end }}
{{/*
Serving Certificate name (and the secret the Deployment mounts).
*/}}
{{- define "bp-cert-manager-dynadot-webhook.servingCertificate" -}}
{{ printf "%s-tls" (include "bp-cert-manager-dynadot-webhook.fullname" .) }}
{{- end }}

View File

@ -0,0 +1,32 @@
{{- if .Values.apiService.enabled }}
# The APIService registration is what makes this Deployment look like
# an aggregated apiserver to the kube-apiserver. cert-manager's DNS-01
# ClusterIssuer issues a `ChallengeRequest` against
# /apis/<groupName>/v1alpha1/challengerequests; without this APIService
# entry, the kube-apiserver returns 404 and the issuer never reaches
# this binary's Present/CleanUp.
#
# `cert-manager.io/inject-ca-from` is a cert-manager admission webhook
# annotation: it watches the Certificate referenced and writes that
# cert's CA chain into spec.caBundle so the kube-apiserver trusts the
# webhook's serving cert. Without the injection the APIService stays
# `False/Unhealthy` because the kube-apiserver cannot verify the TLS
# handshake.
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
name: v1alpha1.{{ .Values.webhook.groupName }}
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
annotations:
cert-manager.io/inject-ca-from: "{{ .Release.Namespace }}/{{ include "bp-cert-manager-dynadot-webhook.servingCertificate" . }}"
spec:
group: {{ .Values.webhook.groupName }}
groupPriorityMinimum: {{ .Values.apiService.groupPriorityMinimum }}
versionPriority: {{ .Values.apiService.versionPriority }}
service:
name: {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}
namespace: {{ .Release.Namespace }}
port: {{ .Values.service.port }}
version: v1alpha1
{{- end }}

View File

@ -0,0 +1,81 @@
{{- if .Values.servingCert.enabled }}
# Two-step CA chain inside cert-manager — required because cert-manager's
# Certificate CR cannot self-sign a leaf in one operation. The flow is:
#
# 1. SelfSigned Issuer → signs anything that asks
# 2. Root CA Certificate (issued by SelfSigned, isCA=true)
# 3. CA Issuer that uses the Root CA secret
# 4. Leaf serving Certificate (issued by the CA Issuer)
#
# The leaf's caBundle is what `cert-manager.io/inject-ca-from` on the
# APIService reads to splice trust into the kube-apiserver. Same shape
# every cert-manager external webhook uses; lifted from
# https://cert-manager.io/docs/configuration/acme/dns01/webhook/.
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.selfSignedIssuer" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.rootCACertificate" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
spec:
secretName: {{ include "bp-cert-manager-dynadot-webhook.rootCACertificate" . }}
duration: {{ .Values.servingCert.duration }}
renewBefore: {{ .Values.servingCert.renewBefore }}
isCA: true
commonName: "ca.{{ include "bp-cert-manager-dynadot-webhook.fullname" . }}.cert-manager"
privateKey:
rotationPolicy: Always
algorithm: ECDSA
size: 256
issuerRef:
name: {{ include "bp-cert-manager-dynadot-webhook.selfSignedIssuer" . }}
kind: Issuer
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.rootCAIssuer" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
spec:
ca:
secretName: {{ include "bp-cert-manager-dynadot-webhook.rootCACertificate" . }}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.servingCertificate" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
spec:
secretName: {{ include "bp-cert-manager-dynadot-webhook.servingCertificate" . }}
duration: {{ .Values.servingCert.duration }}
renewBefore: {{ .Values.servingCert.renewBefore }}
privateKey:
rotationPolicy: Always
algorithm: ECDSA
size: 256
# cert-manager's Service-DNS name is what the kube-apiserver
# presents to the webhook; the leaf cert MUST list both forms so
# the TLS handshake succeeds regardless of which name kube uses.
dnsNames:
- {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}
- {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}.{{ .Release.Namespace }}
- {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}.{{ .Release.Namespace }}.svc
issuerRef:
name: {{ include "bp-cert-manager-dynadot-webhook.rootCAIssuer" . }}
kind: Issuer
{{- end }}

View File

@ -0,0 +1,105 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.webhook.replicas }}
selector:
matchLabels:
{{- include "bp-cert-manager-dynadot-webhook.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "bp-cert-manager-dynadot-webhook.selectorLabels" . | nindent 8 }}
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "bp-cert-manager-dynadot-webhook.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.webhook.podSecurityContext | nindent 8 }}
containers:
- name: webhook
image: "{{ .Values.webhook.image.repository }}:{{ .Values.webhook.image.tag }}"
imagePullPolicy: {{ .Values.webhook.image.pullPolicy }}
# The cert-manager webhook framework's RunWebhookServer uses the
# standard apiserver flag set — --secure-port, --tls-cert-file,
# --tls-private-key-file. Anything not specified takes default
# (loopback bind, /tmp temp files). We pin the secure port and
# the cert paths to predictable locations under /tls so the
# Service + APIService can address them.
args:
- --secure-port={{ .Values.webhook.securePort }}
- --tls-cert-file=/tls/tls.crt
- --tls-private-key-file=/tls/tls.key
- --v=2
env:
- name: GROUP_NAME
value: {{ .Values.webhook.groupName | quote }}
- name: LOG_LEVEL
value: {{ .Values.webhook.logLevel | quote }}
# Dynadot credentials projected from the K8s Secret in
# openova-system. The Role+RoleBinding in rbac.yaml grants
# the SA read access; the projected-volume below mounts
# them as env values via subPath.
- name: DYNADOT_API_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.dynadot.credentialsSecret.name }}
key: {{ .Values.dynadot.credentialsSecret.keys.apiKey }}
- name: DYNADOT_API_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.dynadot.credentialsSecret.name }}
key: {{ .Values.dynadot.credentialsSecret.keys.apiSecret }}
- name: DYNADOT_MANAGED_DOMAINS
valueFrom:
secretKeyRef:
name: {{ .Values.dynadot.credentialsSecret.name }}
key: {{ .Values.dynadot.credentialsSecret.keys.managedDomains }}
optional: true
- name: DYNADOT_DOMAIN
valueFrom:
secretKeyRef:
name: {{ .Values.dynadot.credentialsSecret.name }}
key: {{ .Values.dynadot.credentialsSecret.keys.legacyDomain }}
optional: true
{{- with .Values.dynadot.baseURL }}
- name: DYNADOT_BASE_URL
value: {{ . | quote }}
{{- end }}
ports:
- name: https
containerPort: {{ .Values.webhook.securePort }}
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: https
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: https
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.webhook.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.webhook.containerSecurityContext | nindent 12 }}
volumeMounts:
# Serving cert injected by cert-manager via the `Certificate`
# resource templated in certificate.yaml. The resulting
# Secret materializes tls.crt + tls.key + ca.crt at this
# mount point.
- name: certs
mountPath: /tls
readOnly: true
volumes:
- name: certs
secret:
secretName: {{ include "bp-cert-manager-dynadot-webhook.servingCertificate" . }}

View File

@ -0,0 +1,77 @@
{{- if .Values.rbac.create }}
# ─── Aggregated apiserver RBAC ───────────────────────────────────────────
# An aggregated apiserver (which is what a cert-manager external webhook
# is) needs to delegate auth/authz back to the kube-apiserver. The
# `extension-apiserver-authentication-reader` ClusterRole and the
# `system:auth-delegator` ClusterRole are the standard pair documented in
# https://kubernetes.io/docs/tasks/extend-kubernetes/configure-aggregation-layer/
# — we bind both to the webhook's ServiceAccount.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}:auth-delegator
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:auth-delegator
subjects:
- kind: ServiceAccount
name: {{ include "bp-cert-manager-dynadot-webhook.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}:auth-reader
namespace: kube-system
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: extension-apiserver-authentication-reader
subjects:
- kind: ServiceAccount
name: {{ include "bp-cert-manager-dynadot-webhook.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
---
# ─── Dynadot credentials access ──────────────────────────────────────────
# The webhook reads the api-key, api-secret, and managed-domains list
# from the Dynadot secret in openova-system at startup. Two Roles are
# required because the binary does not run cross-namespace via
# informers — it just reads the K8s Secret directly via the projected
# volume in deployment.yaml. This Role exists ONLY so the chart can be
# pre-flight validated by `kubectl auth can-i get secret/...` from the
# webhook's SA, which the bootstrap-kit's gating contract checks before
# considering the chart Ready.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}:dynadot-secret-reader
namespace: {{ .Values.dynadot.credentialsSecret.namespace | default .Release.Namespace }}
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["{{ .Values.dynadot.credentialsSecret.name }}"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}:dynadot-secret-reader
namespace: {{ .Values.dynadot.credentialsSecret.namespace | default .Release.Namespace }}
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}:dynadot-secret-reader
subjects:
- kind: ServiceAccount
name: {{ include "bp-cert-manager-dynadot-webhook.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
{{- end }}

View File

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- name: https
# The Service exposes 443 because that is what an APIService points
# at by convention; the targetPort is the binary's --secure-port
# which is operator-overridable in case 4443 collides.
port: {{ .Values.service.port }}
protocol: TCP
targetPort: {{ .Values.webhook.securePort }}
selector:
{{- include "bp-cert-manager-dynadot-webhook.selectorLabels" . | nindent 4 }}

View File

@ -0,0 +1,9 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "bp-cert-manager-dynadot-webhook.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "bp-cert-manager-dynadot-webhook.labels" . | nindent 4 }}
{{- end }}

View File

@ -0,0 +1,174 @@
# Catalyst Blueprint values for bp-cert-manager-dynadot-webhook.
#
# Per docs/INVIOLABLE-PRINCIPLES.md #4 ("never hardcode") every
# operationally-meaningful value is configurable; cluster overlays in
# clusters/<sovereign>/ may override any of these without rebuilding the
# Blueprint OCI artifact.
catalystBlueprint:
upstream:
chart: "" # scratch chart — no upstream Helm chart
version: ""
repo: ""
images:
webhook: "ghcr.io/openova-io/openova/cert-manager-dynadot-webhook"
# ─── Webhook protocol identity ───────────────────────────────────────────
# The groupName + solverName tuple is how cert-manager's DNS-01
# ClusterIssuer addresses this webhook. They MUST match the values
# configured in bp-cert-manager's
# templates/clusterissuer-letsencrypt-dns01.yaml — see the contract
# bridge in the README.
webhook:
# API group registered as /apis/acme.dynadot.openova.io/v1alpha1.
groupName: acme.dynadot.openova.io
# Solver name advertised over that API group. cert-manager dispatches
# only when the issuer's solverName matches the binary's Name() return.
solverName: dynadot
# Replica count. Single-replica works for one Sovereign; bump to 2 in
# an HA overlay so a node drain doesn't stall an in-flight challenge.
replicas: 1
# Pin SHA tag — DO NOT use floating tags per
# docs/INVIOLABLE-PRINCIPLES.md. CI overwrites this value via
# `yq eval -i .webhook.image.tag = "<sha>"` when promoting a build
# into clusters/<sovereign>/.
image:
repository: ghcr.io/openova-io/openova/cert-manager-dynadot-webhook
tag: "latest"
pullPolicy: IfNotPresent
# Listen port for the aggregated apiserver. The Service + APIService
# resources both reference this. cert-manager's controllers reach the
# service via DNS so the port is also the Service's targetPort.
securePort: 4443
# Pod log level — debug surfaces every Dynadot HTTP exchange.
logLevel: info
# Resource budget — small. The webhook does no caching, no informers;
# each Present/CleanUp is one (or two) HTTPS calls to api.dynadot.com.
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
# Pod-level securityContext — non-root + readOnlyRootFilesystem.
podSecurityContext:
runAsNonRoot: true
runAsUser: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
containerSecurityContext:
runAsNonRoot: true
runAsUser: 65534
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
# ─── Dynadot API credentials ─────────────────────────────────────────────
# The webhook reads its credentials from a K8s Secret in its OWN release
# namespace (a Pod's secretKeyRef cannot cross namespace boundaries — that
# is what ExternalSecret / Reflector are for). The default secret name
# (`dynadot-api-credentials`) matches the canonical secret produced by
# pool-domain-manager and catalyst-dns; the canonical instance lives in
# `openova-system`, so a Sovereign overlay MUST replicate it into the
# webhook's namespace before this chart's pod can start.
#
# Two patterns the cluster overlay can use:
#
# 1. ExternalSecret (recommended) — bp-external-secrets templates a
# `kind: ExternalSecret` that materializes the same K8s Secret in
# every namespace that needs it. Add an entry for the webhook's
# release namespace.
# 2. Reflector annotations — the upstream sealed-secrets / reflector
# controllers can copy a Secret across namespaces via annotation.
# The canonical secret in openova-system gets
# `reflector.v1.k8s.emberstack.com/reflection-allowed: "true"` etc.
#
# The chart does NOT template the cross-namespace replication itself —
# that is a bootstrap-kit / cluster-overlay concern. See
# clusters/_template/dynadot-credentials-replication.yaml for the
# canonical pattern (issue openova#159 follow-up).
dynadot:
credentialsSecret:
name: dynadot-api-credentials
# Namespace the RBAC Role+RoleBinding target. Defaults to the chart's
# release namespace (.Release.Namespace) when blank. Override only if
# the operator chooses NOT to replicate the secret and instead hosts
# the webhook in the credentials' canonical namespace.
namespace: ""
keys:
apiKey: api-key
apiSecret: api-secret
# Comma- or whitespace-separated allowlist of pool domains the
# webhook is permitted to mutate. The legacy single-domain key
# `domain` is honoured as a fallback (per #108) — see
# core/cmd/cert-manager-dynadot-webhook/main.go loadConfigFromEnv.
managedDomains: domains
legacyDomain: domain
# Optional override for tests / staging (e.g. a recorded fixture
# server). Production leaves blank — the client falls back to
# https://api.dynadot.com/api3.json.
baseURL: ""
# ─── Service + APIService ────────────────────────────────────────────────
service:
# ClusterIP — cert-manager calls the webhook via the kube-apiserver's
# aggregated-apiserver path; no external exposure required.
type: ClusterIP
port: 443
# Skip the APIService registration if the operator has wired the webhook
# in some other way (e.g. an external OIDC-fronted apiserver). Default
# true — the chart owns the contract.
apiService:
enabled: true
groupPriorityMinimum: 1000
versionPriority: 15
# ─── Serving certificate (issued by cert-manager itself) ─────────────────
# The webhook serves TLS via a leaf cert chained to a CA cert that the
# APIService's caBundle references. cert-manager writes to
# `secrets[].annotations.cert-manager.io/inject-ca-from` to splice the
# CA bundle into the APIService at install time.
servingCert:
# Disable if the operator wires the cert from elsewhere (e.g. an
# external Vault PKI). Default true.
enabled: true
duration: 8760h # 1y
renewBefore: 720h # 30d
# ─── ServiceAccount + RBAC ───────────────────────────────────────────────
serviceAccount:
create: true
name: ""
rbac:
# The webhook needs:
# - get on flowcontrol.apiserver.k8s.io and authentication.k8s.io
# (it's an aggregated apiserver, so the standard kube-apiserver
# proxying RBAC applies)
# - get on the Dynadot credentials Secret in openova-system
create: true
# ─── ServiceMonitor ──────────────────────────────────────────────────────
# DEFAULT FALSE per docs/BLUEPRINT-AUTHORING.md §11.2. The
# kube-prometheus-stack CRDs ship with a separate Application Blueprint;
# enabling this on a fresh Sovereign that has not yet reconciled the
# observability tier creates a circular dependency. Operator opts in
# from the per-cluster overlay after observability lands.
serviceMonitor:
enabled: false
# ─── NetworkPolicy ───────────────────────────────────────────────────────
# Egress to api.dynadot.com (TCP/443) is the only outbound the webhook
# needs. Ingress is restricted to the kube-apiserver IP range.
networkPolicy:
enabled: false

View File

@ -23,16 +23,19 @@ spec:
# both issuers; this output names the one a dependent Blueprint should
# default to in production.
outputs:
# Default issuer name. Cluster overlays MAY override this to the DNS-01
# variant once the cert-manager-dynadot-webhook lands; until then the
# interim HTTP-01 issuer is the default since it's the one the chart's
# values.yaml enables out of the box.
issuerName: letsencrypt-http01-prod
# Default issuer name. As of openova#159 (bp-cert-manager-dynadot-webhook
# ships) the wildcard-capable DNS-01 issuer is enabled by default in
# the chart's values.yaml — dependents that issue wildcard certs (the
# Cilium Gateway's *.<sub>.<pool> TLS listener) reference this name
# directly. Cluster overlays MAY revert to letsencrypt-http01-prod by
# flipping certManager.issuers.dns01.enabled=false in the umbrella
# chart values; the http01 issuer remains templated for that path.
issuerName: letsencrypt-dns01-prod
# Kind is always ClusterIssuer for Catalyst — the wildcard cert lives
# in cilium-gateway and is consumed cluster-wide.
issuerKind: ClusterIssuer
# The TARGET-STATE issuer name. Dependents that want wildcard certs
# (e.g. the Gateway's TLS listener) pin to this; flipping
# certManager.issuers.dns01.enabled=true in the umbrella chart's values
# is the only operator action needed to activate it.
# The TARGET-STATE wildcard issuer name. Equal to issuerName above now
# that DNS-01 is the default; kept as a separate field so a dependent
# Blueprint that explicitly needs the wildcard variant can pin to it
# independent of which issuer is currently the default.
wildcardIssuerName: letsencrypt-dns01-prod

View File

@ -27,24 +27,25 @@
# 2026-04-28.
#
# What we ship today (this file):
# - letsencrypt-dns01-prod — TARGET STATE. Templated against a future
# Catalyst-built cert-manager-Dynadot webhook (issue
# #TBD: build cmd/cert-manager-dynadot-webhook/). Disabled by default
# so cert-manager doesn't try to use a webhook that isn't deployed.
# - letsencrypt-http01-prod — INTERIM. Works today for the explicit
# hostnames (console, gitea, harbor, admin, api). Wildcard certs are
# NOT possible on this issuer; the Cilium Gateway must list each
# subdomain explicitly until the DNS-01 webhook is built.
# - letsencrypt-dns01-prod — TARGET STATE, ACTIVE. Templated against
# bp-cert-manager-dynadot-webhook (closes openova#159). Issues
# wildcard certificates for the canonical Sovereign 6-record set.
# - letsencrypt-http01-prod — INTERIM, RETAINED. Continues to issue
# per-host certs for explicit hostnames. Operator may flip the
# active issuer at any time via certManager.issuers.dns01.enabled
# and certManager.issuers.http01.enabled.
#
# Operator runbook for activating DNS-01:
# 1. Build + push ghcr.io/openova-io/openova/cert-manager-dynadot-webhook:<sha>
# (a Go binary implementing webhook.acme.cert-manager.io/v1alpha1
# against products/catalyst/bootstrap/api/internal/dynadot/).
# 2. Deploy the webhook Service (e.g. cert-manager-webhook-dynadot in
# cert-manager namespace, port 443) and APIService registration.
# 3. Set certManager.issuers.dns01.enabled=true in this chart's values.
# 4. cert-manager will start using DNS-01; existing certs reissued at
# next renewal pick up the wildcard.
# Webhook contract — the dns01 issuer below references
# acme.dynadot.openova.io/v1alpha1 / solver "dynadot", which match the
# binary's GROUP_NAME default and Solver.Name() return. See
# core/cmd/cert-manager-dynadot-webhook/main.go and
# platform/cert-manager-dynadot-webhook/chart/values.yaml.
#
# Operator runbook (rollback):
# 1. Set certManager.issuers.dns01.enabled=false in the umbrella values
# to revert to http01 — existing certs remain valid until renewal.
# 2. Optionally `flux suspend` the bp-cert-manager-dynadot-webhook
# HelmRelease to free cert-manager namespace resources.
#
# Reference: https://cert-manager.io/docs/configuration/acme/dns01/

View File

@ -79,9 +79,14 @@ certManager:
# https://acme-staging-v02.api.letsencrypt.org/directory
acmeServer: https://acme-v02.api.letsencrypt.org/directory
dns01:
# Disabled until cmd/cert-manager-dynadot-webhook ships. See the
# runbook in templates/clusterissuer-letsencrypt-dns01.yaml.
enabled: false
# ENABLED as of openova#159 — bp-cert-manager-dynadot-webhook ships
# the binary that satisfies the solver contract referenced below.
# Operator MUST install bp-cert-manager-dynadot-webhook in the same
# Sovereign before this flag activates; the dependent Blueprint's
# `depends: [bp-cert-manager]` keeps the bootstrap-kit DAG ordered.
# Disabling this flag is the rollback path — http01 below remains
# active and continues to issue per-host certs.
enabled: true
webhookGroupName: acme.dynadot.openova.io
webhookSolverName: dynadot
http01: