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 meaningfulGET /
- takes the value of the--domain-filter
argument and returns it in a standardised format that is understood by external-dnsGET /records
- a request for the provider to list all DNS records that are currently registered with the DNS providerPOST /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
In the following values, credentials are sourced from a Kubernetes secret See the Chart README
for a full list of available configuration values.Example
values.yaml
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