- Go 85%
- Makefile 9.9%
- Shell 3.9%
- Dockerfile 1.2%
| .devcontainer | ||
| .github/workflows | ||
| cmd | ||
| config | ||
| hack | ||
| internal/controller | ||
| test | ||
| .custom-gcl.yml | ||
| .dockerignore | ||
| .gitignore | ||
| .golangci.yml | ||
| AGENTS.md | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| PROJECT | ||
| README.md | ||
k8s-direct-dns
k8s-direct-dns is a Kubernetes controller that computes DNS targets for HTTPRoute resources from real backend placement.
It is built for clusters where you want DNS to follow where the route backend is actually running (node-level awareness), while still letting external-dns do provider updates.
What It Does
For each gateway.networking.k8s.io/v1 HTTPRoute, the controller:
- Reads route hostnames (
spec.hostnamesandexternal-dns.alpha.kubernetes.io/hostname) - Resolves backend
Servicereferences from route rules - Reads matching
EndpointSliceobjects and collects ready backend node placement - Resolves backend zones from backend node labels (
topology.kubernetes.io/zone) - Selects all nodes with role
ingressin those backend zones - Resolves publishable targets for those ingress nodes (
ExternalIP, fallbackInternalIP, or explicit node annotation override) - Writes one or more DNS targets per hostname (multi-value A/AAAA/CNAME records as needed)
- Writes/updates an
externaldns.k8s.io/v1alpha1DNSEndpoint
Optional route pinning behavior:
- If
directdns.meisterlala.dev/target-nodesis set on the route, the controller skips backend zone selection and resolves targets only from the listed node names. - This lets workloads run on any node while DNS stays pinned to specific ingress node targets.
Fallback behavior:
- If backend zones are known but no ingress-role nodes with publishable addresses exist in those zones, the controller falls back to using all ingress-role nodes (any zone) so traffic can land on another node.
- If there are no ready backends with zone labels, or there are no ingress-role nodes with publishable addresses at all, no DNS record is created and any existing
DNSEndpointis deleted.
external-dns then reads those DNSEndpoint objects and syncs records to your DNS provider.
Why DNSEndpoint Instead of HTTPRoute Target Annotation
external-dns Gateway sources only read external-dns.alpha.kubernetes.io/target from the Gateway object, not per HTTPRoute target values.
To support per-route, per-backend-node DNS targets, this controller writes DNSEndpoint CRs and external-dns consumes them via --source=crd.
Architecture
Data flow:
HTTPRoute -> Service backend refs -> EndpointSlice endpoints -> Node -> DNSEndpoint -> external-dns -> DNS provider
Reconcile triggers:
HTTPRoutechangesEndpointSlicechanges (reconcile affected routes)Nodechanges (reconcile all routes)
Topology Usage
The controller relies on existing Kubernetes topology and endpoint data:
EndpointSlice.endpoints[].nodeNameEndpointSlice.endpoints[].conditions.readyNode.labels[topology.kubernetes.io/zone]- Ingress role labels on nodes (
node-role.kubernetes.io/ingressor legacykubernetes.io/role=ingress) Node.status.addresses(ExternalDNS,ExternalIP,InternalIP)- Standard topology labels remain available for cluster operations (
topology.kubernetes.io/zone,topology.kubernetes.io/region,kubernetes.io/hostname)
No custom topology API is introduced.
Warning Logs
Some situations are logged as warnings (as structured log fields):
- Falling back to all ingress nodes because none were found in backend zones
- Skipping a route because required inputs are missing (hostnames, backend Service refs, zone labels)
- Skipping some hostname targets because DNS resolution failed while others succeeded
Shared Public IP Behavior (Important)
If two selected ingress nodes resolve to the same publish IP (for example, behind the same NAT), DNS records will contain that target only once.
Behavior:
- The controller selects all ingress nodes in backend zones
- Duplicate targets are de-duplicated before writing DNS records
Optional mitigation:
- Set explicit per-node publish target via node annotation:
directdns.meisterlala.dev/target: <ip-or-hostname>
Supported Annotations
On HTTPRoute:
directdns.meisterlala.dev/enabled:"true"|"false"(default: true)directdns.meisterlala.dev/ttl: integer seconds (default: 1)directdns.meisterlala.dev/resolve-target-hostnames:"true"|"false"(defaults to manager flag value)directdns.meisterlala.dev/dns-server: DNS resolver overridehost[:port](default:1.1.1.1:53)directdns.meisterlala.dev/target-nodes: comma-separated node names to use as DNS target sources for this route (for example:baldrorbaldr,ran)external-dns.alpha.kubernetes.io/hostname: comma-separated additional hostnames
On Node:
directdns.meisterlala.dev/target: explicit DNS target override for that node
Node target resolution order:
directdns.meisterlala.dev/targetk3s.io/external-dnsNode.status.addresses[type=ExternalDNS]k3s.io/external-ipNode.status.addresses[type=ExternalIP]Node.status.addresses[type=InternalIP]
target-nodes behavior details:
- Node names are trimmed, de-duplicated, and sorted
- Missing nodes or nodes without a publishable target are ignored
- If no listed node resolves to a target, no targets are produced for that route
- Standard fallback to all ingress nodes is not used for routes that set
target-nodes
Optional Hostname Target Resolution
When hostname target resolution is enabled globally or by annotation,
hostname targets (for example from k3s.io/external-dns) are resolved to IPs before writing DNSEndpoint records.
This is useful when a route would otherwise produce mixed A + CNAME targets for the same hostname.
- Resolved IPs are emitted as
A/AAAArecords only - Unresolved hostname targets are skipped if at least one target resolves
- If all hostname targets fail to resolve, the original targets are kept as a safety fallback
- Routes with hostname resolution enabled are periodically requeued (default: every 1 minute)
Controller flags:
--dns-resolver=host[:port](default:1.1.1.1:53)--dns-resolve-interval=1m--resolve-target-hostnames=false(default)--enable-ipv4=true(default)--enable-ipv6=false(default)--stale-endpoint-grace-period=5m(default)
Stale endpoint behavior:
- When a route temporarily has no valid targets (for example during pod restarts), the last published
DNSEndpointis kept for the grace period - If valid targets return before the grace period expires, normal updates resume and stale tracking is cleared
- If no valid targets return before the grace period expires, the
DNSEndpointis deleted
IP family behavior:
- IPv4-only by default (
Arecords) - Set both
--enable-ipv4=true --enable-ipv6=truefor dual-stack (A+AAAA) - CNAME targets are not affected by IP family flags
Precedence:
- If
directdns.meisterlala.dev/resolve-target-hostnamesis set on a route, it overrides the manager default for that route - Otherwise, the manager
--resolve-target-hostnamesvalue is used
external-dns Configuration
Enable CRD source in external-dns and point it to DNSEndpoint:
external-dns \
--source=crd \
--crd-source-apiversion=externaldns.k8s.io/v1alpha1 \
--crd-source-kind=DNSEndpoint \
--provider=<your-provider>
Ensure external-dns has RBAC permissions for dnsendpoints.externaldns.k8s.io.
Getting Started
Prerequisites
- Go
v1.24+ - Docker
17.03+ - kubectl
v1.11.3+ - Access to a Kubernetes cluster
- Gateway API CRDs installed
- external-dns installed
DNSEndpointCRD installed (from external-dns)
Build and Deploy Controller
make docker-build docker-push IMG=<some-registry>/k8s-direct-dns:tag
make deploy IMG=<some-registry>/k8s-direct-dns:tag
Examples:
make docker-build docker-push IMG=ghcr.io/<owner>/k8s-direct-dns:latest
make deploy IMG=ghcr.io/<owner>/k8s-direct-dns:latest
Uninstall
make undeploy
Distribution
YAML Bundle
make build-installer IMG=<some-registry>/k8s-direct-dns:tag
This generates dist/install.yaml.
Helm Chart
kubebuilder edit --plugins=helm/v2-alpha
GitHub Image Automation
The repository includes a GitHub Actions workflow at .github/workflows/image.yml that:
- validates deploy manifests with
kustomize build config/default - builds and pushes a multi-arch image (
linux/amd64,linux/arm64) to GHCR - publishes these image tags:
ghcr.io/<owner>/k8s-direct-dns:latestonmasterghcr.io/<owner>/k8s-direct-dns:branch-<branch-name>on branch pushesghcr.io/<owner>/k8s-direct-dns:sha-<shortsha>on all builds
- creates an immutable release tag
v0.0.<run_number>for successfulmasterbuilds
Recommended Flux strategy: track this repository with a semver selector (for example >=0.0.0) so the cluster only rolls forward to validated, tagged releases instead of every commit on master.
Troubleshooting
- No
DNSEndpointcreated:- Route has no hostnames
- Route has no in-namespace
Servicebackend refs - Backend endpoints are not ready
- Endpoints do not carry
nodeName
- DNS target not what you expect:
- Check selected node addresses (
ExternalIPpreferred, thenInternalIP) - Check node override annotation
directdns.meisterlala.dev/target
- Check selected node addresses (
- Route should be ignored:
- Set
directdns.meisterlala.dev/enabled: "false"
- Set
Development
make manifests generate
make lint-fix
make test
License
Copyright 2026.
Licensed under the Apache License, Version 2.0.