.k8s Loading last commit info...
api
bin/doctrine
config
dependencies
drivers
etc/caddy
html
middlewares
routes
tests
.dockerignore
.gitignore
.phpunit.result.cache
Dockerfile
LICENSE.md
README.md
build.sh
cli-config.php
composer.json
composer.lock
docker-compose.yml
phpunit.xml
README.md

ClouDNS Webhook for cert-manager

A cert-manager DNS01 ACME challenge webhook that manages DNS records via ClouDNS. Built on Kipchak (PHP).

How it works

cert-manager calls this service via the Kubernetes API server proxy whenever it needs to create or delete a _acme-challenge TXT record. The webhook handles both present (create) and cleanup (delete) actions through a single endpoint:

POST /apis/acme.mamluk.net/v1alpha1/cloudns

The webhook is registered with Kubernetes as an APIService resource (v1alpha1.acme.mamluk.net). This causes the Kubernetes API server to proxy cert-manager's challenge requests to the PHP service running inside the cluster.

Prerequisites

  • Kubernetes cluster with cert-manager v1.x installed
  • At least one ClouDNS account with API credentials (auth ID + auth password)
  • Docker and access to a container registry (if you want to build the Docker image yourself)

Configuration

This webhook supports two separate ClouDNS accounts (Premium and Geo DNS), allowing you to manage DNS challenges across different account types.

Environment variableDescriptionDefault
CLOUDNS_AUTH_ID_PREMIUMClouDNS Premium API authentication ID
CLOUDNS_AUTH_PASSWORD_PREMIUMClouDNS Premium API authentication password
CLOUDNS_AUTH_ID_GEOClouDNS Geo DNS API authentication ID
CLOUDNS_AUTH_PASSWORD_GEOClouDNS Geo DNS API authentication password
CLOUDNS_TTLTTL for DNS challenge records (seconds)60
DEBUGEnable debug loggingfalse

Integrating with an existing cert-manager installation

This section walks through deploying the webhook into a cluster where cert-manager is already running. None of the steps below touch your existing cert-manager installation — the webhook is a standalone deployment in the cert-manager namespace that cert-manager discovers automatically via the APIService registration.

Step 1 — Verify cert-manager is running

kubectl get pods -n cert-manager

You should see cert-manager, cert-manager-cainjector, and cert-manager-webhook pods all in Running state. The minimum supported version is cert-manager v1.0.

kubectl get crds | grep cert-manager.io

Confirm the cert-manager CRDs are present (certificates.cert-manager.io, clusterissuers.cert-manager.io, etc.).

Step 2 — Create the ClouDNS credentials secret

The webhook requires credentials for both Premium and Geo DNS accounts. Use the provided template to create the secret:

# Copy and edit the template
cp .k8s/secret.yaml.template .k8s/secret.yaml
# Edit .k8s/secret.yaml and fill in your credentials
# Then apply:
kubectl apply -f .k8s/secret.yaml

Or create it directly with kubectl:

kubectl create secret generic cloudns-api-credentials \
  --namespace cert-manager \
  --from-literal=auth-id-premium=YOUR_PREMIUM_AUTH_ID \
  --from-literal=auth-password-premium=YOUR_PREMIUM_AUTH_PASSWORD \
  --from-literal=auth-id-geo=YOUR_GEO_AUTH_ID \
  --from-literal=auth-password-geo=YOUR_GEO_AUTH_PASSWORD

Verify it was created:

kubectl get secret cloudns-api-credentials -n cert-manager

Step 3 — Build and push the Docker image

docker build -t your-registry/cm-webhook-cloudns:latest .
docker push your-registry/cm-webhook-cloudns:latest

Then update the image: field in .k8s/deployment.yaml to point to your registry before deploying.

Step 4 — Configure the ClusterIssuers

The webhook now supports two separate ClusterIssuer resources for Premium and Geo DNS accounts. Use the provided template:

# Copy and edit the template
cp .k8s/clusterissuer.yaml.template .k8s/clusterissuer.yaml
# Edit .k8s/clusterissuer.yaml and update email addresses and dnsZones
# Then apply it in Step 5

The template includes two ClusterIssuer resources:

  1. Premium DNS (letsencrypt-prod-cloudns-premium):

    • Uses groupName: acme.premium.mamluk.net
    • Configure dnsZones for domains managed by your Premium account
  2. Geo DNS (letsencrypt-prod-cloudns-geo):

    • Uses groupName: acme.geo.mamluk.net
    • Configure dnsZones for domains managed by your Geo DNS account

To use the Let's Encrypt staging environment while testing, replace the server URL with:

https://acme-staging-v02.api.letsencrypt.org/directory

Step 5 — Deploy

Deploy all manifests from the .k8s/ directory:

kubectl apply -f .k8s/serviceaccount.yaml
kubectl apply -f .k8s/role-and-binding.yaml
kubectl apply -f .k8s/clusterrole-and-binding.yaml
kubectl apply -f .k8s/deployment.yaml
kubectl apply -f .k8s/service.yaml
kubectl apply -f .k8s/apiservice.yaml
kubectl apply -f .k8s/clusterissuer.yaml  # After editing the template

This creates the following resources:

  • ServiceAccount / Role / RoleBinding / ClusterRole / ClusterRoleBinding — RBAC for the webhook
  • Deployment — the PHP webhook service with both Premium and Geo DNS credentials
  • Service — exposes the webhook on port 80 inside the cluster
  • APIService (both v1alpha1.acme.premium.mamluk.net and v1alpha1.acme.geo.mamluk.net) — registers both webhook endpoints
  • ClusterIssuer resources — separate issuers for Premium and Geo DNS accounts

Note on TLS: The APIService is configured with insecureSkipTLSVerify: true so the webhook can run plain HTTP inside the cluster. For production, mount a TLS certificate into the pod, configure the server to serve HTTPS, and replace insecureSkipTLSVerify: true with caBundle: <base64-encoded-CA> in the APIService.

Step 6 — Verify the deployment

Check the webhook pod is running:

kubectl get pods -n cert-manager -l app=cm-webhook-cloudns

Check both APIService resources are available (this is how cert-manager finds the webhook):

kubectl get apiservice v1alpha1.acme.premium.mamluk.net
kubectl get apiservice v1alpha1.acme.geo.mamluk.net

The AVAILABLE column must show True for both. If it shows False, the pod is likely not ready yet or the Service is misconfigured — check with:

kubectl describe apiservice v1alpha1.acme.premium.mamluk.net
kubectl describe apiservice v1alpha1.acme.geo.mamluk.net
kubectl logs -n cert-manager -l app=cm-webhook-cloudns

Check both ClusterIssuer resources are ready:

kubectl get clusterissuer letsencrypt-prod-cloudns-premium
kubectl get clusterissuer letsencrypt-prod-cloudns-geo

The READY column should show True within a minute or two for both issuers.

Step 7 — Request a certificate

Choose the appropriate ClusterIssuer based on which ClouDNS account manages your domain:

For domains managed by Premium DNS:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: example-com-tls
  namespace: default
spec:
  secretName: example-com-tls
  issuerRef:
    name: letsencrypt-prod-cloudns-premium
    kind: ClusterIssuer
  dnsNames:
    - example.com
    - "*.example.com"

For domains managed by Geo DNS:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: example-com-tls
  namespace: default
spec:
  secretName: example-com-tls
  issuerRef:
    name: letsencrypt-prod-cloudns-geo
    kind: ClusterIssuer
  dnsNames:
    - example.com
    - "*.example.com"
kubectl apply -f certificate.yaml
kubectl describe certificate example-com-tls -n default

Watching cert-manager logs to confirm the webhook is called

In one terminal, tail the cert-manager controller logs:

kubectl logs -n cert-manager -l app=cert-manager -f

In another, tail the webhook pod logs:

kubectl logs -n cert-manager -l app=cm-webhook-cloudns -f

When cert-manager processes a DNS01 challenge you will see it call the appropriate webhook endpoint:

  • Premium: POST /apis/acme.premium.mamluk.net/v1alpha1/cloudns
  • Geo DNS: POST /apis/acme.geo.mamluk.net/v1alpha1/cloudns

The webhook is called with action present to create the TXT record, then later with action cleanup to delete it.


Troubleshooting

APIService is not Available

kubectl describe apiservice v1alpha1.acme.premium.mamluk.net
kubectl describe apiservice v1alpha1.acme.geo.mamluk.net

Common causes:

  • The webhook pod has not started yet — wait for Running state.
  • The Service port does not match what the pod listens on (check deployment.yaml and service.yaml).
  • The pod is crashing — check kubectl logs for startup errors.

ClusterIssuer stays NotReady

kubectl describe clusterissuer letsencrypt-prod-cloudns-premium
kubectl describe clusterissuer letsencrypt-prod-cloudns-geo
  • If the ACME registration fails, check that the email address is valid and reachable.
  • If the webhook call fails during registration, confirm the corresponding APIService is Available.

Certificate stuck in "Pending"

kubectl describe certificate <name> -n <namespace>
kubectl describe challenge -n <namespace>
  • Confirm the dnsZones in the ClusterIssuer match the domain in the Certificate.
  • Ensure you're using the correct ClusterIssuer (Premium or Geo) for the account that manages your domain.
  • Check that the ClouDNS credentials are correct and the domain is managed by the corresponding ClouDNS account.
  • DNS propagation after the TXT record is created can take up to 60 seconds depending on your CLOUDNS_TTL setting. cert-manager polls until the record is visible.

TXT record created but cert-manager does not detect it

cert-manager uses its own recursive DNS resolvers to verify TXT records. If you have split-horizon DNS or a very short TTL, increase CLOUDNS_TTL to 120 or higher and ensure the record is publicly visible:

dig TXT _acme-challenge.example.com @8.8.8.8

Checking webhook request/response directly

You can call the webhook endpoints manually through kubectl proxy:

Premium endpoint:

kubectl proxy &
curl -s -X POST \
  http://localhost:8001/apis/acme.premium.mamluk.net/v1alpha1/cloudns \
  -H "Content-Type: application/json" \
  -d '{
    "apiVersion": "acme.cert-manager.io/v1alpha1",
    "kind": "ChallengePayload",
    "request": {
      "uid": "test-manual",
      "action": "present",
      "type": "dns-01",
      "dnsName": "example.com",
      "key": "test-token",
      "resolvedFQDN": "_acme-challenge.example.com.",
      "resolvedZone": "example.com.",
      "allowAmbientCredentials": false
    }
  }'

Geo DNS endpoint:

Replace acme.premium.mamluk.net with acme.geo.mamluk.net in the URL above.

A successful response looks like:

{
  "apiVersion": "acme.cert-manager.io/v1alpha1",
  "kind": "ChallengePayload",
  "response": {
    "uid": "test-manual",
    "success": true
  }
}

Local development

Update docker-compose.yml with your ClouDNS credentials, then:

docker-compose up -d

Testing the webhook locally

Present (create TXT record):

Test the Premium endpoint:

curl -s -X POST http://localhost:80/apis/acme.premium.mamluk.net/v1alpha1/cloudns \
  -H "Content-Type: application/json" \
  -d '{
    "apiVersion": "acme.cert-manager.io/v1alpha1",
    "kind": "ChallengePayload",
    "request": {
      "uid": "test-1",
      "action": "present",
      "type": "dns-01",
      "dnsName": "example.com",
      "key": "test-challenge-value",
      "resolvedFQDN": "_acme-challenge.example.com.",
      "resolvedZone": "example.com.",
      "allowAmbientCredentials": false
    }
  }'

Or test the Geo DNS endpoint (replace acme.premium.mamluk.net with acme.geo.mamluk.net).

Cleanup (delete TXT record):

Same payload with "action": "cleanup".

Health check:

curl http://localhost:80/healthz

Running tests

composer test

Project structure

api/
  Controllers/
    Base.php                     — base controller (wires up ClouDNS driver)
    Healthz.php                  — GET /healthz
    v1/
      WebhookController.php      — handles webhook requests for both Premium and Geo accounts
  Interfaces/
    DnsOperations.php            — interface for DNS operations (enables mocking in tests)
  Models/
    WebhookRequest.php           — parses cert-manager ChallengePayload
  Services/
    ClouDnsAdapter.php           — adapts kipchak/driver-cloudns to DnsOperations interface
config/
  kipchak.api.php                — app name, debug flag, log level
  kipchak.cloudns.php            — ClouDNS connection credentials (Premium and Geo)
drivers/
  drivers.php                    — Kipchak driver initialisation
routes/
  webhook.php                    — route definitions for both webhook endpoints
.k8s/
  apiservice.yaml                — APIService registrations for both Premium and Geo
  clusterissuer.yaml.template    — Template for ClusterIssuer resources (fill in and apply)
  clusterrole-and-binding.yaml   — Cluster-wide RBAC
  crds.yaml                      — Custom Resource Definitions
  deployment.yaml                — Deployment with both account credentials
  role-and-binding.yaml          — Namespace RBAC
  secret.yaml.template           — Template for secret with both account credentials
  service.yaml                   — Service exposing the webhook
  serviceaccount.yaml            — ServiceAccount

cert-manager webhook protocol reference

cert-manager sends a ChallengePayload to the webhook:

{
  "apiVersion": "acme.cert-manager.io/v1alpha1",
  "kind": "ChallengePayload",
  "request": {
    "uid": "<unique-id>",
    "action": "present",
    "type": "dns-01",
    "dnsName": "example.com",
    "key": "<challenge-token>",
    "resolvedFQDN": "_acme-challenge.example.com.",
    "resolvedZone": "example.com.",
    "allowAmbientCredentials": false
  }
}

The webhook must respond with:

{
  "apiVersion": "acme.cert-manager.io/v1alpha1",
  "kind": "ChallengePayload",
  "response": {
    "uid": "<same-uid-from-request>",
    "success": true
  }
}

Acknowledgements

License

MIT

Please wait...
Connection lost or session expired, reload to recover
Page is in error, reload to recover