Files
webhook/main.go
T

285 lines
11 KiB
Go
Raw Normal View History

// 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
}