Currently, while external-dns does have a provider for Cloudflare, it does not currently support Tunnel backends. Hence, I attempted to implement one using the webhook mechanism (as in-tree providers are being deprecated).

While external-dns does provide some documentation, it is (in my opinion) sparse on implementation details required to develop a provider. In this article, I will walk through how I implemented external-dns-cloudflare-tunnel-webhook, omitting any provider-specific details.

Specification

For a high-level overview, external-dns operates with webhook providers as follows:

sequenceDiagram
    participant k8s as Kubernetes API
    participant external-dns as External DNS
    participant webhook-provider as Webhook Provider
    participant dns-provider as DNS Provider

    opt init
        external-dns ->>+ webhook-provider: GET /
        webhook-provider ->>- external-dns: Provide DomainFilter configuration
    end

    loop listen for events
        k8s ->>+ external-dns: Notify CRUD event
on service or ingress external-dns ->>+ webhook-provider: GET /records webhook-provider ->>+ dns-provider: Get all existing records dns-provider ->>- webhook-provider: Return records webhook-provider ->>- external-dns: Return mapped records opt if changes are needed external-dns ->>+ webhook-provider: POST /adjustendpoints webhook-provider ->>- external-dns: Adjusted records external-dns ->>+ webhook-provider: POST /records webhook-provider ->>+ dns-provider: Apply changes end end

Looking closely, there are a number of endpoints the webhook provider must serve (in order) are:

  • GET /healthz - exposes the health status of the webhook provider, no requirements other than ensuring the check is meaningful
  • GET / - takes the value of the --domain-filter argument and returns it in a standardised format that is understood by external-dns
  • GET /records - a request for the provider to list all DNS records that are currently registered with the DNS provider
  • POST /adjustendpoints - the full list of changes external-dns is proposing to apply, the provider may take this opportunity to add, remove, or modify records to ensure they are compatible with the DNS provider (e.g. if the DNS provider does not support TXT records)
  • POST /records - a finalised list of DNS records and the appropriate action to take to ensure records are in-sync with services and ingresses

Implementation

I will not go into too much detail about how the Cloudflare-specific implementation works as I would like to keep this post short and to the point (if you’re interested, see the GitHub repository).

Note: error handling has been omitted for berevity. It goes without saying, you should properly handle errors in production code.

The Provider

The provider should satisfy the sigs.k8s.io/external-dns/provider.Provider interface which map to the endpoints defined in the specification above. While not strictly necessary, it provides the correct types of inputs and outputs for communication between external-dns and the webhook provider.

Example provider.go
package main

import (
    "context"
    "fmt"

    "github.com/axatol/external-dns-cloudflare-tunnel-webhook/pkg/cf"
    "sigs.k8s.io/external-dns/endpoint"
    "sigs.k8s.io/external-dns/provider"
)

// ensure it matches the provider interface
var _ provider.Provider = (*CloudflareTunnelProvider)(nil)

type CloudflareTunnelProvider struct {
    // can be sourced from env, flags, etc
    // see https://pkg.go.dev/sigs.k8s.io/external-dns/endpoint#DomainFilter
    DomainFilter []string
    // the api client
    Cloudflare cf.Cloudflare
}

// interrogate the dns provider for relevant records
func (p CloudflareTunnelProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
    tunnel, _ := p.Cloudflare.GetTunnelConfiguration(ctx, ...)

    endpoints := []*endpoint.Endpoint{}
    for _, ingress := range tunnel.Config.Ingress {
        endpoints = append(endpoints, &endpoint.Endpoint{ ... })
    }

    return endpoints, nil
}

// we only care about cname records
func (CloudflareTunnelProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
	  adjusted := []*endpoint.Endpoint{}
    for _, e := range endpoints {
        if e.RecordType == endpoint.RecordTypeCNAME {
            adjusted = append(adjusted, e)
        }
    }

    return adjusted, nil
}

// in our case, simply need to forward the domain filter back to external-dns
func (p CloudflareTunnelProvider) GetDomainFilter() endpoint.DomainFilter {
  	return endpoint.NewDomainFilter(p.DomainFilter)
}

// go and create/update/delete records
func (p CloudflareTunnelProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
    for _, change := range changes.Create {
        p.Cloudflare.CreateRecord(ctx, ...)
    }

    for _, delete := range changes.Delete {
        p.Cloudflare.DeleteRecord(ctx, ...)
    }

    for _, update := range changes.UpdateNew {
        p.Cloudflare.UpdateRecord(ctx, ...)
    }

    return nil
}

The Server

The server implementation is fairly straightforward. Apart from serving the required endpoints, the only other requirement is to only accept request and only respond with the content type application/external.dns.webhook+json;version=1.

Using a vanilla HTTP server, the implementation would look something like the following:

Example server.go
package main

import (
    "encoding/json"
    "fmt"
    "net/http"

    "sigs.k8s.io/external-dns/endpoint"
    "sigs.k8s.io/external-dns/plan"
)

func NewServer(p *CloudflareTunnelProvider) *http.Server {
    mux := http.NewServeMux()

    mux.HandleFunc("/", func(w, http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodGet {
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
            return
        }

        records, _ := p.Records()
        w.WriteHeader("Content-Type", "application/external.dns.webhook+json;version=1")
        json.NewEncoder(w).Encode(records)
    })

    mux.HandleFunc("/adjustendpoints", func(w, http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
            return
        }

        var endpoints []*endpoint.Endpoint
        json.NewDecoder(r.Body).Decode(&endpoints)
        endpoints, _ := p.AdjustEndpoints(endpoints)

        w.WriteHeader("Content-Type", "application/external.dns.webhook+json;version=1")
        json.NewEncoder(w).Encode(endpoints)
    })

    mux.HandleFunc("/records", func(w, http.ResponseWriter, r *http.Request) {
        switch r.Method {
        case r.MethodGet:
            records, _ := p.Records(r.Context())
            w.WriteHeader("Content-Type", "application/external.dns.webhook+json;version=1")
            json.NewEncoder(w).Encode(records)

        case r.MethodPost:
            var changes plan.Changes
            json.NewDecoder(r.Body).Decode(&changes)
            p.ApplyChanges(r.Context(), &changes)
            w.WriteHeader(http.StatusNoContent)

        default:
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        }
    })

    return &http.Server{
        // the default expected by external-dns
        Addr:    "localhost:8888",
        Handler: mux,
    }
}

Tying It Together

The rest should be bog-standard go http server setup:

Example main.go
package main

import (
    "os"
    "strings"

	"github.com/axatol/external-dns-cloudflare-tunnel-webhook/pkg/cf"
)

func main() {
    ctx := context.Background()

    client, _ := cf.NewCloudflareClient(...)
    provider := CloudflareTunnelProvider{
        DomainFilter: strings.Split(os.Getenv("DOMAIN_FILTER"), ",")
        Cloudflare:   client,
    }

    server := NewServer(provider)

    ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, os.Kill)
    defer cancel()
    go server.ListenAndServe()
    <-ctx.Done()
    server.Shutdown(ctx)
}

Example Dockerfile
FROM golang:1 as build
WORKDIR /go/src/app
COPY . .
RUN go build -o /go/bin/app main.go
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /go/bin/app /app
CMD ["/app"]

At this point, you should have a minimal webhook provider which you can build and publish to an image repository.

Deploying

You can now deploy external-dns and the webhook provider all in one configuration using the provided Helm chart.

helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm upgrade external-dns external-dns/external-dns \
    --install \
    --namespace external-dns \
    --values values.yaml

Example values.yaml

In the following values, credentials are sourced from a Kubernetes secret

See the Chart README for a full list of available configuration values.

logLevel: info
logFormat: json

policy: sync
registry: noop
interval: 1h
triggerLoopOnEvent: true

sources: [service]
domainFilters: [example.com]

image:
  pullPolicy: Always

provider:
  name: webhook
  webhook:
    image:
      repository: docker.io/axatol/external-dns-cloudflare-tunnel-webhook
      tag: latest
    securityContext:
      allowPrivilegeEscalation: false
      capabilities:
        drop:
          - ALL
      privileged: false
      readOnlyRootFilesystem: true
      runAsGroup: 65532
      runAsNonRoot: true
      runAsUser: 65532
    env:
      - name: LOG_LEVEL
        value: debug
      - name: DOMAIN_FILTERS
        value: example.com
      - name: CLOUDFLARE_ACCOUNT_ID
        valueFrom:
          secretKeyRef:
            name: external-dns-cloudflare-tunnel
            key: CLOUDFLARE_ACCOUNT_ID
      - name: CLOUDFLARE_TUNNEL_ID
        valueFrom:
          secretKeyRef:
            name: external-dns-cloudflare-tunnel
            key: CLOUDFLARE_TUNNEL_ID
      - name: CLOUDFLARE_API_TOKEN
        valueFrom:
          secretKeyRef:
            name: external-dns-cloudflare-tunnel
            key: CLOUDFLARE_API_TOKEN

extraArgs:
  - --annotation-filter=external-dns.alpha.kubernetes.io/hostname