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:
parent
c09109a61a
commit
5502d9aa48
135
.github/workflows/build-cert-manager-dynadot-webhook.yaml
vendored
Normal file
135
.github/workflows/build-cert-manager-dynadot-webhook.yaml
vendored
Normal 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}"
|
||||
58
core/cmd/cert-manager-dynadot-webhook/Containerfile
Normal file
58
core/cmd/cert-manager-dynadot-webhook/Containerfile
Normal 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"]
|
||||
116
core/cmd/cert-manager-dynadot-webhook/go.mod
Normal file
116
core/cmd/cert-manager-dynadot-webhook/go.mod
Normal 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
|
||||
309
core/cmd/cert-manager-dynadot-webhook/go.sum
Normal file
309
core/cmd/cert-manager-dynadot-webhook/go.sum
Normal 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=
|
||||
309
core/cmd/cert-manager-dynadot-webhook/main.go
Normal file
309
core/cmd/cert-manager-dynadot-webhook/main.go
Normal 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)
|
||||
406
core/cmd/cert-manager-dynadot-webhook/solver_test.go
Normal file
406
core/cmd/cert-manager-dynadot-webhook/solver_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
14
core/cmd/cert-manager-dynadot-webhook/timeouts.go
Normal file
14
core/cmd/cert-manager-dynadot-webhook/timeouts.go
Normal 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
|
||||
389
core/pkg/dynadot-client/client.go
Normal file
389
core/pkg/dynadot-client/client.go
Normal 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] + "..."
|
||||
}
|
||||
228
core/pkg/dynadot-client/client_test.go
Normal file
228
core/pkg/dynadot-client/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
42
core/pkg/dynadot-client/doc.go
Normal file
42
core/pkg/dynadot-client/doc.go
Normal 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
|
||||
3
core/pkg/dynadot-client/go.mod
Normal file
3
core/pkg/dynadot-client/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module github.com/openova-io/openova/core/pkg/dynadot-client
|
||||
|
||||
go 1.23
|
||||
85
core/pkg/dynadot-client/managed.go
Normal file
85
core/pkg/dynadot-client/managed.go
Normal 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
|
||||
}
|
||||
130
platform/cert-manager-dynadot-webhook/README.md
Normal file
130
platform/cert-manager-dynadot-webhook/README.md
Normal 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/)
|
||||
33
platform/cert-manager-dynadot-webhook/blueprint.yaml
Normal file
33
platform/cert-manager-dynadot-webhook/blueprint.yaml
Normal 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
|
||||
45
platform/cert-manager-dynadot-webhook/chart/Chart.yaml
Normal file
45
platform/cert-manager-dynadot-webhook/chart/Chart.yaml
Normal 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"
|
||||
@ -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 }}
|
||||
@ -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 }}
|
||||
@ -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 }}
|
||||
@ -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" . }}
|
||||
@ -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 }}
|
||||
@ -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 }}
|
||||
@ -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 }}
|
||||
174
platform/cert-manager-dynadot-webhook/chart/values.yaml
Normal file
174
platform/cert-manager-dynadot-webhook/chart/values.yaml
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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/
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user