k8s operator for managing HTTPRoutes
  • Go 85%
  • Makefile 9.9%
  • Shell 3.9%
  • Dockerfile 1.2%
Find a file
2026-08-08 10:28:12 +00:00
.devcontainer feat: initialize kubebuilder project with scaffolding 2026-03-03 17:44:14 +01:00
.github/workflows fix(workflow): update image substitution regex for manager manifests 2026-03-06 05:48:59 +01:00
cmd feat(httproute): add grace period for stale dns endpoints 2026-03-14 09:25:30 +01:00
config chore(image): pin manager image to ghcr.io/meisterlala/k8s-direct-dns@sha256:c288d5c70c62d54150cc6709c1ad5a630e5e621bb9858603ad470e1e0310ff60 2026-08-08 10:28:12 +00:00
hack feat: initialize kubebuilder project with scaffolding 2026-03-03 17:44:14 +01:00
internal/controller feat(httproute): add support for Envoy backends with service name resolution 2026-08-08 12:24:31 +02:00
test feat: initialize kubebuilder project with scaffolding 2026-03-03 17:44:14 +01:00
.custom-gcl.yml feat: initialize kubebuilder project with scaffolding 2026-03-03 17:44:14 +01:00
.dockerignore feat: initialize kubebuilder project with scaffolding 2026-03-03 17:44:14 +01:00
.gitignore feat: initialize kubebuilder project with scaffolding 2026-03-03 17:44:14 +01:00
.golangci.yml feat: initialize kubebuilder project with scaffolding 2026-03-03 17:44:14 +01:00
AGENTS.md feat: initialize kubebuilder project with scaffolding 2026-03-03 17:44:14 +01:00
Dockerfile chore(dockerfile): simplify multi-platform build process and refactor comments 2026-03-06 04:25:52 +01:00
go.mod feat(controller): add HTTPRoute reconciler for direct-dns management 2026-03-06 02:58:54 +01:00
go.sum feat(controller): add HTTPRoute reconciler for direct-dns management 2026-03-06 02:58:54 +01:00
Makefile chore(makefile): parameterize buildx builder name 2026-04-02 22:21:03 +02:00
PROJECT feat(controller): add HTTPRoute reconciler for direct-dns management 2026-03-06 02:58:54 +01:00
README.md feat(httproute): add route pinning behavior with target-nodes annotation 2026-04-02 22:08:10 +02:00

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:

  1. Reads route hostnames (spec.hostnames and external-dns.alpha.kubernetes.io/hostname)
  2. Resolves backend Service references from route rules
  3. Reads matching EndpointSlice objects and collects ready backend node placement
  4. Resolves backend zones from backend node labels (topology.kubernetes.io/zone)
  5. Selects all nodes with role ingress in those backend zones
  6. Resolves publishable targets for those ingress nodes (ExternalIP, fallback InternalIP, or explicit node annotation override)
  7. Writes one or more DNS targets per hostname (multi-value A/AAAA/CNAME records as needed)
  8. Writes/updates an externaldns.k8s.io/v1alpha1 DNSEndpoint

Optional route pinning behavior:

  • If directdns.meisterlala.dev/target-nodes is 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 DNSEndpoint is 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:

  • HTTPRoute changes
  • EndpointSlice changes (reconcile affected routes)
  • Node changes (reconcile all routes)

Topology Usage

The controller relies on existing Kubernetes topology and endpoint data:

  • EndpointSlice.endpoints[].nodeName
  • EndpointSlice.endpoints[].conditions.ready
  • Node.labels[topology.kubernetes.io/zone]
  • Ingress role labels on nodes (node-role.kubernetes.io/ingress or legacy kubernetes.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 override host[: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: baldr or baldr,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:

  1. directdns.meisterlala.dev/target
  2. k3s.io/external-dns
  3. Node.status.addresses[type=ExternalDNS]
  4. k3s.io/external-ip
  5. Node.status.addresses[type=ExternalIP]
  6. 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/AAAA records 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 DNSEndpoint is 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 DNSEndpoint is deleted

IP family behavior:

  • IPv4-only by default (A records)
  • Set both --enable-ipv4=true --enable-ipv6=true for dual-stack (A + AAAA)
  • CNAME targets are not affected by IP family flags

Precedence:

  • If directdns.meisterlala.dev/resolve-target-hostnames is set on a route, it overrides the manager default for that route
  • Otherwise, the manager --resolve-target-hostnames value 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
  • DNSEndpoint CRD 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:latest on master
    • ghcr.io/<owner>/k8s-direct-dns:branch-<branch-name> on branch pushes
    • ghcr.io/<owner>/k8s-direct-dns:sha-<shortsha> on all builds
  • creates an immutable release tag v0.0.<run_number> for successful master builds

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 DNSEndpoint created:
    • Route has no hostnames
    • Route has no in-namespace Service backend refs
    • Backend endpoints are not ready
    • Endpoints do not carry nodeName
  • DNS target not what you expect:
    • Check selected node addresses (ExternalIP preferred, then InternalIP)
    • Check node override annotation directdns.meisterlala.dev/target
  • Route should be ignored:
    • Set directdns.meisterlala.dev/enabled: "false"

Development

make manifests generate
make lint-fix
make test

License

Copyright 2026.

Licensed under the Apache License, Version 2.0.