Den Tailscale-Operator für Headscale nachbauen: Das tailnet-gateway

Warum der offizielle Tailscale-Kubernetes-Operator nicht zu meinem selbstgehosteten Headscale passt — und wie ich die Teile, die ich brauche, als ein einziges StatefulSet nachgebaut habe
Table of contents

Mein Lab hängt an einem selbstgehosteten Headscale — einer quelloffenen Reimplementierung der Tailscale-Control-Plane. Das funktioniert wunderbar für Menschen und für die Talos-Knoten. Sobald ich aber wollte, dass auch die Cluster-Dienste sauber im Tailnet auftauchen — die Kubernetes-API, die Talos-API, die clusterinterne Namensauflösung —, stieß ich auf eine Lücke: Der offizielle Tailscale-Kubernetes-Operator ist für die Tailscale-SaaS gebaut, nicht für Headscale. Also habe ich die Teile, die ich tatsächlich brauche, selbst nachgebaut. Das Ergebnis ist ein einziges, gut lesbares StatefulSet: das tailnet-gateway.

Was der Operator kann — und was ich davon brauche

Der offizielle Operator ist mächtig. Er kennt API-Server-Proxies, Ingress- und Egress-Proxies, Subnet-Router, ProxyGroups und MagicDNS, alles getrieben über CRDs und Reconciler. Diese Mächtigkeit hat aber zwei Haken für meinen Fall: Sie ist eng auf die Tailscale-SaaS samt deren API zugeschnitten, und sie ist deutlich mehr Maschinerie, als ein Homelab-Cluster braucht.

Wenn ich ehrlich auflöse, was ich von alldem wirklich will, bleibt eine kurze Liste:

Operator-FähigkeitBrauche ich?Mein Ersatz
Cluster als Tailnet-Nodejatailscale-Container (containerboot)
Kubernetes-API über Tailnetjasocat → API-VIP :6443
Talos-API über Tailnetjasocat → Talos-Endpoint :50000
ClusterMesh über Tailnetoptionalsocatclustermesh-apiserver:2379
MagicDNS für Pods (*.tif.internal)jaCoreDNS-Sidecar + tailnet-dns-Service
Per-Service-Ingress/Egress-Proxiesnein
Subnet-Router auf jedem Knotennein

Die untere Hälfte fällt weg. Was übrig bleibt, ist keine generische Operator-Maschine mit CRDs, sondern eine stabile Brücke pro Cluster — und genau die lässt sich als ein einzelnes, vollständig verstandenes StatefulSet ausdrücken.

Anatomie des tailnet-gateway

Das tailnet-gateway läuft als StatefulSet mit replicas: 1 auf einem Control-Plane-Knoten (mit den passenden Tolerations und system-node-critical). In einem Pod stecken mehrere kleine, jeweils offensichtliche Container:

flowchart TB subgraph POD["Pod: tailnet-gateway (auf Control-Plane)"] TS["tailscale (containerboot)<br/>userspace · Node ${CLUSTER_NAME}-gateway<br/>--login-server = Headscale"] SK["socat-k8s<br/>:6443 → API-VIP"] ST["socat-talos<br/>:50000 → Talos-Endpoint"] SM["socat-mesh<br/>:2379 → clustermesh-apiserver"] DNS["coredns<br/>:53 → MagicDNS 100.100.100.100"] end STATE["Secret: tailnet-gateway-state<br/>(Identität in etcd)"] TS --- STATE

Der tailscale-Container ist das Herzstück. Er meldet den Cluster über containerboot als Tailnet-Knoten ${CLUSTER_NAME}-gateway an — entscheidend ist dabei das TS_EXTRA_ARGS-Flag, das ihn nicht zur Tailscale-SaaS, sondern zu meinem Headscale schickt:

env:
  - name: TS_USERSPACE
    value: "true"
  - name: TS_HOSTNAME
    value: "${CLUSTER_NAME}-gateway"
  - name: TS_KUBE_SECRET
    value: tailnet-gateway-state
  - name: TS_EXTRA_ARGS
    value: "--login-server=https://ts.${EXTERNAL_DOMAIN}"
Warum die Identität in einem Secret statt auf einem PVC? Das StatefulSet läuft bewusst auf einem Control-Plane-Knoten, und meine CP-Knoten haben keine Rook-CSI — sie können kein ceph-block mounten. containerboot kann seinen Tailscale-State aber direkt in einem Kubernetes-Secret (TS_KUBE_SECRET) persistieren, das in etcd liegt. Dafür genügt eine winzige Role mit get/create/update/patch auf Secrets.

Die API-Server-Brücke

Die Kernfunktion, die ich aus dem Operator nachbaue, ist sein API-Server-Proxy: die Kubernetes-API von außerhalb des LAN erreichbar machen, ohne Ports im Router aufzureißen. Beim Operator ist das ein eigener Proxy mit Auth-Logik — bei mir ist es schlicht ein socat, das auf dem Tailnet-Interface lauscht und auf die Cluster-interne API-VIP weiterreicht:

- name: socat-k8s
  image: alpine/socat:1.8.0.3
  command: ["socat"]
  args:
    - "-d"
    - "TCP-LISTEN:6443,fork,reuseaddr"
    - "TCP:${KUBERNETES_API_VIP}:6443"

Weil der Pod als Tailnet-Knoten ${CLUSTER_NAME}-gateway auftritt und Headscale ihm per MagicDNS einen Namen gibt, lande ich von überall im Tailnet mit einem stabilen Ziel auf der API:

sequenceDiagram participant C as kubectl / talosctl (Tailnet) participant HS as Headscale (MagicDNS) participant GW as tailnet-gateway (socat) participant API as K8s-API-VIP :6443 C->>HS: hydra-gateway.tif.internal? HS-->>C: Tailnet-IP des Gateways C->>GW: TLS :6443 (über WireGuard) GW->>API: TCP :6443 API-->>C: API-Antwort

Dieselbe Mechanik trägt auch die Talos-API (:50000) und — sobald aktiviert — den ClusterMesh-Endpunkt (:2379). Drei Brücken, dreimal dasselbe simple Muster.

MagicDNS für Pods: Das Split-Horizon-Problem

Der kniffligste Teil ist die Namensauflösung. Knoten und Menschen im Tailnet bekommen MagicDNS frei Haus über 100.100.100.100. Pods im Cluster aber nicht — und selbst wenn sie einen Namen auflösen könnten, könnten sie die zurückgelieferten 100.x-Tailnet-IPs gar nicht routen. Ein Pod, der vault.tif.internal aufrufen will, braucht also eine andere Antwort als ein Laptop im Tailnet.

Die Lösung ist Split-Horizon-DNS, aufgeteilt auf zwei CoreDNS-Instanzen. Der CoreDNS-Sidecar im Gateway-Pod ist ein reiner Forwarder auf die MagicDNS-Adresse:

.:53 {
    forward . 100.100.100.100 { prefer_udp }
    cache 30
}

Davor sitzt ein stabiler ClusterIP-Service tailnet-dns, an den das kube-system-CoreDNS die gesamte tif.internal-Zone delegiert. Für die lokalen Gateway-Hostnamen wird dabei getrickst: Statt der nicht-routbaren Tailnet-IP bekommt der Pod ein CNAME auf den clusterinternen Service:

${TAILNET_DOMAIN}:53 {
    template IN A ${CLUSTER_NAME}-gateway.${TAILNET_DOMAIN} {
        answer "{{ .Name }} 30 IN CNAME tailnet-gateway.tailscale-system.svc.cluster.local."
    }
    forward . ${TAILNET_DNS_CLUSTERIP} { prefer_udp }
}

Der vollständige Auflösungspfad eines Pods sieht damit so aus:

flowchart LR POD["Pod<br/>fragt vault.tif.internal"] --> KCD["kube-system CoreDNS"] KCD -->|"Zone tif.internal"| TDNS["Service tailnet-dns<br/>(stabile ClusterIP)"] TDNS --> GWCD["coredns-Sidecar<br/>im tailnet-gateway"] GWCD -->|"forward"| MDNS["Headscale MagicDNS<br/>100.100.100.100"] MDNS -->|"Antwort"| POD
Warum nicht einfach /etc/resolv.conf der Knoten nutzen? Weil Talos’ TS_ACCEPT_DNS die Knoten auf Headscale-MagicDNS zeigen lassen kann — und wenn Headscale mal wackelt, würde damit auch jede öffentliche Auflösung (cert-manager, Flux, Let’s-Encrypt-ACME) sterben. Deshalb forwardet das kube-system-CoreDNS öffentliche Namen explizit an einen festen Lab-Resolver und nur die tif.internal-Zone an den Gateway. Headscale-Ausfälle bleiben so auf das Tailnet beschränkt.

Wo der gateway ins Gesamtbild passt

Mit dem tailnet-gateway fügt sich das Tailnet sauber zusammen: Headscale ist die Control-Plane, die Talos-Knoten und Menschen treten als gewöhnliche Tailnet-Teilnehmer bei, und pro Cluster gibt es genau eine Brücke, die API, Talos, Mesh und DNS bündelt.

flowchart TB subgraph TAILNET["Headscale-Tailnet (WireGuard)"] HS["Headscale Control-Plane<br/>ts.${EXTERNAL_DOMAIN}"] HUMAN["Operator-Laptop<br/>(OIDC-Enrollment)"] NODES["Talos-Knoten<br/>tag:k8s-node"] GW["tailnet-gateway<br/>${CLUSTER_NAME}-gateway · tag:k8s-api"] end subgraph CLUSTER["hydra"] API["K8s-API-VIP"] PODS["Pods (*.tif.internal)"] end HUMAN & NODES & GW --- HS HUMAN -->|"kubectl"| GW --> API PODS -->|"DNS"| GW

HTTP(S)-Anwendungen laufen übrigens nicht über diesen Pfad, sondern über ein eigenes Tailnet-Gateway der Cilium-LB-IPs (shared-gateway-tailnet). Das tailnet-gateway ist bewusst auf die Steuerungsebene beschränkt: API, Talos, Mesh, DNS.

Fazit

Den ganzen Operator nachzubauen wäre Unsinn gewesen — die meisten seiner Fähigkeiten brauche ich schlicht nicht. Aber die Handvoll, die ich brauche, ließ sich auf etwas erfreulich Begreifbares eindampfen: ein StatefulSet, ein tailscale-Container gegen Headscale, drei socat-Brücken und ein CoreDNS-Sidecar. Kein CRD, kein Reconciler, kein Blackbox-Verhalten — nur Bausteine, die ich vollständig lesen und im Fehlerfall in dreißig Sekunden im Kopf durchspielen kann. Manchmal ist der beste Operator der, den man nicht braucht.