diff --git a/.github/workflows/build-pr-image.yml b/.github/workflows/build-pr-image.yml new file mode 100644 index 0000000..61f0c6b --- /dev/null +++ b/.github/workflows/build-pr-image.yml @@ -0,0 +1,86 @@ +name: Build PR image + +on: + pull_request: + branches: [main] + paths: + - '**.go' + - 'go.mod' + - 'go.sum' + - 'Dockerfile' + +jobs: + ci: + uses: ./.github/workflows/ci.yml + + build_pr_image: + needs: ci + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build & push Docker image + uses: mr-smithers-excellent/docker-build-push@v6 + with: + image: snowdrop/cert-manager-webhook-godaddy + tags: pr-${{ github.event.pull_request.number }} + enableBuildKit: true + multiPlatform: true + platform: linux/amd64,linux/arm64 + registry: quay.io + username: ${{ secrets.QUAY_ROBOT_USER }} + password: ${{ secrets.QUAY_ROBOT_TOKEN }} + + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const tag = `pr-${{ github.event.pull_request.number }}`; + const image = `quay.io/snowdrop/cert-manager-webhook-godaddy:${tag}`; + const body = `### Test image ready + + \`${image}\` + + **To test on a kind cluster:** + + \`\`\`fish + # Pull, save, and load into kind + podman pull ${image} + podman save ${image} -o /tmp/webhook-pr.tar + kind load image-archive /tmp/webhook-pr.tar --name cert-manager-test + rm /tmp/webhook-pr.tar + + # Install or upgrade the webhook + helm upgrade --install -n cert-manager godaddy-webhook ./deploy/charts/godaddy-webhook \\ + --set groupName=\\$DOMAIN \\ + --set image.tag=${tag} \\ + --set image.pullPolicy=Never + \`\`\``; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes('### Test image ready')); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/.github/workflows/build-push-image-commit.yaml b/.github/workflows/build-push-image-commit.yml similarity index 70% rename from .github/workflows/build-push-image-commit.yaml rename to .github/workflows/build-push-image-commit.yml index 33d251c..a0a5049 100644 --- a/.github/workflows/build-push-image-commit.yaml +++ b/.github/workflows/build-push-image-commit.yml @@ -1,17 +1,24 @@ -name: Build and push godaddy webhook image +name: Build and push GoDaddy webhook image on: - pull_request: - branches: [ main ] push: - branches: - - main + branches: [main] + paths: + - '**.go' + - 'go.mod' + - 'go.sum' + - 'Dockerfile' + workflow_dispatch: jobs: + ci: + uses: ./.github/workflows/ci.yml + build_push_image: + needs: ci runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v5 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f5fdf02 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_call: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + run: go build ./... + + test-unit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run unit tests + run: go test -v -race ./internal/... diff --git a/.gitignore b/.gitignore index 7359eb0..8e5b7de 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ webhook # Ignore the built binary cert-manager-webhook-godaddy /vendor/ -/testdata/godaddy/secret.yaml +/testdata/godaddy/*/secret.yaml +cmd.md \ No newline at end of file diff --git a/Makefile b/Makefile index 6eb9d8b..2c03b41 100644 --- a/Makefile +++ b/Makefile @@ -9,21 +9,35 @@ $(shell mkdir -p "$(OUT)") clean: rm -rf vendor - rm -Rf $(OUT) + chmod -R u+w $(OUT) 2>/dev/null || true + rm -rf $(OUT) rm -rf apiserver.local.config install-tools: sh ./scripts/fetch-test-binaries.sh +test-v1: + go test -v ./internal/godaddy/v1/ + +test-v3: + go test -v ./internal/godaddy/v3/ + +test-unit: + go test -v ./internal/... + verify: clean install-tools go test -v . +TEST_API_VERSION ?= v1 + test: clean install-tools TEST_ASSET_ETCD=$(OUT)/kubebuilder/bin/etcd \ TEST_ASSET_KUBECTL=$(OUT)/kubebuilder/bin/kubectl \ TEST_ASSET_KUBE_APISERVER=$(OUT)/kubebuilder/bin/kube-apiserver \ TEST_ZONE_NAME=$(TEST_ZONE_NAME) \ - TEST_DNS_SERVER=$(TEST_DNS_SERVER) go test . + TEST_DNS_SERVER=$(TEST_DNS_SERVER) \ + TEST_API_VERSION=$(TEST_API_VERSION) \ + TEST_TIMEOUT=$(TEST_TIMEOUT) go test . compile: echo "### Go mod vendor ..." diff --git a/README.md b/README.md index aa479e2..0609f32 100644 --- a/README.md +++ b/README.md @@ -9,26 +9,30 @@ Table of Contents - [Platform](#platform) - [Installation](#installation) - [Cert Manager](#cert-manager) - - [The Godaddy webhook](#the-godaddy-webhook) + - [The GoDaddy webhook](#the-godaddy-webhook) - [Helm deployment](#helm-deployment) - [Manual installation](#manual-installation) - [Issuer](#issuer) - [Secret](#secret) - [ClusterIssuer](#clusterissuer) + - [API Version](#api-version) - [Development](#development) - - [Running the test suite](#running-the-test-suite) + - [Project structure](#project-structure) + - [Unit tests](#unit-tests) + - [DNS resolver test](#dns-resolver-test) - [Common testing issues](#common-testing-issues) - [Generate the container image](#generate-the-container-image) + - [Release](#release) ## Introduction -This project maintains the code used by the [certificate manager](https://cert-manager.io/docs/configuration/acme/dns01/) to access the Godaddy [DNS provider](https://www.godaddy.com/) using a Kubernetes webhook -which needs to be deployed on your kubernetes cluster. When called, the webhook will execute an ACME DNS challenge request to the DNS provider +This project maintains the code used by the [certificate manager](https://cert-manager.io/docs/configuration/acme/dns01/) to access the GoDaddy [DNS provider](https://www.godaddy.com/) using a Kubernetes webhook +which needs to be deployed on your kubernetes cluster. When called, the webhook will execute an [ACME DNS challenge](https://cert-manager.io/docs/configuration/acme/) request to the DNS provider to verify if the provider hosts the domain you are requesting a certificate. This project supports the following versions of the certificate manager: -| Certificate Manager | Godaddy webhook | +| Certificate Manager | GoDaddy webhook | |---------------------|--------------------| | [1.6 - 1.12] | v0.1.0 | | [> 1.13] | [v0.2.0 .. v0.5.0] | @@ -54,11 +58,11 @@ The image built supports as Arch: am64 and arm64 since release `>= 0.2.0` Follow the [instructions](https://cert-manager.io/docs/installation/) using the cert manager documentation to install it within your cluster. On kubernetes (>= 1.21), the process is pretty straightforward if you use the following commands: ```bash -kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.13.0/cert-manager.yaml +kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.17.4/cert-manager.yaml ``` **NOTES**: Check the cert-manager releases note to verify which [version of certmanager](https://cert-manager.io/docs/installation/supported-releases/) is supported with Kubernetes or OpenShift -### The Godaddy webhook +### The GoDaddy webhook #### Helm deployment @@ -68,7 +72,7 @@ export DOMAIN=acme.mydomain.com # replace with your domain helm install -n cert-manager godaddy-webhook ./deploy/charts/godaddy-webhook --set groupName=$DOMAIN ``` -The `groupName` refers to a prior nonexistant Kubernetes API Group, under which custom resources are created. +The `groupName` refers to a prior nonexistent Kubernetes API Group, under which custom resources are created. The name itself has no connection to the domain names for which certificates are issued, and using the default of `acme.mycompany.com` is fine. @@ -106,14 +110,18 @@ sed "s/acme.mycompany.com/$DOMAIN/g" deploy/webhook-all.yml | kubectl apply --va ## Issuer -In order to communicate with Godaddy DNS provider, we will create a Kubernetes Secret -to store the Godaddy `API` and `GoDaddy Secret`. +In order to communicate with GoDaddy DNS provider, we will create a Kubernetes Secret +to store the GoDaddy `API` and `GoDaddy Secret`. Next, we will define a `ClusterIssuer` containing the information to access the ACME Letsencrypt Server and the DNS provider to be used ### Secret -- Create a `Secret` containing as key parameter the concatenation of the Godaddy Api and Secret separated by ":" +Create a `Secret` containing your GoDaddy credentials. The format depends on the API version you use: + +- **API v1** — concatenation of the GoDaddy API key and secret separated by `:` +- **API v3** — a Personal Access Token (see [Creating a PAT](#creating-a-personal-access-token-pat)) + ```yaml cat < secret.yml apiVersion: v1 @@ -122,7 +130,9 @@ metadata: name: godaddy-api-key type: Opaque stringData: - token: + # For API v1: : + # For API v3: + token: EOF ``` - Next, deploy it under the namespace where you would like to get your certificate/key signed by the ACME CA Authority (e.g. cert-manager) @@ -133,7 +143,7 @@ kubectl apply -f secret.yml -n ### ClusterIssuer - Create a `ClusterIssuer` resource to specify the address of the ACME staging or production server to access. - Add the DNS01 Solver Config that this webhook will use to communicate with the API of the Godaddy Server in order to create + Add the DNS01 Solver Config that this webhook will use to communicate with the API of the GoDaddy Server in order to create or delete an ACME Challenge TXT record that the DNS Provider will accept/refuse if the domain name exists. ```yaml @@ -163,11 +173,15 @@ spec: name: godaddy-api-key key: token production: true + # apiVersion: "v3" # Uncomment to use GoDaddy API v3 (requires a PAT, see API Version section) ttl: 600 groupName: acme.mycompany.com solverName: godaddy EOF ``` + +> **Note**: By default, the webhook uses GoDaddy API **v1**. To use **v3**, add `apiVersion: "v3"` to the webhook config and use a [Personal Access Token](#creating-a-personal-access-token-pat) in your secret instead of an API key pair. See [API Version](#api-version) for details. + - Next, install it on your kubernetes cluster ```bash kubectl apply -f clusterissuer.yml @@ -228,46 +242,200 @@ kubectl apply -f ingress.yml -n **NOTE**: If you prefer to delegate to the certmanager the responsibility to create the Certificate resource, then add the following annotation as described within the documentation ` certmanager.k8s.io/cluster-issuer: "letsencrypt-prod"` +## API Version + +This webhook supports two versions of the GoDaddy API. You can select which version to use via the `apiVersion` field in the webhook solver config. + +> **Deprecation notice**: GoDaddy will deprecate API v1 in a future release. Starting with godaddy-webhook **1.x**, API v3 will become the default and v1 support will be removed. We recommend migrating your configuration to `apiVersion: "v3"` now. + +### API v1 (default) + +The original GoDaddy Domains API. Uses `sso-key` authentication with an API key and secret pair. + +```yaml +dns01: + webhook: + config: + apiKeySecretRef: + name: godaddy-api-key + key: token + production: true + apiVersion: "v1" + ttl: 600 + groupName: acme.mycompany.com + solverName: godaddy +``` + +When `apiVersion` is omitted, it defaults to `"v1"`. + +### API v3 + +The newer GoDaddy Domains API. This version introduces several breaking changes compared to v1. + +#### What changes with v3 + +| | API v1 | API v3 | +|---|--------|--------| +| **Authentication** | `sso-key` (API key + secret) | **Bearer token** (Personal Access Token) | +| **Endpoint paths** | `/v1/domains/{domain}/records/TXT/{name}` | `/v3/domains/zones/{zone}/dns-records` | +| **Record filtering** | Path segments | Query parameters (`?type=TXT&name=...`) | +| **Create method** | `PUT` (replace all) | `POST` (append) | +| **Delete method** | `DELETE` by type/name | `DELETE` by record ID (auto-resolved) | +| **Secret format** | `:` | `` | + +See the [GoDaddy v3 API documentation](https://developer.godaddy.com/en/docs/references/rest/domains/v3/records) for details. + +#### Creating a Personal Access Token (PAT) + +API v3 **does not support** the legacy `sso-key` authentication. You must create a Personal Access Token: + +1. Go to the [GoDaddy Developer Portal](https://developer.godaddy.com) +2. Navigate to **API Keys** > **Create New API Key** +3. Select **Personal Access Token** as the key type +4. Grant the following scopes: + - `domains.domain:read` + - `domains.dns:update` +5. Copy the generated token + +#### Secret for v3 + +The Kubernetes secret for v3 contains the PAT as a single value (no colon-separated key pair): + +```yaml +cat < secret.yml +apiVersion: v1 +kind: Secret +metadata: + name: godaddy-api-key +type: Opaque +stringData: + token: +EOF +``` + +```bash +kubectl apply -f secret.yml -n +``` + +#### ClusterIssuer config for v3 + +```yaml +dns01: + webhook: + config: + apiKeySecretRef: + name: godaddy-api-key + key: token + production: true + apiVersion: "v3" + ttl: 600 + groupName: acme.mycompany.com + solverName: godaddy +``` + ## Development -### Running the test suite +### Project structure + +``` +main.go -- webhook solver (entry point) +dns_resolver_test.go -- cert-manager conformance tests +internal/ + auth/auth.go -- credential extraction from K8s secrets + auth/auth_test.go -- auth unit tests + dns/dns.go -- DNS zone and record name helpers + godaddy/ + types.go -- Client interface, DNSRecord, shared HTTP helper + v1/client.go -- GoDaddy API v1 implementation + v1/client_test.go -- v1 unit tests + v3/client.go -- GoDaddy API v3 implementation + v3/client_test.go -- v3 unit tests + logging/logging.go -- log configuration +``` + +### Unit tests + +Run the API client unit tests (no cluster or credentials required): + +```bash +make test-unit # all unit tests +make test-v1 # v1 client tests only +make test-v3 # v3 client tests only +``` + +### DNS resolver test -**IMPORTANT**: Use the testsuite carefully and do not launch it too much times as the DNS servers could fail and report such a message `suite.go:62: error waiting for record to be deleted: unexpected error from DNS server: SERVFAIL` +The DNS resolver test (`dns_resolver_test.go`) runs the cert-manager conformance suite against a real GoDaddy domain. It creates and deletes actual TXT records, so use it sparingly. -To test one of your registered domains on godaddy, create a secret.yml file using as [example] file(./testdata/godaddy/godaddy.secret.example) -Replace the `$GODADDY_TOKEN` with your Godaddy API token which corresponds to your `:`: +**IMPORTANT**: Do not run this test too frequently — GoDaddy DNS servers may fail and report `SERVFAIL`. + +#### Setup + +Create secret files with your GoDaddy credentials. The example template is at `testdata/godaddy/godaddy.secret.example` and must be generated into each API version folder: ```bash -pushd testdata/godaddy -export GODADDY_TOKEN=$(echo -n "") -envsubst < godaddy.secret.example > secret.yaml -popd +# For API v1 (API key + secret separated by ':') +export GODADDY_TOKEN=$(echo -n ":") +envsubst < testdata/godaddy/godaddy.secret.example > testdata/godaddy/v1/secret.yaml + +# For API v3 (Personal Access Token) +export GODADDY_TOKEN=$(echo -n "") +envsubst < testdata/godaddy/godaddy.secret.example > testdata/godaddy/v3/secret.yaml ``` -Install a kube-apiserver, etcd locally using the following bash script +Install the kubebuilder test binaries (etcd, kube-apiserver, kubectl): ```bash ./scripts/fetch-test-binaries.sh ``` -Now, execute the test suite and pass as parameter the domain name to be tested +#### Running with API v1 (default) ```bash -TEST_ASSET_ETCD=_out/kubebuilder/bin/etcd \ -TEST_ASSET_KUBECTL=_out/kubebuilder/bin/kubectl \ -TEST_ASSET_KUBE_APISERVER=_out/kubebuilder/bin/kube-apiserver \ -TEST_ZONE_NAME=. go test -v . +make test TEST_ZONE_NAME=. TEST_DNS_SERVER=":53" ``` -or the following `make` command +#### Running with API v3 + +```bash +make test TEST_ZONE_NAME=. TEST_DNS_SERVER=":53" TEST_API_VERSION=v3 +``` + +#### Increasing the propagation timeout + +By default, the test waits up to **3 minutes** for DNS propagation. If GoDaddy DNS is slow, you can increase this via `TEST_TIMEOUT`: + ```bash -make test TEST_ZONE_NAME= +make test TEST_ZONE_NAME=. TEST_DNS_SERVER=":53" TEST_TIMEOUT=5m ``` + +#### Example + +```bash +# Find your domain's authoritative nameserver +dig NS snowdrop.dev +short +# ns33.domaincontrol.com. + +# Run with v1 +make test TEST_ZONE_NAME=snowdrop.dev. TEST_DNS_SERVER="ns33.domaincontrol.com:53" + +# Run with v3 +make test TEST_ZONE_NAME=snowdrop.dev. TEST_DNS_SERVER="ns33.domaincontrol.com:53" TEST_API_VERSION=v3 +``` + #### Common testing issues -- As godaddy server could be very slow to reply, it could be needed to increase the TTL defined within the `config.json` file. - - If increasing the TTL does not solve the issue, you can also try overriding the DNS server used for testing by setting the `TEST_DNS_SERVER` environment variable to match one of the name servers used by your domain. Ex `TEST_DNS_SERVER="pdns01.domaincontrol.com:53"` -- The test could also fail as the kube api server is currently finalizing the deletion of the namespace `"spec":{"finalizers":["kubernetes"]},"status":{"phase":"Terminating"}}` +- **`SERVFAIL` or `REFUSED` during DNS propagation check**: The integration test creates a real TXT record via the GoDaddy API and then queries a DNS server to verify the record propagated. Public resolvers like `1.1.1.1` may return `SERVFAIL` due to slow propagation. To work around this, use the authoritative nameserver for your domain. Find it with: + ```bash + dig NS +short + ``` + Then pass it to the test: + ```bash + make test TEST_ZONE_NAME=. TEST_DNS_SERVER=":53" + # Example: make test TEST_ZONE_NAME=snowdrop.dev. TEST_DNS_SERVER="ns33.domaincontrol.com:53" + ``` +- **Slow GoDaddy responses**: If the above does not help, increase the propagation timeout by passing `TEST_TIMEOUT` (default `3m`), e.g. `make test ... TEST_TIMEOUT=5m`. +- **Namespace finalizer errors**: The test could also fail if the kube-apiserver is still finalizing the deletion of a namespace from a previous run (`"status":{"phase":"Terminating"}`). Wait a moment and retry. ### Generate the container image @@ -291,3 +459,22 @@ IMAGE_REPOSITORY="quay.io/snowdrop" make build IMAGE_NAME=${IMAGE_REPOSITORY} make push ``` + +## Release + +Releases are driven by `.github/project.yml`. To create a new release: + +1. Update `current-version` and `next-version` in `.github/project.yml`: + ```yaml + current-version: "0.8.0" + next-version: "0.9.0" + ``` +2. Push the change to `main`. The `prepare-release` workflow will automatically: + - Create a `release/` branch + - Update the Helm chart and image tag to match the release version + - Open a Pull Request +3. Review and merge the PR. The `release` workflow will then: + - Build and push the Docker image to quay.io + - Publish the Helm chart via chart-releaser + - Tag the release as `v` + - Bump the chart files to the next development version diff --git a/common/util.go b/common/util.go deleted file mode 100644 index e9d30be..0000000 --- a/common/util.go +++ /dev/null @@ -1,17 +0,0 @@ -package common - -import ( - "github.com/sirupsen/logrus" - "os" -) - -func GetValFromEnVar(envVar string) (val string) { - val, ok := os.LookupEnv(envVar) - if !ok { - logrus.Debugf("%s not set", envVar) - return "" - } else { - logrus.Debugf("%s=%s", envVar, val) - return val - } -} diff --git a/deploy/charts/godaddy-webhook/templates/pki.yaml b/deploy/charts/godaddy-webhook/templates/pki.yaml index 26f75af..5d82466 100644 --- a/deploy/charts/godaddy-webhook/templates/pki.yaml +++ b/deploy/charts/godaddy-webhook/templates/pki.yaml @@ -1,5 +1,5 @@ --- -# Create a selfsigned Issuer, in order to create a root CA certificate for +# Create a self-signed Issuer, in order to create a root CA certificate for # signing webhook serving certificates apiVersion: cert-manager.io/v1 kind: Issuer diff --git a/dns_resolver_test.go b/dns_resolver_test.go new file mode 100644 index 0000000..7f18e31 --- /dev/null +++ b/dns_resolver_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "os" + "testing" + "time" + + "github.com/cert-manager/cert-manager/test/acme" +) + +var ( + zone = os.Getenv("TEST_ZONE_NAME") + dnsServer = os.Getenv("TEST_DNS_SERVER") + apiVersion = os.Getenv("TEST_API_VERSION") + testTimeout = os.Getenv("TEST_TIMEOUT") +) + +func TestRunsSuite(t *testing.T) { + pollTime, _ := time.ParseDuration("5s") + + timeoutStr := testTimeout + if timeoutStr == "" { + timeoutStr = "3m" + } + timeOut, _ := time.ParseDuration(timeoutStr) + + if dnsServer == "" { + dnsServer = "1.1.1.1:53" + } + + version := apiVersion + if version == "" { + version = "v1" + } + manifestPath := "testdata/godaddy/" + version + + t.Logf("Using GoDaddy API version: %s (manifest: %s)", apiVersion, manifestPath) + + fixture := dns.NewFixture(&godaddyDNSSolver{}, + dns.SetResolvedZone(zone), + dns.SetAllowAmbientCredentials(false), + dns.SetManifestPath(manifestPath), + dns.SetDNSServer(dnsServer), + dns.SetUseAuthoritative(false), + + // Disable the extended test as godaddy do not support to create several records for the same Record DNS Name !! + dns.SetStrict(false), + + dns.SetPollInterval(pollTime), + dns.SetPropagationLimit(timeOut), + ) + + fixture.RunConformance(t) +} diff --git a/go.mod b/go.mod index 65101eb..fb479a4 100644 --- a/go.mod +++ b/go.mod @@ -94,7 +94,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.32.0 // indirect + k8s.io/api v0.32.0 k8s.io/apiserver v0.32.0 // indirect k8s.io/component-base v0.32.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/howto-kind-cluster.md b/howto-kind-cluster.md new file mode 100644 index 0000000..e7e6ff9 --- /dev/null +++ b/howto-kind-cluster.md @@ -0,0 +1,322 @@ +# How-To: Deploy cert-manager + GoDaddy Webhook on a Local Kind Cluster + +This guide walks through deploying cert-manager v1.17.4 and the GoDaddy webhook on a local kind cluster, creating the necessary resources, and running the integration test suite. + +## Prerequisites + +- [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation) installed +- [kubectl](https://kubernetes.io/docs/tasks/tools/) installed +- [Helm](https://helm.sh/docs/intro/install/) installed +- A GoDaddy domain with API access +- A GoDaddy Personal Access Token (PAT) for API v3 + +## 1. Create a Kind Cluster + +```fish +set -x KIND_CLUSTER snowdrop # choose your cluster name +kind create cluster --name $KIND_CLUSTER +``` + +Verify the cluster is running: + +```fish +kubectl cluster-info --context kind-$KIND_CLUSTER +``` + +## 2. Deploy cert-manager v1.17.4 + +```fish +kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.17.4/cert-manager.yaml +``` + +Wait for cert-manager pods to be ready: + +```fish +kubectl wait --for=condition=Ready pods --all -n cert-manager --timeout=120s +``` + +Verify all three pods are running (cainjector, controller, webhook): + +```fish +kubectl get pods -n cert-manager +``` + +### Fix CoreDNS to resolve external domains + +By default, CoreDNS in kind forwards to `/etc/resolv.conf` inside the node container, which may not resolve external domains. Update CoreDNS to forward to public DNS servers: + +```fish +kubectl get configmap coredns -n kube-system -o json \ + | jq '.data.Corefile |= gsub("forward \\. /etc/resolv\\.conf"; "forward . 8.8.8.8 1.1.1.1")' \ + | kubectl apply -f - +kubectl rollout restart deployment coredns -n kube-system +``` + +Verify it works: + +```fish +kubectl run dns-test --image=busybox --restart=Never --command -- sh -c "nslookup -type=SOA snowdrop.dev" +sleep 5 +kubectl logs dns-test +kubectl delete pod dns-test +``` + +### Patch cert-manager for DNS01 recursive nameservers + +Cert-manager also needs to use public DNS (not CoreDNS) for DNS01 challenge propagation checks: + +```fish +kubectl patch deployment cert-manager -n cert-manager --type=json -p '[ + {"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--dns01-recursive-nameservers=8.8.8.8:53,1.1.1.1:53"}, + {"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--dns01-recursive-nameservers-only"} +]' +``` + +The cert-manager pod will restart automatically. + +## 3. Build and Install the GoDaddy Webhook + +### Build the image and load it into kind + +The Helm chart defaults to a pre-built image from the container registry. To run the latest code (e.g., after local changes), build the image locally and load it into kind. + +Kind uses podman as its container runtime. Since `kind load docker-image` does not work with podman, save the image to a tar archive and use `kind load image-archive`: + +```fish +podman build -t quay.io/snowdrop/cert-manager-webhook-godaddy:latest . +podman save quay.io/snowdrop/cert-manager-webhook-godaddy:latest -o /tmp/webhook.tar +kind load image-archive /tmp/webhook.tar --name $KIND_CLUSTER +rm /tmp/webhook.tar +``` + +### Install with Helm + +Install the webhook using the Helm chart from the `deploy/` directory. Set `image.tag=latest` and `image.pullPolicy=Never` so kind uses the locally loaded image. The `groupName` is a custom Kubernetes API group name (it does not need to match your domain): + +```fish +set -x DOMAIN acme.mydomain.com +helm install -n cert-manager godaddy-webhook ./deploy/charts/godaddy-webhook \ + --set groupName=$DOMAIN \ + --set image.tag=latest \ + --set image.pullPolicy=Never +``` + +To upgrade after rebuilding the image: + +```fish +podman build -t quay.io/snowdrop/cert-manager-webhook-godaddy:latest . +podman save quay.io/snowdrop/cert-manager-webhook-godaddy:latest -o ./webhook.tar +kind load image-archive ./webhook.tar --name $KIND_CLUSTER +rm ./webhook.tar +helm upgrade -n cert-manager godaddy-webhook ./deploy/charts/godaddy-webhook \ + --set groupName=$DOMAIN \ + --set image.tag=latest \ + --set image.pullPolicy=Never +``` + +### Use a PR image + +When a pull request is opened, CI automatically builds a container image tagged `pr-` and posts a comment on the PR with the image reference. To test a PR image on your kind cluster: + +```fish +set PR_NUMBER 42 # replace with the actual PR number +set PR_IMAGE quay.io/snowdrop/cert-manager-webhook-godaddy:pr-$PR_NUMBER + +podman pull $PR_IMAGE +podman save $PR_IMAGE -o /tmp/webhook-pr.tar +kind load image-archive /tmp/webhook-pr.tar --name $KIND_CLUSTER +rm /tmp/webhook-pr.tar +helm upgrade --install -n cert-manager godaddy-webhook ./deploy/charts/godaddy-webhook \ + --set groupName=$DOMAIN \ + --set image.tag=pr-$PR_NUMBER \ + --set image.pullPolicy=Never +``` + +Verify the webhook pod is running: + +```fish +kubectl get pods -n cert-manager -l app.kubernetes.io/name=godaddy-webhook +``` + +## 4. Create the Secret with PAT + +For API v3, the secret contains a Personal Access Token (not an API key pair). + +To create a PAT, go to the [GoDaddy Developer Portal](https://developer.godaddy.com), navigate to API Keys, create a new Personal Access Token with scopes `domains.domain:read` and `domains.dns:update`. + +Export your PAT as an environment variable, then create the secret: + +```fish +set -x GODADDY_PAT "your-personal-access-token-here" + +kubectl apply -n cert-manager -f (echo " +apiVersion: v1 +kind: Secret +metadata: + name: godaddy-api-key +type: Opaque +stringData: + token: $GODADDY_PAT +" | psub) +``` + +## 5. Create the ClusterIssuer + +```fish +set -x EMAIL "your-email@example.com" +set -x YOUR_DOMAIN "snowdrop.dev" + +kubectl apply -f (echo " +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-staging +spec: + acme: + server: https://acme-staging-v02.api.letsencrypt.org/directory + email: $EMAIL + privateKeySecretRef: + name: letsencrypt-staging + solvers: + - selector: + dnsZones: + - '$YOUR_DOMAIN' + dns01: + webhook: + config: + apiKeySecretRef: + name: godaddy-api-key + key: token + production: true + apiVersion: 'v3' + ttl: 600 + groupName: $DOMAIN + solverName: godaddy +" | psub) +``` + +## 6. Create a Certificate + +```fish +kubectl apply -n cert-manager -f (echo " +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: wildcard-$YOUR_DOMAIN +spec: + secretName: wildcard-$YOUR_DOMAIN-tls + renewBefore: 240h + dnsNames: + - '*.$YOUR_DOMAIN' + issuerRef: + name: letsencrypt-staging + kind: ClusterIssuer +" | psub) +``` + +Monitor the certificate status: + +```fish +kubectl get certificate -n cert-manager +kubectl describe certificate wildcard-$YOUR_DOMAIN -n cert-manager +kubectl describe certificaterequest -n cert-manager +kubectl get challenges -n cert-manager +kubectl describe challenge -n cert-manager +``` + +### Clean up and recreate a Certificate + +To start fresh (e.g., after fixing a config issue), delete the certificate and all its dependent resources in order: + +```fish +# Delete challenges first, then orders, certificate requests, and finally the certificate +kubectl delete challenges --all -n cert-manager +kubectl delete orders --all -n cert-manager +kubectl delete certificaterequests --all -n cert-manager +kubectl delete certificate wildcard-$YOUR_DOMAIN -n cert-manager + +# Delete the associated secret and private key +kubectl delete secret wildcard-$YOUR_DOMAIN-tls -n cert-manager 2>/dev/null +kubectl delete secret (kubectl get secret -n cert-manager -o name | grep wildcard-$YOUR_DOMAIN | head -1) -n cert-manager 2>/dev/null +``` + +Then recreate the certificate by re-running the `kubectl apply` command from the section above. + +## 7. Running `make test` (Integration / DNS Conformance Test) + +### What `make test` does + +The `make test` target Druns the cert-manager ACME DNS01 conformance test suite against a **real GoDaddy domain**. Here is the sequence: + +1. **Clean up**: removes `vendor/`, `_out/`, and `apiserver.local.config/` directories. +2. **Fetch test binaries**: runs `scripts/fetch-test-binaries.sh`, which uses `setup-envtest` to download kubebuilder binaries (`etcd`, `kube-apiserver`, `kubectl`) into `_out/kubebuilder/`. +3. **Start an in-process control plane**: the test launches a local `etcd` and `kube-apiserver` (via the envtest library) -- no full cluster is required. +4. **Run the conformance suite** (`dns_resolver_test.go`): + - Loads the solver config from `testdata/godaddy//config.json` and credentials from `testdata/godaddy//secret.yaml`. + - Calls `Present()` to create a real TXT record (`cert-manager-dns01-tests`) on your GoDaddy domain via the API. + - Polls the DNS server (`TEST_DNS_SERVER`) to verify the TXT record propagated. + - Calls `CleanUp()` to delete the TXT record via the API. + - Polls the DNS server again to verify the record was removed. + +### Environment variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `TEST_ZONE_NAME` | Yes | `example.com.` | Your GoDaddy domain (must end with a dot) | +| `TEST_DNS_SERVER` | No | `1.1.1.1:53` | DNS server to query for propagation checks. Use your domain's authoritative nameserver for best results. | +| `TEST_API_VERSION` | No | `v1` | GoDaddy API version to test (`v1` or `v3`) | +| `TEST_TIMEOUT` | No | `3m` | Maximum time to wait for DNS propagation | + +### Credential setup + +Before running the test, make sure the credential secret file exists for your API version: + +```fish +# For API v3: write the secret file with your PAT +echo " +apiVersion: v1 +kind: Secret +metadata: + name: godaddy-credentials +type: Opaque +stringData: + token: $GODADDY_PAT +" > testdata/godaddy/v3/secret.yaml +``` + +### Find your authoritative nameserver + +```fish +dig NS $YOUR_DOMAIN +short +# Example output: ns33.domaincontrol.com. +``` + +### Run the test + +```fish +# API v1 (default) +make test TEST_ZONE_NAME=snowdrop.dev. TEST_DNS_SERVER="ns33.domaincontrol.com:53" + +# API v3 +make test TEST_ZONE_NAME=snowdrop.dev. TEST_DNS_SERVER="ns33.domaincontrol.com:53" TEST_API_VERSION=v3 + +# With extended timeout (useful when DNS propagation is slow) +make test TEST_ZONE_NAME=snowdrop.dev. TEST_DNS_SERVER="ns33.domaincontrol.com:53" TEST_API_VERSION=v3 TEST_TIMEOUT=5m +``` + +### Unit tests (no credentials needed) + +You can also run just the unit tests, which use mock HTTP servers and do not require GoDaddy credentials or a cluster: + +```fish +make test-unit # all internal package tests +make test-v1 # v1 client tests only +make test-v3 # v3 client tests only +``` + +### Troubleshooting + +- **DNS SERVFAIL or REFUSED**: use the authoritative nameserver for your domain instead of a public resolver. +- **Timeout waiting for propagation**: increase `TEST_TIMEOUT` (e.g., `5m` or `10m`). +- **DUPLICATE_RECORD errors**: a previous test run may have left a stale record. Delete `cert-manager-dns01-tests` TXT records from your domain's DNS zone manually via the GoDaddy dashboard, then retry. +- **Namespace finalizer errors**: wait a moment and retry -- the envtest kube-apiserver may still be cleaning up from a previous run. \ No newline at end of file diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..646f424 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,64 @@ +package auth + +import ( + "context" + "fmt" + "strings" + + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type Credentials struct { + APIKey string + APISecret string +} + +// ExtractFromSecret reads a Kubernetes Secret and parses the "key:secret" value +// into API credentials. The secret value is expected to be in the format "apiKey:apiSecret". +func ExtractFromSecret(client kubernetes.Interface, namespace, secretName, secretKey string) (*Credentials, error) { + sec, err := client.CoreV1(). + Secrets(namespace). + Get(context.TODO(), secretName, metaV1.GetOptions{}) + if err != nil { + return nil, err + } + + secBytes, ok := sec.Data[secretKey] + if !ok { + return nil, fmt.Errorf("key %q not found in secret %q", secretKey, fmt.Sprintf("%s/%s", namespace, secretName)) + } + + parts := strings.SplitN(string(secBytes), ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("secret \"%s/%s\" key %q: expected format \"apiKey:apiSecret\"", secretName, namespace, secretKey) + } + + return &Credentials{ + APIKey: parts[0], + APISecret: parts[1], + }, nil +} + +// ExtractPATFromSecret reads a Kubernetes Secret and returns the raw value as a +// Personal Access Token. The secret value is used as-is (no splitting). +func ExtractPATFromSecret(client kubernetes.Interface, namespace, secretName, secretKey string) (string, error) { + sec, err := client.CoreV1(). + Secrets(namespace). + Get(context.TODO(), secretName, metaV1.GetOptions{}) + if err != nil { + return "", err + } + + secBytes, ok := sec.Data[secretKey] + if !ok { + return "", fmt.Errorf("key %q not found in secret \"%s/%s\"", secretKey, secretName, namespace) + } + + token := strings.TrimSpace(string(secBytes)) + if token == "" { + return "", fmt.Errorf("secret \"%s/%s\" key %q: token is empty", secretName, namespace, secretKey) + } + + return token, nil +} \ No newline at end of file diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..d98b9f6 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,164 @@ +package auth + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestExtractFromSecret_Success(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "godaddy-api-key", + Namespace: "cert-manager", + }, + Data: map[string][]byte{ + "token": []byte("myApiKey:myApiSecret"), + }, + }) + + creds, err := ExtractFromSecret(client, "cert-manager", "godaddy-api-key", "token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if creds.APIKey != "myApiKey" { + t.Errorf("expected APIKey 'myApiKey', got %q", creds.APIKey) + } + if creds.APISecret != "myApiSecret" { + t.Errorf("expected APISecret 'myApiSecret', got %q", creds.APISecret) + } +} + +func TestExtractFromSecret_ColonInSecret(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "godaddy-api-key", + Namespace: "cert-manager", + }, + Data: map[string][]byte{ + "token": []byte("myApiKey:secret:with:colons"), + }, + }) + + creds, err := ExtractFromSecret(client, "cert-manager", "godaddy-api-key", "token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if creds.APIKey != "myApiKey" { + t.Errorf("expected APIKey 'myApiKey', got %q", creds.APIKey) + } + if creds.APISecret != "secret:with:colons" { + t.Errorf("expected APISecret 'secret:with:colons', got %q", creds.APISecret) + } +} + +func TestExtractFromSecret_SecretNotFound(t *testing.T) { + client := fake.NewSimpleClientset() + + _, err := ExtractFromSecret(client, "cert-manager", "nonexistent", "token") + if err == nil { + t.Fatal("expected error for missing secret") + } +} + +func TestExtractFromSecret_KeyNotFound(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "godaddy-api-key", + Namespace: "cert-manager", + }, + Data: map[string][]byte{ + "other-key": []byte("value"), + }, + }) + + _, err := ExtractFromSecret(client, "cert-manager", "godaddy-api-key", "token") + if err == nil { + t.Fatal("expected error for missing key") + } +} + +func TestExtractFromSecret_InvalidFormat(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "godaddy-api-key", + Namespace: "cert-manager", + }, + Data: map[string][]byte{ + "token": []byte("no-colon-separator"), + }, + }) + + _, err := ExtractFromSecret(client, "cert-manager", "godaddy-api-key", "token") + if err == nil { + t.Fatal("expected error for invalid format") + } +} + +func TestExtractPATFromSecret_Success(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "godaddy-pat", + Namespace: "cert-manager", + }, + Data: map[string][]byte{ + "token": []byte("my-personal-access-token"), + }, + }) + + pat, err := ExtractPATFromSecret(client, "cert-manager", "godaddy-pat", "token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pat != "my-personal-access-token" { + t.Errorf("expected 'my-personal-access-token', got %q", pat) + } +} + +func TestExtractPATFromSecret_TrimWhitespace(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "godaddy-pat", + Namespace: "cert-manager", + }, + Data: map[string][]byte{ + "token": []byte(" my-token\n"), + }, + }) + + pat, err := ExtractPATFromSecret(client, "cert-manager", "godaddy-pat", "token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pat != "my-token" { + t.Errorf("expected 'my-token', got %q", pat) + } +} + +func TestExtractPATFromSecret_SecretNotFound(t *testing.T) { + client := fake.NewSimpleClientset() + + _, err := ExtractPATFromSecret(client, "cert-manager", "nonexistent", "token") + if err == nil { + t.Fatal("expected error for missing secret") + } +} + +func TestExtractPATFromSecret_EmptyToken(t *testing.T) { + client := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "godaddy-pat", + Namespace: "cert-manager", + }, + Data: map[string][]byte{ + "token": []byte(""), + }, + }) + + _, err := ExtractPATFromSecret(client, "cert-manager", "godaddy-pat", "token") + if err == nil { + t.Fatal("expected error for empty token") + } +} diff --git a/internal/dns/dns.go b/internal/dns/dns.go new file mode 100644 index 0000000..286f48a --- /dev/null +++ b/internal/dns/dns.go @@ -0,0 +1,23 @@ +package dns + +import ( + "context" + "strings" + + "github.com/cert-manager/cert-manager/pkg/issuer/acme/dns/util" +) + +func ExtractRecordName(fqdn, domain string) string { + if idx := strings.Index(fqdn, "."+domain); idx != -1 { + return fqdn[:idx] + } + return util.UnFqdn(fqdn) +} + +func GetZone(fqdn string) (string, error) { + authZone, err := util.FindZoneByFqdn(context.TODO(), fqdn, util.RecursiveNameservers) + if err != nil { + return "", err + } + return util.UnFqdn(authZone), nil +} diff --git a/internal/godaddy/types.go b/internal/godaddy/types.go new file mode 100644 index 0000000..c6333d6 --- /dev/null +++ b/internal/godaddy/types.go @@ -0,0 +1,67 @@ +package godaddy + +import ( + "fmt" + "io" + "net/http" + "time" + + useragent "github.com/cert-manager/cert-manager/pkg/util" + logrus "github.com/sirupsen/logrus" +) + +type DNSRecord struct { + Type string `json:"type"` + Name string `json:"name"` + Data string `json:"data"` + Priority int `json:"priority,omitempty"` + TTL int `json:"ttl"` +} + +type Client interface { + HasTXTRecord(domainZone, recordName, challengeKey string) (bool, error) + UpdateRecords(records []DNSRecord, domainZone, recordName string) error + DeleteTxtRecord(domainZone, recordName string) error +} + +type ClientConfig struct { + AuthAPIKey string + AuthAPISecret string + AuthPAT string + Production bool + // BaseURL overrides the default API URL. Used for testing. + BaseURL string +} + +func APIBaseURL(production bool) string { + if production { + return "https://api.godaddy.com" + } + return "https://api.ote-godaddy.com" +} + +func MakeRequest(cfg *ClientConfig, method, uri string, body io.Reader, setAuth func(*http.Request, *ClientConfig)) (*http.Response, error) { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = APIBaseURL(cfg.Production) + } + req, err := http.NewRequest(method, fmt.Sprintf("%s%s", baseURL, uri), body) + if err != nil { + return nil, err + } + + certManagerUserAgent := "cert-manager/" + useragent.AppVersion + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", certManagerUserAgent) + setAuth(req, cfg) + + logrus.Debugf("### Godaddy HTTP request: %s", req.URL.String()) + logrus.Debug("### Authorization header set") + client := http.Client{ + Timeout: 30 * time.Second, + } + + return client.Do(req) +} \ No newline at end of file diff --git a/internal/godaddy/v1/client.go b/internal/godaddy/v1/client.go new file mode 100644 index 0000000..0f4baf5 --- /dev/null +++ b/internal/godaddy/v1/client.go @@ -0,0 +1,108 @@ +package v1 + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/sirupsen/logrus" + "github.com/snowdrop/godaddy-webhook/internal/godaddy" +) + +type client struct { + cfg *godaddy.ClientConfig +} + +func NewClient(cfg *godaddy.ClientConfig) godaddy.Client { + return &client{cfg: cfg} +} + +func setAuth(req *http.Request, cfg *godaddy.ClientConfig) { + req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", cfg.AuthAPIKey, cfg.AuthAPISecret)) +} + +func (c *client) HasTXTRecord(domainZone, recordName, challengeKey string) (bool, error) { + url := fmt.Sprintf("/v1/domains/%s/records/TXT/%s", domainZone, recordName) + logrus.Debug("### GoDaddy credentials loaded") + logrus.Infof("### URL request issued to check if the TXT DNS record is present: %s", url) + + resp, err := godaddy.MakeRequest(c.cfg, http.MethodGet, url, nil, setAuth) + if err != nil { + logrus.Infof("### HTTP request failed with Godaddy: %s", err) + return false, err + } + defer resp.Body.Close() + logrus.Debugf("### Godaddy HTTP body response: %s", resp.Body) + + if resp.StatusCode == http.StatusNotFound { + return false, nil + } else if resp.StatusCode == http.StatusOK { + var dnsRecords []godaddy.DNSRecord + err = json.NewDecoder(resp.Body).Decode(&dnsRecords) + if err != nil { + return false, fmt.Errorf("### HTTP response body cannot be parsed to JSON: %s", err) + } + + if len(dnsRecords) == 0 { + logrus.Info("### No TXT Record found using godaddy REST API !") + return false, nil + } + + for _, dnsRecord := range dnsRecords { + logrus.Infof("### TXT Record collected from godaddy: %#v", dnsRecord) + if dnsRecord.Data == challengeKey { + logrus.Infof("### TXT Record found : %#v, for challengeKey: %s", dnsRecord, challengeKey) + return true, nil + } + } + logrus.Infof("### No TXT Record found within the response for challengeKey: %s", challengeKey) + return false, nil + } + + return false, fmt.Errorf("### Unexpected HTTP status: %d", resp.StatusCode) +} + +func (c *client) UpdateRecords(records []godaddy.DNSRecord, domainZone, recordName string) error { + body, err := json.Marshal(records) + if err != nil { + return err + } + + url := fmt.Sprintf("/v1/domains/%s/records/TXT/%s", domainZone, recordName) + logrus.Infof("### URL request issued to create/update the DNS record: %s", url) + logrus.Debugf("### DNS record(s): %s", body) + + resp, err := godaddy.MakeRequest(c.cfg, http.MethodPut, url, bytes.NewReader(body), setAuth) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("### Could not create record %v; Status: %v; Body: %s", string(body), resp.StatusCode, string(bodyBytes)) + } + + logrus.Info("### TXT record created/updated using godaddy REST API !") + return nil +} + +func (c *client) DeleteTxtRecord(domainZone, recordName string) error { + url := fmt.Sprintf("/v1/domains/%s/records/TXT/%s", domainZone, recordName) + logrus.Infof("### URL request issued to delete the DNS record: %s", url) + + resp, err := godaddy.MakeRequest(c.cfg, http.MethodDelete, url, nil, setAuth) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("### Failed deleting TXT record: status of the response: %d", resp.StatusCode) + } + + logrus.Infof("### TXT Record deleted using Godaddy REST API") + return nil +} \ No newline at end of file diff --git a/internal/godaddy/v1/client_test.go b/internal/godaddy/v1/client_test.go new file mode 100644 index 0000000..86eab85 --- /dev/null +++ b/internal/godaddy/v1/client_test.go @@ -0,0 +1,179 @@ +package v1 + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/snowdrop/godaddy-webhook/internal/godaddy" +) + +func testConfig(serverURL string) *godaddy.ClientConfig { + return &godaddy.ClientConfig{ + AuthAPIKey: "testkey", + AuthAPISecret: "testsecret", + BaseURL: serverURL, + } +} + +func TestHasTXTRecord_Found(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/domains/example.com/records/TXT/_acme-challenge" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("unexpected method: %s", r.Method) + } + if r.Header.Get("Authorization") != "sso-key testkey:testsecret" { + t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization")) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]godaddy.DNSRecord{ + {Type: "TXT", Name: "_acme-challenge", Data: "challenge-token"}, + }) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + found, err := c.HasTXTRecord("example.com", "_acme-challenge", "challenge-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !found { + t.Error("expected record to be found") + } +} + +func TestHasTXTRecord_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + found, err := c.HasTXTRecord("example.com", "_acme-challenge", "challenge-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Error("expected record not to be found") + } +} + +func TestHasTXTRecord_EmptyResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]godaddy.DNSRecord{}) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + found, err := c.HasTXTRecord("example.com", "_acme-challenge", "challenge-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Error("expected record not to be found for empty response") + } +} + +func TestHasTXTRecord_WrongKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]godaddy.DNSRecord{ + {Type: "TXT", Name: "_acme-challenge", Data: "other-token"}, + }) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + found, err := c.HasTXTRecord("example.com", "_acme-challenge", "challenge-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Error("expected record not to match different challenge key") + } +} + +func TestUpdateRecords(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/domains/example.com/records/TXT/_acme-challenge" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodPut { + t.Errorf("unexpected method: %s", r.Method) + } + + body, _ := io.ReadAll(r.Body) + var records []godaddy.DNSRecord + if err := json.Unmarshal(body, &records); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + if len(records) != 1 || records[0].Data != "challenge-token" { + t.Errorf("unexpected records: %+v", records) + } + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + err := c.UpdateRecords([]godaddy.DNSRecord{ + {Type: "TXT", Name: "_acme-challenge", Data: "challenge-token", TTL: 600}, + }, "example.com", "_acme-challenge") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUpdateRecords_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"message":"internal error"}`)) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + err := c.UpdateRecords([]godaddy.DNSRecord{ + {Type: "TXT", Name: "_acme-challenge", Data: "challenge-token"}, + }, "example.com", "_acme-challenge") + if err == nil { + t.Fatal("expected error for server error response") + } +} + +func TestDeleteTxtRecord(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/domains/example.com/records/TXT/_acme-challenge" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodDelete { + t.Errorf("unexpected method: %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + err := c.DeleteTxtRecord("example.com", "_acme-challenge") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDeleteTxtRecord_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + err := c.DeleteTxtRecord("example.com", "_acme-challenge") + if err == nil { + t.Fatal("expected error for server error response") + } +} diff --git a/internal/godaddy/v3/client.go b/internal/godaddy/v3/client.go new file mode 100644 index 0000000..fd04da6 --- /dev/null +++ b/internal/godaddy/v3/client.go @@ -0,0 +1,163 @@ +package v3 + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/sirupsen/logrus" + "github.com/snowdrop/godaddy-webhook/internal/godaddy" +) + +type dnsRecordResponse struct { + RecordID string `json:"recordId"` + Type string `json:"type"` + Name string `json:"name"` + Data string `json:"data"` + TTL int `json:"ttl,omitempty"` +} + +type dnsRecordsListResponse struct { + Items []dnsRecordResponse `json:"items"` +} + +type client struct { + cfg *godaddy.ClientConfig +} + +func NewClient(cfg *godaddy.ClientConfig) godaddy.Client { + return &client{cfg: cfg} +} + +func setAuth(req *http.Request, cfg *godaddy.ClientConfig) { + req.Header.Set("Authorization", "Bearer "+cfg.AuthPAT) +} + +func (c *client) HasTXTRecord(domainZone, recordName, challengeKey string) (bool, error) { + url := fmt.Sprintf("/v3/domains/zones/%s/dns-records?type=TXT&name=%s", domainZone, recordName) + logrus.Debug("### GoDaddy credentials loaded") + logrus.Infof("### URL request issued to check if the TXT DNS record is present: %s", url) + + resp, err := godaddy.MakeRequest(c.cfg, http.MethodGet, url, nil, setAuth) + if err != nil { + logrus.Infof("### HTTP request failed with Godaddy: %s", err) + return false, err + } + defer resp.Body.Close() + logrus.Debugf("### Godaddy HTTP body response: %s", resp.Body) + + if resp.StatusCode == http.StatusNotFound { + return false, nil + } else if resp.StatusCode == http.StatusOK { + var listResp dnsRecordsListResponse + err = json.NewDecoder(resp.Body).Decode(&listResp) + if err != nil { + return false, fmt.Errorf("### HTTP response body cannot be parsed to JSON: %s", err) + } + + if len(listResp.Items) == 0 { + logrus.Info("### No TXT Record found using godaddy REST API !") + return false, nil + } + + for _, dnsRecord := range listResp.Items { + logrus.Infof("### TXT Record collected from godaddy: %#v", dnsRecord) + if dnsRecord.Data == challengeKey { + logrus.Infof("### TXT Record found : %#v, for challengeKey: %s", dnsRecord, challengeKey) + return true, nil + } + } + logrus.Infof("### No TXT Record found within the response for challengeKey: %s", challengeKey) + return false, nil + } + + return false, fmt.Errorf("### Unexpected HTTP status: %d", resp.StatusCode) +} + +func (c *client) UpdateRecords(records []godaddy.DNSRecord, domainZone, recordName string) error { + for _, record := range records { + if record.TTL == 0 { + record.TTL = 600 + } + body, err := json.Marshal(record) + if err != nil { + return err + } + + url := fmt.Sprintf("/v3/domains/zones/%s/dns-records", domainZone) + logrus.Infof("### URL request issued to create the DNS record: %s", url) + logrus.Debugf("### DNS record: %s", body) + + resp, err := godaddy.MakeRequest(c.cfg, http.MethodPost, url, bytes.NewReader(body), setAuth) + if err != nil { + return err + } + if err := func() error { + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnprocessableEntity { + bodyBytes, _ := io.ReadAll(resp.Body) + if bytes.Contains(bodyBytes, []byte("DUPLICATE_RECORD")) { + logrus.Infof("### TXT record already exists, skipping: %s", recordName) + return nil + } + return fmt.Errorf("### Could not create record %v; Status: %v; Body: %s", string(body), resp.StatusCode, string(bodyBytes)) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("### Could not create record %v; Status: %v; Body: %s", string(body), resp.StatusCode, string(bodyBytes)) + } + + logrus.Info("### TXT record created using godaddy v3 REST API !") + return nil + }(); err != nil { + return err + } + } + return nil +} + +func (c *client) DeleteTxtRecord(domainZone, recordName string) error { + url := fmt.Sprintf("/v3/domains/zones/%s/dns-records?type=TXT&name=%s", domainZone, recordName) + logrus.Infof("### URL request issued to list TXT records for deletion: %s", url) + + resp, err := godaddy.MakeRequest(c.cfg, http.MethodGet, url, nil, setAuth) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("### Failed listing TXT records for deletion: status %d", resp.StatusCode) + } + + var listResp dnsRecordsListResponse + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + return fmt.Errorf("### Failed to parse DNS records response: %s", err) + } + + for _, record := range listResp.Items { + deleteURL := fmt.Sprintf("/v3/domains/zones/%s/dns-records/%s", domainZone, record.RecordID) + logrus.Infof("### URL request issued to delete DNS record ID %s: %s", record.RecordID, deleteURL) + + delResp, err := godaddy.MakeRequest(c.cfg, http.MethodDelete, deleteURL, nil, setAuth) + if err != nil { + return err + } + if err := func() error { + defer delResp.Body.Close() + if delResp.StatusCode != http.StatusOK && delResp.StatusCode != http.StatusNoContent { + return fmt.Errorf("### Failed deleting TXT record ID %s: status %d", record.RecordID, delResp.StatusCode) + } + logrus.Infof("### TXT Record ID %s deleted using Godaddy v3 REST API", record.RecordID) + return nil + }(); err != nil { + return err + } + } + + return nil +} \ No newline at end of file diff --git a/internal/godaddy/v3/client_test.go b/internal/godaddy/v3/client_test.go new file mode 100644 index 0000000..9699aba --- /dev/null +++ b/internal/godaddy/v3/client_test.go @@ -0,0 +1,222 @@ +package v3 + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/snowdrop/godaddy-webhook/internal/godaddy" +) + +func testConfig(serverURL string) *godaddy.ClientConfig { + return &godaddy.ClientConfig{ + AuthPAT: "test-pat-token", + BaseURL: serverURL, + } +} + +func TestHasTXTRecord_Found(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/domains/zones/example.com/dns-records" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("type") != "TXT" { + t.Errorf("expected type=TXT query param, got: %s", r.URL.Query().Get("type")) + } + if r.URL.Query().Get("name") != "_acme-challenge" { + t.Errorf("expected name=_acme-challenge query param, got: %s", r.URL.Query().Get("name")) + } + if r.Method != http.MethodGet { + t.Errorf("unexpected method: %s", r.Method) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(dnsRecordsListResponse{ + Items: []dnsRecordResponse{ + {RecordID: "rec-1", Type: "TXT", Name: "_acme-challenge", Data: "challenge-token"}, + }, + }) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + found, err := c.HasTXTRecord("example.com", "_acme-challenge", "challenge-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !found { + t.Error("expected record to be found") + } +} + +func TestHasTXTRecord_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + found, err := c.HasTXTRecord("example.com", "_acme-challenge", "challenge-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Error("expected record not to be found") + } +} + +func TestHasTXTRecord_EmptyResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(dnsRecordsListResponse{Items: []dnsRecordResponse{}}) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + found, err := c.HasTXTRecord("example.com", "_acme-challenge", "challenge-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Error("expected record not to be found for empty response") + } +} + +func TestHasTXTRecord_WrongKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(dnsRecordsListResponse{ + Items: []dnsRecordResponse{ + {RecordID: "rec-1", Type: "TXT", Name: "_acme-challenge", Data: "other-token"}, + }, + }) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + found, err := c.HasTXTRecord("example.com", "_acme-challenge", "challenge-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Error("expected record not to match different challenge key") + } +} + +func TestUpdateRecords(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/domains/zones/example.com/dns-records" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("unexpected method: %s", r.Method) + } + + body, _ := io.ReadAll(r.Body) + var record godaddy.DNSRecord + if err := json.Unmarshal(body, &record); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + if record.Data != "challenge-token" { + t.Errorf("unexpected record data: %s", record.Data) + } + + w.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + err := c.UpdateRecords([]godaddy.DNSRecord{ + {Type: "TXT", Name: "_acme-challenge", Data: "challenge-token", TTL: 600}, + }, "example.com", "_acme-challenge") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUpdateRecords_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"message":"internal error"}`)) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + err := c.UpdateRecords([]godaddy.DNSRecord{ + {Type: "TXT", Name: "_acme-challenge", Data: "challenge-token"}, + }, "example.com", "_acme-challenge") + if err == nil { + t.Fatal("expected error for server error response") + } +} + +func TestDeleteTxtRecord(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + switch requestCount { + case 1: + if r.URL.Path != "/v3/domains/zones/example.com/dns-records" { + t.Errorf("unexpected list path: %s", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("expected GET for listing, got: %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(dnsRecordsListResponse{ + Items: []dnsRecordResponse{ + {RecordID: "rec-123", Type: "TXT", Name: "_acme-challenge", Data: "challenge-token"}, + }, + }) + case 2: + if r.URL.Path != "/v3/domains/zones/example.com/dns-records/rec-123" { + t.Errorf("unexpected delete path: %s", r.URL.Path) + } + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got: %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected request #%d", requestCount) + } + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + err := c.DeleteTxtRecord("example.com", "_acme-challenge") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if requestCount != 2 { + t.Errorf("expected 2 requests (list + delete), got %d", requestCount) + } +} + +func TestDeleteTxtRecord_NoRecords(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(dnsRecordsListResponse{Items: []dnsRecordResponse{}}) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + err := c.DeleteTxtRecord("example.com", "_acme-challenge") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDeleteTxtRecord_ListError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + c := NewClient(testConfig(server.URL)) + err := c.DeleteTxtRecord("example.com", "_acme-challenge") + if err == nil { + t.Fatal("expected error for server error response") + } +} diff --git a/logging/logging.go b/internal/logging/logging.go similarity index 100% rename from logging/logging.go rename to internal/logging/logging.go diff --git a/main.go b/main.go index f7692a5..5c04b27 100644 --- a/main.go +++ b/main.go @@ -1,27 +1,22 @@ package main import ( - "bytes" - "context" "encoding/json" "errors" "fmt" - "io" - "io/ioutil" - "net/http" "os" "strconv" - "strings" - "time" "github.com/cert-manager/cert-manager/pkg/acme/webhook/cmd" - "github.com/cert-manager/cert-manager/pkg/issuer/acme/dns/util" - useragent "github.com/cert-manager/cert-manager/pkg/util" - "github.com/snowdrop/godaddy-webhook/logging" + "github.com/snowdrop/godaddy-webhook/internal/auth" + "github.com/snowdrop/godaddy-webhook/internal/dns" + "github.com/snowdrop/godaddy-webhook/internal/godaddy" + v1 "github.com/snowdrop/godaddy-webhook/internal/godaddy/v1" + v3 "github.com/snowdrop/godaddy-webhook/internal/godaddy/v3" + "github.com/snowdrop/godaddy-webhook/internal/logging" logrus "github.com/sirupsen/logrus" apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -34,6 +29,7 @@ const ( DefaultLevel = "info" DefaultLogTimestamp = false DefaultLogFormat = "color" + DefaultAPIVersion = "v1" LOGGING_LEVEL_ENV_NAME = "LOGGING_LEVEL" LOGGING_FORMAT_ENV_NAME = "LOGGING_FORMAT" @@ -41,22 +37,13 @@ const ( ) var ( - logLevel = os.Getenv(LOGGING_LEVEL_ENV_NAME) // Log level (trace, debug, info, warn, error, fatal, panic) - logFormat = os.Getenv(LOGGING_FORMAT_ENV_NAME) // Log format (text, color, json) - logTimestampStr = os.Getenv(LOGGING_TIMESTAMP_ENV_NAME) // Timestamp in log output + logLevel = os.Getenv(LOGGING_LEVEL_ENV_NAME) + logFormat = os.Getenv(LOGGING_FORMAT_ENV_NAME) + logTimestampStr = os.Getenv(LOGGING_TIMESTAMP_ENV_NAME) logTimestamp bool GroupName = os.Getenv("GROUP_NAME") ) -// DNSRecord a DNS record -type DNSRecord struct { - Type string `json:"type"` - Name string `json:"name"` - Data string `json:"data"` - Priority int `json:"priority,omitempty"` - TTL int `json:"ttl,omitempty"` -} - func main() { if GroupName == "" { panic("GROUP_NAME must be specified") @@ -85,48 +72,23 @@ func main() { panic(err) } - // This will register our godaddy DNS provider with the webhook serving - // library, making it available as an API under the provided GroupName. - // You can register multiple DNS provider implementations with a single - // webhook, where the Name() method will be used to disambiguate between - // the different implementations. cmd.RunWebhookServer(GroupName, &godaddyDNSSolver{}, ) } -// godaddyDNSSolver implements the provider-specific logic needed to -// 'present' an ACME challenge TXT record for your own DNS provider. -// To do so, it must implement the `github.com/cert-manager/cert-manager/pkg/acme/webhook.Solver` -// interface. type godaddyDNSSolver struct { client *kubernetes.Clientset - cfg *godaddyDNSProviderConfig } -// godaddyDNSProviderConfig is a structure that is used to decode into when -// solving a DNS01 challenge. -// This information is provided by cert-manager, and may be a reference to -// additional configuration that's needed to solve the challenge for this -// particular certificate or issuer. -// This typically includes references to Secret resources containing DNS -// provider credentials, in cases where a 'multi-tenant' DNS solver is being -// created. -// If you do *not* require per-issuer or per-certificate configuration to be -// provided to your webhook, you can skip decoding altogether in favour of -// using CLI flags or similar to provide configuration. -// You should not include sensitive information here. If credentials need to -// be used by your provider here, you should reference a Kubernetes Secret -// resource and fetch these credentials using a Kubernetes clientset. type godaddyDNSProviderConfig struct { - // These fields will be set by users in the - // `issuer.spec.acme.dns01.providers.webhook.config` field. - APIKeySecretRef certmgrv1.SecretKeySelector `json:"apiKeySecretRef"` AuthAPIKey string `json:"authApiKey"` AuthAPISecret string `json:"authApiSecret"` + AuthPAT string `json:"authPAT"` Production bool `json:"production"` + APIVersion string `json:"apiVersion"` // +optional. The TTL of the TXT record used for the DNS challenge TTL int `json:"ttl"` @@ -141,103 +103,119 @@ type godaddyDNSProviderConfig struct { } func (c *godaddyDNSSolver) validate(cfg *godaddyDNSProviderConfig) error { - // Try to load the API key if cfg.APIKeySecretRef.LocalObjectReference.Name == "" { - return errors.New("API token field were not provided as no Kubernetes Secret exists !") + return errors.New("apiKeySecretRef.name must be set") + } + if cfg.APIKeySecretRef.Key == "" { + return errors.New("apiKeySecretRef.key must be set") + } + switch cfg.APIVersion { + case "", "v1", "v3": + return nil + default: + return fmt.Errorf("apiVersion must be one of: v1, v3") } - return nil } -// Name is used as the name for this DNS solver when referencing it on the ACME -// Issuer resource. -// This should be unique **within the group name**, i.e. you can have two -// solvers configured with the same Name() **so long as they do not co-exist -// within a single webhook deployment**. -// For example, `cloudflare` may be used as the name of a solver. func (c *godaddyDNSSolver) Name() string { return providerName } -// Return GoDaddi API URL to query the API domains -// See - https://developer.godaddy.com/doc/endpoint/domains -// OTE environment: https://api.ote-godaddy.com -// PRODUCTION environment: https://api.godaddy.com -func (c *godaddyDNSSolver) apiURL(cfg *godaddyDNSProviderConfig) string { - baseURL := "https://api.ote-godaddy.com" - if cfg.Production { - baseURL = "https://api.godaddy.com" +func (c *godaddyDNSSolver) newGodaddyClient(cfg *godaddyDNSProviderConfig) godaddy.Client { + clientCfg := &godaddy.ClientConfig{ + AuthAPIKey: cfg.AuthAPIKey, + AuthAPISecret: cfg.AuthAPISecret, + AuthPAT: cfg.AuthPAT, + Production: cfg.Production, } - return baseURL -} -func (c *godaddyDNSSolver) extractApiTokenFromSecret(cfg *godaddyDNSProviderConfig, ch *v1alpha1.ChallengeRequest) error { - sec, err := c.client.CoreV1(). - Secrets(ch.ResourceNamespace). - Get(context.TODO(), cfg.APIKeySecretRef.LocalObjectReference.Name, metaV1.GetOptions{}) - if err != nil { - return err + apiVersion := cfg.APIVersion + if apiVersion == "" { + apiVersion = DefaultAPIVersion } - secBytes, ok := sec.Data[cfg.APIKeySecretRef.Key] - if !ok { - return fmt.Errorf("Key %q not found in secret \"%s/%s\"", - cfg.APIKeySecretRef.Key, - cfg.APIKeySecretRef.LocalObjectReference.Name, - ch.ResourceNamespace) + logrus.Infof("### Using GoDaddy API version: %s", apiVersion) + + switch apiVersion { + case "v3": + return v3.NewClient(clientCfg) + default: + return v1.NewClient(clientCfg) } +} - token := strings.Split(string(secBytes), ":") - cfg.AuthAPIKey = token[0] - cfg.AuthAPISecret = token[1] +func (c *godaddyDNSSolver) extractApiTokenFromSecret(cfg *godaddyDNSProviderConfig, ch *v1alpha1.ChallengeRequest) error { + if cfg.APIVersion == "v3" { + pat, err := auth.ExtractPATFromSecret( + c.client, + ch.ResourceNamespace, + cfg.APIKeySecretRef.LocalObjectReference.Name, + cfg.APIKeySecretRef.Key, + ) + if err != nil { + return err + } + cfg.AuthPAT = pat + return nil + } + creds, err := auth.ExtractFromSecret( + c.client, + ch.ResourceNamespace, + cfg.APIKeySecretRef.LocalObjectReference.Name, + cfg.APIKeySecretRef.Key, + ) + if err != nil { + return err + } + cfg.AuthAPIKey = creds.APIKey + cfg.AuthAPISecret = creds.APISecret return nil } -// Present is responsible for actually presenting the DNS record with the -// DNS provider. -// This method should tolerate being called multiple times with the same value. -// cert-manager itself will later perform a self check to ensure that the -// solver has correctly configured the DNS provider. func (c *godaddyDNSSolver) Present(ch *v1alpha1.ChallengeRequest) error { cfg, err := loadConfig(ch.Config) if err != nil { return err } - // Verify if the config contains the required parameters such as SecretRef if err := c.validate(cfg); err != nil { return err } - // Extract the Godaddy Api and Secret from the K8s Secret - // and assign it the AuthAPIKey and AuthAPISecret of the Config if err := c.extractApiTokenFromSecret(cfg, ch); err != nil { return err } - recordName := c.extractRecordName(ch.ResolvedFQDN, ch.ResolvedZone) + recordName := dns.ExtractRecordName(ch.ResolvedFQDN, ch.ResolvedZone) logrus.Infof("TXT Record name: %s", recordName) - dnsZone, err := c.getZone(ch.ResolvedZone) + dnsZone, err := dns.GetZone(ch.ResolvedZone) if err != nil { return err } + apiClient := c.newGodaddyClient(cfg) + logrus.Infof("### Try to present the DNS record with the DNS provider using as challengeKey: %s", ch.Key) - _, err = c.HasTXTRecord(cfg, dnsZone, recordName, ch.Key) + present, err := apiClient.HasTXTRecord(dnsZone, recordName, ch.Key) if err != nil { return fmt.Errorf("Unable to check the TXT record: %v", err) } - rec := []DNSRecord{{ - Data: c.TXTRecordContent(ch.Key), + if present { + logrus.Infof("### TXT record already exists for challengeKey: %s, skipping create", ch.Key) + return nil + } + + rec := []godaddy.DNSRecord{{ + Data: txtRecordContent(ch.Key), TTL: cfg.TTL, Type: "TXT", Name: recordName, - }, - } + }} - err = c.UpdateRecords(cfg, rec, dnsZone, recordName) + err = apiClient.UpdateRecords(rec, dnsZone, recordName) if err != nil { return fmt.Errorf("### Unable to create TXT record: %v", err) } @@ -245,52 +223,44 @@ func (c *godaddyDNSSolver) Present(ch *v1alpha1.ChallengeRequest) error { return nil } -func (c *godaddyDNSSolver) TXTRecordContent(key string) string { +func txtRecordContent(key string) string { if key != "" { return key - } else { - return "null" } + return "null" } -// CleanUp should delete the relevant TXT record from the DNS provider console. -// If multiple TXT records exist with the same record name (e.g. -// _acme-challenge.example.com) then **only** the record with the same `key` -// value provided on the ChallengeRequest should be cleaned up. -// This is in order to facilitate multiple DNS validations for the same domain -// concurrently. func (c *godaddyDNSSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { cfg, err := loadConfig(ch.Config) if err != nil { return err } - // Verify if the config contains the required parameters such as SecretRef if err := c.validate(cfg); err != nil { return err } - // Extract the Godaddy Api and Secret from the K8s Secret - // and assign it the AuthAPIKey and AuthAPISecret of the Config if err := c.extractApiTokenFromSecret(cfg, ch); err != nil { return err } - recordName := c.extractRecordName(ch.ResolvedFQDN, ch.ResolvedZone) - dnsZone, err := c.getZone(ch.ResolvedZone) + recordName := dns.ExtractRecordName(ch.ResolvedFQDN, ch.ResolvedZone) + dnsZone, err := dns.GetZone(ch.ResolvedZone) if err != nil { return err } + apiClient := c.newGodaddyClient(cfg) + logrus.Infof("### CleanUp should delete the relevant TXT record for the challengeKey: %s", ch.Key) - present, err := c.HasTXTRecord(cfg, dnsZone, recordName, ch.Key) + present, err := apiClient.HasTXTRecord(dnsZone, recordName, ch.Key) if err != nil { return fmt.Errorf("### Unable to check TXT record: %s", err) } if present { logrus.Infof("### Deleting entry=%s, domain=%s", recordName, dnsZone) - err := c.DeleteTxtRecord(cfg, dnsZone, recordName) + err := apiClient.DeleteTxtRecord(dnsZone, recordName) if err != nil { return fmt.Errorf("### Unable to delete the TXT record: %v", err) } @@ -299,15 +269,6 @@ func (c *godaddyDNSSolver) CleanUp(ch *v1alpha1.ChallengeRequest) error { return nil } -// Initialize will be called when the webhook first starts. -// This method can be used to instantiate the webhook, i.e. initialising -// connections or warming up caches. -// Typically, the kubeClientConfig parameter is used to build a Kubernetes -// client that can be used to fetch resources from the Kubernetes API, e.g. -// Secret resources containing credentials used to authenticate with DNS -// provider accounts. -// The stopCh can be used to handle early termination of the webhook, in cases -// where a SIGTERM or similar signal is sent to the webhook process. func (c *godaddyDNSSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error { cl, err := kubernetes.NewForConfig(kubeClientConfig) if err != nil { @@ -318,11 +279,8 @@ func (c *godaddyDNSSolver) Initialize(kubeClientConfig *rest.Config, stopCh <-ch return nil } -// loadConfig is a small helper function that decodes JSON configuration into -// the typed config struct. func loadConfig(cfgJSON *apiext.JSON) (*godaddyDNSProviderConfig, error) { cfg := &godaddyDNSProviderConfig{} - // handle the 'base case' where no configuration has been provided if cfgJSON == nil { return cfg, nil } @@ -332,151 +290,3 @@ func loadConfig(cfgJSON *apiext.JSON) (*godaddyDNSProviderConfig, error) { return cfg, nil } - -func (c *godaddyDNSSolver) HasTXTRecord(cfg *godaddyDNSProviderConfig, domainZone string, recordName string, challengeKey string) (bool, error) { - // curl -X GET -H "Authorization: sso-key $TOKEN" - // "https://api.godaddy.com/v1/domains//records/TXT/" - url := fmt.Sprintf("/v1/domains/%s/records/TXT/%s", domainZone, recordName) - logrus.Debugf("### Godaddy Api: %s, Secret: %s keys", cfg.AuthAPIKey, cfg.AuthAPISecret) - logrus.Infof("### URL request issued to check if the TXT DNS record is present: %s", url) - - resp, err := c.makeRequest(cfg, http.MethodGet, url, nil) - if err != nil { - logrus.Infof("### HTTP request failed with Godaddy: %s", err) - return false, err - } - logrus.Debugf("### Godaddy HTTP body response: %s", resp.Body) - - if resp.StatusCode == http.StatusNotFound { - return false, nil - } else if resp.StatusCode == http.StatusOK { - var dnsRecords = []DNSRecord{} - err = json.NewDecoder(resp.Body).Decode(&dnsRecords) - if err != nil { - return false, fmt.Errorf("### HTTP response body cannot be parsed to JSON: %s", err) - } - - if len(dnsRecords) == 0 { - logrus.Info("### No TXT Record found using godaddy REST API !") - return false, nil - } else { - for _, dnsRecord := range dnsRecords { - logrus.Infof("### TXT Record collected from godaddy: %#v", dnsRecord) - if dnsRecord.Data == challengeKey { - logrus.Infof("### TXT Record found : %#v, for challengeKey: %s", dnsRecord, challengeKey) - return true, nil - } - } - logrus.Infof("### No TXT Record found within the response for challengeKey: %s", challengeKey) - return false, nil - } - } else { - return false, fmt.Errorf("### Unexpected HTTP status: %d", resp.StatusCode) - } - - return false, nil -} - -// Function to be used to create/update a TXT record -// Godaddy uses an array of DNS records as input ! -// See: https://developer.godaddy.com/doc/endpoint/domains#/v1/recordReplaceType -func (c *godaddyDNSSolver) UpdateRecords(cfg *godaddyDNSProviderConfig, records []DNSRecord, domainZone string, recordName string) error { - body, err := json.Marshal(records) - if err != nil { - return err - } - - var resp *http.Response - url := fmt.Sprintf("/v1/domains/%s/records/TXT/%s", domainZone, recordName) - logrus.Infof("### URL request issued to create/update the DNS record: %s", url) - logrus.Debugf("### DNS record(s): %s", body) - resp, err = c.makeRequest(cfg, http.MethodPut, url, bytes.NewReader(body)) - if err != nil { - return err - } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := ioutil.ReadAll(resp.Body) - return fmt.Errorf("### Could not create record %v; Status: %v; Body: %s", string(body), resp.StatusCode, string(bodyBytes)) - } else { - logrus.Info("### TXT record created/updated using godaddy REST API !") - } - return nil -} - -// Function to be used to delete a TXT record -// See: https://developer.godaddy.com/doc/endpoint/domains#/v1/recordDeleteTypeName -func (c *godaddyDNSSolver) DeleteTxtRecord(cfg *godaddyDNSProviderConfig, domainZone string, recordName string) error { - var resp *http.Response - url := fmt.Sprintf("/v1/domains/%s/records/TXT/%s", domainZone, recordName) - logrus.Infof("### URL request issued to delete the DNS record: %s", url) - - resp, err := c.makeRequest(cfg, http.MethodDelete, url, nil) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - return fmt.Errorf("### Failed deleting TXT record: %v, status of the response: %d", err, resp.StatusCode) - } - logrus.Infof("### TXT Record deleted using Godaddy REST API") - return nil -} - -func (c *godaddyDNSSolver) makeRequest(cfg *godaddyDNSProviderConfig, method string, uri string, body io.Reader) (*http.Response, error) { - req, err := http.NewRequest(method, fmt.Sprintf("%s%s", c.apiURL(cfg), uri), body) - if err != nil { - return nil, err - } - - var CertManagerUserAgent = "cert-manager/" + useragent.AppVersion - - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", CertManagerUserAgent) - req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", cfg.AuthAPIKey, cfg.AuthAPISecret)) - - logrus.Debugf("### Godaddy HTTP request: %s", req.URL.String()) - logrus.Debugf("### Header authorisation: %s", req.Header.Get("Authorization")) - - client := http.Client{ - Timeout: 30 * time.Second, - } - - return client.Do(req) -} - -func (c *godaddyDNSSolver) extractRecordName(fqdn, domain string) string { - if idx := strings.Index(fqdn, "."+domain); idx != -1 { - return fqdn[:idx] - } - return util.UnFqdn(fqdn) -} - -func (c *godaddyDNSSolver) extractDomainName(zone string) string { - authZone, err := util.FindZoneByFqdn(context.TODO(), zone, util.RecursiveNameservers) - if err != nil { - return zone - } - return util.UnFqdn(authZone) -} - -func (c *godaddyDNSSolver) getZone(fqdn string) (string, error) { - authZone, err := util.FindZoneByFqdn(context.TODO(), fqdn, util.RecursiveNameservers) - if err != nil { - return "", err - } - - return util.UnFqdn(authZone), nil -} - -func (c *godaddyDNSSolver) getDomainAndEntry(ch *v1alpha1.ChallengeRequest) (string, string) { - // Both ch.ResolvedZone and ch.ResolvedFQDN end with a dot: '.' - entry := strings.TrimSuffix(ch.ResolvedFQDN, ch.ResolvedZone) - entry = strings.TrimSuffix(entry, ".") - domain := strings.TrimSuffix(ch.ResolvedZone, ".") - return entry, domain -} diff --git a/main_test.go b/main_test.go index 4ae51f5..1529ed6 100644 --- a/main_test.go +++ b/main_test.go @@ -1,45 +1,73 @@ package main import ( - "os" + "strings" "testing" - "time" - "github.com/cert-manager/cert-manager/test/acme" + certmgrv1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" ) -var ( - zone = os.Getenv("TEST_ZONE_NAME") - dnsServer = os.Getenv("TEST_DNS_SERVER") -) - -func TestRunsSuite(t *testing.T) { - // The manifest path should contain a file named config.json that is a - // snippet of valid configuration that should be included on the - // ChallengeRequest passed as part of the test cases. - - pollTime, _ := time.ParseDuration("5s") - timeOut, _ := time.ParseDuration("3m") - - if dnsServer == "" { - dnsServer = "1.1.1.1:53" +func TestGodaddyDNSSolverValidate(t *testing.T) { + tests := []struct { + name string + cfg *godaddyDNSProviderConfig + wantErr string + }{ + { + name: "missing secret name", + cfg: &godaddyDNSProviderConfig{ + APIKeySecretRef: certmgrv1.SecretKeySelector{Key: "token"}, + }, + wantErr: "apiKeySecretRef.name must be set", + }, + { + name: "missing secret key", + cfg: &godaddyDNSProviderConfig{ + APIKeySecretRef: certmgrv1.SecretKeySelector{ + LocalObjectReference: certmgrv1.LocalObjectReference{Name: "godaddy-secret"}, + }, + }, + wantErr: "apiKeySecretRef.key must be set", + }, + { + name: "unsupported api version", + cfg: &godaddyDNSProviderConfig{ + APIKeySecretRef: certmgrv1.SecretKeySelector{ + LocalObjectReference: certmgrv1.LocalObjectReference{Name: "godaddy-secret"}, + Key: "token", + }, + APIVersion: "v2", + }, + wantErr: "apiVersion must be one of: v1, v3", + }, + { + name: "supported api version", + cfg: &godaddyDNSProviderConfig{ + APIKeySecretRef: certmgrv1.SecretKeySelector{ + LocalObjectReference: certmgrv1.LocalObjectReference{Name: "godaddy-secret"}, + Key: "token", + }, + APIVersion: "v3", + }, + }, } - fixture := dns.NewFixture(&godaddyDNSSolver{}, - dns.SetResolvedZone(zone), - dns.SetAllowAmbientCredentials(false), - dns.SetManifestPath("testdata/godaddy"), - dns.SetDNSServer(dnsServer), - dns.SetUseAuthoritative(false), - - // Disable the extended test as godaddy do not support to create several records for the same Record DNS Name !! - dns.SetStrict(false), - - // Increase the poll interval to 10s - dns.SetPollInterval(pollTime), - // Increase the limit from 2 min to 5 min - dns.SetPropagationLimit(timeOut), - ) - - fixture.RunConformance(t) + solver := &godaddyDNSSolver{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := solver.validate(tt.cfg) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + }) + } } diff --git a/scripts/fetch-test-binaries.sh b/scripts/fetch-test-binaries.sh index 5fe967d..e9b67c6 100755 --- a/scripts/fetch-test-binaries.sh +++ b/scripts/fetch-test-binaries.sh @@ -2,28 +2,10 @@ set -e -k8s_version=1.29.1 -goarch=$(go env GOARCH) -goos="unknown" - -if [[ "$OSTYPE" == "linux-gnu" ]]; then - goos="linux" -elif [[ "$OSTYPE" == "darwin"* ]]; then - goos="darwin" -fi - -if [[ "$goos" == "unknown" ]]; then - echo "OS '$OSTYPE' not supported. Aborting." >&2 - exit 1 -fi - +k8s_version=1.29.x tmp_root=./_out kb_root_dir=$tmp_root/kubebuilder -# Turn colors in this script off by setting the NO_COLOR variable in your -# environment to any value: -# -# $ NO_COLOR=1 test.sh NO_COLOR=${NO_COLOR:-""} if [ -z "$NO_COLOR" ]; then header=$'\e[1;33m' @@ -37,22 +19,23 @@ function header_text { echo "$header$*$reset" } -# fetch k8s API gen tools and make it available under kb_root_dir/bin. -function fetch_kb_tools { - header_text "fetching tools" - mkdir -p $tmp_root - kb_tools_archive_name="kubebuilder-tools-$k8s_version-$goos-$goarch.tar.gz" - kb_tools_download_url="https://storage.googleapis.com/kubebuilder-tools/$kb_tools_archive_name" - echo "URL: $kb_tools_download_url" - - kb_tools_archive_path="$tmp_root/$kb_tools_archive_name" - if [ ! -f $kb_tools_archive_path ]; then - curl -sL ${kb_tools_download_url} -o "$kb_tools_archive_path" - fi - tar -zvxf "$kb_tools_archive_path" -C "$tmp_root/" -} +header_text "fetching kubebuilder tools via setup-envtest" -fetch_kb_tools +if ! command -v setup-envtest &> /dev/null; then + header_text "installing setup-envtest..." + go install sigs.k8s.io/controller-runtime/tools/setup-envtest@${SETUP_ENVTEST_VERSION:-latest} +fi + +mkdir -p "$kb_root_dir/bin" + +ENVTEST_DIR=$(setup-envtest use "$k8s_version" --bin-dir "$kb_root_dir" -p path) +header_text "envtest binaries installed at: $ENVTEST_DIR" + +# Symlink binaries to the expected location for the Makefile +for bin in etcd kube-apiserver kubectl; do + if [ -f "$ENVTEST_DIR/$bin" ]; then + ln -sf "$(cd "$ENVTEST_DIR" && pwd)/$bin" "$kb_root_dir/bin/$bin" + fi +done -header_text "kubebuilder v$k8s_version tools (etcd, kubectl, kube-apiserver) used to perform local tests. It has been installed: $tmp_root/kubebuilder/bin/" -exit 0 +header_text "kubebuilder tools (etcd, kubectl, kube-apiserver) available at: $kb_root_dir/bin/" diff --git a/testdata/godaddy/README.md b/testdata/godaddy/README.md deleted file mode 100644 index ea65005..0000000 --- a/testdata/godaddy/README.md +++ /dev/null @@ -1 +0,0 @@ -# Testdata directory \ No newline at end of file diff --git a/testdata/godaddy/config.json b/testdata/godaddy/v1/config.json similarity index 100% rename from testdata/godaddy/config.json rename to testdata/godaddy/v1/config.json diff --git a/testdata/godaddy/v3/config.json b/testdata/godaddy/v3/config.json new file mode 100644 index 0000000..a6ed7c5 --- /dev/null +++ b/testdata/godaddy/v3/config.json @@ -0,0 +1,8 @@ +{ + "apiKeySecretRef": { + "name": "godaddy-credentials", + "key": "token" + }, + "production": true, + "apiVersion": "v3" +}