Files
webhook/main.go
T
Donavan Fritz 01e4b58c91
Build dns-webhook Image / build (push) Has been cancelled
Initial commit: dns-webhook MutatingAdmissionWebhook
Rewrites dnsPolicy+dnsConfig on ClusterFirst pods to distribute
queries across 3 randomly-selected auth-dns nameservers with
edns0/rotate/ndots:5. Includes Gitea CI workflow and README.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:14:56 -05:00

285 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// dns-webhook is a Kubernetes MutatingAdmissionWebhook.
//
// What is a MutatingAdmissionWebhook?
//
// When Kubernetes is about to create a resource (a Pod, Deployment, etc.),
// it first sends the resource's definition to every registered webhook for
// that resource type. The webhook can inspect the definition and return a
// set of JSON Patch operations — instructions that say "change field X to
// value Y before saving". Kubernetes applies those patches, then stores
// the final object.
//
// What does this webhook do?
//
// Every new Pod that uses Kubernetes cluster DNS (dnsPolicy: ClusterFirst)
// gets its DNS configuration rewritten so that:
//
// 1. Three nameserver IPs are chosen at random from the four production
// auth-dns pods (ns1ns4). This spreads DNS query load across the
// pool instead of every pod hitting the same two servers.
//
// 2. The resolv.conf options "edns0" and "rotate" are enabled.
// - edns0 allows DNS responses larger than 512 bytes (needed for
// DNSSEC and large TXT records).
// - rotate makes the resolver cycle through the nameserver list so
// queries are distributed rather than always going to the
// first server that responds.
//
// Pods that already have explicit DNS config (DNSNone), use the node's
// own resolver (DNSDefault), or run with host networking + cluster DNS
// (ClusterFirstWithHostNet) are left untouched.
//
// TLS requirement:
//
// Kubernetes requires admission webhooks to speak HTTPS. This server
// loads a TLS certificate and key from /tls/ (written by cert-manager).
// The paths can be overridden with TLS_CERT / TLS_KEY environment variables.
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
// admissionv1 contains the AdmissionReview, AdmissionRequest, and
// AdmissionResponse types that Kubernetes sends to / expects from the webhook.
admissionv1 "k8s.io/api/admission/v1"
// corev1 contains Pod, PodSpec, PodDNSConfig, etc.
corev1 "k8s.io/api/core/v1"
)
// nameserverPool lists the IPv6 addresses of the four production auth-dns pods
// (ns1ns4). ns0 is the staging instance and is intentionally excluded.
//
// These are static pod IPs allocated by Calico IPAM. They are stable across
// pod restarts because each Deployment pins its IP with the annotation
// cni.projectcalico.org/ipAddrs.
var nameserverPool = [4]string{
"2602:817:3000:c608::202", // ns1
"2602:817:3008:c607::204", // ns2
"2602:817:3000:c608::203", // ns3
"2602:817:3008:c607::203", // ns4
}
// clusterDomain is the Kubernetes cluster domain configured in kubelet.
// Service FQDNs follow the pattern: <service>.<namespace>.svc.<clusterDomain>
const clusterDomain = "k8s.sjc001.fritzlab.net"
// jsonPatch represents a single RFC 6902 JSON Patch operation.
//
// Kubernetes admission webhooks return patches in this format. The three
// fields mean:
// - Op: the operation — "add", "replace", or "remove"
// - Path: a slash-separated path into the JSON document, e.g. /spec/dnsPolicy
// - Value: the new value to set (omitted for "remove")
type jsonPatch struct {
Op string `json:"op"`
Path string `json:"path"`
Value interface{} `json:"value,omitempty"`
}
// pickThree returns three randomly selected nameserver IPs from nameserverPool.
//
// rand.Perm(4) returns a random permutation of [0, 1, 2, 3], so we take the
// first three indices to get three distinct servers. Using a permutation
// (rather than three independent random picks) guarantees no duplicates.
func pickThree() []string {
idx := rand.Perm(4)
return []string{nameserverPool[idx[0]], nameserverPool[idx[1]], nameserverPool[idx[2]]}
}
// strPtr is a small helper that returns a pointer to a string.
//
// The PodDNSConfigOption type uses *string for option values so that an
// absent value (nil) is distinguishable from an empty string ("").
func strPtr(s string) *string { return &s }
// handleMutate is the HTTP handler for the /mutate endpoint.
//
// Kubernetes calls this endpoint with an AdmissionReview JSON body whenever a
// Pod CREATE request matches the MutatingWebhookConfiguration rules. The
// handler must:
//
// 1. Decode the AdmissionReview to get the Pod definition.
// 2. Decide whether to mutate the Pod.
// 3. Return an AdmissionReview response with Allowed: true and any patches.
func handleMutate(w http.ResponseWriter, r *http.Request) {
// Read the full request body.
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("ERROR reading request body: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Unmarshal the outer AdmissionReview envelope.
// AdmissionReview is the top-level wrapper Kubernetes uses for both the
// incoming request and the outgoing response.
var review admissionv1.AdmissionReview
if err := json.Unmarshal(body, &review); err != nil {
log.Printf("ERROR unmarshalling AdmissionReview: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Extract the Pod from review.Request.Object.Raw.
// Raw is the verbatim JSON of the resource being admitted.
req := review.Request
var pod corev1.Pod
if err := json.Unmarshal(req.Object.Raw, &pod); err != nil {
log.Printf("ERROR unmarshalling Pod (uid=%s): %v", req.UID, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Only mutate pods that use ClusterFirst DNS policy.
//
// DNSPolicy determines how a pod resolves names:
// ClusterFirst — use cluster DNS (CoreDNS); fall back to upstream.
// This is the default for most workloads.
// DNSNone — pod supplies its own dnsConfig; nothing to do.
// DNSDefault — inherit the node's /etc/resolv.conf directly.
// Used by auth-dns pods themselves to avoid a
// circular dependency (DNS pod needs DNS to start).
// ClusterFirstWithHostNet — host-network pod that still wants cluster DNS;
// leave as-is to avoid breaking host-network semantics.
if pod.Spec.DNSPolicy != corev1.DNSClusterFirst {
log.Printf("SKIP pod=%s/%s uid=%s policy=%s (not ClusterFirst)",
req.Namespace, pod.Name, req.UID, pod.Spec.DNSPolicy)
respond(w, review, nil) // no patches, just allow
return
}
// Determine the pod's namespace for the search domain list.
// Kubernetes guarantees this is set for namespaced resources, but fall back
// to "default" just in case.
ns := req.Namespace
if ns == "" {
ns = "default"
}
// Build the DNS configuration we want every pod to have.
//
// Nameservers: three randomly chosen auth-dns pod IPs.
//
// Searches: resolv.conf search domains let short names be resolved without
// a fully-qualified domain name. For a pod in namespace "myapp":
// - myapp.svc.k8s.sjc001.fritzlab.net → finds Services in myapp
// - svc.k8s.sjc001.fritzlab.net → finds Services in any namespace
// - k8s.sjc001.fritzlab.net → catches cluster-level names
// So "kubectl exec mypod -- curl myservice" resolves without a FQDN.
//
// Options:
// ndots:5 — names with fewer than 5 dots are tried with search domains
// before being treated as absolute. This is the Kubernetes
// default and ensures short service names resolve correctly.
// edns0 — enables EDNS extension headers, allowing DNS responses up to
// 65535 bytes (default UDP cap is 512 bytes).
// rotate — cycle through the nameserver list on each query so load is
// distributed rather than always hitting the first entry.
nameservers := pickThree()
dnsConfig := corev1.PodDNSConfig{
Nameservers: nameservers,
Searches: []string{
fmt.Sprintf("%s.svc.%s", ns, clusterDomain),
fmt.Sprintf("svc.%s", clusterDomain),
clusterDomain,
},
Options: []corev1.PodDNSConfigOption{
{Name: "edns0"},
{Name: "rotate"},
{Name: "ndots", Value: strPtr("5")},
},
}
// JSON Patch requires "add" if the field doesn't exist yet in the manifest,
// or "replace" if it does. Sending "replace" for a non-existent field
// (or "add" for an existing one) is a patch error that would reject the pod.
dnsConfigOp := "add"
if pod.Spec.DNSConfig != nil {
dnsConfigOp = "replace"
}
log.Printf("MUTATE pod=%s/%s uid=%s nameservers=%v op=%s",
ns, pod.Name, req.UID, nameservers, dnsConfigOp)
// Build the two patch operations:
// 1. Change dnsPolicy from ClusterFirst → None.
// DNSNone is required when you provide a custom dnsConfig; it tells
// kubelet to use exactly the config we supply and nothing else.
// 2. Set dnsConfig to our constructed value.
patches := []jsonPatch{
{Op: "replace", Path: "/spec/dnsPolicy", Value: corev1.DNSNone},
{Op: dnsConfigOp, Path: "/spec/dnsConfig", Value: dnsConfig},
}
respond(w, review, patches)
}
// respond writes an AdmissionReview response back to the Kubernetes API server.
//
// Every call to the /mutate endpoint must return an AdmissionReview with:
// - UID matching the incoming request (so Kubernetes can correlate them)
// - Allowed: true (we never block pods, just modify them)
// - Patch / PatchType set when we have mutations to apply
func respond(w http.ResponseWriter, review admissionv1.AdmissionReview, patches []jsonPatch) {
resp := &admissionv1.AdmissionResponse{
UID: review.Request.UID,
Allowed: true,
}
if patches != nil {
// Serialize the patch list to JSON and set the patch type.
// JSONPatch is the only patch type supported by admission webhooks.
b, _ := json.Marshal(patches)
pt := admissionv1.PatchTypeJSONPatch
resp.Patch = b
resp.PatchType = &pt
}
// Place the response inside the same AdmissionReview envelope that came in.
// Kubernetes reads review.Response; review.Request is ignored on the way out.
review.Response = resp
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(review); err != nil {
log.Printf("ERROR encoding AdmissionReview response (uid=%s): %v", resp.UID, err)
}
}
func main() {
// TLS cert and key are written by cert-manager into a Kubernetes Secret,
// which is mounted into the pod at /tls/. The environment variables allow
// the paths to be overridden in development or testing.
certFile := getenv("TLS_CERT", "/tls/tls.crt")
keyFile := getenv("TLS_KEY", "/tls/tls.key")
// /mutate receives AdmissionReview requests from the Kubernetes API server.
http.HandleFunc("/mutate", handleMutate)
// /healthz is a simple liveness/readiness probe endpoint.
// kubelet calls this to decide whether the pod is healthy.
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Printf("dns-webhook starting: cert=%s key=%s", certFile, keyFile)
log.Println("dns-webhook listening on :8443")
// Kubernetes requires admission webhooks to use TLS — plain HTTP is rejected.
log.Fatal(http.ListenAndServeTLS(":8443", certFile, keyFile, nil))
}
// getenv returns the value of the environment variable key, or def if unset.
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}